diff --git a/.eslintplugin/code-no-potentially-unsafe-disposables.ts b/.eslintplugin/code-no-potentially-unsafe-disposables.ts new file mode 100644 index 0000000000000..699762750519c --- /dev/null +++ b/.eslintplugin/code-no-potentially-unsafe-disposables.ts @@ -0,0 +1,37 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as eslint from 'eslint'; + +/** + * Checks for potentially unsafe usage of `DisposableStore` / `MutableDisposable`. + * + * These have been the source of leaks in the past. + */ +export = new class implements eslint.Rule.RuleModule { + + create(context: eslint.Rule.RuleContext): eslint.Rule.RuleListener { + function checkVariableDeclaration(inNode: any) { + context.report({ + node: inNode, + message: `Use const for 'DisposableStore' to avoid leaks by accidental reassignment.` + }); + } + + function checkProperty(inNode: any) { + context.report({ + node: inNode, + message: `Use readonly for DisposableStore/MutableDisposable to avoid leaks through accidental reassignment.` + }); + } + + return { + 'VariableDeclaration[kind!="const"] NewExpression[callee.name="DisposableStore"]': checkVariableDeclaration, + + 'PropertyDefinition[readonly!=true][typeAnnotation.typeAnnotation.typeName.name=/DisposableStore|MutableDisposable/]': checkProperty, + 'PropertyDefinition[readonly!=true] NewExpression[callee.name=/DisposableStore|MutableDisposable/]': checkProperty, + }; + } +}; diff --git a/.eslintplugin/code-parameter-properties-must-have-explicit-accessibility.ts b/.eslintplugin/code-parameter-properties-must-have-explicit-accessibility.ts new file mode 100644 index 0000000000000..458afd5b0ba31 --- /dev/null +++ b/.eslintplugin/code-parameter-properties-must-have-explicit-accessibility.ts @@ -0,0 +1,41 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as eslint from 'eslint'; +import { TSESTree } from '@typescript-eslint/experimental-utils'; + +/** + * Enforces that all parameter properties have an explicit access modifier (public, protected, private). + * + * This catches a common bug where a service is accidentally made public by simply writing: `readonly prop: Foo` + */ +export = new class implements eslint.Rule.RuleModule { + + create(context: eslint.Rule.RuleContext): eslint.Rule.RuleListener { + function check(inNode: any) { + const node: TSESTree.TSParameterProperty = inNode; + + // For now, only apply to injected services + const firstDecorator = node.decorators?.at(0); + if ( + firstDecorator?.expression.type !== 'Identifier' + || !firstDecorator.expression.name.endsWith('Service') + ) { + return; + } + + if (!node.accessibility) { + context.report({ + node: inNode, + message: 'Parameter properties must have an explicit access modifier.' + }); + } + } + + return { + ['TSParameterProperty']: check, + }; + } +}; diff --git a/.eslintrc.json b/.eslintrc.json index 0922babfe92ad..19d2c1e06a589 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -70,7 +70,9 @@ ], "local/code-translation-remind": "warn", "local/code-no-native-private": "warn", + "local/code-parameter-properties-must-have-explicit-accessibility": "warn", "local/code-no-nls-in-standalone-editor": "warn", + "local/code-no-potentially-unsafe-disposables": "warn", "local/code-no-standalone-editor": "warn", "local/code-no-unexternalized-strings": "warn", "local/code-must-use-super-dispose": "warn", @@ -154,14 +156,11 @@ "src/vs/editor/contrib/codeAction/test/browser/codeActionModel.test.ts", "src/vs/editor/test/common/services/languageService.test.ts", "src/vs/editor/test/node/classification/typescript.test.ts", - "src/vs/editor/test/node/diffing/defaultLinesDiffComputer.test.ts", - "src/vs/editor/test/node/diffing/fixtures.test.ts", "src/vs/platform/configuration/test/common/configuration.test.ts", "src/vs/platform/extensions/test/common/extensionValidator.test.ts", "src/vs/platform/opener/test/common/opener.test.ts", "src/vs/platform/registry/test/common/platform.test.ts", "src/vs/platform/remote/test/common/remoteHosts.test.ts", - "src/vs/platform/telemetry/test/browser/1dsAppender.test.ts", "src/vs/platform/workspace/test/common/workspace.test.ts", "src/vs/platform/workspaces/test/electron-main/workspaces.test.ts", "src/vs/workbench/api/test/browser/mainThreadConfiguration.test.ts", @@ -859,7 +858,11 @@ }, // TODO@layers "tas-client-umd", // node module allowed even in /common/ "vscode-textmate", // node module allowed even in /common/ - "@vscode/vscode-languagedetection" // node module allowed even in /common/ + "@vscode/vscode-languagedetection", // node module allowed even in /common/ + { + "when": "hasBrowser", + "pattern": "@xterm/xterm" + } // node module allowed even in /browser/ ] }, { diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000000000..8da51487c846f --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,3 @@ +# ensure the API police is aware of changes to the vscode-dts file +# this is only about the final API, not about proposed API changes +src/vscode-dts/vscode.d.ts @jrieken @mjbvz diff --git a/.github/workflows/deep-classifier-runner.yml b/.github/workflows/deep-classifier-runner.yml index 576bfa12fc39d..81fd351675132 100644 --- a/.github/workflows/deep-classifier-runner.yml +++ b/.github/workflows/deep-classifier-runner.yml @@ -1,4 +1,9 @@ name: "Deep Classifier: Runner" + +permissions: + id-token: write + contents: read + on: schedule: - cron: 0 * * * * @@ -9,7 +14,13 @@ on: jobs: main: runs-on: ubuntu-latest + environment: main steps: + - uses: azure/login@v2 + with: + client-id: ${{ vars.AZURE_CLIENT_ID }} + tenant-id: ${{ vars.AZURE_TENANT_ID }} + allow-no-subscriptions: true - name: Checkout Actions uses: actions/checkout@v4 with: @@ -47,8 +58,4 @@ jobs: with: configPath: classifier allowLabels: "info-needed|new release|error-telemetry|*english-please|translation-required" - tenantId: ${{secrets.TOOLS_TENANT_ID}} - clientId: ${{secrets.TOOLS_CLIENT_ID}} - clientSecret: ${{secrets.TOOLS_CLIENT_SECRET}} - clientScope: ${{secrets.TOOLS_CLIENT_SCOPE}} token: ${{secrets.VSCODE_ISSUE_TRIAGE_BOT_PAT}} diff --git a/.gitignore b/.gitignore index c0459c8604323..123b3567059df 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ npm-debug.log Thumbs.db node_modules/ .build/ +.vscode/extensions/**/out/ extensions/**/dist/ /out*/ /extensions/**/out/ diff --git a/.nvmrc b/.nvmrc index 4a1f488b6c3b6..a9d087399d711 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -18.17.1 +18.19.0 diff --git a/.vscode/extensions.json b/.vscode/extensions.json index 3d58135095b19..737efece5a492 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -6,7 +6,6 @@ "editorconfig.editorconfig", "github.vscode-pull-request-github", "ms-vscode.vscode-github-issue-notebooks", - "ms-vscode.vscode-selfhost-test-provider", "ms-vscode.extension-test-runner", "jrieken.vscode-pr-pinger" ] diff --git a/.vscode/extensions/vscode-selfhost-test-provider/icon.png b/.vscode/extensions/vscode-selfhost-test-provider/icon.png new file mode 100644 index 0000000000000..46bf5e4d3bcf2 Binary files /dev/null and b/.vscode/extensions/vscode-selfhost-test-provider/icon.png differ diff --git a/.vscode/extensions/vscode-selfhost-test-provider/package.json b/.vscode/extensions/vscode-selfhost-test-provider/package.json new file mode 100644 index 0000000000000..f27953f3cdb4f --- /dev/null +++ b/.vscode/extensions/vscode-selfhost-test-provider/package.json @@ -0,0 +1,78 @@ +{ + "name": "vscode-selfhost-test-provider", + "displayName": "VS Code Selfhost Test Provider", + "description": "Test provider for the VS Code project", + "enabledApiProposals": [ + "testObserver" + ], + "engines": { + "vscode": "^1.88.0" + }, + "contributes": { + "commands": [ + { + "command": "selfhost-test-provider.updateSnapshot", + "title": "Update Snapshot", + "icon": "$(merge)" + } + ], + "menus": { + "commandPalette": [ + { + "command": "selfhost-test-provider.updateSnapshot", + "when": "false" + } + ], + "testing/message/context": [ + { + "command": "selfhost-test-provider.updateSnapshot", + "group": "inline@1", + "when": "testMessage == isSelfhostSnapshotMessage && !testResultOutdated" + } + ], + "testing/message/content": [ + { + "command": "selfhost-test-provider.updateSnapshot", + "when": "testMessage == isSelfhostSnapshotMessage && !testResultOutdated" + } + ] + } + }, + "icon": "icon.png", + "version": "0.4.0", + "publisher": "ms-vscode", + "categories": [ + "Other" + ], + "activationEvents": [ + "workspaceContains:src/vs/loader.js" + ], + "workspaceTrust": { + "request": "onDemand", + "description": "Trust is required to execute tests in the workspace." + }, + "main": "./out/extension.js", + "prettier": { + "printWidth": 100, + "singleQuote": true, + "tabWidth": 2, + "arrowParens": "avoid" + }, + "repository": { + "type": "git", + "url": "https://github.com/microsoft/vscode.git" + }, + "license": "MIT", + "scripts": { + "compile": "gulp compile-extension:vscode-selfhost-test-provider", + "watch": "gulp watch-extension:vscode-selfhost-test-provider" + }, + "devDependencies": { + "@types/node": "18.x" + }, + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.25", + "ansi-styles": "^5.2.0", + "istanbul-to-vscode": "^2.0.1" + } +} diff --git a/.vscode/extensions/vscode-selfhost-test-provider/src/coverageProvider.ts b/.vscode/extensions/vscode-selfhost-test-provider/src/coverageProvider.ts new file mode 100644 index 0000000000000..1a891523655ab --- /dev/null +++ b/.vscode/extensions/vscode-selfhost-test-provider/src/coverageProvider.ts @@ -0,0 +1,7 @@ +/*--------------------------------------------------------- + * Copyright (C) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------*/ + +import { IstanbulCoverageContext } from 'istanbul-to-vscode'; + +export const coverageContext = new IstanbulCoverageContext(); diff --git a/.vscode/extensions/vscode-selfhost-test-provider/src/debounce.ts b/.vscode/extensions/vscode-selfhost-test-provider/src/debounce.ts new file mode 100644 index 0000000000000..2d2f0ba1bf4b6 --- /dev/null +++ b/.vscode/extensions/vscode-selfhost-test-provider/src/debounce.ts @@ -0,0 +1,29 @@ +/*--------------------------------------------------------- + * Copyright (C) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------*/ + +/** + * Debounces the function call for an interval. + */ +export function debounce(duration: number, fn: () => void): (() => void) & { clear: () => void } { + let timeout: NodeJS.Timeout | void; + const debounced = () => { + if (timeout !== undefined) { + clearTimeout(timeout); + } + + timeout = setTimeout(() => { + timeout = undefined; + fn(); + }, duration); + }; + + debounced.clear = () => { + if (timeout) { + clearTimeout(timeout); + timeout = undefined; + } + }; + + return debounced; +} diff --git a/.vscode/extensions/vscode-selfhost-test-provider/src/extension.ts b/.vscode/extensions/vscode-selfhost-test-provider/src/extension.ts new file mode 100644 index 0000000000000..b7269def2e725 --- /dev/null +++ b/.vscode/extensions/vscode-selfhost-test-provider/src/extension.ts @@ -0,0 +1,314 @@ +/*--------------------------------------------------------- + * Copyright (C) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------*/ + +import { randomBytes } from 'crypto'; +import { tmpdir } from 'os'; +import * as path from 'path'; +import * as vscode from 'vscode'; +import { coverageContext } from './coverageProvider'; +import { FailingDeepStrictEqualAssertFixer } from './failingDeepStrictEqualAssertFixer'; +import { registerSnapshotUpdate } from './snapshot'; +import { scanTestOutput } from './testOutputScanner'; +import { + TestCase, + TestFile, + clearFileDiagnostics, + guessWorkspaceFolder, + itemData, +} from './testTree'; +import { BrowserTestRunner, PlatformTestRunner, VSCodeTestRunner } from './vscodeTestRunner'; + +const TEST_FILE_PATTERN = 'src/vs/**/*.{test,integrationTest}.ts'; + +const getWorkspaceFolderForTestFile = (uri: vscode.Uri) => + (uri.path.endsWith('.test.ts') || uri.path.endsWith('.integrationTest.ts')) && + uri.path.includes('/src/vs/') + ? vscode.workspace.getWorkspaceFolder(uri) + : undefined; + +const browserArgs: [name: string, arg: string][] = [ + ['Chrome', 'chromium'], + ['Firefox', 'firefox'], + ['Webkit', 'webkit'], +]; + +type FileChangeEvent = { uri: vscode.Uri; removed: boolean }; + +export async function activate(context: vscode.ExtensionContext) { + const ctrl = vscode.tests.createTestController('selfhost-test-controller', 'VS Code Tests'); + const fileChangedEmitter = new vscode.EventEmitter(); + + ctrl.resolveHandler = async test => { + if (!test) { + context.subscriptions.push(await startWatchingWorkspace(ctrl, fileChangedEmitter)); + return; + } + + const data = itemData.get(test); + if (data instanceof TestFile) { + // No need to watch this, updates will be triggered on file changes + // either by the text document or file watcher. + await data.updateFromDisk(ctrl, test); + } + }; + + const createRunHandler = ( + runnerCtor: { new(folder: vscode.WorkspaceFolder): VSCodeTestRunner }, + kind: vscode.TestRunProfileKind, + args: string[] = [] + ) => { + const doTestRun = async ( + req: vscode.TestRunRequest, + cancellationToken: vscode.CancellationToken + ) => { + const folder = await guessWorkspaceFolder(); + if (!folder) { + return; + } + + const runner = new runnerCtor(folder); + const map = await getPendingTestMap(ctrl, req.include ?? gatherTestItems(ctrl.items)); + const task = ctrl.createTestRun(req); + for (const test of map.values()) { + task.enqueued(test); + } + + let coverageDir: string | undefined; + let currentArgs = args; + if (kind === vscode.TestRunProfileKind.Coverage) { + coverageDir = path.join(tmpdir(), `vscode-test-coverage-${randomBytes(8).toString('hex')}`); + currentArgs = [ + ...currentArgs, + '--coverage', + '--coveragePath', + coverageDir, + '--coverageFormats', + 'json', + '--coverageFormats', + 'html', + ]; + } + + return await scanTestOutput( + map, + task, + kind === vscode.TestRunProfileKind.Debug + ? await runner.debug(currentArgs, req.include) + : await runner.run(currentArgs, req.include), + coverageDir, + cancellationToken + ); + }; + + return async (req: vscode.TestRunRequest, cancellationToken: vscode.CancellationToken) => { + if (!req.continuous) { + return doTestRun(req, cancellationToken); + } + + const queuedFiles = new Set(); + let debounced: NodeJS.Timeout | undefined; + + const listener = fileChangedEmitter.event(({ uri, removed }) => { + clearTimeout(debounced); + + if (req.include && !req.include.some(i => i.uri?.toString() === uri.toString())) { + return; + } + + if (removed) { + queuedFiles.delete(uri.toString()); + } else { + queuedFiles.add(uri.toString()); + } + + debounced = setTimeout(() => { + const include = + req.include?.filter(t => t.uri && queuedFiles.has(t.uri?.toString())) ?? + [...queuedFiles] + .map(f => getOrCreateFile(ctrl, vscode.Uri.parse(f))) + .filter((f): f is vscode.TestItem => !!f); + queuedFiles.clear(); + + doTestRun( + new vscode.TestRunRequest(include, req.exclude, req.profile, true), + cancellationToken + ); + }, 1000); + }); + + cancellationToken.onCancellationRequested(() => { + clearTimeout(debounced); + listener.dispose(); + }); + }; + }; + + ctrl.createRunProfile( + 'Run in Electron', + vscode.TestRunProfileKind.Run, + createRunHandler(PlatformTestRunner, vscode.TestRunProfileKind.Run), + true, + undefined, + true + ); + + ctrl.createRunProfile( + 'Debug in Electron', + vscode.TestRunProfileKind.Debug, + createRunHandler(PlatformTestRunner, vscode.TestRunProfileKind.Debug), + true, + undefined, + true + ); + + const coverage = ctrl.createRunProfile( + 'Coverage in Electron', + vscode.TestRunProfileKind.Coverage, + createRunHandler(PlatformTestRunner, vscode.TestRunProfileKind.Coverage), + true, + undefined, + true + ); + + coverage.loadDetailedCoverage = coverageContext.loadDetailedCoverage; + + for (const [name, arg] of browserArgs) { + const cfg = ctrl.createRunProfile( + `Run in ${name}`, + vscode.TestRunProfileKind.Run, + createRunHandler(BrowserTestRunner, vscode.TestRunProfileKind.Run, [' --browser', arg]), + undefined, + undefined, + true + ); + + cfg.configureHandler = () => vscode.window.showInformationMessage(`Configuring ${name}`); + + ctrl.createRunProfile( + `Debug in ${name}`, + vscode.TestRunProfileKind.Debug, + createRunHandler(BrowserTestRunner, vscode.TestRunProfileKind.Debug, [ + '--browser', + arg, + '--debug-browser', + ]), + undefined, + undefined, + true + ); + } + + function updateNodeForDocument(e: vscode.TextDocument) { + const node = getOrCreateFile(ctrl, e.uri); + const data = node && itemData.get(node); + if (data instanceof TestFile) { + data.updateFromContents(ctrl, e.getText(), node!); + } + } + + for (const document of vscode.workspace.textDocuments) { + updateNodeForDocument(document); + } + + context.subscriptions.push( + ctrl, + fileChangedEmitter.event(({ uri, removed }) => { + if (!removed) { + const node = getOrCreateFile(ctrl, uri); + if (node) { + ctrl.invalidateTestResults(); + } + } + }), + vscode.workspace.onDidOpenTextDocument(updateNodeForDocument), + vscode.workspace.onDidChangeTextDocument(e => updateNodeForDocument(e.document)), + registerSnapshotUpdate(ctrl), + new FailingDeepStrictEqualAssertFixer() + ); +} + +export function deactivate() { + // no-op +} + +function getOrCreateFile( + controller: vscode.TestController, + uri: vscode.Uri +): vscode.TestItem | undefined { + const folder = getWorkspaceFolderForTestFile(uri); + if (!folder) { + return undefined; + } + + const data = new TestFile(uri, folder); + const existing = controller.items.get(data.getId()); + if (existing) { + return existing; + } + + const file = controller.createTestItem(data.getId(), data.getLabel(), uri); + controller.items.add(file); + file.canResolveChildren = true; + itemData.set(file, data); + + return file; +} + +function gatherTestItems(collection: vscode.TestItemCollection) { + const items: vscode.TestItem[] = []; + collection.forEach(item => items.push(item)); + return items; +} + +async function startWatchingWorkspace( + controller: vscode.TestController, + fileChangedEmitter: vscode.EventEmitter +) { + const workspaceFolder = await guessWorkspaceFolder(); + if (!workspaceFolder) { + return new vscode.Disposable(() => undefined); + } + + const pattern = new vscode.RelativePattern(workspaceFolder, TEST_FILE_PATTERN); + const watcher = vscode.workspace.createFileSystemWatcher(pattern); + + watcher.onDidCreate(uri => { + getOrCreateFile(controller, uri); + fileChangedEmitter.fire({ removed: false, uri }); + }); + watcher.onDidChange(uri => fileChangedEmitter.fire({ removed: false, uri })); + watcher.onDidDelete(uri => { + fileChangedEmitter.fire({ removed: true, uri }); + clearFileDiagnostics(uri); + controller.items.delete(uri.toString()); + }); + + for (const file of await vscode.workspace.findFiles(pattern)) { + getOrCreateFile(controller, file); + } + + return watcher; +} + +async function getPendingTestMap(ctrl: vscode.TestController, tests: Iterable) { + const queue = [tests]; + const titleMap = new Map(); + while (queue.length) { + for (const item of queue.pop()!) { + const data = itemData.get(item); + if (data instanceof TestFile) { + if (!data.hasBeenRead) { + await data.updateFromDisk(ctrl, item); + } + queue.push(gatherTestItems(item.children)); + } else if (data instanceof TestCase) { + titleMap.set(data.fullName, item); + } else { + queue.push(gatherTestItems(item.children)); + } + } + } + + return titleMap; +} diff --git a/.vscode/extensions/vscode-selfhost-test-provider/src/failingDeepStrictEqualAssertFixer.ts b/.vscode/extensions/vscode-selfhost-test-provider/src/failingDeepStrictEqualAssertFixer.ts new file mode 100644 index 0000000000000..c6236c1840284 --- /dev/null +++ b/.vscode/extensions/vscode-selfhost-test-provider/src/failingDeepStrictEqualAssertFixer.ts @@ -0,0 +1,255 @@ +/*--------------------------------------------------------- + * Copyright (C) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------*/ + +import * as ts from 'typescript'; +import { + commands, + Disposable, + languages, + Position, + Range, + TestMessage, + TestResultSnapshot, + TestRunResult, + tests, + TextDocument, + Uri, + workspace, + WorkspaceEdit, +} from 'vscode'; +import { memoizeLast } from './memoize'; +import { getTestMessageMetadata } from './metadata'; + +const enum Constants { + FixCommandId = 'selfhost-test.fix-test', +} + +export class FailingDeepStrictEqualAssertFixer { + private disposables: Disposable[] = []; + + constructor() { + this.disposables.push( + commands.registerCommand(Constants.FixCommandId, async (uri: Uri, position: Position) => { + const document = await workspace.openTextDocument(uri); + + const failingAssertion = detectFailingDeepStrictEqualAssertion(document, position); + if (!failingAssertion) { + return; + } + + const expectedValueNode = failingAssertion.assertion.expectedValue; + if (!expectedValueNode) { + return; + } + + const start = document.positionAt(expectedValueNode.getStart()); + const end = document.positionAt(expectedValueNode.getEnd()); + + const edit = new WorkspaceEdit(); + edit.replace(uri, new Range(start, end), formatJsonValue(failingAssertion.actualJSONValue)); + await workspace.applyEdit(edit); + }) + ); + + this.disposables.push( + languages.registerCodeActionsProvider('typescript', { + provideCodeActions: (document, range) => { + const failingAssertion = detectFailingDeepStrictEqualAssertion(document, range.start); + if (!failingAssertion) { + return undefined; + } + + return [ + { + title: 'Fix Expected Value', + command: Constants.FixCommandId, + arguments: [document.uri, range.start], + }, + ]; + }, + }) + ); + + tests.testResults; + } + + dispose() { + for (const d of this.disposables) { + d.dispose(); + } + } +} + +const identifierLikeRe = /^[$a-z_][a-z0-9_$]*$/i; + +const tsPrinter = ts.createPrinter({ newLine: ts.NewLineKind.LineFeed }); + +const formatJsonValue = (value: unknown) => { + if (typeof value !== 'object') { + return JSON.stringify(value); + } + + const src = ts.createSourceFile('', `(${JSON.stringify(value)})`, ts.ScriptTarget.ES5, true); + const outerExpression = src.statements[0] as ts.ExpressionStatement; + const parenExpression = outerExpression.expression as ts.ParenthesizedExpression; + + const unquoted = ts.transform(parenExpression, [ + context => (node: ts.Node) => { + const visitor = (node: ts.Node): ts.Node => + ts.isPropertyAssignment(node) && + ts.isStringLiteralLike(node.name) && + identifierLikeRe.test(node.name.text) + ? ts.factory.createPropertyAssignment( + ts.factory.createIdentifier(node.name.text), + ts.visitNode(node.initializer, visitor) as ts.Expression + ) + : ts.isStringLiteralLike(node) && node.text === '[undefined]' + ? ts.factory.createIdentifier('undefined') + : ts.visitEachChild(node, visitor, context); + + return ts.visitNode(node, visitor); + }, + ]); + + return tsPrinter.printNode(ts.EmitHint.Expression, unquoted.transformed[0], src); +}; + +/** Parses the source file, memoizing the last document so cursor moves are efficient */ +const parseSourceFile = memoizeLast((text: string) => + ts.createSourceFile('', text, ts.ScriptTarget.ES5, true) +); + +const assertionFailureMessageRe = /^Expected values to be strictly (deep-)?equal:/; + +/** Gets information about the failing assertion at the poisition, if any. */ +function detectFailingDeepStrictEqualAssertion( + document: TextDocument, + position: Position +): { assertion: StrictEqualAssertion; actualJSONValue: unknown } | undefined { + const sf = parseSourceFile(document.getText()); + const offset = document.offsetAt(position); + const assertion = StrictEqualAssertion.atPosition(sf, offset); + if (!assertion) { + return undefined; + } + + const startLine = document.positionAt(assertion.offsetStart).line; + const messages = getAllTestStatusMessagesAt(document.uri, startLine); + const strictDeepEqualMessage = messages.find(m => + assertionFailureMessageRe.test(typeof m.message === 'string' ? m.message : m.message.value) + ); + + if (!strictDeepEqualMessage) { + return undefined; + } + + const metadata = getTestMessageMetadata(strictDeepEqualMessage); + if (!metadata) { + return undefined; + } + + return { + assertion: assertion, + actualJSONValue: metadata.actualValue, + }; +} + +class StrictEqualAssertion { + /** + * Extracts the assertion at the current node, if it is one. + */ + public static fromNode(node: ts.Node): StrictEqualAssertion | undefined { + if (!ts.isCallExpression(node)) { + return undefined; + } + + const expr = node.expression.getText(); + if (expr !== 'assert.deepStrictEqual' && expr !== 'assert.strictEqual') { + return undefined; + } + + return new StrictEqualAssertion(node); + } + + /** + * Gets the equals assertion at the given offset in the file. + */ + public static atPosition(sf: ts.SourceFile, offset: number): StrictEqualAssertion | undefined { + let node = findNodeAt(sf, offset); + + while (node.parent) { + const obj = StrictEqualAssertion.fromNode(node); + if (obj) { + return obj; + } + node = node.parent; + } + + return undefined; + } + + constructor(private readonly expression: ts.CallExpression) {} + + /** Gets the expected value */ + public get expectedValue(): ts.Expression | undefined { + return this.expression.arguments[1]; + } + + /** Gets the position of the assertion expression. */ + public get offsetStart(): number { + return this.expression.getStart(); + } +} + +function findNodeAt(parent: ts.Node, offset: number): ts.Node { + for (const child of parent.getChildren()) { + if (child.getStart() <= offset && offset <= child.getEnd()) { + return findNodeAt(child, offset); + } + } + return parent; +} + +function getAllTestStatusMessagesAt(uri: Uri, lineNumber: number): TestMessage[] { + if (tests.testResults.length === 0) { + return []; + } + + const run = tests.testResults[0]; + const snapshots = getTestResultsWithUri(run, uri); + const result: TestMessage[] = []; + + for (const snapshot of snapshots) { + for (const m of snapshot.taskStates[0].messages) { + if ( + m.location && + m.location.range.start.line <= lineNumber && + lineNumber <= m.location.range.end.line + ) { + result.push(m); + } + } + } + + return result; +} + +function getTestResultsWithUri(testRun: TestRunResult, uri: Uri): TestResultSnapshot[] { + const results: TestResultSnapshot[] = []; + + const walk = (r: TestResultSnapshot) => { + for (const c of r.children) { + walk(c); + } + if (r.uri?.toString() === uri.toString()) { + results.push(r); + } + }; + + for (const r of testRun.results) { + walk(r); + } + + return results; +} diff --git a/.vscode/extensions/vscode-selfhost-test-provider/src/memoize.ts b/.vscode/extensions/vscode-selfhost-test-provider/src/memoize.ts new file mode 100644 index 0000000000000..f655f58f62a96 --- /dev/null +++ b/.vscode/extensions/vscode-selfhost-test-provider/src/memoize.ts @@ -0,0 +1,16 @@ +/*--------------------------------------------------------- + * Copyright (C) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------*/ + +export const memoizeLast = (fn: (args: A) => T): ((args: A) => T) => { + let last: { arg: A; result: T } | undefined; + return arg => { + if (last && last.arg === arg) { + return last.result; + } + + const result = fn(arg); + last = { arg, result }; + return result; + }; +}; diff --git a/.vscode/extensions/vscode-selfhost-test-provider/src/metadata.ts b/.vscode/extensions/vscode-selfhost-test-provider/src/metadata.ts new file mode 100644 index 0000000000000..0c03614b8bb8c --- /dev/null +++ b/.vscode/extensions/vscode-selfhost-test-provider/src/metadata.ts @@ -0,0 +1,60 @@ +/*--------------------------------------------------------- + * Copyright (C) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------*/ +import { TestMessage } from 'vscode'; + +export interface TestMessageMetadata { + expectedValue: unknown; + actualValue: unknown; +} + +const cache = new Array<{ id: string; metadata: TestMessageMetadata }>(); + +let id = 0; + +function getId(): string { + return `msg:${id++}:`; +} + +const regexp = /msg:\d+:/; + +export function attachTestMessageMetadata( + message: TestMessage, + metadata: TestMessageMetadata +): void { + const existingMetadata = getTestMessageMetadata(message); + if (existingMetadata) { + Object.assign(existingMetadata, metadata); + return; + } + + const id = getId(); + + if (typeof message.message === 'string') { + message.message = `${message.message}\n${id}`; + } else { + message.message.appendText(`\n${id}`); + } + + cache.push({ id, metadata }); + while (cache.length > 100) { + cache.shift(); + } +} + +export function getTestMessageMetadata(message: TestMessage): TestMessageMetadata | undefined { + let value: string; + if (typeof message.message === 'string') { + value = message.message; + } else { + value = message.message.value; + } + + const result = regexp.exec(value); + if (!result) { + return undefined; + } + + const id = result[0]; + return cache.find(c => c.id === id)?.metadata; +} diff --git a/.vscode/extensions/vscode-selfhost-test-provider/src/snapshot.ts b/.vscode/extensions/vscode-selfhost-test-provider/src/snapshot.ts new file mode 100644 index 0000000000000..f6936094c2200 --- /dev/null +++ b/.vscode/extensions/vscode-selfhost-test-provider/src/snapshot.ts @@ -0,0 +1,22 @@ +/*--------------------------------------------------------- + * Copyright (C) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------*/ + +import { promises as fs } from 'fs'; +import * as vscode from 'vscode'; + +export const snapshotComment = '\n\n// Snapshot file: '; + +export const registerSnapshotUpdate = (ctrl: vscode.TestController) => + vscode.commands.registerCommand('selfhost-test-provider.updateSnapshot', async args => { + const message: vscode.TestMessage = args.message; + const index = message.expectedOutput?.indexOf(snapshotComment); + if (!message.expectedOutput || !message.actualOutput || !index || index === -1) { + vscode.window.showErrorMessage('Could not find snapshot comment in message'); + return; + } + + const file = message.expectedOutput.slice(index + snapshotComment.length); + await fs.writeFile(file, message.actualOutput); + ctrl.invalidateTestResults(args.test); + }); diff --git a/.vscode/extensions/vscode-selfhost-test-provider/src/sourceUtils.ts b/.vscode/extensions/vscode-selfhost-test-provider/src/sourceUtils.ts new file mode 100644 index 0000000000000..334b39f0aa3d8 --- /dev/null +++ b/.vscode/extensions/vscode-selfhost-test-provider/src/sourceUtils.ts @@ -0,0 +1,67 @@ +/*--------------------------------------------------------- + * Copyright (C) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------*/ + +import * as ts from 'typescript'; +import * as vscode from 'vscode'; +import { TestCase, TestConstruct, TestSuite, VSCodeTest } from './testTree'; + +const suiteNames = new Set(['suite', 'flakySuite']); + +export const enum Action { + Skip, + Recurse, +} + +export const extractTestFromNode = (src: ts.SourceFile, node: ts.Node, parent: VSCodeTest) => { + if (!ts.isCallExpression(node)) { + return Action.Recurse; + } + + let lhs = node.expression; + if (isSkipCall(lhs)) { + return Action.Skip; + } + + if (isPropertyCall(lhs) && lhs.name.text === 'only') { + lhs = lhs.expression; + } + + const name = node.arguments[0]; + const func = node.arguments[1]; + if (!name || !ts.isIdentifier(lhs) || !ts.isStringLiteralLike(name)) { + return Action.Recurse; + } + + if (!func) { + return Action.Recurse; + } + + const start = src.getLineAndCharacterOfPosition(name.pos); + const end = src.getLineAndCharacterOfPosition(func.end); + const range = new vscode.Range( + new vscode.Position(start.line, start.character), + new vscode.Position(end.line, end.character) + ); + + const cparent = parent instanceof TestConstruct ? parent : undefined; + if (lhs.escapedText === 'test') { + return new TestCase(name.text, range, cparent); + } + + if (suiteNames.has(lhs.escapedText.toString())) { + return new TestSuite(name.text, range, cparent); + } + + return Action.Recurse; +}; + +const isPropertyCall = ( + lhs: ts.LeftHandSideExpression +): lhs is ts.PropertyAccessExpression & { expression: ts.Identifier; name: ts.Identifier } => + ts.isPropertyAccessExpression(lhs) && + ts.isIdentifier(lhs.expression) && + ts.isIdentifier(lhs.name); + +const isSkipCall = (lhs: ts.LeftHandSideExpression) => + isPropertyCall(lhs) && suiteNames.has(lhs.expression.text) && lhs.name.text === 'skip'; diff --git a/.vscode/extensions/vscode-selfhost-test-provider/src/streamSplitter.ts b/.vscode/extensions/vscode-selfhost-test-provider/src/streamSplitter.ts new file mode 100644 index 0000000000000..fd28b3772da47 --- /dev/null +++ b/.vscode/extensions/vscode-selfhost-test-provider/src/streamSplitter.ts @@ -0,0 +1,60 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +// DO NOT EDIT DIRECTLY: copied from src/vs/base/node/nodeStreams.ts + +import { Transform } from 'stream'; + +/** + * A Transform stream that splits the input on the "splitter" substring. + * The resulting chunks will contain (and trail with) the splitter match. + * The last chunk when the stream ends will be emitted even if a splitter + * is not encountered. + */ +export class StreamSplitter extends Transform { + private buffer: Buffer | undefined; + private readonly splitter: number; + private readonly spitterLen: number; + + constructor(splitter: string | number | Buffer) { + super(); + if (typeof splitter === 'number') { + this.splitter = splitter; + this.spitterLen = 1; + } else { + throw new Error('not implemented here'); + } + } + + override _transform(chunk: Buffer, _encoding: string, callback: (error?: Error | null, data?: any) => void): void { + if (!this.buffer) { + this.buffer = chunk; + } else { + this.buffer = Buffer.concat([this.buffer, chunk]); + } + + let offset = 0; + while (offset < this.buffer.length) { + const index = this.buffer.indexOf(this.splitter, offset); + if (index === -1) { + break; + } + + this.push(this.buffer.slice(offset, index + this.spitterLen)); + offset = index + this.spitterLen; + } + + this.buffer = offset === this.buffer.length ? undefined : this.buffer.slice(offset); + callback(); + } + + override _flush(callback: (error?: Error | null, data?: any) => void): void { + if (this.buffer) { + this.push(this.buffer); + } + + callback(); + } +} diff --git a/.vscode/extensions/vscode-selfhost-test-provider/src/testOutputScanner.ts b/.vscode/extensions/vscode-selfhost-test-provider/src/testOutputScanner.ts new file mode 100644 index 0000000000000..8b8de4dc8cc41 --- /dev/null +++ b/.vscode/extensions/vscode-selfhost-test-provider/src/testOutputScanner.ts @@ -0,0 +1,550 @@ +/*--------------------------------------------------------- + * Copyright (C) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------*/ + +import { + GREATEST_LOWER_BOUND, + LEAST_UPPER_BOUND, + originalPositionFor, + TraceMap, +} from '@jridgewell/trace-mapping'; +import * as styles from 'ansi-styles'; +import { ChildProcessWithoutNullStreams } from 'child_process'; +import * as vscode from 'vscode'; +import { coverageContext } from './coverageProvider'; +import { attachTestMessageMetadata } from './metadata'; +import { snapshotComment } from './snapshot'; +import { getContentFromFilesystem } from './testTree'; +import { StreamSplitter } from './streamSplitter'; + +export const enum MochaEvent { + Start = 'start', + TestStart = 'testStart', + Pass = 'pass', + Fail = 'fail', + End = 'end', +} + +export interface IStartEvent { + total: number; +} + +export interface ITestStartEvent { + title: string; + fullTitle: string; + file: string; + currentRetry: number; + speed: string; +} + +export interface IPassEvent extends ITestStartEvent { + duration: number; +} + +export interface IFailEvent extends IPassEvent { + err: string; + stack: string | null; + expected?: string; + actual?: string; + expectedJSON?: unknown; + actualJSON?: unknown; + snapshotPath?: string; +} + +export interface IEndEvent { + suites: number; + tests: number; + passes: number; + pending: number; + failures: number; + start: string /* ISO date */; + end: string /* ISO date */; +} + +export type MochaEventTuple = + | [MochaEvent.Start, IStartEvent] + | [MochaEvent.TestStart, ITestStartEvent] + | [MochaEvent.Pass, IPassEvent] + | [MochaEvent.Fail, IFailEvent] + | [MochaEvent.End, IEndEvent]; + +const LF = '\n'.charCodeAt(0); + +export class TestOutputScanner implements vscode.Disposable { + protected mochaEventEmitter = new vscode.EventEmitter(); + protected outputEventEmitter = new vscode.EventEmitter(); + protected onExitEmitter = new vscode.EventEmitter(); + + /** + * Fired when a mocha event comes in. + */ + public readonly onMochaEvent = this.mochaEventEmitter.event; + + /** + * Fired when other output from the process comes in. + */ + public readonly onOtherOutput = this.outputEventEmitter.event; + + /** + * Fired when the process encounters an error, or exits. + */ + public readonly onRunnerExit = this.onExitEmitter.event; + + constructor(private readonly process: ChildProcessWithoutNullStreams, private args?: string[]) { + process.stdout.pipe(new StreamSplitter(LF)).on('data', this.processData); + process.stderr.pipe(new StreamSplitter(LF)).on('data', this.processData); + process.on('error', e => this.onExitEmitter.fire(e.message)); + process.on('exit', code => + this.onExitEmitter.fire(code ? `Test process exited with code ${code}` : undefined) + ); + } + + /** + * @override + */ + public dispose() { + try { + this.process.kill(); + } catch { + // ignored + } + } + + protected readonly processData = (data: string) => { + if (this.args) { + this.outputEventEmitter.fire(`./scripts/test ${this.args.join(' ')}`); + this.args = undefined; + } + + try { + const parsed = JSON.parse(data.trim()) as unknown; + if (parsed instanceof Array && parsed.length === 2 && typeof parsed[0] === 'string') { + this.mochaEventEmitter.fire(parsed as MochaEventTuple); + } else { + this.outputEventEmitter.fire(data); + } + } catch { + this.outputEventEmitter.fire(data); + } + }; +} + +type QueuedOutput = string | [string, vscode.Location | undefined, vscode.TestItem | undefined]; + +export async function scanTestOutput( + tests: Map, + task: vscode.TestRun, + scanner: TestOutputScanner, + coverageDir: string | undefined, + cancellation: vscode.CancellationToken +): Promise { + const exitBlockers: Set> = new Set(); + const skippedTests = new Set(tests.values()); + const store = new SourceMapStore(); + let outputQueue = Promise.resolve(); + const enqueueOutput = (fn: QueuedOutput | (() => Promise)) => { + exitBlockers.delete(outputQueue); + outputQueue = outputQueue.finally(async () => { + const r = typeof fn === 'function' ? await fn() : fn; + typeof r === 'string' ? task.appendOutput(r) : task.appendOutput(...r); + }); + exitBlockers.add(outputQueue); + return outputQueue; + }; + const enqueueExitBlocker = (prom: Promise): Promise => { + exitBlockers.add(prom); + prom.finally(() => exitBlockers.delete(prom)); + return prom; + }; + + let lastTest: vscode.TestItem | undefined; + let ranAnyTest = false; + + try { + if (cancellation.isCancellationRequested) { + return; + } + + await new Promise(resolve => { + cancellation.onCancellationRequested(() => { + resolve(); + }); + + let currentTest: vscode.TestItem | undefined; + + scanner.onRunnerExit(err => { + if (err) { + enqueueOutput(err + crlf); + } + resolve(); + }); + + scanner.onOtherOutput(str => { + const match = spdlogRe.exec(str); + if (!match) { + enqueueOutput(str + crlf); + return; + } + + const logLocation = store.getSourceLocation(match[2], Number(match[3])); + const logContents = replaceAllLocations(store, match[1]); + const test = currentTest; + + enqueueOutput(() => + Promise.all([logLocation, logContents]).then(([location, contents]) => [ + contents + crlf, + location, + test, + ]) + ); + }); + + scanner.onMochaEvent(evt => { + switch (evt[0]) { + case MochaEvent.Start: + break; // no-op + case MochaEvent.TestStart: + currentTest = tests.get(evt[1].fullTitle); + if (!currentTest) { + console.warn(`Could not find test ${evt[1].fullTitle}`); + return; + } + skippedTests.delete(currentTest); + task.started(currentTest); + ranAnyTest = true; + break; + case MochaEvent.Pass: + { + const title = evt[1].fullTitle; + const tcase = tests.get(title); + enqueueOutput(` ${styles.green.open}√${styles.green.close} ${title}\r\n`); + if (tcase) { + lastTest = tcase; + task.passed(tcase, evt[1].duration); + tests.delete(title); + } + } + break; + case MochaEvent.Fail: + { + const { + err, + stack, + duration, + expected, + expectedJSON, + actual, + actualJSON, + snapshotPath, + fullTitle: id, + } = evt[1]; + let tcase = tests.get(id); + // report failures on hook to the last-seen test, or first test if none run yet + if (!tcase && (id.includes('hook for') || id.includes('hook in'))) { + tcase = lastTest ?? tests.values().next().value; + } + + enqueueOutput(`${styles.red.open} x ${id}${styles.red.close}\r\n`); + const rawErr = stack || err; + const locationsReplaced = replaceAllLocations(store, forceCRLF(rawErr)); + if (rawErr) { + enqueueOutput(async () => [await locationsReplaced, undefined, tcase]); + } + + if (!tcase) { + return; + } + + tests.delete(id); + + const hasDiff = + actual !== undefined && + expected !== undefined && + (expected !== '[undefined]' || actual !== '[undefined]'); + const testFirstLine = + tcase.range && + new vscode.Location( + tcase.uri!, + new vscode.Range( + tcase.range.start, + new vscode.Position(tcase.range.start.line, 100) + ) + ); + + enqueueExitBlocker( + (async () => { + const location = await tryDeriveStackLocation(store, rawErr, tcase!); + let message: vscode.TestMessage; + + if (hasDiff) { + message = new vscode.TestMessage(tryMakeMarkdown(err)); + message.actualOutput = outputToString(actual); + message.expectedOutput = outputToString(expected); + if (snapshotPath) { + message.contextValue = 'isSelfhostSnapshotMessage'; + message.expectedOutput += snapshotComment + snapshotPath; + } + + attachTestMessageMetadata(message, { + expectedValue: expectedJSON, + actualValue: actualJSON, + }); + } else { + message = new vscode.TestMessage( + stack ? await sourcemapStack(store, stack) : await locationsReplaced + ); + } + + message.location = location ?? testFirstLine; + task.failed(tcase!, message, duration); + })() + ); + } + break; + case MochaEvent.End: + // no-op, we wait until the process exits to ensure coverage is written out + break; + } + }); + }); + + await Promise.all([...exitBlockers]); + + if (coverageDir) { + try { + await coverageContext.apply(task, coverageDir, { + mapFileUri: uri => store.getSourceFile(uri.toString()), + mapLocation: (uri, position) => + store.getSourceLocation(uri.toString(), position.line, position.character), + }); + } catch (e) { + const msg = `Error loading coverage:\n\n${e}\n`; + task.appendOutput(msg.replace(/\n/g, crlf)); + } + } + + // no tests? Possible crash, show output: + if (!ranAnyTest) { + await vscode.commands.executeCommand('testing.showMostRecentOutput'); + } + } catch (e) { + task.appendOutput((e as Error).stack || (e as Error).message); + } finally { + scanner.dispose(); + for (const test of skippedTests) { + task.skipped(test); + } + task.end(); + } +} + +const spdlogRe = /"(.+)", source: (file:\/\/\/.*?)+ \(([0-9]+)\)/; +const crlf = '\r\n'; + +const forceCRLF = (str: string) => str.replace(/(? { + locationRe.lastIndex = 0; + + const replacements = await Promise.all( + [...str.matchAll(locationRe)].map(async match => { + const location = await deriveSourceLocation(store, match); + if (!location) { + return; + } + return { + from: match[0], + to: location?.uri.with({ + fragment: `L${location.range.start.line + 1}:${location.range.start.character + 1}`, + }), + }; + }) + ); + + for (const replacement of replacements) { + if (replacement) { + str = str.replace(replacement.from, replacement.to.toString(true)); + } + } + + return str; +}; + +const outputToString = (output: unknown) => + typeof output === 'object' ? JSON.stringify(output, null, 2) : String(output); + +const tryMakeMarkdown = (message: string) => { + const lines = message.split('\n'); + const start = lines.findIndex(l => l.includes('+ actual')); + if (start === -1) { + return message; + } + + lines.splice(start, 1, '```diff'); + lines.push('```'); + return new vscode.MarkdownString(lines.join('\n')); +}; + +const inlineSourcemapRe = /^\/\/# sourceMappingURL=data:application\/json;base64,(.+)/m; +const sourceMapBiases = [GREATEST_LOWER_BOUND, LEAST_UPPER_BOUND] as const; + +export class SourceMapStore { + private readonly cache = new Map>(); + + async getSourceLocation(fileUri: string, line: number, col = 1) { + const sourceMap = await this.loadSourceMap(fileUri); + if (!sourceMap) { + return undefined; + } + + for (const bias of sourceMapBiases) { + const position = originalPositionFor(sourceMap, { column: col, line: line + 1, bias }); + if (position.line !== null && position.column !== null && position.source !== null) { + return new vscode.Location( + this.completeSourceMapUrl(sourceMap, position.source), + new vscode.Position(position.line - 1, position.column) + ); + } + } + + return undefined; + } + + async getSourceFile(compiledUri: string) { + const sourceMap = await this.loadSourceMap(compiledUri); + if (!sourceMap) { + return undefined; + } + + if (sourceMap.sources[0]) { + return this.completeSourceMapUrl(sourceMap, sourceMap.sources[0]); + } + + for (const bias of sourceMapBiases) { + const position = originalPositionFor(sourceMap, { column: 0, line: 1, bias }); + if (position.source !== null) { + return this.completeSourceMapUrl(sourceMap, position.source); + } + } + + return undefined; + } + + private completeSourceMapUrl(sm: TraceMap, source: string) { + if (sm.sourceRoot) { + try { + return vscode.Uri.parse(new URL(source, sm.sourceRoot).toString()); + } catch { + // ignored + } + } + + return vscode.Uri.parse(source); + } + + private loadSourceMap(fileUri: string) { + const existing = this.cache.get(fileUri); + if (existing) { + return existing; + } + + const promise = (async () => { + try { + const contents = await getContentFromFilesystem(vscode.Uri.parse(fileUri)); + const sourcemapMatch = inlineSourcemapRe.exec(contents); + if (!sourcemapMatch) { + return; + } + + const decoded = Buffer.from(sourcemapMatch[1], 'base64').toString(); + return new TraceMap(decoded, fileUri); + } catch (e) { + console.warn(`Error parsing sourcemap for ${fileUri}: ${(e as Error).stack}`); + return; + } + })(); + + this.cache.set(fileUri, promise); + return promise; + } +} + +const locationRe = /(file:\/{3}.+):([0-9]+):([0-9]+)/g; + +async function replaceAllLocations(store: SourceMapStore, str: string) { + const output: (string | Promise)[] = []; + let lastIndex = 0; + + for (const match of str.matchAll(locationRe)) { + const locationPromise = deriveSourceLocation(store, match); + const startIndex = match.index || 0; + const endIndex = startIndex + match[0].length; + + if (startIndex > lastIndex) { + output.push(str.substring(lastIndex, startIndex)); + } + + output.push( + locationPromise.then(location => + location + ? `${location.uri}:${location.range.start.line + 1}:${location.range.start.character + 1}` + : match[0] + ) + ); + + lastIndex = endIndex; + } + + // Preserve the remaining string after the last match + if (lastIndex < str.length) { + output.push(str.substring(lastIndex)); + } + + const values = await Promise.all(output); + return values.join(''); +} + +async function tryDeriveStackLocation( + store: SourceMapStore, + stack: string, + tcase: vscode.TestItem +) { + locationRe.lastIndex = 0; + + return new Promise(resolve => { + const matches = [...stack.matchAll(locationRe)]; + let todo = matches.length; + if (todo === 0) { + return resolve(undefined); + } + + let best: undefined | { location: vscode.Location; i: number; score: number }; + for (const [i, match] of matches.entries()) { + deriveSourceLocation(store, match) + .catch(() => undefined) + .then(location => { + if (location) { + let score = 0; + if (tcase.uri && tcase.uri.toString() === location.uri.toString()) { + score = 1; + if (tcase.range && tcase.range.contains(location?.range)) { + score = 2; + } + } + if (!best || score > best.score || (score === best.score && i < best.i)) { + best = { location, i, score }; + } + } + + if (!--todo) { + resolve(best?.location); + } + }); + } + }); +} + +async function deriveSourceLocation(store: SourceMapStore, parts: RegExpMatchArray) { + const [, fileUri, line, col] = parts; + return store.getSourceLocation(fileUri, Number(line), Number(col)); +} diff --git a/.vscode/extensions/vscode-selfhost-test-provider/src/testTree.ts b/.vscode/extensions/vscode-selfhost-test-provider/src/testTree.ts new file mode 100644 index 0000000000000..453740535a3a9 --- /dev/null +++ b/.vscode/extensions/vscode-selfhost-test-provider/src/testTree.ts @@ -0,0 +1,175 @@ +/*--------------------------------------------------------- + * Copyright (C) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------*/ + +import { join, relative } from 'path'; +import * as ts from 'typescript'; +import { TextDecoder } from 'util'; +import * as vscode from 'vscode'; +import { Action, extractTestFromNode } from './sourceUtils'; + +const textDecoder = new TextDecoder('utf-8'); +const diagnosticCollection = vscode.languages.createDiagnosticCollection('selfhostTestProvider'); + +type ContentGetter = (uri: vscode.Uri) => Promise; + +export const itemData = new WeakMap(); + +export const clearFileDiagnostics = (uri: vscode.Uri) => diagnosticCollection.delete(uri); + +/** + * Tries to guess which workspace folder VS Code is in. + */ +export const guessWorkspaceFolder = async () => { + if (!vscode.workspace.workspaceFolders) { + return undefined; + } + + if (vscode.workspace.workspaceFolders.length < 2) { + return vscode.workspace.workspaceFolders[0]; + } + + for (const folder of vscode.workspace.workspaceFolders) { + try { + await vscode.workspace.fs.stat(vscode.Uri.joinPath(folder.uri, 'src/vs/loader.js')); + return folder; + } catch { + // ignored + } + } + + return undefined; +}; + +export const getContentFromFilesystem: ContentGetter = async uri => { + try { + const rawContent = await vscode.workspace.fs.readFile(uri); + return textDecoder.decode(rawContent); + } catch (e) { + console.warn(`Error providing tests for ${uri.fsPath}`, e); + return ''; + } +}; + +export class TestFile { + public hasBeenRead = false; + + constructor( + public readonly uri: vscode.Uri, + public readonly workspaceFolder: vscode.WorkspaceFolder + ) {} + + public getId() { + return this.uri.toString().toLowerCase(); + } + + public getLabel() { + return relative(join(this.workspaceFolder.uri.fsPath, 'src'), this.uri.fsPath); + } + + public async updateFromDisk(controller: vscode.TestController, item: vscode.TestItem) { + try { + const content = await getContentFromFilesystem(item.uri!); + item.error = undefined; + this.updateFromContents(controller, content, item); + } catch (e) { + item.error = (e as Error).stack; + } + } + + /** + * Refreshes all tests in this file, `sourceReader` provided by the root. + */ + public updateFromContents( + controller: vscode.TestController, + content: string, + file: vscode.TestItem + ) { + try { + const diagnostics: vscode.Diagnostic[] = []; + const ast = ts.createSourceFile( + this.uri.path.split('/').pop()!, + content, + ts.ScriptTarget.ESNext, + false, + ts.ScriptKind.TS + ); + + const parents: { item: vscode.TestItem; children: vscode.TestItem[] }[] = [ + { item: file, children: [] }, + ]; + const traverse = (node: ts.Node) => { + const parent = parents[parents.length - 1]; + const childData = extractTestFromNode(ast, node, itemData.get(parent.item)!); + if (childData === Action.Skip) { + return; + } + + if (childData === Action.Recurse) { + ts.forEachChild(node, traverse); + return; + } + + const id = `${file.uri}/${childData.fullName}`.toLowerCase(); + + // Skip duplicated tests. They won't run correctly with the way + // mocha reports them, and will error if we try to insert them. + const existing = parent.children.find(c => c.id === id); + if (existing) { + const diagnostic = new vscode.Diagnostic( + childData.range, + 'Duplicate tests cannot be run individually and will not be reported correctly by the test framework. Please rename them.', + vscode.DiagnosticSeverity.Warning + ); + + diagnostic.relatedInformation = [ + new vscode.DiagnosticRelatedInformation( + new vscode.Location(existing.uri!, existing.range!), + 'First declared here' + ), + ]; + + diagnostics.push(diagnostic); + return; + } + + const item = controller.createTestItem(id, childData.name, file.uri); + itemData.set(item, childData); + item.range = childData.range; + parent.children.push(item); + + if (childData instanceof TestSuite) { + parents.push({ item: item, children: [] }); + ts.forEachChild(node, traverse); + item.children.replace(parents.pop()!.children); + } + }; + + ts.forEachChild(ast, traverse); + file.error = undefined; + file.children.replace(parents[0].children); + diagnosticCollection.set(this.uri, diagnostics.length ? diagnostics : undefined); + this.hasBeenRead = true; + } catch (e) { + file.error = String((e as Error).stack || (e as Error).message); + } + } +} + +export abstract class TestConstruct { + public fullName: string; + + constructor( + public readonly name: string, + public readonly range: vscode.Range, + parent?: TestConstruct + ) { + this.fullName = parent ? `${parent.fullName} ${name}` : name; + } +} + +export class TestSuite extends TestConstruct {} + +export class TestCase extends TestConstruct {} + +export type VSCodeTest = TestFile | TestSuite | TestCase; diff --git a/.vscode/extensions/vscode-selfhost-test-provider/src/vscodeTestRunner.ts b/.vscode/extensions/vscode-selfhost-test-provider/src/vscodeTestRunner.ts new file mode 100644 index 0000000000000..3870be367ed2a --- /dev/null +++ b/.vscode/extensions/vscode-selfhost-test-provider/src/vscodeTestRunner.ts @@ -0,0 +1,306 @@ +/*--------------------------------------------------------- + * Copyright (C) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------*/ + +import { spawn } from 'child_process'; +import { promises as fs } from 'fs'; +import { AddressInfo, createServer } from 'net'; +import * as path from 'path'; +import * as vscode from 'vscode'; +import { TestOutputScanner } from './testOutputScanner'; +import { TestCase, TestFile, TestSuite, itemData } from './testTree'; + +/** + * From MDN + * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions#Escaping + */ +const escapeRe = (s: string) => s.replace(/[.*+\-?^${}()|[\]\\]/g, '\\$&'); + +const TEST_ELECTRON_SCRIPT_PATH = 'test/unit/electron/index.js'; +const TEST_BROWSER_SCRIPT_PATH = 'test/unit/browser/index.js'; + +const ATTACH_CONFIG_NAME = 'Attach to VS Code'; +const DEBUG_TYPE = 'pwa-chrome'; + +export abstract class VSCodeTestRunner { + constructor(protected readonly repoLocation: vscode.WorkspaceFolder) { } + + public async run(baseArgs: ReadonlyArray, filter?: ReadonlyArray) { + const args = this.prepareArguments(baseArgs, filter); + const cp = spawn(await this.binaryPath(), args, { + cwd: this.repoLocation.uri.fsPath, + stdio: 'pipe', + env: this.getEnvironment(), + }); + + return new TestOutputScanner(cp, args); + } + + public async debug(baseArgs: ReadonlyArray, filter?: ReadonlyArray) { + const port = await this.findOpenPort(); + const baseConfiguration = vscode.workspace + .getConfiguration('launch', this.repoLocation) + .get('configurations', []) + .find(c => c.name === ATTACH_CONFIG_NAME); + + if (!baseConfiguration) { + throw new Error(`Could not find launch configuration ${ATTACH_CONFIG_NAME}`); + } + + const server = this.createWaitServer(); + const args = [ + ...this.prepareArguments(baseArgs, filter), + `--remote-debugging-port=${port}`, + // for breakpoint freeze: https://github.com/microsoft/vscode/issues/122225#issuecomment-885377304 + '--js-flags="--regexp_interpret_all"', + // for general runtime freezes: https://github.com/microsoft/vscode/issues/127861#issuecomment-904144910 + '--disable-features=CalculateNativeWinOcclusion', + '--timeout=0', + `--waitServer=${server.port}`, + ]; + + const cp = spawn(await this.binaryPath(), args, { + cwd: this.repoLocation.uri.fsPath, + stdio: 'pipe', + env: this.getEnvironment(), + }); + + // Register a descriptor factory that signals the server when any + // breakpoint set requests on the debugee have been completed. + const factory = vscode.debug.registerDebugAdapterTrackerFactory(DEBUG_TYPE, { + createDebugAdapterTracker(session) { + if (!session.parentSession || session.parentSession !== rootSession) { + return; + } + + let initRequestId: number | undefined; + + return { + onDidSendMessage(message) { + if (message.type === 'response' && message.request_seq === initRequestId) { + server.ready(); + } + }, + onWillReceiveMessage(message) { + if (initRequestId !== undefined) { + return; + } + + if (message.command === 'launch' || message.command === 'attach') { + initRequestId = message.seq; + } + }, + }; + }, + }); + + vscode.debug.startDebugging(this.repoLocation, { ...baseConfiguration, port }); + + let exited = false; + let rootSession: vscode.DebugSession | undefined; + cp.once('exit', () => { + exited = true; + server.dispose(); + listener.dispose(); + factory.dispose(); + + if (rootSession) { + vscode.debug.stopDebugging(rootSession); + } + }); + + const listener = vscode.debug.onDidStartDebugSession(s => { + if (s.name === ATTACH_CONFIG_NAME && !rootSession) { + if (exited) { + vscode.debug.stopDebugging(rootSession); + } else { + rootSession = s; + } + } + }); + + return new TestOutputScanner(cp, args); + } + + private findOpenPort(): Promise { + return new Promise((resolve, reject) => { + const server = createServer(); + server.listen(0, () => { + const address = server.address() as AddressInfo; + const port = address.port; + server.close(() => { + resolve(port); + }); + }); + server.on('error', (error: Error) => { + reject(error); + }); + }); + } + + protected getEnvironment(): NodeJS.ProcessEnv { + return { + ...process.env, + ELECTRON_RUN_AS_NODE: undefined, + ELECTRON_ENABLE_LOGGING: '1', + }; + } + + private prepareArguments( + baseArgs: ReadonlyArray, + filter?: ReadonlyArray + ) { + const args = [...this.getDefaultArgs(), ...baseArgs, '--reporter', 'full-json-stream']; + if (!filter) { + return args; + } + + const grepRe: string[] = []; + const runPaths = new Set(); + const addTestFileRunPath = (data: TestFile) => + runPaths.add( + path.relative(data.workspaceFolder.uri.fsPath, data.uri.fsPath).replace(/\\/g, '/') + ); + + for (const test of filter) { + const data = itemData.get(test); + if (data instanceof TestCase || data instanceof TestSuite) { + grepRe.push(escapeRe(data.fullName) + (data instanceof TestCase ? '$' : ' ')); + for (let p = test.parent; p; p = p.parent) { + const parentData = itemData.get(p); + if (parentData instanceof TestFile) { + addTestFileRunPath(parentData); + } + } + } else if (data instanceof TestFile) { + addTestFileRunPath(data); + } + } + + if (grepRe.length) { + args.push('--grep', `/^(${grepRe.join('|')})/`); + } + + if (runPaths.size) { + args.push(...[...runPaths].flatMap(p => ['--run', p])); + } + + return args; + } + + protected abstract getDefaultArgs(): string[]; + + protected abstract binaryPath(): Promise; + + protected async readProductJson() { + const projectJson = await fs.readFile( + path.join(this.repoLocation.uri.fsPath, 'product.json'), + 'utf-8' + ); + try { + return JSON.parse(projectJson); + } catch (e) { + throw new Error(`Error parsing product.json: ${(e as Error).message}`); + } + } + + private createWaitServer() { + const onReady = new vscode.EventEmitter(); + let ready = false; + + const server = createServer(socket => { + if (ready) { + socket.end(); + } else { + onReady.event(() => socket.end()); + } + }); + + server.listen(0); + + return { + port: (server.address() as AddressInfo).port, + ready: () => { + ready = true; + onReady.fire(); + }, + dispose: () => { + server.close(); + }, + }; + } +} + +export class BrowserTestRunner extends VSCodeTestRunner { + /** @override */ + protected binaryPath(): Promise { + return Promise.resolve(process.execPath); + } + + /** @override */ + protected override getEnvironment() { + return { + ...super.getEnvironment(), + ELECTRON_RUN_AS_NODE: '1', + }; + } + + /** @override */ + protected getDefaultArgs() { + return [TEST_BROWSER_SCRIPT_PATH]; + } +} + +export class WindowsTestRunner extends VSCodeTestRunner { + /** @override */ + protected async binaryPath() { + const { nameShort } = await this.readProductJson(); + return path.join(this.repoLocation.uri.fsPath, `.build/electron/${nameShort}.exe`); + } + + /** @override */ + protected getDefaultArgs() { + return [TEST_ELECTRON_SCRIPT_PATH]; + } +} + +export class PosixTestRunner extends VSCodeTestRunner { + /** @override */ + protected async binaryPath() { + const { applicationName } = await this.readProductJson(); + return path.join(this.repoLocation.uri.fsPath, `.build/electron/${applicationName}`); + } + + /** @override */ + protected getDefaultArgs() { + return [TEST_ELECTRON_SCRIPT_PATH]; + } +} + +export class DarwinTestRunner extends PosixTestRunner { + /** @override */ + protected override getDefaultArgs() { + return [ + TEST_ELECTRON_SCRIPT_PATH, + '--no-sandbox', + '--disable-dev-shm-usage', + '--use-gl=swiftshader', + ]; + } + + /** @override */ + protected override async binaryPath() { + const { nameLong } = await this.readProductJson(); + return path.join( + this.repoLocation.uri.fsPath, + `.build/electron/${nameLong}.app/Contents/MacOS/Electron` + ); + } +} + +export const PlatformTestRunner = + process.platform === 'win32' + ? WindowsTestRunner + : process.platform === 'darwin' + ? DarwinTestRunner + : PosixTestRunner; diff --git a/.vscode/extensions/vscode-selfhost-test-provider/tsconfig.json b/.vscode/extensions/vscode-selfhost-test-provider/tsconfig.json new file mode 100644 index 0000000000000..4bc025b62ba71 --- /dev/null +++ b/.vscode/extensions/vscode-selfhost-test-provider/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "../../../extensions/tsconfig.base.json", + "compilerOptions": { + "outDir": "./out", + "types": [ + "node" + ] + }, + "include": [ + "src/**/*", + "../../../src/vscode-dts/vscode.d.ts", + "../../../src/vscode-dts/vscode.proposed.testObserver.d.ts", + ] +} diff --git a/.vscode/extensions/vscode-selfhost-test-provider/yarn.lock b/.vscode/extensions/vscode-selfhost-test-provider/yarn.lock new file mode 100644 index 0000000000000..bf2295ed7b373 --- /dev/null +++ b/.vscode/extensions/vscode-selfhost-test-provider/yarn.lock @@ -0,0 +1,50 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +"@jridgewell/resolve-uri@^3.1.0": + version "3.1.2" + resolved "https://registry.yarnpkg.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz#7a0ee601f60f99a20c7c7c5ff0c80388c1189bd6" + integrity sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw== + +"@jridgewell/sourcemap-codec@^1.4.14": + version "1.4.15" + resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz#d7c6e6755c78567a951e04ab52ef0fd26de59f32" + integrity sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg== + +"@jridgewell/trace-mapping@^0.3.25": + version "0.3.25" + resolved "https://registry.yarnpkg.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz#15f190e98895f3fc23276ee14bc76b675c2e50f0" + integrity sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ== + dependencies: + "@jridgewell/resolve-uri" "^3.1.0" + "@jridgewell/sourcemap-codec" "^1.4.14" + +"@types/istanbul-lib-coverage@^2.0.6": + version "2.0.6" + resolved "https://registry.yarnpkg.com/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz#7739c232a1fee9b4d3ce8985f314c0c6d33549d7" + integrity sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w== + +"@types/node@18.x": + version "18.19.26" + resolved "https://registry.yarnpkg.com/@types/node/-/node-18.19.26.tgz#18991279d0a0e53675285e8cf4a0823766349729" + integrity sha512-+wiMJsIwLOYCvUqSdKTrfkS8mpTp+MPINe6+Np4TAGFWWRWiBQ5kSq9nZGCSPkzx9mvT+uEukzpX4MOSCydcvw== + dependencies: + undici-types "~5.26.4" + +ansi-styles@^5.2.0: + version "5.2.0" + resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-5.2.0.tgz#07449690ad45777d1924ac2abb2fc8895dba836b" + integrity sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA== + +istanbul-to-vscode@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/istanbul-to-vscode/-/istanbul-to-vscode-2.0.1.tgz#84994d06e604b68cac7301840f338b1e74eb888b" + integrity sha512-V9Hhr7kX3UvkvkaT1lK3AmCRPkaIAIogQBrduTpNiLTkp1eVsybnJhWiDSVeCQap/3aGeZ2019oIivhX9MNsCQ== + dependencies: + "@types/istanbul-lib-coverage" "^2.0.6" + +undici-types@~5.26.4: + version "5.26.5" + resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-5.26.5.tgz#bcd539893d00b56e964fd2657a4866b221a65617" + integrity sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA== diff --git a/.vscode/notebooks/api.github-issues b/.vscode/notebooks/api.github-issues index 2a6f3ec1bc56c..8e42a117fccf2 100644 --- a/.vscode/notebooks/api.github-issues +++ b/.vscode/notebooks/api.github-issues @@ -7,7 +7,7 @@ { "kind": 2, "language": "github-issues", - "value": "$REPO=repo:microsoft/vscode\n$MILESTONE=milestone:\"February 2024\"" + "value": "$REPO=repo:microsoft/vscode\n$MILESTONE=milestone:\"April 2024\"" }, { "kind": 1, diff --git a/.vscode/notebooks/endgame.github-issues b/.vscode/notebooks/endgame.github-issues index ee1084be56d2c..750e53e4b269c 100644 --- a/.vscode/notebooks/endgame.github-issues +++ b/.vscode/notebooks/endgame.github-issues @@ -7,7 +7,7 @@ { "kind": 2, "language": "github-issues", - "value": "$REPOS=repo:microsoft/lsprotocol repo:microsoft/monaco-editor repo:microsoft/vscode repo:microsoft/vscode-anycode repo:microsoft/vscode-autopep8 repo:microsoft/vscode-black-formatter repo:microsoft/vscode-copilot repo:microsoft/vscode-copilot-release repo:microsoft/vscode-dev repo:microsoft/vscode-dev-chrome-launcher repo:microsoft/vscode-emmet-helper repo:microsoft/vscode-extension-telemetry repo:microsoft/vscode-flake8 repo:microsoft/vscode-github-issue-notebooks repo:microsoft/vscode-hexeditor repo:microsoft/vscode-internalbacklog repo:microsoft/vscode-isort repo:microsoft/vscode-js-debug repo:microsoft/vscode-jupyter repo:microsoft/vscode-jupyter-internal repo:microsoft/vscode-l10n repo:microsoft/vscode-livepreview repo:microsoft/vscode-markdown-languageservice repo:microsoft/vscode-markdown-tm-grammar repo:microsoft/vscode-mypy repo:microsoft/vscode-pull-request-github repo:microsoft/vscode-pylint repo:microsoft/vscode-python repo:microsoft/vscode-python-debugger repo:microsoft/vscode-python-tools-extension-template repo:microsoft/vscode-references-view repo:microsoft/vscode-remote-release repo:microsoft/vscode-remote-repositories-github repo:microsoft/vscode-remote-tunnels repo:microsoft/vscode-remotehub repo:microsoft/vscode-settings-sync-server repo:microsoft/vscode-unpkg repo:microsoft/vscode-vsce\n\n$MILESTONE=milestone:\"February 2024\"" + "value": "$REPOS=repo:microsoft/lsprotocol repo:microsoft/monaco-editor repo:microsoft/vscode repo:microsoft/vscode-anycode repo:microsoft/vscode-autopep8 repo:microsoft/vscode-black-formatter repo:microsoft/vscode-copilot repo:microsoft/vscode-copilot-release repo:microsoft/vscode-dev repo:microsoft/vscode-dev-chrome-launcher repo:microsoft/vscode-emmet-helper repo:microsoft/vscode-extension-telemetry repo:microsoft/vscode-flake8 repo:microsoft/vscode-github-issue-notebooks repo:microsoft/vscode-hexeditor repo:microsoft/vscode-internalbacklog repo:microsoft/vscode-isort repo:microsoft/vscode-js-debug repo:microsoft/vscode-jupyter repo:microsoft/vscode-jupyter-internal repo:microsoft/vscode-l10n repo:microsoft/vscode-livepreview repo:microsoft/vscode-markdown-languageservice repo:microsoft/vscode-markdown-tm-grammar repo:microsoft/vscode-mypy repo:microsoft/vscode-pull-request-github repo:microsoft/vscode-pylint repo:microsoft/vscode-python repo:microsoft/vscode-python-debugger repo:microsoft/vscode-python-tools-extension-template repo:microsoft/vscode-references-view repo:microsoft/vscode-remote-release repo:microsoft/vscode-remote-repositories-github repo:microsoft/vscode-remote-tunnels repo:microsoft/vscode-remotehub repo:microsoft/vscode-settings-sync-server repo:microsoft/vscode-unpkg repo:microsoft/vscode-vsce\n\n$MILESTONE=milestone:\"March 2024\"" }, { "kind": 1, diff --git a/.vscode/notebooks/my-endgame.github-issues b/.vscode/notebooks/my-endgame.github-issues index a286082c73859..ab59f23283f9d 100644 --- a/.vscode/notebooks/my-endgame.github-issues +++ b/.vscode/notebooks/my-endgame.github-issues @@ -7,7 +7,7 @@ { "kind": 2, "language": "github-issues", - "value": "$REPOS=repo:microsoft/lsprotocol repo:microsoft/monaco-editor repo:microsoft/vscode repo:microsoft/vscode-anycode repo:microsoft/vscode-autopep8 repo:microsoft/vscode-black-formatter repo:microsoft/vscode-copilot repo:microsoft/vscode-copilot-release repo:microsoft/vscode-dev repo:microsoft/vscode-dev-chrome-launcher repo:microsoft/vscode-emmet-helper repo:microsoft/vscode-extension-telemetry repo:microsoft/vscode-flake8 repo:microsoft/vscode-github-issue-notebooks repo:microsoft/vscode-hexeditor repo:microsoft/vscode-internalbacklog repo:microsoft/vscode-isort repo:microsoft/vscode-js-debug repo:microsoft/vscode-jupyter repo:microsoft/vscode-jupyter-internal repo:microsoft/vscode-l10n repo:microsoft/vscode-livepreview repo:microsoft/vscode-markdown-languageservice repo:microsoft/vscode-markdown-tm-grammar repo:microsoft/vscode-mypy repo:microsoft/vscode-pull-request-github repo:microsoft/vscode-pylint repo:microsoft/vscode-python repo:microsoft/vscode-python-debugger repo:microsoft/vscode-python-tools-extension-template repo:microsoft/vscode-references-view repo:microsoft/vscode-remote-release repo:microsoft/vscode-remote-repositories-github repo:microsoft/vscode-remote-tunnels repo:microsoft/vscode-remotehub repo:microsoft/vscode-settings-sync-server repo:microsoft/vscode-unpkg repo:microsoft/vscode-vsce\n\n$MILESTONE=milestone:\"February 2024\"\n\n$MINE=assignee:@me" + "value": "$REPOS=repo:microsoft/lsprotocol repo:microsoft/monaco-editor repo:microsoft/vscode repo:microsoft/vscode-anycode repo:microsoft/vscode-autopep8 repo:microsoft/vscode-black-formatter repo:microsoft/vscode-copilot repo:microsoft/vscode-copilot-release repo:microsoft/vscode-dev repo:microsoft/vscode-dev-chrome-launcher repo:microsoft/vscode-emmet-helper repo:microsoft/vscode-extension-telemetry repo:microsoft/vscode-flake8 repo:microsoft/vscode-github-issue-notebooks repo:microsoft/vscode-hexeditor repo:microsoft/vscode-internalbacklog repo:microsoft/vscode-isort repo:microsoft/vscode-js-debug repo:microsoft/vscode-jupyter repo:microsoft/vscode-jupyter-internal repo:microsoft/vscode-l10n repo:microsoft/vscode-livepreview repo:microsoft/vscode-markdown-languageservice repo:microsoft/vscode-markdown-tm-grammar repo:microsoft/vscode-mypy repo:microsoft/vscode-pull-request-github repo:microsoft/vscode-pylint repo:microsoft/vscode-python repo:microsoft/vscode-python-debugger repo:microsoft/vscode-python-tools-extension-template repo:microsoft/vscode-references-view repo:microsoft/vscode-remote-release repo:microsoft/vscode-remote-repositories-github repo:microsoft/vscode-remote-tunnels repo:microsoft/vscode-remotehub repo:microsoft/vscode-settings-sync-server repo:microsoft/vscode-unpkg repo:microsoft/vscode-vsce\n\n$MILESTONE=milestone:\"March 2024\"\n\n$MINE=assignee:@me" }, { "kind": 1, diff --git a/.vscode/notebooks/my-work.github-issues b/.vscode/notebooks/my-work.github-issues index 2a3f9159703b2..3da7aac1fea8c 100644 --- a/.vscode/notebooks/my-work.github-issues +++ b/.vscode/notebooks/my-work.github-issues @@ -7,7 +7,7 @@ { "kind": 2, "language": "github-issues", - "value": "// list of repos we work in\n$REPOS=repo:microsoft/lsprotocol repo:microsoft/monaco-editor repo:microsoft/vscode repo:microsoft/vscode-anycode repo:microsoft/vscode-autopep8 repo:microsoft/vscode-black-formatter repo:microsoft/vscode-copilot repo:microsoft/vscode-copilot-release repo:microsoft/vscode-dev repo:microsoft/vscode-dev-chrome-launcher repo:microsoft/vscode-emmet-helper repo:microsoft/vscode-extension-telemetry repo:microsoft/vscode-flake8 repo:microsoft/vscode-github-issue-notebooks repo:microsoft/vscode-hexeditor repo:microsoft/vscode-internalbacklog repo:microsoft/vscode-isort repo:microsoft/vscode-js-debug repo:microsoft/vscode-jupyter repo:microsoft/vscode-jupyter-internal repo:microsoft/vscode-l10n repo:microsoft/vscode-livepreview repo:microsoft/vscode-markdown-languageservice repo:microsoft/vscode-markdown-tm-grammar repo:microsoft/vscode-mypy repo:microsoft/vscode-pull-request-github repo:microsoft/vscode-pylint repo:microsoft/vscode-python repo:microsoft/vscode-python-debugger repo:microsoft/vscode-python-tools-extension-template repo:microsoft/vscode-references-view repo:microsoft/vscode-remote-release repo:microsoft/vscode-remote-repositories-github repo:microsoft/vscode-remote-tunnels repo:microsoft/vscode-remotehub repo:microsoft/vscode-settings-sync-server repo:microsoft/vscode-unpkg repo:microsoft/vscode-vsce\n\n// current milestone name\n$MILESTONE=milestone:\"February 2024\"\n" + "value": "// list of repos we work in\n$REPOS=repo:microsoft/lsprotocol repo:microsoft/monaco-editor repo:microsoft/vscode repo:microsoft/vscode-anycode repo:microsoft/vscode-autopep8 repo:microsoft/vscode-black-formatter repo:microsoft/vscode-copilot repo:microsoft/vscode-copilot-release repo:microsoft/vscode-dev repo:microsoft/vscode-dev-chrome-launcher repo:microsoft/vscode-emmet-helper repo:microsoft/vscode-extension-telemetry repo:microsoft/vscode-flake8 repo:microsoft/vscode-github-issue-notebooks repo:microsoft/vscode-hexeditor repo:microsoft/vscode-internalbacklog repo:microsoft/vscode-isort repo:microsoft/vscode-js-debug repo:microsoft/vscode-jupyter repo:microsoft/vscode-jupyter-internal repo:microsoft/vscode-l10n repo:microsoft/vscode-livepreview repo:microsoft/vscode-markdown-languageservice repo:microsoft/vscode-markdown-tm-grammar repo:microsoft/vscode-mypy repo:microsoft/vscode-pull-request-github repo:microsoft/vscode-pylint repo:microsoft/vscode-python repo:microsoft/vscode-python-debugger repo:microsoft/vscode-python-tools-extension-template repo:microsoft/vscode-references-view repo:microsoft/vscode-remote-release repo:microsoft/vscode-remote-repositories-github repo:microsoft/vscode-remote-tunnels repo:microsoft/vscode-remotehub repo:microsoft/vscode-settings-sync-server repo:microsoft/vscode-unpkg repo:microsoft/vscode-vsce\n\n// current milestone name\n$MILESTONE=milestone:\"April 2024\"\n" }, { "kind": 1, diff --git a/.vscode/settings.json b/.vscode/settings.json index ba7c0f4dd2b4a..251f8c0617a0b 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -168,8 +168,8 @@ "[github-issues]": { "editor.wordWrap": "on" }, + "extensions.experimental.supportWorkspaceExtensions": true, "css.format.spaceAroundSelectorSeparator": true, "inlineChat.mode": "live", - "testing.defaultGutterClickAction": "contextMenu", "typescript.enablePromptUseWorkspaceTsdk": true, } diff --git a/.yarnrc b/.yarnrc index e7aba7e6b7634..616968bddffca 100644 --- a/.yarnrc +++ b/.yarnrc @@ -1,5 +1,5 @@ disturl "https://electronjs.org/headers" -target "27.3.1" -ms_build_id "26731440" +target "28.2.8" +ms_build_id "27744544" runtime "electron" build_from_source "true" diff --git a/SECURITY.md b/SECURITY.md index 4fa5946a867c6..82db58aa7c8d7 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,34 +1,34 @@ - + ## Security -Microsoft takes the security of our software products and services seriously, which includes all source code repositories managed through our GitHub organizations, which include [Microsoft](https://github.com/Microsoft), [Azure](https://github.com/Azure), [DotNet](https://github.com/dotnet), [AspNet](https://github.com/aspnet), [Xamarin](https://github.com/xamarin), and [our GitHub organizations](https://opensource.microsoft.com/). +Microsoft takes the security of our software products and services seriously, which includes all source code repositories managed through our GitHub organizations, which include [Microsoft](https://github.com/Microsoft), [Azure](https://github.com/Azure), [DotNet](https://github.com/dotnet), [AspNet](https://github.com/aspnet) and [Xamarin](https://github.com/xamarin). -If you believe you have found a security vulnerability in any Microsoft-owned repository that meets [Microsoft's definition of a security vulnerability](https://docs.microsoft.com/en-us/previous-versions/tn-archive/cc751383(v=technet.10)), please report it to us as described below. +If you believe you have found a security vulnerability in any Microsoft-owned repository that meets [Microsoft's definition of a security vulnerability](https://aka.ms/security.md/definition), please report it to us as described below. ## Reporting Security Issues **Please do not report security vulnerabilities through public GitHub issues.** -Instead, please report them to the Microsoft Security Response Center (MSRC) at [https://msrc.microsoft.com/create-report](https://msrc.microsoft.com/create-report). +Instead, please report them to the Microsoft Security Response Center (MSRC) at [https://msrc.microsoft.com/create-report](https://aka.ms/security.md/msrc/create-report). -If you prefer to submit without logging in, send email to [secure@microsoft.com](mailto:secure@microsoft.com). If possible, encrypt your message with our PGP key; please download it from the [Microsoft Security Response Center PGP Key page](https://www.microsoft.com/en-us/msrc/pgp-key-msrc). +If you prefer to submit without logging in, send email to [secure@microsoft.com](mailto:secure@microsoft.com). If possible, encrypt your message with our PGP key; please download it from the [Microsoft Security Response Center PGP Key page](https://aka.ms/security.md/msrc/pgp). You should receive a response within 24 hours. If for some reason you do not, please follow up via email to ensure we received your original message. Additional information can be found at [microsoft.com/msrc](https://www.microsoft.com/msrc). Please include the requested information listed below (as much as you can provide) to help us better understand the nature and scope of the possible issue: -* Type of issue (e.g. buffer overflow, SQL injection, cross-site scripting, etc.) -* Full paths of source file(s) related to the manifestation of the issue -* The location of the affected source code (tag/branch/commit or direct URL) -* Any special configuration required to reproduce the issue -* Step-by-step instructions to reproduce the issue -* Proof-of-concept or exploit code (if possible) -* Impact of the issue, including how an attacker might exploit the issue + * Type of issue (e.g. buffer overflow, SQL injection, cross-site scripting, etc.) + * Full paths of source file(s) related to the manifestation of the issue + * The location of the affected source code (tag/branch/commit or direct URL) + * Any special configuration required to reproduce the issue + * Step-by-step instructions to reproduce the issue + * Proof-of-concept or exploit code (if possible) + * Impact of the issue, including how an attacker might exploit the issue This information will help us triage your report more quickly. -If you are reporting for a bug bounty, more complete reports can contribute to a higher bounty award. Please visit our [Microsoft Bug Bounty Program](https://microsoft.com/msrc/bounty) page for more details about our active programs. +If you are reporting for a bug bounty, more complete reports can contribute to a higher bounty award. Please visit our [Microsoft Bug Bounty Program](https://aka.ms/security.md/msrc/bounty) page for more details about our active programs. ## Preferred Languages @@ -36,6 +36,6 @@ We prefer all communications to be in English. ## Policy -Microsoft follows the principle of [Coordinated Vulnerability Disclosure](https://www.microsoft.com/en-us/msrc/cvd). +Microsoft follows the principle of [Coordinated Vulnerability Disclosure](https://aka.ms/security.md/cvd). diff --git a/ThirdPartyNotices.txt b/ThirdPartyNotices.txt index 14af9ca8b8a6b..5f0de3e33658e 100644 --- a/ThirdPartyNotices.txt +++ b/ThirdPartyNotices.txt @@ -169,7 +169,7 @@ OTHER DEALINGS IN THE SOFTWARE. --------------------------------------------------------- -atom/language-sass 0.62.1 - MIT +atom/language-sass 0.61.4 - MIT https://github.com/atom/language-sass The MIT License (MIT) @@ -517,7 +517,7 @@ to the base-name name of the original file, and an extension of txt, html, or si --------------------------------------------------------- -go-syntax 0.5.1 - MIT +go-syntax 0.6.1 - MIT https://github.com/worlpaker/go-syntax MIT License @@ -777,7 +777,7 @@ SOFTWARE. --------------------------------------------------------- -jeff-hykin/better-shell-syntax 1.6.2 - MIT +jeff-hykin/better-shell-syntax 1.7.1 - MIT https://github.com/jeff-hykin/better-shell-syntax MIT License @@ -833,7 +833,7 @@ SOFTWARE. --------------------------------------------------------- -jlelong/vscode-latex-basics 1.5.4 - MIT +jlelong/vscode-latex-basics 1.6.0 - MIT https://github.com/jlelong/vscode-latex-basics Copyright (c) vscode-latex-basics authors @@ -2463,7 +2463,7 @@ Creative Commons may be contacted at creativecommons.org. --------------------------------------------------------- -vscode-logfile-highlighter 2.15.0 - MIT +vscode-logfile-highlighter 2.17.0 - MIT https://github.com/emilast/vscode-logfile-highlighter The MIT License (MIT) diff --git a/build/.cachesalt b/build/.cachesalt index 89591977f282f..8051d84124eb1 100644 --- a/build/.cachesalt +++ b/build/.cachesalt @@ -1 +1 @@ -2024-02-05T09:34:15.476Z +2024-03-18T08:47:22.277Z diff --git a/build/.moduleignore b/build/.moduleignore index 87dafa92b0f45..e40224556c62d 100644 --- a/build/.moduleignore +++ b/build/.moduleignore @@ -63,10 +63,11 @@ native-watchdog/build/** native-watchdog/src/** !native-watchdog/build/Release/*.node -node-vsce-sign/** -!node-vsce-sign/src/main.js -!node-vsce-sign/package.json -!node-vsce-sign/bin/** +@vscode/vsce-sign/** +!@vscode/vsce-sign/src/main.d.ts +!@vscode/vsce-sign/src/main.js +!@vscode/vsce-sign/package.json +!@vscode/vsce-sign/bin/** windows-foreground-love/binding.gyp windows-foreground-love/build/** diff --git a/build/azure-pipelines/alpine/cli-build-alpine.yml b/build/azure-pipelines/alpine/cli-build-alpine.yml index 5d4e79424d239..a6442dfe29032 100644 --- a/build/azure-pipelines/alpine/cli-build-alpine.yml +++ b/build/azure-pipelines/alpine/cli-build-alpine.yml @@ -33,7 +33,7 @@ steps: workingDirectory: build displayName: Install pipeline build - - template: ../cli/cli-apply-patches.yml + - template: ../cli/cli-apply-patches.yml@self - task: Npm@1 displayName: Download openssl prebuilt @@ -58,7 +58,7 @@ steps: sudo ln -s "/usr/bin/g++" "/usr/bin/musl-g++" || echo "link exists" displayName: Install musl build dependencies - - template: ../cli/install-rust-posix.yml + - template: ../cli/install-rust-posix.yml@self parameters: targets: - ${{ if eq(parameters.VSCODE_BUILD_ALPINE_ARM64, true) }}: @@ -67,7 +67,7 @@ steps: - x86_64-unknown-linux-musl - ${{ if eq(parameters.VSCODE_BUILD_ALPINE_ARM64, true) }}: - - template: ../cli/cli-compile.yml + - template: ../cli/cli-compile.yml@self parameters: VSCODE_CLI_TARGET: aarch64-unknown-linux-musl VSCODE_CLI_ARTIFACT: vscode_cli_alpine_arm64_cli @@ -80,7 +80,7 @@ steps: OPENSSL_STATIC: "1" - ${{ if eq(parameters.VSCODE_BUILD_ALPINE, true) }}: - - template: ../cli/cli-compile.yml + - template: ../cli/cli-compile.yml@self parameters: VSCODE_CLI_TARGET: x86_64-unknown-linux-musl VSCODE_CLI_ARTIFACT: vscode_cli_alpine_x64_cli @@ -92,14 +92,23 @@ steps: OPENSSL_INCLUDE_DIR: $(Build.ArtifactStagingDirectory)/openssl/x64-linux-musl/include OPENSSL_STATIC: "1" - - ${{ if eq(parameters.VSCODE_BUILD_ALPINE_ARM64, true) }}: - - template: ../cli/cli-publish.yml - parameters: - VSCODE_CLI_ARTIFACT: vscode_cli_alpine_arm64_cli - VSCODE_CHECK_ONLY: ${{ parameters.VSCODE_CHECK_ONLY }} + - ${{ if not(parameters.VSCODE_CHECK_ONLY) }}: + - ${{ if eq(parameters.VSCODE_BUILD_ALPINE_ARM64, true) }}: + - task: 1ES.PublishPipelineArtifact@1 + inputs: + targetPath: $(Build.ArtifactStagingDirectory)/vscode_cli_alpine_arm64_cli.tar.gz + artifactName: vscode_cli_alpine_arm64_cli + sbomBuildDropPath: $(Build.ArtifactStagingDirectory)/cli + sbomPackageName: "VS Code Alpine arm64 CLI" + sbomPackageVersion: $(Build.SourceVersion) + displayName: Publish vscode_cli_alpine_arm64_cli artifact - - ${{ if eq(parameters.VSCODE_BUILD_ALPINE, true) }}: - - template: ../cli/cli-publish.yml - parameters: - VSCODE_CLI_ARTIFACT: vscode_cli_alpine_x64_cli - VSCODE_CHECK_ONLY: ${{ parameters.VSCODE_CHECK_ONLY }} + - ${{ if eq(parameters.VSCODE_BUILD_ALPINE, true) }}: + - task: 1ES.PublishPipelineArtifact@1 + inputs: + targetPath: $(Build.ArtifactStagingDirectory)/vscode_cli_alpine_x64_cli.tar.gz + artifactName: vscode_cli_alpine_x64_cli + sbomBuildDropPath: $(Build.ArtifactStagingDirectory)/cli + sbomPackageName: "VS Code Alpine x64 CLI" + sbomPackageVersion: $(Build.SourceVersion) + displayName: Publish vscode_cli_alpine_x64_cli artifact diff --git a/build/azure-pipelines/alpine/product-build-alpine.yml b/build/azure-pipelines/alpine/product-build-alpine.yml index b055a0ae3d0e0..2c55132a9f99d 100644 --- a/build/azure-pipelines/alpine/product-build-alpine.yml +++ b/build/azure-pipelines/alpine/product-build-alpine.yml @@ -5,7 +5,7 @@ steps: versionFilePath: .nvmrc nodejsMirror: https://github.com/joaomoreno/node-mirror/releases/download - - template: ../distro/download-distro.yml + - template: ../distro/download-distro.yml@self - task: AzureKeyVault@1 displayName: "Azure Key Vault: Get Secrets" @@ -107,7 +107,7 @@ steps: - script: node build/azure-pipelines/distro/mixin-quality displayName: Mixin distro quality - - template: ../common/install-builtin-extensions.yml + - template: ../common/install-builtin-extensions.yml@self - script: | set -e @@ -115,8 +115,10 @@ steps: yarn gulp vscode-reh-$TARGET-min-ci (cd .. && mv vscode-reh-$TARGET vscode-server-$TARGET) # TODO@joaomoreno ARCHIVE_PATH=".build/linux/server/vscode-server-$TARGET.tar.gz" + DIR_PATH="$(realpath ../vscode-server-$TARGET)" mkdir -p $(dirname $ARCHIVE_PATH) tar --owner=0 --group=0 -czf $ARCHIVE_PATH -C .. vscode-server-$TARGET + echo "##vso[task.setvariable variable=SERVER_DIR_PATH]$DIR_PATH" echo "##vso[task.setvariable variable=SERVER_PATH]$ARCHIVE_PATH" env: GITHUB_TOKEN: "$(github-distro-mixin-password)" @@ -128,8 +130,10 @@ steps: yarn gulp vscode-reh-web-$TARGET-min-ci (cd .. && mv vscode-reh-web-$TARGET vscode-server-$TARGET-web) # TODO@joaomoreno ARCHIVE_PATH=".build/linux/web/vscode-server-$TARGET-web.tar.gz" + DIR_PATH="$(realpath ../vscode-server-$TARGET-web)" mkdir -p $(dirname $ARCHIVE_PATH) tar --owner=0 --group=0 -czf $ARCHIVE_PATH -C .. vscode-server-$TARGET-web + echo "##vso[task.setvariable variable=WEB_DIR_PATH]$DIR_PATH" echo "##vso[task.setvariable variable=WEB_PATH]$ARCHIVE_PATH" env: GITHUB_TOKEN: "$(github-distro-mixin-password)" @@ -139,36 +143,40 @@ steps: condition: and(succeededOrFailed(), notIn(variables['Agent.JobStatus'], 'Succeeded', 'SucceededWithIssues')) displayName: Generate artifact prefix - - script: mkdir $(agent.builddirectory)/vscode-alpine-$(VSCODE_ARCH) - displayName: Make folder for SBOM - - - task: AzureArtifacts.manifest-generator-task.manifest-generator-task.ManifestGeneratorTask@0 - displayName: Generate SBOM + - task: 1ES.PublishPipelineArtifact@1 inputs: - BuildDropPath: $(agent.builddirectory)/vscode-alpine-$(VSCODE_ARCH) - PackageName: Visual Studio Code Server - - - publish: $(agent.builddirectory)/vscode-alpine-$(VSCODE_ARCH)/_manifest - displayName: Publish SBOM - artifact: $(ARTIFACT_PREFIX)sbom_vscode_alpine_$(VSCODE_ARCH) - - - publish: $(SERVER_PATH) - artifact: $(ARTIFACT_PREFIX)vscode_server_alpine_$(VSCODE_ARCH)_archive-unsigned + targetPath: $(SERVER_PATH) + artifactName: $(ARTIFACT_PREFIX)vscode_server_alpine_$(VSCODE_ARCH)_archive-unsigned + sbomBuildDropPath: $(SERVER_DIR_PATH) + sbomPackageName: "VS Code Alpine $(VSCODE_ARCH) Server" + sbomPackageVersion: $(Build.SourceVersion) displayName: Publish server archive condition: and(succeededOrFailed(), ne(variables['SERVER_PATH'], ''), ne(variables['VSCODE_ARCH'], 'x64')) - - publish: $(WEB_PATH) - artifact: $(ARTIFACT_PREFIX)vscode_web_alpine_$(VSCODE_ARCH)_archive-unsigned + - task: 1ES.PublishPipelineArtifact@1 + inputs: + targetPath: $(WEB_PATH) + artifactName: $(ARTIFACT_PREFIX)vscode_web_alpine_$(VSCODE_ARCH)_archive-unsigned + sbomBuildDropPath: $(WEB_DIR_PATH) + sbomPackageName: "VS Code Alpine $(VSCODE_ARCH) Web" + sbomPackageVersion: $(Build.SourceVersion) displayName: Publish web server archive condition: and(succeededOrFailed(), ne(variables['WEB_PATH'], ''), ne(variables['VSCODE_ARCH'], 'x64')) - # Legacy x64 artifact name - - publish: $(SERVER_PATH) - artifact: $(ARTIFACT_PREFIX)vscode_server_linux_alpine_archive-unsigned + # same as above, keep legacy name + - task: 1ES.PublishPipelineArtifact@1 + inputs: + targetPath: $(SERVER_PATH) + artifactName: $(ARTIFACT_PREFIX)vscode_server_linux_alpine_archive-unsigned + sbomEnabled: false displayName: Publish x64 server archive condition: and(succeededOrFailed(), ne(variables['SERVER_PATH'], ''), eq(variables['VSCODE_ARCH'], 'x64')) - - publish: $(WEB_PATH) - artifact: $(ARTIFACT_PREFIX)vscode_web_linux_alpine_archive-unsigned + # same as above, keep legacy name + - task: 1ES.PublishPipelineArtifact@1 + inputs: + targetPath: $(WEB_PATH) + artifactName: $(ARTIFACT_PREFIX)vscode_web_linux_alpine_archive-unsigned + sbomEnabled: false displayName: Publish x64 web server archive condition: and(succeededOrFailed(), ne(variables['WEB_PATH'], ''), eq(variables['VSCODE_ARCH'], 'x64')) diff --git a/build/azure-pipelines/cli/cli-apply-patches.yml b/build/azure-pipelines/cli/cli-apply-patches.yml index b96aa4ef7dd94..2815124efb69e 100644 --- a/build/azure-pipelines/cli/cli-apply-patches.yml +++ b/build/azure-pipelines/cli/cli-apply-patches.yml @@ -1,5 +1,5 @@ steps: - - template: ../distro/download-distro.yml + - template: ../distro/download-distro.yml@self - script: node build/azure-pipelines/distro/mixin-quality displayName: Mixin distro quality diff --git a/build/azure-pipelines/cli/cli-compile.yml b/build/azure-pipelines/cli/cli-compile.yml index 8d8b313253e2b..267682f7f6d07 100644 --- a/build/azure-pipelines/cli/cli-compile.yml +++ b/build/azure-pipelines/cli/cli-compile.yml @@ -110,13 +110,14 @@ steps: Write-Host "##vso[task.setvariable variable=VSCODE_CLI_APPLICATION_NAME]$env:VSCODE_CLI_APPLICATION_NAME" - Move-Item -Path $(Build.SourcesDirectory)/cli/target/${{ parameters.VSCODE_CLI_TARGET }}/release/code.exe -Destination "$(Build.ArtifactStagingDirectory)/${env:VSCODE_CLI_APPLICATION_NAME}.exe" + New-Item -ItemType Directory -Force -Path "$(Build.ArtifactStagingDirectory)/cli" + Move-Item -Path $(Build.SourcesDirectory)/cli/target/${{ parameters.VSCODE_CLI_TARGET }}/release/code.exe -Destination "$(Build.ArtifactStagingDirectory)/cli/${env:VSCODE_CLI_APPLICATION_NAME}.exe" displayName: Stage CLI - task: ArchiveFiles@2 displayName: Archive CLI inputs: - rootFolderOrFile: $(Build.ArtifactStagingDirectory)/$(VSCODE_CLI_APPLICATION_NAME).exe + rootFolderOrFile: $(Build.ArtifactStagingDirectory)/cli/$(VSCODE_CLI_APPLICATION_NAME).exe includeRootFolder: false archiveType: zip archiveFile: $(Build.ArtifactStagingDirectory)/${{ parameters.VSCODE_CLI_ARTIFACT }}.zip @@ -127,43 +128,19 @@ steps: VSCODE_CLI_APPLICATION_NAME=$(node -p "require(\"$VSCODE_CLI_PRODUCT_JSON\").applicationName") echo "##vso[task.setvariable variable=VSCODE_CLI_APPLICATION_NAME]$VSCODE_CLI_APPLICATION_NAME" - mv $(Build.SourcesDirectory)/cli/target/${{ parameters.VSCODE_CLI_TARGET }}/release/code $(Build.ArtifactStagingDirectory)/$VSCODE_CLI_APPLICATION_NAME + mkdir -p $(Build.ArtifactStagingDirectory)/cli + mv $(Build.SourcesDirectory)/cli/target/${{ parameters.VSCODE_CLI_TARGET }}/release/code $(Build.ArtifactStagingDirectory)/cli/$VSCODE_CLI_APPLICATION_NAME displayName: Stage CLI - - ${{ if contains(parameters.VSCODE_CLI_TARGET, '-darwin') }}: - - task: ArchiveFiles@2 - displayName: Archive CLI - inputs: - rootFolderOrFile: $(Build.ArtifactStagingDirectory)/$(VSCODE_CLI_APPLICATION_NAME) - includeRootFolder: false + - task: ArchiveFiles@2 + displayName: Archive CLI + inputs: + rootFolderOrFile: $(Build.ArtifactStagingDirectory)/cli/$(VSCODE_CLI_APPLICATION_NAME) + includeRootFolder: false + ${{ if contains(parameters.VSCODE_CLI_TARGET, '-darwin') }}: archiveType: zip archiveFile: $(Build.ArtifactStagingDirectory)/${{ parameters.VSCODE_CLI_ARTIFACT }}.zip - - - ${{ else }}: - - task: ArchiveFiles@2 - displayName: Archive CLI - inputs: - rootFolderOrFile: $(Build.ArtifactStagingDirectory)/$(VSCODE_CLI_APPLICATION_NAME) - includeRootFolder: false + ${{ else }}: archiveType: tar tarCompression: gz archiveFile: $(Build.ArtifactStagingDirectory)/${{ parameters.VSCODE_CLI_ARTIFACT }}.tar.gz - - # Make a folder for the SBOM for the specific artifact - - ${{ if contains(parameters.VSCODE_CLI_TARGET, '-windows-') }}: - - powershell: mkdir $(Build.ArtifactStagingDirectory)/sbom_${{ parameters.VSCODE_CLI_ARTIFACT }} - displayName: Make folder for SBOM (Windows) - - - ${{ else }}: - - script: mkdir $(Build.ArtifactStagingDirectory)/sbom_${{ parameters.VSCODE_CLI_ARTIFACT }} - displayName: Make folder for SBOM (non-Windows) - - # The if cases above are for different OSes, - # but we're still in the branch where the cli is being published in general. - # Generate and publish an SBOM. - - task: AzureArtifacts.manifest-generator-task.manifest-generator-task.ManifestGeneratorTask@0 - displayName: Generate SBOM - inputs: - BuildComponentPath: $(Build.SourcesDirectory)/cli - BuildDropPath: $(Build.ArtifactStagingDirectory)/sbom_${{ parameters.VSCODE_CLI_ARTIFACT }} - PackageName: Visual Studio Code CLI diff --git a/build/azure-pipelines/cli/cli-darwin-sign.yml b/build/azure-pipelines/cli/cli-darwin-sign.yml index 925d8435dae9f..75a8235ff3a11 100644 --- a/build/azure-pipelines/cli/cli-darwin-sign.yml +++ b/build/azure-pipelines/cli/cli-darwin-sign.yml @@ -26,6 +26,12 @@ steps: artifact: ${{ target }} path: $(Build.ArtifactStagingDirectory)/pkg/${{ target }} + - task: ExtractFiles@1 + displayName: Extract artifact + inputs: + archiveFilePatterns: $(Build.ArtifactStagingDirectory)/pkg/${{ target }}/*.zip + destinationFolder: $(Build.ArtifactStagingDirectory)/sign/${{ target }} + - script: node build/azure-pipelines/common/sign $(Agent.ToolsDirectory)/esrpclient/*/*/net6.0/esrpcli.dll sign-darwin $(ESRP-PKI) $(esrp-aad-username) $(esrp-aad-password) $(Build.ArtifactStagingDirectory)/pkg "*.zip" displayName: Codesign @@ -40,6 +46,11 @@ steps: echo "##vso[task.setvariable variable=ASSET_ID]$ASSET_ID" displayName: Set asset id variable - - publish: $(Build.ArtifactStagingDirectory)/pkg/${{ target }}/$(ASSET_ID).zip + - task: 1ES.PublishPipelineArtifact@1 + inputs: + targetPath: $(Build.ArtifactStagingDirectory)/pkg/${{ target }}/$(ASSET_ID).zip + artifactName: $(ASSET_ID) + sbomBuildDropPath: $(Build.ArtifactStagingDirectory)/sign/${{ target }} + sbomPackageName: "VS Code macOS ${{ target }} CLI" + sbomPackageVersion: $(Build.SourceVersion) displayName: Publish signed artifact with ID $(ASSET_ID) - artifact: $(ASSET_ID) diff --git a/build/azure-pipelines/cli/cli-publish.yml b/build/azure-pipelines/cli/cli-publish.yml deleted file mode 100644 index fa3eacd0f961d..0000000000000 --- a/build/azure-pipelines/cli/cli-publish.yml +++ /dev/null @@ -1,28 +0,0 @@ -parameters: - - name: VSCODE_CLI_ARTIFACT - type: string - - name: VSCODE_CHECK_ONLY - type: boolean - default: false - -steps: - - ${{ if not(parameters.VSCODE_CHECK_ONLY) }}: - - ${{ if contains(parameters.VSCODE_CLI_ARTIFACT, 'win32') }}: - - publish: $(Build.ArtifactStagingDirectory)/${{ parameters.VSCODE_CLI_ARTIFACT }}.zip - artifact: ${{ parameters.VSCODE_CLI_ARTIFACT }} - displayName: Publish ${{ parameters.VSCODE_CLI_ARTIFACT }} artifact - - - ${{ else }}: - - ${{ if contains(parameters.VSCODE_CLI_ARTIFACT, 'darwin') }}: - - publish: $(Build.ArtifactStagingDirectory)/${{ parameters.VSCODE_CLI_ARTIFACT }}.zip - artifact: ${{ parameters.VSCODE_CLI_ARTIFACT }} - displayName: Publish ${{ parameters.VSCODE_CLI_ARTIFACT }} artifact - - - ${{ else }}: - - publish: $(Build.ArtifactStagingDirectory)/${{ parameters.VSCODE_CLI_ARTIFACT }}.tar.gz - artifact: ${{ parameters.VSCODE_CLI_ARTIFACT }} - displayName: Publish ${{ parameters.VSCODE_CLI_ARTIFACT }} artifact - - - publish: $(Build.ArtifactStagingDirectory)/sbom_${{ parameters.VSCODE_CLI_ARTIFACT }}/_manifest - displayName: Publish SBOM - artifact: sbom_${{ parameters.VSCODE_CLI_ARTIFACT }} diff --git a/build/azure-pipelines/cli/cli-win32-sign.yml b/build/azure-pipelines/cli/cli-win32-sign.yml index 10d305b92b357..f8d11e806f276 100644 --- a/build/azure-pipelines/cli/cli-win32-sign.yml +++ b/build/azure-pipelines/cli/cli-win32-sign.yml @@ -59,6 +59,11 @@ steps: archiveType: zip archiveFile: $(Build.ArtifactStagingDirectory)/$(ASSET_ID).zip - - publish: $(Build.ArtifactStagingDirectory)/$(ASSET_ID).zip + - task: 1ES.PublishPipelineArtifact@1 + inputs: + targetPath: $(Build.ArtifactStagingDirectory)/$(ASSET_ID).zip + artifactName: $(ASSET_ID) + sbomBuildDropPath: $(Build.ArtifactStagingDirectory)/sign/${{ target }} + sbomPackageName: "VS Code Windows ${{ target }} CLI" + sbomPackageVersion: $(Build.SourceVersion) displayName: Publish signed artifact with ID $(ASSET_ID) - artifact: $(ASSET_ID) diff --git a/build/azure-pipelines/cli/install-rust-posix.yml b/build/azure-pipelines/cli/install-rust-posix.yml index 78641cfb3a85e..89867143938fc 100644 --- a/build/azure-pipelines/cli/install-rust-posix.yml +++ b/build/azure-pipelines/cli/install-rust-posix.yml @@ -1,7 +1,7 @@ parameters: - name: channel type: string - default: 1.73.0 + default: 1.77 - name: targets default: [] type: object diff --git a/build/azure-pipelines/cli/install-rust-win32.yml b/build/azure-pipelines/cli/install-rust-win32.yml index 3c88d9adc867a..22fba8d7f6abb 100644 --- a/build/azure-pipelines/cli/install-rust-win32.yml +++ b/build/azure-pipelines/cli/install-rust-win32.yml @@ -1,7 +1,7 @@ parameters: - name: channel type: string - default: 1.73.0 + default: 1.77 - name: targets default: [] type: object diff --git a/build/azure-pipelines/cli/test.yml b/build/azure-pipelines/cli/test.yml index 29dcf502f6a5b..8b525845548ff 100644 --- a/build/azure-pipelines/cli/test.yml +++ b/build/azure-pipelines/cli/test.yml @@ -1,5 +1,5 @@ steps: - - template: ./install-rust-posix.yml + - template: ./install-rust-posix.yml@self - script: cargo clippy -- -D warnings workingDirectory: cli diff --git a/build/azure-pipelines/common/publish.js b/build/azure-pipelines/common/publish.js index d035de8bb84a5..c990e3a7146cb 100644 --- a/build/azure-pipelines/common/publish.js +++ b/build/azure-pipelines/common/publish.js @@ -41,6 +41,9 @@ class Temp { } } } +function isCreateProvisionedFilesErrorResponse(response) { + return response?.ErrorDetails?.Code !== undefined; +} class ProvisionService { log; accessToken; @@ -63,6 +66,10 @@ class ProvisionService { }); this.log(`Provisioning ${fileName} (releaseId: ${releaseId}, fileId: ${fileId})...`); const res = await (0, retry_1.retry)(() => this.request('POST', '/api/v2/ProvisionedFiles/CreateProvisionedFiles', { body })); + if (isCreateProvisionedFilesErrorResponse(res) && res.ErrorDetails.Code === 'FriendlyFileNameAlreadyProvisioned') { + this.log(`File already provisioned (most likley due to a re-run), skipping: ${fileName}`); + return; + } if (!res.IsSuccess) { throw new Error(`Failed to submit provisioning request: ${JSON.stringify(res.ErrorDetails)}`); } @@ -78,8 +85,10 @@ class ProvisionService { } }; const res = await fetch(`https://dsprovisionapi.microsoft.com${url}`, opts); - if (!res.ok || res.status < 200 || res.status >= 500) { - throw new Error(`Unexpected status code: ${res.status}`); + // 400 normally means the request is bad or something is already provisioned, so we will return as retries are useless + // Otherwise log the text body and headers. We do text because some responses are not JSON. + if ((!res.ok || res.status < 200 || res.status >= 500) && res.status !== 400) { + throw new Error(`Unexpected status code: ${res.status}\nResponse Headers: ${JSON.stringify(res.headers)}\nBody Text: ${await res.text()}`); } return await res.json(); } @@ -331,10 +340,11 @@ async function downloadArtifact(artifact, downloadPath) { } async function unzip(packagePath, outputPath) { return new Promise((resolve, reject) => { - yauzl.open(packagePath, { lazyEntries: true }, (err, zipfile) => { + yauzl.open(packagePath, { lazyEntries: true, autoClose: true }, (err, zipfile) => { if (err) { return reject(err); } + const result = []; zipfile.on('entry', entry => { if (/\/$/.test(entry.fileName)) { zipfile.readEntry(); @@ -348,20 +358,21 @@ async function unzip(packagePath, outputPath) { fs.mkdirSync(path.dirname(filePath), { recursive: true }); const ostream = fs.createWriteStream(filePath); ostream.on('finish', () => { - zipfile.close(); - resolve(filePath); + result.push(filePath); + zipfile.readEntry(); }); istream?.on('error', err => reject(err)); istream.pipe(ostream); }); } }); + zipfile.on('close', () => resolve(result)); zipfile.readEntry(); }); }); } // Contains all of the logic for mapping details to our actual product names in CosmosDB -function getPlatform(product, os, arch, type) { +function getPlatform(product, os, arch, type, isLegacy) { switch (os) { case 'win32': switch (product) { @@ -412,9 +423,12 @@ function getPlatform(product, os, arch, type) { case 'client': return `linux-${arch}`; case 'server': - return `server-linux-${arch}`; + return isLegacy ? `server-linux-legacy-${arch}` : `server-linux-${arch}`; case 'web': - return arch === 'standalone' ? 'web-standalone' : `server-linux-${arch}-web`; + if (arch === 'standalone') { + return 'web-standalone'; + } + return isLegacy ? `server-linux-legacy-${arch}-web` : `server-linux-${arch}-web`; default: throw new Error(`Unrecognized: ${product} ${os} ${arch} ${type}`); } @@ -467,7 +481,7 @@ function getRealType(type) { } async function processArtifact(artifact, artifactFilePath) { const log = (...args) => console.log(`[${artifact.name}]`, ...args); - const match = /^vscode_(?[^_]+)_(?[^_]+)_(?[^_]+)_(?[^_]+)$/.exec(artifact.name); + const match = /^vscode_(?[^_]+)_(?[^_]+)(?:_legacy)?_(?[^_]+)_(?[^_]+)$/.exec(artifact.name); if (!match) { throw new Error(`Invalid artifact name: ${artifact.name}`); } @@ -475,14 +489,15 @@ async function processArtifact(artifact, artifactFilePath) { const quality = e('VSCODE_QUALITY'); const commit = e('BUILD_SOURCEVERSION'); const { product, os, arch, unprocessedType } = match.groups; - const platform = getPlatform(product, os, arch, unprocessedType); + const isLegacy = artifact.name.includes('_legacy'); + const platform = getPlatform(product, os, arch, unprocessedType, isLegacy); const type = getRealType(unprocessedType); const size = fs.statSync(artifactFilePath).size; const stream = fs.createReadStream(artifactFilePath); const [hash, sha256hash] = await Promise.all([hashStream('sha1', stream), hashStream('sha256', stream)]); // CodeQL [SM04514] Using SHA1 only for legacy reasons, we are actually only respecting SHA256 const url = await releaseAndProvision(log, e('RELEASE_TENANT_ID'), e('RELEASE_CLIENT_ID'), e('RELEASE_AUTH_CERT_SUBJECT_NAME'), e('RELEASE_REQUEST_SIGNING_CERT_SUBJECT_NAME'), e('PROVISION_TENANT_ID'), e('PROVISION_AAD_USERNAME'), e('PROVISION_AAD_PASSWORD'), commit, quality, artifactFilePath); const asset = { platform, type, url, hash, sha256hash, size, supportsFastUpdate: true }; - log('Creating asset...', JSON.stringify(asset)); + log('Creating asset...', JSON.stringify(asset, undefined, 2)); await (0, retry_1.retry)(async (attempt) => { log(`Creating asset in Cosmos DB (attempt ${attempt})...`); const aadCredentials = new identity_1.ClientSecretCredential(e('AZURE_TENANT_ID'), e('AZURE_CLIENT_ID'), e('AZURE_CLIENT_SECRET')); @@ -516,6 +531,9 @@ async function main() { if (e('VSCODE_BUILD_STAGE_LINUX') === 'True') { stages.add('Linux'); } + if (e('VSCODE_BUILD_STAGE_LINUX_LEGACY_SERVER') === 'True') { + stages.add('LinuxLegacyServer'); + } if (e('VSCODE_BUILD_STAGE_ALPINE') === 'True') { stages.add('Alpine'); } @@ -559,12 +577,8 @@ async function main() { const downloadSpeedKBS = Math.round((archiveSize / 1024) / downloadDurationS); console.log(`[${artifact.name}] Successfully downloaded after ${Math.floor(downloadDurationS)} seconds(${downloadSpeedKBS} KB/s).`); }); - const artifactFilePath = await unzip(artifactZipPath, e('AGENT_TEMPDIRECTORY')); - const artifactSize = fs.statSync(artifactFilePath).size; - if (artifactSize !== Number(artifact.resource.properties.artifactsize)) { - console.log(`[${artifact.name}] Artifact size mismatch.Expected ${artifact.resource.properties.artifactsize}. Actual ${artifactSize} `); - throw new Error(`Artifact size mismatch.`); - } + const artifactFilePaths = await unzip(artifactZipPath, e('AGENT_TEMPDIRECTORY')); + const artifactFilePath = artifactFilePaths.filter(p => !/_manifest/.test(p))[0]; processing.add(artifact.name); const promise = new Promise((resolve, reject) => { const worker = new node_worker_threads_1.Worker(__filename, { workerData: { artifact, artifactFilePath } }); @@ -586,7 +600,7 @@ async function main() { operations.push({ name: artifact.name, operation }); resultPromise = Promise.allSettled(operations.map(o => o.operation)); } - await new Promise(c => setTimeout(c, 10000)); + await new Promise(c => setTimeout(c, 10_000)); } console.log(`Found all ${done.size + processing.size} artifacts, waiting for ${processing.size} artifacts to finish publishing...`); const artifactsInProgress = operations.filter(o => processing.has(o.name)); diff --git a/build/azure-pipelines/common/publish.ts b/build/azure-pipelines/common/publish.ts index 6b20492c87c08..75065ffa2d3ab 100644 --- a/build/azure-pipelines/common/publish.ts +++ b/build/azure-pipelines/common/publish.ts @@ -69,6 +69,10 @@ interface CreateProvisionedFilesErrorResponse { type CreateProvisionedFilesResponse = CreateProvisionedFilesSuccessResponse | CreateProvisionedFilesErrorResponse; +function isCreateProvisionedFilesErrorResponse(response: unknown): response is CreateProvisionedFilesErrorResponse { + return (response as CreateProvisionedFilesErrorResponse)?.ErrorDetails?.Code !== undefined; +} + class ProvisionService { constructor( @@ -93,6 +97,11 @@ class ProvisionService { this.log(`Provisioning ${fileName} (releaseId: ${releaseId}, fileId: ${fileId})...`); const res = await retry(() => this.request('POST', '/api/v2/ProvisionedFiles/CreateProvisionedFiles', { body })); + if (isCreateProvisionedFilesErrorResponse(res) && res.ErrorDetails.Code === 'FriendlyFileNameAlreadyProvisioned') { + this.log(`File already provisioned (most likley due to a re-run), skipping: ${fileName}`); + return; + } + if (!res.IsSuccess) { throw new Error(`Failed to submit provisioning request: ${JSON.stringify(res.ErrorDetails)}`); } @@ -112,8 +121,11 @@ class ProvisionService { const res = await fetch(`https://dsprovisionapi.microsoft.com${url}`, opts); - if (!res.ok || res.status < 200 || res.status >= 500) { - throw new Error(`Unexpected status code: ${res.status}`); + + // 400 normally means the request is bad or something is already provisioned, so we will return as retries are useless + // Otherwise log the text body and headers. We do text because some responses are not JSON. + if ((!res.ok || res.status < 200 || res.status >= 500) && res.status !== 400) { + throw new Error(`Unexpected status code: ${res.status}\nResponse Headers: ${JSON.stringify(res.headers)}\nBody Text: ${await res.text()}`); } return await res.json(); @@ -471,13 +483,14 @@ async function downloadArtifact(artifact: Artifact, downloadPath: string): Promi } } -async function unzip(packagePath: string, outputPath: string): Promise { +async function unzip(packagePath: string, outputPath: string): Promise { return new Promise((resolve, reject) => { - yauzl.open(packagePath, { lazyEntries: true }, (err, zipfile) => { + yauzl.open(packagePath, { lazyEntries: true, autoClose: true }, (err, zipfile) => { if (err) { return reject(err); } + const result: string[] = []; zipfile!.on('entry', entry => { if (/\/$/.test(entry.fileName)) { zipfile!.readEntry(); @@ -492,8 +505,8 @@ async function unzip(packagePath: string, outputPath: string): Promise { const ostream = fs.createWriteStream(filePath); ostream.on('finish', () => { - zipfile!.close(); - resolve(filePath); + result.push(filePath); + zipfile!.readEntry(); }); istream?.on('error', err => reject(err)); istream!.pipe(ostream); @@ -501,6 +514,7 @@ async function unzip(packagePath: string, outputPath: string): Promise { } }); + zipfile!.on('close', () => resolve(result)); zipfile!.readEntry(); }); }); @@ -519,7 +533,7 @@ interface Asset { } // Contains all of the logic for mapping details to our actual product names in CosmosDB -function getPlatform(product: string, os: string, arch: string, type: string): string { +function getPlatform(product: string, os: string, arch: string, type: string, isLegacy: boolean): string { switch (os) { case 'win32': switch (product) { @@ -570,9 +584,12 @@ function getPlatform(product: string, os: string, arch: string, type: string): s case 'client': return `linux-${arch}`; case 'server': - return `server-linux-${arch}`; + return isLegacy ? `server-linux-legacy-${arch}` : `server-linux-${arch}`; case 'web': - return arch === 'standalone' ? 'web-standalone' : `server-linux-${arch}-web`; + if (arch === 'standalone') { + return 'web-standalone'; + } + return isLegacy ? `server-linux-legacy-${arch}-web` : `server-linux-${arch}-web`; default: throw new Error(`Unrecognized: ${product} ${os} ${arch} ${type}`); } @@ -627,7 +644,7 @@ function getRealType(type: string) { async function processArtifact(artifact: Artifact, artifactFilePath: string): Promise { const log = (...args: any[]) => console.log(`[${artifact.name}]`, ...args); - const match = /^vscode_(?[^_]+)_(?[^_]+)_(?[^_]+)_(?[^_]+)$/.exec(artifact.name); + const match = /^vscode_(?[^_]+)_(?[^_]+)(?:_legacy)?_(?[^_]+)_(?[^_]+)$/.exec(artifact.name); if (!match) { throw new Error(`Invalid artifact name: ${artifact.name}`); @@ -637,7 +654,8 @@ async function processArtifact(artifact: Artifact, artifactFilePath: string): Pr const quality = e('VSCODE_QUALITY'); const commit = e('BUILD_SOURCEVERSION'); const { product, os, arch, unprocessedType } = match.groups!; - const platform = getPlatform(product, os, arch, unprocessedType); + const isLegacy = artifact.name.includes('_legacy'); + const platform = getPlatform(product, os, arch, unprocessedType, isLegacy); const type = getRealType(unprocessedType); const size = fs.statSync(artifactFilePath).size; const stream = fs.createReadStream(artifactFilePath); @@ -658,7 +676,7 @@ async function processArtifact(artifact: Artifact, artifactFilePath: string): Pr ); const asset: Asset = { platform, type, url, hash, sha256hash, size, supportsFastUpdate: true }; - log('Creating asset...', JSON.stringify(asset)); + log('Creating asset...', JSON.stringify(asset, undefined, 2)); await retry(async (attempt) => { log(`Creating asset in Cosmos DB (attempt ${attempt})...`); @@ -694,6 +712,7 @@ async function main() { const stages = new Set(['Compile', 'CompileCLI']); if (e('VSCODE_BUILD_STAGE_WINDOWS') === 'True') { stages.add('Windows'); } if (e('VSCODE_BUILD_STAGE_LINUX') === 'True') { stages.add('Linux'); } + if (e('VSCODE_BUILD_STAGE_LINUX_LEGACY_SERVER') === 'True') { stages.add('LinuxLegacyServer'); } if (e('VSCODE_BUILD_STAGE_ALPINE') === 'True') { stages.add('Alpine'); } if (e('VSCODE_BUILD_STAGE_MACOS') === 'True') { stages.add('macOS'); } if (e('VSCODE_BUILD_STAGE_WEB') === 'True') { stages.add('Web'); } @@ -736,13 +755,8 @@ async function main() { console.log(`[${artifact.name}] Successfully downloaded after ${Math.floor(downloadDurationS)} seconds(${downloadSpeedKBS} KB/s).`); }); - const artifactFilePath = await unzip(artifactZipPath, e('AGENT_TEMPDIRECTORY')); - const artifactSize = fs.statSync(artifactFilePath).size; - - if (artifactSize !== Number(artifact.resource.properties.artifactsize)) { - console.log(`[${artifact.name}] Artifact size mismatch.Expected ${artifact.resource.properties.artifactsize}. Actual ${artifactSize} `); - throw new Error(`Artifact size mismatch.`); - } + const artifactFilePaths = await unzip(artifactZipPath, e('AGENT_TEMPDIRECTORY')); + const artifactFilePath = artifactFilePaths.filter(p => !/_manifest/.test(p))[0]; processing.add(artifact.name); const promise = new Promise((resolve, reject) => { diff --git a/build/azure-pipelines/common/retry.js b/build/azure-pipelines/common/retry.js index 7b90b0cac5b39..91f60bf24b204 100644 --- a/build/azure-pipelines/common/retry.js +++ b/build/azure-pipelines/common/retry.js @@ -4,7 +4,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ Object.defineProperty(exports, "__esModule", { value: true }); -exports.retry = void 0; +exports.retry = retry; async function retry(fn) { let lastError; for (let run = 1; run <= 10; run++) { @@ -24,5 +24,4 @@ async function retry(fn) { console.error(`Too many retries, aborting.`); throw lastError; } -exports.retry = retry; //# sourceMappingURL=retry.js.map \ No newline at end of file diff --git a/build/azure-pipelines/common/sign.js b/build/azure-pipelines/common/sign.js index 4dba4765ff6c0..32996a7db0309 100644 --- a/build/azure-pipelines/common/sign.js +++ b/build/azure-pipelines/common/sign.js @@ -4,7 +4,8 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ Object.defineProperty(exports, "__esModule", { value: true }); -exports.main = exports.Temp = void 0; +exports.Temp = void 0; +exports.main = main; const cp = require("child_process"); const fs = require("fs"); const crypto = require("crypto"); @@ -164,7 +165,6 @@ function main([esrpCliPath, type, cert, username, password, folderPath, pattern] process.exit(1); } } -exports.main = main; if (require.main === module) { main(process.argv.slice(2)); process.exit(0); diff --git a/build/azure-pipelines/config/CredScanSuppressions.json b/build/azure-pipelines/config/CredScanSuppressions.json index 312a5560cbd6e..bf52c06cf899c 100644 --- a/build/azure-pipelines/config/CredScanSuppressions.json +++ b/build/azure-pipelines/config/CredScanSuppressions.json @@ -3,9 +3,87 @@ "suppressions": [ { "file": [ - "src/vs/base/test/common/uri.test.ts" + "src/vs/base/test/common/uri.test.ts", + "src/vs/workbench/api/test/browser/extHostTelemetry.test.ts" ], - "_justification": "These are not passwords, they are URIs." + "_justification": "These are dummy credentials in tests." + }, + { + "file": [ + ".build/linux/rpm/x86_64/rpmbuild/BUILD/usr/share/code/resources/app/extensions/github-authentication/dist/extension.js", + ".build/linux/rpm/x86_64/rpmbuild/BUILD/usr/share/code/resources/app/extensions/emmet/dist/node/emmetNodeMain.js", + ".build/linux/rpm/armv7hl/rpmbuild/BUILD/usr/share/code/resources/app/extensions/github-authentication/dist/extension.js", + ".build/linux/rpm/armv7hl/rpmbuild/BUILD/usr/share/code/resources/app/extensions/emmet/dist/node/emmetNodeMain.js", + ".build/linux/rpm/aarch64/rpmbuild/BUILD/usr/share/code/resources/app/extensions/github-authentication/dist/extension.js", + ".build/linux/rpm/aarch64/rpmbuild/BUILD/usr/share/code/resources/app/extensions/emmet/dist/node/emmetNodeMain.js", + ".build/linux/snap/x64/code-x64/usr/share/code/resources/app/extensions/github-authentication/dist/extension.js", + ".build/linux/snap/x64/code-x64/usr/share/code/resources/app/extensions/emmet/dist/node/emmetNodeMain.js", + ".build/linux/snap/x64/code-x64/stage/usr/share/code/resources/app/extensions/github-authentication/dist/extension.js", + ".build/linux/snap/x64/code-x64/stage/usr/share/code/resources/app/extensions/emmet/dist/node/emmetNodeMain.js", + ".build/linux/snap/x64/code-x64/prime/usr/share/code/resources/app/extensions/github-authentication/dist/extension.js", + ".build/linux/snap/x64/code-x64/prime/usr/share/code/resources/app/extensions/emmet/dist/node/emmetNodeMain.js", + ".build/linux/snap/x64/code-x64/parts/code/build/usr/share/code/resources/app/extensions/github-authentication/dist/extension.js", + ".build/linux/snap/x64/code-x64/parts/code/install/usr/share/code/resources/app/extensions/github-authentication/dist/extension.js", + ".build/linux/snap/x64/code-x64/parts/code/src/usr/share/code/resources/app/extensions/github-authentication/dist/extension.js", + ".build/linux/snap/x64/code-x64/parts/code/build/usr/share/code/resources/app/extensions/emmet/dist/node/emmetNodeMain.js", + ".build/linux/snap/x64/code-x64/parts/code/install/usr/share/code/resources/app/extensions/emmet/dist/node/emmetNodeMain.js", + ".build/linux/snap/x64/code-x64/parts/code/src/usr/share/code/resources/app/extensions/emmet/dist/node/emmetNodeMain.js" + ], + "_justification": "These are safe to ignore, since they are built artifacts (stable)." + }, + { + "file": [ + ".build/linux/rpm/x86_64/rpmbuild/BUILD/usr/share/code-insiders/resources/app/extensions/github-authentication/dist/extension.js", + ".build/linux/rpm/x86_64/rpmbuild/BUILD/usr/share/code-insiders/resources/app/extensions/emmet/dist/node/emmetNodeMain.js", + ".build/linux/rpm/armv7hl/rpmbuild/BUILD/usr/share/code-insiders/resources/app/extensions/github-authentication/dist/extension.js", + ".build/linux/rpm/armv7hl/rpmbuild/BUILD/usr/share/code-insiders/resources/app/extensions/emmet/dist/node/emmetNodeMain.js", + ".build/linux/rpm/aarch64/rpmbuild/BUILD/usr/share/code-insiders/resources/app/extensions/github-authentication/dist/extension.js", + ".build/linux/rpm/aarch64/rpmbuild/BUILD/usr/share/code-insiders/resources/app/extensions/emmet/dist/node/emmetNodeMain.js", + ".build/linux/snap/x64/code-insiders-x64/usr/share/code-insiders/resources/app/extensions/github-authentication/dist/extension.js", + ".build/linux/snap/x64/code-insiders-x64/usr/share/code-insiders/resources/app/extensions/emmet/dist/node/emmetNodeMain.js", + ".build/linux/snap/x64/code-insiders-x64/stage/usr/share/code-insiders/resources/app/extensions/github-authentication/dist/extension.js", + ".build/linux/snap/x64/code-insiders-x64/stage/usr/share/code-insiders/resources/app/extensions/emmet/dist/node/emmetNodeMain.js", + ".build/linux/snap/x64/code-insiders-x64/prime/usr/share/code-insiders/resources/app/extensions/github-authentication/dist/extension.js", + ".build/linux/snap/x64/code-insiders-x64/prime/usr/share/code-insiders/resources/app/extensions/emmet/dist/node/emmetNodeMain.js", + ".build/linux/snap/x64/code-insiders-x64/parts/code/build/usr/share/code-insiders/resources/app/extensions/github-authentication/dist/extension.js", + ".build/linux/snap/x64/code-insiders-x64/parts/code/install/usr/share/code-insiders/resources/app/extensions/github-authentication/dist/extension.js", + ".build/linux/snap/x64/code-insiders-x64/parts/code/src/usr/share/code-insiders/resources/app/extensions/github-authentication/dist/extension.js", + ".build/linux/snap/x64/code-insiders-x64/parts/code/build/usr/share/code-insiders/resources/app/extensions/emmet/dist/node/emmetNodeMain.js", + ".build/linux/snap/x64/code-insiders-x64/parts/code/install/usr/share/code-insiders/resources/app/extensions/emmet/dist/node/emmetNodeMain.js", + ".build/linux/snap/x64/code-insiders-x64/parts/code/src/usr/share/code-insiders/resources/app/extensions/emmet/dist/node/emmetNodeMain.js" + ], + "_justification": "These are safe to ignore, since they are built artifacts (insiders)." + }, + { + "file": [ + ".build/linux/rpm/x86_64/rpmbuild/BUILD/usr/share/code-exploration/resources/app/extensions/github-authentication/dist/extension.js", + ".build/linux/rpm/x86_64/rpmbuild/BUILD/usr/share/code-exploration/resources/app/extensions/emmet/dist/node/emmetNodeMain.js", + ".build/linux/rpm/armv7hl/rpmbuild/BUILD/usr/share/code-exploration/resources/app/extensions/github-authentication/dist/extension.js", + ".build/linux/rpm/armv7hl/rpmbuild/BUILD/usr/share/code-exploration/resources/app/extensions/emmet/dist/node/emmetNodeMain.js", + ".build/linux/rpm/aarch64/rpmbuild/BUILD/usr/share/code-exploration/resources/app/extensions/github-authentication/dist/extension.js", + ".build/linux/rpm/aarch64/rpmbuild/BUILD/usr/share/code-exploration/resources/app/extensions/emmet/dist/node/emmetNodeMain.js", + ".build/linux/snap/x64/code-exploration-x64/usr/share/code-exploration/resources/app/extensions/github-authentication/dist/extension.js", + ".build/linux/snap/x64/code-exploration-x64/usr/share/code-exploration/resources/app/extensions/emmet/dist/node/emmetNodeMain.js", + ".build/linux/snap/x64/code-exploration-x64/stage/usr/share/code-exploration/resources/app/extensions/github-authentication/dist/extension.js", + ".build/linux/snap/x64/code-exploration-x64/stage/usr/share/code-exploration/resources/app/extensions/emmet/dist/node/emmetNodeMain.js", + ".build/linux/snap/x64/code-exploration-x64/prime/usr/share/code-exploration/resources/app/extensions/github-authentication/dist/extension.js", + ".build/linux/snap/x64/code-exploration-x64/prime/usr/share/code-exploration/resources/app/extensions/emmet/dist/node/emmetNodeMain.js", + ".build/linux/snap/x64/code-exploration-x64/parts/code/build/usr/share/code-exploration/resources/app/extensions/github-authentication/dist/extension.js", + ".build/linux/snap/x64/code-exploration-x64/parts/code/install/usr/share/code-exploration/resources/app/extensions/github-authentication/dist/extension.js", + ".build/linux/snap/x64/code-exploration-x64/parts/code/src/usr/share/code-exploration/resources/app/extensions/github-authentication/dist/extension.js", + ".build/linux/snap/x64/code-exploration-x64/parts/code/build/usr/share/code-exploration/resources/app/extensions/emmet/dist/node/emmetNodeMain.js", + ".build/linux/snap/x64/code-exploration-x64/parts/code/install/usr/share/code-exploration/resources/app/extensions/emmet/dist/node/emmetNodeMain.js", + ".build/linux/snap/x64/code-exploration-x64/parts/code/src/usr/share/code-exploration/resources/app/extensions/emmet/dist/node/emmetNodeMain.js" + ], + "_justification": "These are safe to ignore, since they are built artifacts (exploration)." + }, + { + "file": [ + ".build/web/extensions/github-authentication/dist/browser/extension.js", + ".build/web/extensions/emmet/dist/browser/emmetBrowserMain.js.map", + ".build/web/extensions/emmet/dist/browser/emmetBrowserMain.js" + ], + "_justification": "These are safe to ignore, since they are built artifacts (web)." } ] } diff --git a/build/azure-pipelines/config/tsaoptions.json b/build/azure-pipelines/config/tsaoptions.json deleted file mode 100644 index fa8e182d8f3a2..0000000000000 --- a/build/azure-pipelines/config/tsaoptions.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "codebaseName": "devdiv_vscode-client", - "ppe": false, - "notificationAliases": [ - "sbatten@microsoft.com" - ], - "codebaseAdmins": [ - "REDMOND\\stbatt", - "REDMOND\\monacotools" - ], - "instanceUrl": "https://devdiv.visualstudio.com/defaultcollection", - "projectName": "DevDiv", - "areaPath": "DevDiv\\VS Code (compliance tracking only)\\Visual Studio Code Client", - "notifyAlways": true, - "template": "TFSDEVDIV", - "tools": [ - "BinSkim", - "CredScan", - "CodeQL" - ] -} diff --git a/build/azure-pipelines/darwin/cli-build-darwin.yml b/build/azure-pipelines/darwin/cli-build-darwin.yml index ac5adaec1750d..1d8dffc464d38 100644 --- a/build/azure-pipelines/darwin/cli-build-darwin.yml +++ b/build/azure-pipelines/darwin/cli-build-darwin.yml @@ -19,7 +19,7 @@ steps: nodejsMirror: https://github.com/joaomoreno/node-mirror/releases/download - ${{ if ne(parameters.VSCODE_QUALITY, 'oss') }}: - - template: ../cli/cli-apply-patches.yml + - template: ../cli/cli-apply-patches.yml@self - task: Npm@1 displayName: Download openssl prebuilt @@ -36,7 +36,7 @@ steps: tar -xvzf $(Build.ArtifactStagingDirectory)/vscode-internal-openssl-prebuilt-0.0.11.tgz --strip-components=1 --directory=$(Build.ArtifactStagingDirectory)/openssl displayName: Extract openssl prebuilt - - template: ../cli/install-rust-posix.yml + - template: ../cli/install-rust-posix.yml@self parameters: targets: - ${{ if eq(parameters.VSCODE_BUILD_MACOS, true) }}: @@ -45,7 +45,7 @@ steps: - aarch64-apple-darwin - ${{ if eq(parameters.VSCODE_BUILD_MACOS, true) }}: - - template: ../cli/cli-compile.yml + - template: ../cli/cli-compile.yml@self parameters: VSCODE_QUALITY: ${{ parameters.VSCODE_QUALITY }} VSCODE_CLI_TARGET: x86_64-apple-darwin @@ -56,7 +56,7 @@ steps: OPENSSL_INCLUDE_DIR: $(Build.ArtifactStagingDirectory)/openssl/x64-osx/include - ${{ if eq(parameters.VSCODE_BUILD_MACOS_ARM64, true) }}: - - template: ../cli/cli-compile.yml + - template: ../cli/cli-compile.yml@self parameters: VSCODE_QUALITY: ${{ parameters.VSCODE_QUALITY }} VSCODE_CLI_TARGET: aarch64-apple-darwin @@ -66,14 +66,23 @@ steps: OPENSSL_LIB_DIR: $(Build.ArtifactStagingDirectory)/openssl/arm64-osx/lib OPENSSL_INCLUDE_DIR: $(Build.ArtifactStagingDirectory)/openssl/arm64-osx/include - - ${{ if eq(parameters.VSCODE_BUILD_MACOS, true) }}: - - template: ../cli/cli-publish.yml - parameters: - VSCODE_CLI_ARTIFACT: unsigned_vscode_cli_darwin_x64_cli - VSCODE_CHECK_ONLY: ${{ parameters.VSCODE_CHECK_ONLY }} + - ${{ if not(parameters.VSCODE_CHECK_ONLY) }}: + - ${{ if eq(parameters.VSCODE_BUILD_MACOS, true) }}: + - task: 1ES.PublishPipelineArtifact@1 + inputs: + targetPath: $(Build.ArtifactStagingDirectory)/unsigned_vscode_cli_darwin_x64_cli.zip + artifactName: unsigned_vscode_cli_darwin_x64_cli + sbomBuildDropPath: $(Build.ArtifactStagingDirectory)/cli + sbomPackageName: "VS Code macOS x64 CLI (unsigned)" + sbomPackageVersion: $(Build.SourceVersion) + displayName: Publish unsigned_vscode_cli_darwin_x64_cli artifact - - ${{ if eq(parameters.VSCODE_BUILD_MACOS_ARM64, true) }}: - - template: ../cli/cli-publish.yml - parameters: - VSCODE_CLI_ARTIFACT: unsigned_vscode_cli_darwin_arm64_cli - VSCODE_CHECK_ONLY: ${{ parameters.VSCODE_CHECK_ONLY }} + - ${{ if eq(parameters.VSCODE_BUILD_MACOS_ARM64, true) }}: + - task: 1ES.PublishPipelineArtifact@1 + inputs: + targetPath: $(Build.ArtifactStagingDirectory)/unsigned_vscode_cli_darwin_arm64_cli.zip + artifactName: unsigned_vscode_cli_darwin_arm64_cli + sbomBuildDropPath: $(Build.ArtifactStagingDirectory)/cli + sbomPackageName: "VS Code macOS arm64 CLI (unsigned)" + sbomPackageVersion: $(Build.SourceVersion) + displayName: Publish unsigned_vscode_cli_darwin_arm64_cli artifact diff --git a/build/azure-pipelines/darwin/product-build-darwin-cli-sign.yml b/build/azure-pipelines/darwin/product-build-darwin-cli-sign.yml index 6f4132f45ff76..80e90a52bac8f 100644 --- a/build/azure-pipelines/darwin/product-build-darwin-cli-sign.yml +++ b/build/azure-pipelines/darwin/product-build-darwin-cli-sign.yml @@ -46,7 +46,7 @@ steps: workingDirectory: build displayName: Install build dependencies - - template: ../cli/cli-darwin-sign.yml + - template: ../cli/cli-darwin-sign.yml@self parameters: VSCODE_CLI_ARTIFACTS: - ${{ if eq(parameters.VSCODE_BUILD_MACOS, true) }}: diff --git a/build/azure-pipelines/darwin/product-build-darwin-sign.yml b/build/azure-pipelines/darwin/product-build-darwin-sign.yml index 3f31ac7bd35dd..fb8cf8341c360 100644 --- a/build/azure-pipelines/darwin/product-build-darwin-sign.yml +++ b/build/azure-pipelines/darwin/product-build-darwin-sign.yml @@ -32,7 +32,6 @@ steps: - script: unzip $(Pipeline.Workspace)/unsigned_vscode_client_darwin_$(VSCODE_ARCH)_archive/VSCode-darwin-$(VSCODE_ARCH).zip -d $(Agent.BuildDirectory)/VSCode-darwin-$(VSCODE_ARCH) displayName: Extract signed app - condition: and(succeeded(), ne(variables['VSCODE_ARCH'], 'arm64')) - script: | set -e @@ -58,5 +57,11 @@ steps: displayName: Rename x64 build to its legacy name condition: and(succeeded(), eq(variables['VSCODE_ARCH'], 'x64')) - - publish: $(Pipeline.Workspace)/unsigned_vscode_client_darwin_$(VSCODE_ARCH)_archive/VSCode-$(ASSET_ID).zip - artifact: vscode_client_darwin_$(VSCODE_ARCH)_archive + - task: 1ES.PublishPipelineArtifact@1 + inputs: + targetPath: $(Pipeline.Workspace)/unsigned_vscode_client_darwin_$(VSCODE_ARCH)_archive/VSCode-$(ASSET_ID).zip + artifactName: vscode_client_darwin_$(VSCODE_ARCH)_archive + sbomBuildDropPath: $(Agent.BuildDirectory)/VSCode-darwin-$(VSCODE_ARCH) + sbomPackageName: "VS Code macOS $(VSCODE_ARCH)" + sbomPackageVersion: $(Build.SourceVersion) + displayName: Publish client archive diff --git a/build/azure-pipelines/darwin/product-build-darwin-test.yml b/build/azure-pipelines/darwin/product-build-darwin-test.yml index 1ca8c9ec1a9ec..ed6d023651680 100644 --- a/build/azure-pipelines/darwin/product-build-darwin-test.yml +++ b/build/azure-pipelines/darwin/product-build-darwin-test.yml @@ -155,7 +155,7 @@ steps: condition: succeededOrFailed() - ${{ if or(eq(parameters.VSCODE_RUN_INTEGRATION_TESTS, true), eq(parameters.VSCODE_RUN_SMOKE_TESTS, true)) }}: - - task: PublishPipelineArtifact@0 + - task: 1ES.PublishPipelineArtifact@1 inputs: targetPath: .build/crashes ${{ if and(eq(parameters.VSCODE_RUN_INTEGRATION_TESTS, true), eq(parameters.VSCODE_RUN_SMOKE_TESTS, false)) }}: @@ -164,13 +164,14 @@ steps: artifactName: crash-dump-macos-$(VSCODE_ARCH)-smoke-$(System.JobAttempt) ${{ else }}: artifactName: crash-dump-macos-$(VSCODE_ARCH)-$(System.JobAttempt) + sbomEnabled: false displayName: "Publish Crash Reports" continueOnError: true condition: failed() # In order to properly symbolify above crash reports # (if any), we need the compiled native modules too - - task: PublishPipelineArtifact@0 + - task: 1ES.PublishPipelineArtifact@1 inputs: targetPath: node_modules ${{ if and(eq(parameters.VSCODE_RUN_INTEGRATION_TESTS, true), eq(parameters.VSCODE_RUN_SMOKE_TESTS, false)) }}: @@ -179,11 +180,12 @@ steps: artifactName: node-modules-macos-$(VSCODE_ARCH)-smoke-$(System.JobAttempt) ${{ else }}: artifactName: node-modules-macos-$(VSCODE_ARCH)-$(System.JobAttempt) + sbomEnabled: false displayName: "Publish Node Modules" continueOnError: true condition: failed() - - task: PublishPipelineArtifact@0 + - task: 1ES.PublishPipelineArtifact@1 inputs: targetPath: .build/logs ${{ if and(eq(parameters.VSCODE_RUN_INTEGRATION_TESTS, true), eq(parameters.VSCODE_RUN_SMOKE_TESTS, false)) }}: @@ -192,6 +194,7 @@ steps: artifactName: logs-macos-$(VSCODE_ARCH)-smoke-$(System.JobAttempt) ${{ else }}: artifactName: logs-macos-$(VSCODE_ARCH)-$(System.JobAttempt) + sbomEnabled: false displayName: "Publish Log Files" continueOnError: true condition: succeededOrFailed() diff --git a/build/azure-pipelines/darwin/product-build-darwin-universal.yml b/build/azure-pipelines/darwin/product-build-darwin-universal.yml index 1c21bb778ce34..f8b201f40d46f 100644 --- a/build/azure-pipelines/darwin/product-build-darwin-universal.yml +++ b/build/azure-pipelines/darwin/product-build-darwin-universal.yml @@ -5,7 +5,7 @@ steps: versionFilePath: .nvmrc nodejsMirror: https://github.com/joaomoreno/node-mirror/releases/download - - template: ../distro/download-distro.yml + - template: ../distro/download-distro.yml@self - task: AzureKeyVault@1 displayName: "Azure Key Vault: Get Secrets" @@ -82,6 +82,11 @@ steps: - script: pushd $(agent.builddirectory)/VSCode-darwin-$(VSCODE_ARCH) && zip -r -X -y $(agent.builddirectory)/VSCode-darwin-$(VSCODE_ARCH).zip * && popd displayName: Archive build - - publish: $(Agent.BuildDirectory)/VSCode-darwin-$(VSCODE_ARCH).zip - artifact: unsigned_vscode_client_darwin_$(VSCODE_ARCH)_archive + - task: 1ES.PublishPipelineArtifact@1 + inputs: + targetPath: $(Agent.BuildDirectory)/VSCode-darwin-$(VSCODE_ARCH).zip + artifactName: unsigned_vscode_client_darwin_$(VSCODE_ARCH)_archive + sbomBuildDropPath: $(Agent.BuildDirectory)/VSCode-darwin-$(VSCODE_ARCH) + sbomPackageName: "VS Code macOS $(VSCODE_ARCH) (unsigned)" + sbomPackageVersion: $(Build.SourceVersion) displayName: Publish client archive diff --git a/build/azure-pipelines/darwin/product-build-darwin.yml b/build/azure-pipelines/darwin/product-build-darwin.yml index 6bd68712c9df1..11aa7605f6365 100644 --- a/build/azure-pipelines/darwin/product-build-darwin.yml +++ b/build/azure-pipelines/darwin/product-build-darwin.yml @@ -23,7 +23,7 @@ steps: nodejsMirror: https://github.com/joaomoreno/node-mirror/releases/download - ${{ if ne(parameters.VSCODE_QUALITY, 'oss') }}: - - template: ../distro/download-distro.yml + - template: ../distro/download-distro.yml@self - task: AzureKeyVault@1 displayName: "Azure Key Vault: Get Secrets" @@ -114,7 +114,7 @@ steps: - script: node build/azure-pipelines/distro/mixin-quality displayName: Mixin distro quality - - template: ../common/install-builtin-extensions.yml + - template: ../common/install-builtin-extensions.yml@self - ${{ if ne(parameters.VSCODE_QUALITY, 'oss') }}: - script: | @@ -156,7 +156,7 @@ steps: displayName: Transpile - ${{ if or(eq(parameters.VSCODE_RUN_UNIT_TESTS, true), eq(parameters.VSCODE_RUN_INTEGRATION_TESTS, true), eq(parameters.VSCODE_RUN_SMOKE_TESTS, true)) }}: - - template: product-build-darwin-test.yml + - template: product-build-darwin-test.yml@self parameters: VSCODE_QUALITY: ${{ parameters.VSCODE_QUALITY }} VSCODE_RUN_UNIT_TESTS: ${{ parameters.VSCODE_RUN_UNIT_TESTS }} @@ -176,8 +176,7 @@ steps: APP_ROOT="$(Agent.BuildDirectory)/VSCode-darwin-$(VSCODE_ARCH)" APP_NAME="`ls $APP_ROOT | head -n 1`" APP_PATH="$APP_ROOT/$APP_NAME" - ARCHIVE_NAME=$(ls "$(Build.ArtifactStagingDirectory)/cli" | head -n 1) - unzip "$(Build.ArtifactStagingDirectory)/cli/$ARCHIVE_NAME" -d "$(Build.ArtifactStagingDirectory)/cli" + unzip $(Build.ArtifactStagingDirectory)/cli/*.zip -d $(Build.ArtifactStagingDirectory)/cli CLI_APP_NAME=$(node -p "require(\"$APP_PATH/Contents/Resources/app/product.json\").tunnelApplicationName") APP_NAME=$(node -p "require(\"$APP_PATH/Contents/Resources/app/product.json\").applicationName") mv "$(Build.ArtifactStagingDirectory)/cli/$APP_NAME" "$APP_PATH/Contents/Resources/app/bin/$CLI_APP_NAME" @@ -212,38 +211,31 @@ steps: condition: and(succeededOrFailed(), notIn(variables['Agent.JobStatus'], 'Succeeded', 'SucceededWithIssues')) displayName: Generate artifact prefix - - task: AzureArtifacts.manifest-generator-task.manifest-generator-task.ManifestGeneratorTask@0 - displayName: Generate SBOM (client) + - task: 1ES.PublishPipelineArtifact@1 inputs: - BuildDropPath: $(agent.builddirectory)/VSCode-darwin-$(VSCODE_ARCH) - PackageName: Visual Studio Code - - - task: AzureArtifacts.manifest-generator-task.manifest-generator-task.ManifestGeneratorTask@0 - displayName: Generate SBOM (server) - inputs: - BuildComponentPath: $(Build.SourcesDirectory)/remote - BuildDropPath: $(agent.builddirectory)/vscode-server-darwin-$(VSCODE_ARCH) - PackageName: Visual Studio Code Server - - - publish: $(agent.builddirectory)/VSCode-darwin-$(VSCODE_ARCH)/_manifest - displayName: Publish SBOM (client) - artifact: $(ARTIFACT_PREFIX)sbom_vscode_client_darwin_$(VSCODE_ARCH) - - - publish: $(agent.builddirectory)/vscode-server-darwin-$(VSCODE_ARCH)/_manifest - displayName: Publish SBOM (server) - artifact: $(ARTIFACT_PREFIX)sbom_vscode_server_darwin_$(VSCODE_ARCH) - - - publish: $(CLIENT_PATH) - artifact: $(ARTIFACT_PREFIX)unsigned_vscode_client_darwin_$(VSCODE_ARCH)_archive - condition: and(succeededOrFailed(), ne(variables['CLIENT_PATH'], '')) + targetPath: $(CLIENT_PATH) + artifactName: $(ARTIFACT_PREFIX)unsigned_vscode_client_darwin_$(VSCODE_ARCH)_archive + sbomBuildDropPath: $(Agent.BuildDirectory)/VSCode-darwin-$(VSCODE_ARCH) + sbomPackageName: "VS Code macOS $(VSCODE_ARCH) (unsigned)" + sbomPackageVersion: $(Build.SourceVersion) displayName: Publish client archive - - publish: $(SERVER_PATH) - artifact: $(ARTIFACT_PREFIX)vscode_server_darwin_$(VSCODE_ARCH)_archive-unsigned + - task: 1ES.PublishPipelineArtifact@1 + inputs: + targetPath: $(SERVER_PATH) + artifactName: $(ARTIFACT_PREFIX)vscode_server_darwin_$(VSCODE_ARCH)_archive-unsigned + sbomBuildDropPath: $(Agent.BuildDirectory)/vscode-server-darwin-$(VSCODE_ARCH) + sbomPackageName: "VS Code macOS $(VSCODE_ARCH) Server" + sbomPackageVersion: $(Build.SourceVersion) condition: and(succeededOrFailed(), ne(variables['SERVER_PATH'], '')) displayName: Publish server archive - - publish: $(WEB_PATH) - artifact: $(ARTIFACT_PREFIX)vscode_web_darwin_$(VSCODE_ARCH)_archive-unsigned + - task: 1ES.PublishPipelineArtifact@1 + inputs: + targetPath: $(WEB_PATH) + artifactName: $(ARTIFACT_PREFIX)vscode_web_darwin_$(VSCODE_ARCH)_archive-unsigned + sbomBuildDropPath: $(Agent.BuildDirectory)/vscode-server-darwin-$(VSCODE_ARCH)-web + sbomPackageName: "VS Code macOS $(VSCODE_ARCH) Web" + sbomPackageVersion: $(Build.SourceVersion) condition: and(succeededOrFailed(), ne(variables['WEB_PATH'], '')) displayName: Publish web server archive diff --git a/build/azure-pipelines/distro-build.yml b/build/azure-pipelines/distro-build.yml index c0a8e354c7b8a..ee5dd5d99197f 100644 --- a/build/azure-pipelines/distro-build.yml +++ b/build/azure-pipelines/distro-build.yml @@ -12,4 +12,4 @@ steps: versionSource: fromFile versionFilePath: .nvmrc nodejsMirror: https://github.com/joaomoreno/node-mirror/releases/download - - template: ./distro/download-distro.yml + - template: ./distro/download-distro.yml@self diff --git a/build/azure-pipelines/linux/cli-build-linux.yml b/build/azure-pipelines/linux/cli-build-linux.yml index ff851c63c96df..f3e2ef88b9def 100644 --- a/build/azure-pipelines/linux/cli-build-linux.yml +++ b/build/azure-pipelines/linux/cli-build-linux.yml @@ -22,7 +22,7 @@ steps: nodejsMirror: https://github.com/joaomoreno/node-mirror/releases/download - ${{ if ne(parameters.VSCODE_QUALITY, 'oss') }}: - - template: ../cli/cli-apply-patches.yml + - template: ../cli/cli-apply-patches.yml@self - task: Npm@1 displayName: Download openssl prebuilt @@ -79,7 +79,7 @@ steps: mkdir -p $(Build.SourcesDirectory)/.build displayName: Create .build folder for misc dependencies - - template: ../cli/install-rust-posix.yml + - template: ../cli/install-rust-posix.yml@self parameters: targets: - ${{ if eq(parameters.VSCODE_BUILD_LINUX_ARM64, true) }}: @@ -90,7 +90,7 @@ steps: - armv7-unknown-linux-gnueabihf - ${{ if eq(parameters.VSCODE_BUILD_LINUX_ARM64, true) }}: - - template: ../cli/cli-compile.yml + - template: ../cli/cli-compile.yml@self parameters: VSCODE_QUALITY: ${{ parameters.VSCODE_QUALITY }} VSCODE_CLI_TARGET: aarch64-unknown-linux-gnu @@ -102,7 +102,7 @@ steps: SYSROOT_ARCH: arm64 - ${{ if eq(parameters.VSCODE_BUILD_LINUX, true) }}: - - template: ../cli/cli-compile.yml + - template: ../cli/cli-compile.yml@self parameters: VSCODE_QUALITY: ${{ parameters.VSCODE_QUALITY }} VSCODE_CLI_TARGET: x86_64-unknown-linux-gnu @@ -114,7 +114,7 @@ steps: SYSROOT_ARCH: amd64 - ${{ if eq(parameters.VSCODE_BUILD_LINUX_ARMHF, true) }}: - - template: ../cli/cli-compile.yml + - template: ../cli/cli-compile.yml@self parameters: VSCODE_QUALITY: ${{ parameters.VSCODE_QUALITY }} VSCODE_CLI_TARGET: armv7-unknown-linux-gnueabihf @@ -125,20 +125,33 @@ steps: OPENSSL_INCLUDE_DIR: $(Build.ArtifactStagingDirectory)/openssl/arm-linux/include SYSROOT_ARCH: armhf - - ${{ if eq(parameters.VSCODE_BUILD_LINUX_ARMHF, true) }}: - - template: ../cli/cli-publish.yml - parameters: - VSCODE_CLI_ARTIFACT: vscode_cli_linux_armhf_cli - VSCODE_CHECK_ONLY: ${{ parameters.VSCODE_CHECK_ONLY }} - - - ${{ if eq(parameters.VSCODE_BUILD_LINUX, true) }}: - - template: ../cli/cli-publish.yml - parameters: - VSCODE_CLI_ARTIFACT: vscode_cli_linux_x64_cli - VSCODE_CHECK_ONLY: ${{ parameters.VSCODE_CHECK_ONLY }} - - - ${{ if eq(parameters.VSCODE_BUILD_LINUX_ARM64, true) }}: - - template: ../cli/cli-publish.yml - parameters: - VSCODE_CLI_ARTIFACT: vscode_cli_linux_arm64_cli - VSCODE_CHECK_ONLY: ${{ parameters.VSCODE_CHECK_ONLY }} + - ${{ if not(parameters.VSCODE_CHECK_ONLY) }}: + - ${{ if eq(parameters.VSCODE_BUILD_LINUX_ARMHF, true) }}: + - task: 1ES.PublishPipelineArtifact@1 + inputs: + targetPath: $(Build.ArtifactStagingDirectory)/vscode_cli_linux_armhf_cli.tar.gz + artifactName: vscode_cli_linux_armhf_cli + sbomBuildDropPath: $(Build.ArtifactStagingDirectory)/cli + sbomPackageName: "VS Code Linux armhf CLI" + sbomPackageVersion: $(Build.SourceVersion) + displayName: Publish vscode_cli_linux_armhf_cli artifact + + - ${{ if eq(parameters.VSCODE_BUILD_LINUX, true) }}: + - task: 1ES.PublishPipelineArtifact@1 + inputs: + targetPath: $(Build.ArtifactStagingDirectory)/vscode_cli_linux_x64_cli.tar.gz + artifactName: vscode_cli_linux_x64_cli + sbomBuildDropPath: $(Build.ArtifactStagingDirectory)/cli + sbomPackageName: "VS Code Linux x64 CLI" + sbomPackageVersion: $(Build.SourceVersion) + displayName: Publish vscode_cli_linux_x64_cli artifact + + - ${{ if eq(parameters.VSCODE_BUILD_LINUX_ARM64, true) }}: + - task: 1ES.PublishPipelineArtifact@1 + inputs: + targetPath: $(Build.ArtifactStagingDirectory)/vscode_cli_linux_arm64_cli.tar.gz + artifactName: vscode_cli_linux_arm64_cli + sbomBuildDropPath: $(Build.ArtifactStagingDirectory)/cli + sbomPackageName: "VS Code Linux arm64 CLI" + sbomPackageVersion: $(Build.SourceVersion) + displayName: Publish vscode_cli_linux_arm64_cli artifact diff --git a/build/azure-pipelines/linux/install.sh b/build/azure-pipelines/linux/install.sh deleted file mode 100755 index 57f58763ccaab..0000000000000 --- a/build/azure-pipelines/linux/install.sh +++ /dev/null @@ -1,58 +0,0 @@ -#!/usr/bin/env bash - -set -e - -# To workaround the issue of yarn not respecting the registry value from .npmrc -yarn config set registry "$NPM_REGISTRY" - -SYSROOT_ARCH=$VSCODE_ARCH -if [ "$SYSROOT_ARCH" == "x64" ]; then - SYSROOT_ARCH="amd64" -fi - -export VSCODE_SYSROOT_DIR=$PWD/.build/sysroots -SYSROOT_ARCH="$SYSROOT_ARCH" node -e '(async () => { const { getVSCodeSysroot } = require("./build/linux/debian/install-sysroot.js"); await getVSCodeSysroot(process.env["SYSROOT_ARCH"]); })()' - -if [ "$npm_config_arch" == "x64" ]; then - # Download clang based on chromium revision used by vscode - curl -s https://raw.githubusercontent.com/chromium/chromium/118.0.5993.159/tools/clang/scripts/update.py | python - --output-dir=$PWD/.build/CR_Clang --host-os=linux - - # Download libcxx headers and objects from upstream electron releases - DEBUG=libcxx-fetcher \ - VSCODE_LIBCXX_OBJECTS_DIR=$PWD/.build/libcxx-objects \ - VSCODE_LIBCXX_HEADERS_DIR=$PWD/.build/libcxx_headers \ - VSCODE_LIBCXXABI_HEADERS_DIR=$PWD/.build/libcxxabi_headers \ - VSCODE_ARCH="$npm_config_arch" \ - node build/linux/libcxx-fetcher.js - - # Set compiler toolchain - # Flags for the client build are based on - # https://source.chromium.org/chromium/chromium/src/+/refs/tags/118.0.5993.159:build/config/arm.gni - # https://source.chromium.org/chromium/chromium/src/+/refs/tags/118.0.5993.159:build/config/compiler/BUILD.gn - # https://source.chromium.org/chromium/chromium/src/+/refs/tags/118.0.5993.159:build/config/c++/BUILD.gn - export CC="$PWD/.build/CR_Clang/bin/clang --gcc-toolchain=$VSCODE_SYSROOT_DIR/x86_64-linux-gnu" - export CXX="$PWD/.build/CR_Clang/bin/clang++ --gcc-toolchain=$VSCODE_SYSROOT_DIR/x86_64-linux-gnu" - export CXXFLAGS="-nostdinc++ -D__NO_INLINE__ -I$PWD/.build/libcxx_headers -isystem$PWD/.build/libcxx_headers/include -isystem$PWD/.build/libcxxabi_headers/include -fPIC -flto=thin -fsplit-lto-unit -D_LIBCPP_ABI_NAMESPACE=Cr --sysroot=$VSCODE_SYSROOT_DIR/x86_64-linux-gnu/x86_64-linux-gnu/sysroot" - export LDFLAGS="-stdlib=libc++ --sysroot=$VSCODE_SYSROOT_DIR/x86_64-linux-gnu/x86_64-linux-gnu/sysroot -fuse-ld=lld -flto=thin -L$PWD/.build/libcxx-objects -lc++abi -L$VSCODE_SYSROOT_DIR/x86_64-linux-gnu/x86_64-linux-gnu/sysroot/usr/lib/x86_64-linux-gnu -L$VSCODE_SYSROOT_DIR/x86_64-linux-gnu/x86_64-linux-gnu/sysroot/lib/x86_64-linux-gnu -Wl,--lto-O0" -elif [ "$npm_config_arch" == "arm64" ]; then - # Set compiler toolchain for client native modules and remote server - export CC=$VSCODE_SYSROOT_DIR/aarch64-linux-gnu/bin/aarch64-linux-gnu-gcc - export CXX=$VSCODE_SYSROOT_DIR/aarch64-linux-gnu/bin/aarch64-linux-gnu-g++ - export CXXFLAGS="--sysroot=$VSCODE_SYSROOT_DIR/aarch64-linux-gnu/aarch64-linux-gnu/sysroot" - export LDFLAGS="--sysroot=$VSCODE_SYSROOT_DIR/aarch64-linux-gnu/aarch64-linux-gnu/sysroot -L$VSCODE_SYSROOT_DIR/aarch64-linux-gnu/aarch64-linux-gnu/sysroot/usr/lib/aarch64-linux-gnu -L$VSCODE_SYSROOT_DIR/aarch64-linux-gnu/aarch64-linux-gnu/sysroot/lib/aarch64-linux-gnu" -elif [ "$npm_config_arch" == "arm" ]; then - # Set compiler toolchain for client native modules and remote server - export CC=$VSCODE_SYSROOT_DIR/arm-rpi-linux-gnueabihf/bin/arm-rpi-linux-gnueabihf-gcc - export CXX=$VSCODE_SYSROOT_DIR/arm-rpi-linux-gnueabihf/bin/arm-rpi-linux-gnueabihf-g++ - export CXXFLAGS="--sysroot=$VSCODE_SYSROOT_DIR/arm-rpi-linux-gnueabihf/arm-rpi-linux-gnueabihf/sysroot" - export LDFLAGS="--sysroot=$VSCODE_SYSROOT_DIR/arm-rpi-linux-gnueabihf/arm-rpi-linux-gnueabihf/sysroot -L$VSCODE_SYSROOT_DIR/arm-rpi-linux-gnueabihf/arm-rpi-linux-gnueabihf/sysroot/usr/lib/arm-linux-gnueabihf -L$VSCODE_SYSROOT_DIR/arm-rpi-linux-gnueabihf/arm-rpi-linux-gnueabihf/sysroot/lib/arm-linux-gnueabihf" -fi - -for i in {1..5}; do # try 5 times - yarn --frozen-lockfile --check-files && break - if [ $i -eq 3 ]; then - echo "Yarn failed too many times" >&2 - exit 1 - fi - echo "Yarn failed $i, trying again..." -done diff --git a/build/azure-pipelines/linux/product-build-linux-legacy-server.yml b/build/azure-pipelines/linux/product-build-linux-legacy-server.yml new file mode 100644 index 0000000000000..dc8424f26eea4 --- /dev/null +++ b/build/azure-pipelines/linux/product-build-linux-legacy-server.yml @@ -0,0 +1,223 @@ +parameters: + - name: VSCODE_QUALITY + type: string + - name: VSCODE_RUN_INTEGRATION_TESTS + type: boolean + - name: VSCODE_ARCH + type: string + +steps: + - task: NodeTool@0 + inputs: + versionSource: fromFile + versionFilePath: .nvmrc + nodejsMirror: https://github.com/joaomoreno/node-mirror/releases/download + + - template: ../distro/download-distro.yml + + - task: AzureKeyVault@1 + displayName: "Azure Key Vault: Get Secrets" + inputs: + azureSubscription: "vscode-builds-subscription" + KeyVaultName: vscode-build-secrets + SecretsFilter: "github-distro-mixin-password" + + - task: DownloadPipelineArtifact@2 + inputs: + artifact: Compilation + path: $(Build.ArtifactStagingDirectory) + displayName: Download compilation output + + - script: tar -xzf $(Build.ArtifactStagingDirectory)/compilation.tar.gz + displayName: Extract compilation output + + - script: | + set -e + # Start X server + sudo apt-get update + sudo apt-get install -y pkg-config \ + dbus \ + xvfb \ + libgtk-3-0 \ + libxkbfile-dev \ + libkrb5-dev \ + libgbm1 \ + rpm + sudo cp build/azure-pipelines/linux/xvfb.init /etc/init.d/xvfb + sudo chmod +x /etc/init.d/xvfb + sudo update-rc.d xvfb defaults + sudo service xvfb start + # Start dbus session + sudo mkdir -p /var/run/dbus + DBUS_LAUNCH_RESULT=$(sudo dbus-daemon --config-file=/usr/share/dbus-1/system.conf --print-address) + echo "##vso[task.setvariable variable=DBUS_SESSION_BUS_ADDRESS]$DBUS_LAUNCH_RESULT" + displayName: Setup system services + + - script: node build/setup-npm-registry.js $NPM_REGISTRY + condition: and(succeeded(), ne(variables['NPM_REGISTRY'], 'none')) + displayName: Setup NPM Registry + + - script: | + set -e + npm config set registry "$NPM_REGISTRY" --location=project + # npm >v7 deprecated the `always-auth` config option, refs npm/cli@72a7eeb + # following is a workaround for yarn to send authorization header + # for GET requests to the registry. + echo "always-auth=true" >> .npmrc + yarn config set registry "$NPM_REGISTRY" + condition: and(succeeded(), ne(variables['NPM_REGISTRY'], 'none')) + displayName: Setup NPM & Yarn + + - task: npmAuthenticate@0 + inputs: + workingFile: .npmrc + condition: and(succeeded(), ne(variables['NPM_REGISTRY'], 'none')) + displayName: Setup NPM Authentication + + - ${{ if or(eq(parameters.VSCODE_ARCH, 'x64'), eq(parameters.VSCODE_ARCH, 'arm64')) }}: + - task: Docker@1 + displayName: "Pull Docker image" + inputs: + azureSubscriptionEndpoint: "vscode-builds-subscription" + azureContainerRegistry: vscodehub.azurecr.io + command: "Run an image" + imageName: vscode-linux-build-agent:centos7-devtoolset8-$(VSCODE_ARCH) + containerCommand: uname + + - ${{ if eq(parameters.VSCODE_ARCH, 'armhf') }}: + - task: Docker@1 + displayName: "Pull Docker image" + inputs: + azureSubscriptionEndpoint: "vscode-builds-subscription" + azureContainerRegistry: vscodehub.azurecr.io + command: "Run an image" + imageName: vscode-linux-build-agent:bionic-arm32v7 + containerCommand: uname + + - script: | + set -e + # To workaround the issue of yarn not respecting the registry value from .npmrc + yarn config set registry "$NPM_REGISTRY" + + for i in {1..5}; do # try 5 times + yarn --cwd build --frozen-lockfile --check-files && break + if [ $i -eq 3 ]; then + echo "Yarn failed too many times" >&2 + exit 1 + fi + echo "Yarn failed $i, trying again..." + done + + export VSCODE_SYSROOT_PREFIX='-glibc-2.17' + source ./build/azure-pipelines/linux/setup-env.sh --only-remote + + for i in {1..5}; do # try 5 times + yarn --frozen-lockfile --check-files && break + if [ $i -eq 3 ]; then + echo "Yarn failed too many times" >&2 + exit 1 + fi + echo "Yarn failed $i, trying again..." + done + env: + npm_config_arch: $(NPM_ARCH) + VSCODE_ARCH: $(VSCODE_ARCH) + NPM_REGISTRY: "$(NPM_REGISTRY)" + ELECTRON_SKIP_BINARY_DOWNLOAD: 1 + PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1 + GITHUB_TOKEN: "$(github-distro-mixin-password)" + VSCODE_HOST_MOUNT: "/mnt/vss/_work/1/s" + ${{ if or(eq(parameters.VSCODE_ARCH, 'x64'), eq(parameters.VSCODE_ARCH, 'arm64')) }}: + VSCODE_REMOTE_DEPENDENCIES_CONTAINER_NAME: vscodehub.azurecr.io/vscode-linux-build-agent:centos7-devtoolset8-$(VSCODE_ARCH) + ${{ if eq(parameters.VSCODE_ARCH, 'armhf') }}: + VSCODE_REMOTE_DEPENDENCIES_CONTAINER_NAME: vscodehub.azurecr.io/vscode-linux-build-agent:bionic-arm32v7 + displayName: Install dependencies + + - ${{ if or(eq(parameters.VSCODE_ARCH, 'x64'), eq(parameters.VSCODE_ARCH, 'arm64')) }}: + - script: | + set -e + EXPECTED_GLIBC_VERSION="2.17" \ + EXPECTED_GLIBCXX_VERSION="3.4.19" \ + ./build/azure-pipelines/linux/verify-glibc-requirements.sh + displayName: Check GLIBC and GLIBCXX dependencies in remote/node_modules + + - script: node build/azure-pipelines/distro/mixin-npm + displayName: Mixin distro node modules + + - script: node build/azure-pipelines/distro/mixin-quality + displayName: Mixin distro quality + + - template: ../common/install-builtin-extensions.yml + + - script: | + set -e + yarn gulp vscode-linux-$(VSCODE_ARCH)-min-ci + ARCHIVE_PATH=".build/linux/client/code-${{ parameters.VSCODE_QUALITY }}-$(VSCODE_ARCH)-$(date +%s).tar.gz" + mkdir -p $(dirname $ARCHIVE_PATH) + echo "##vso[task.setvariable variable=CLIENT_PATH]$ARCHIVE_PATH" + env: + GITHUB_TOKEN: "$(github-distro-mixin-password)" + displayName: Build client + + - script: | + set -e + tar -czf $CLIENT_PATH -C .. VSCode-linux-$(VSCODE_ARCH) + env: + GITHUB_TOKEN: "$(github-distro-mixin-password)" + displayName: Archive client + + - script: | + set -e + export VSCODE_NODE_GLIBC="-glibc-2.17" + yarn gulp vscode-reh-linux-$(VSCODE_ARCH)-min-ci + mv ../vscode-reh-linux-$(VSCODE_ARCH) ../vscode-server-linux-$(VSCODE_ARCH) # TODO@joaomoreno + ARCHIVE_PATH=".build/linux/server/vscode-server-linux-legacy-$(VSCODE_ARCH).tar.gz" + mkdir -p $(dirname $ARCHIVE_PATH) + tar --owner=0 --group=0 -czf $ARCHIVE_PATH -C .. vscode-server-linux-$(VSCODE_ARCH) + echo "##vso[task.setvariable variable=SERVER_PATH]$ARCHIVE_PATH" + env: + GITHUB_TOKEN: "$(github-distro-mixin-password)" + displayName: Build server + + - script: | + set -e + export VSCODE_NODE_GLIBC="-glibc-2.17" + yarn gulp vscode-reh-web-linux-$(VSCODE_ARCH)-min-ci + mv ../vscode-reh-web-linux-$(VSCODE_ARCH) ../vscode-server-linux-$(VSCODE_ARCH)-web # TODO@joaomoreno + ARCHIVE_PATH=".build/linux/web/vscode-server-linux-legacy-$(VSCODE_ARCH)-web.tar.gz" + mkdir -p $(dirname $ARCHIVE_PATH) + tar --owner=0 --group=0 -czf $ARCHIVE_PATH -C .. vscode-server-linux-$(VSCODE_ARCH)-web + echo "##vso[task.setvariable variable=WEB_PATH]$ARCHIVE_PATH" + env: + GITHUB_TOKEN: "$(github-distro-mixin-password)" + displayName: Build server (web) + + - ${{ if eq(parameters.VSCODE_RUN_INTEGRATION_TESTS, true) }}: + - template: product-build-linux-test.yml + parameters: + VSCODE_QUALITY: ${{ parameters.VSCODE_QUALITY }} + VSCODE_RUN_UNIT_TESTS: false + VSCODE_RUN_INTEGRATION_TESTS: ${{ parameters.VSCODE_RUN_INTEGRATION_TESTS }} + VSCODE_RUN_SMOKE_TESTS: false + ${{ if ne(parameters.VSCODE_QUALITY, 'oss') }}: + PUBLISH_TASK_NAME: 1ES.PublishPipelineArtifact@1 + + - task: 1ES.PublishPipelineArtifact@1 + inputs: + targetPath: $(SERVER_PATH) + artifactName: $(ARTIFACT_PREFIX)vscode_server_linux_legacy_$(VSCODE_ARCH)_archive-unsigned + sbomBuildDropPath: $(Agent.BuildDirectory)/vscode-server-linux-$(VSCODE_ARCH) + sbomPackageName: "VS Code Linux $(VSCODE_ARCH) Legacy Server" + sbomPackageVersion: $(Build.SourceVersion) + condition: and(succeededOrFailed(), ne(variables['SERVER_PATH'], '')) + displayName: Publish server archive + + - task: 1ES.PublishPipelineArtifact@1 + inputs: + targetPath: $(WEB_PATH) + artifactName: $(ARTIFACT_PREFIX)vscode_web_linux_legacy_$(VSCODE_ARCH)_archive-unsigned + sbomBuildDropPath: $(Agent.BuildDirectory)/vscode-server-linux-$(VSCODE_ARCH)-web + sbomPackageName: "VS Code Linux $(VSCODE_ARCH) Legacy Web" + sbomPackageVersion: $(Build.SourceVersion) + condition: and(succeededOrFailed(), ne(variables['WEB_PATH'], '')) + displayName: Publish web server archive diff --git a/build/azure-pipelines/linux/product-build-linux-test.yml b/build/azure-pipelines/linux/product-build-linux-test.yml index 74ebb91e3445c..f5c00aa0cf05a 100644 --- a/build/azure-pipelines/linux/product-build-linux-test.yml +++ b/build/azure-pipelines/linux/product-build-linux-test.yml @@ -7,6 +7,9 @@ parameters: type: boolean - name: VSCODE_RUN_SMOKE_TESTS type: boolean + - name: PUBLISH_TASK_NAME + type: string + default: PublishPipelineArtifact@0 steps: - script: yarn npm-run-all -lp "electron $(VSCODE_ARCH)" "playwright-install" @@ -197,7 +200,7 @@ steps: condition: succeededOrFailed() - ${{ if or(eq(parameters.VSCODE_RUN_INTEGRATION_TESTS, true), eq(parameters.VSCODE_RUN_SMOKE_TESTS, true)) }}: - - task: PublishPipelineArtifact@0 + - task: ${{ parameters.PUBLISH_TASK_NAME }} inputs: targetPath: .build/crashes ${{ if and(eq(parameters.VSCODE_RUN_INTEGRATION_TESTS, true), eq(parameters.VSCODE_RUN_SMOKE_TESTS, false)) }}: @@ -206,13 +209,14 @@ steps: artifactName: crash-dump-linux-$(VSCODE_ARCH)-smoke-$(System.JobAttempt) ${{ else }}: artifactName: crash-dump-linux-$(VSCODE_ARCH)-$(System.JobAttempt) + sbomEnabled: false displayName: "Publish Crash Reports" continueOnError: true condition: failed() # In order to properly symbolify above crash reports # (if any), we need the compiled native modules too - - task: PublishPipelineArtifact@0 + - task: ${{ parameters.PUBLISH_TASK_NAME }} inputs: targetPath: node_modules ${{ if and(eq(parameters.VSCODE_RUN_INTEGRATION_TESTS, true), eq(parameters.VSCODE_RUN_SMOKE_TESTS, false)) }}: @@ -221,11 +225,12 @@ steps: artifactName: node-modules-linux-$(VSCODE_ARCH)-smoke-$(System.JobAttempt) ${{ else }}: artifactName: node-modules-linux-$(VSCODE_ARCH)-$(System.JobAttempt) + sbomEnabled: false displayName: "Publish Node Modules" continueOnError: true condition: failed() - - task: PublishPipelineArtifact@0 + - task: ${{ parameters.PUBLISH_TASK_NAME }} inputs: targetPath: .build/logs ${{ if and(eq(parameters.VSCODE_RUN_INTEGRATION_TESTS, true), eq(parameters.VSCODE_RUN_SMOKE_TESTS, false)) }}: @@ -234,6 +239,7 @@ steps: artifactName: logs-linux-$(VSCODE_ARCH)-smoke-$(System.JobAttempt) ${{ else }}: artifactName: logs-linux-$(VSCODE_ARCH)-$(System.JobAttempt) + sbomEnabled: false displayName: "Publish Log Files" continueOnError: true condition: succeededOrFailed() diff --git a/build/azure-pipelines/linux/product-build-linux.yml b/build/azure-pipelines/linux/product-build-linux.yml index e4b4fab899b3c..cdc687fe7ac52 100644 --- a/build/azure-pipelines/linux/product-build-linux.yml +++ b/build/azure-pipelines/linux/product-build-linux.yml @@ -25,7 +25,7 @@ steps: nodejsMirror: https://github.com/joaomoreno/node-mirror/releases/download - ${{ if ne(parameters.VSCODE_QUALITY, 'oss') }}: - - template: ../distro/download-distro.yml + - template: ../distro/download-distro.yml@self - task: AzureKeyVault@1 displayName: "Azure Key Vault: Get Secrets" @@ -103,6 +103,8 @@ steps: - ${{ if ne(parameters.VSCODE_QUALITY, 'oss') }}: - script: | set -e + # To workaround the issue of yarn not respecting the registry value from .npmrc + yarn config set registry "$NPM_REGISTRY" for i in {1..5}; do # try 5 times yarn --cwd build --frozen-lockfile --check-files && break @@ -113,7 +115,16 @@ steps: echo "Yarn failed $i, trying again..." done - ./build/azure-pipelines/linux/install.sh + source ./build/azure-pipelines/linux/setup-env.sh + + for i in {1..5}; do # try 5 times + yarn --frozen-lockfile --check-files && break + if [ $i -eq 3 ]; then + echo "Yarn failed too many times" >&2 + exit 1 + fi + echo "Yarn failed $i, trying again..." + done env: npm_config_arch: $(NPM_ARCH) VSCODE_ARCH: $(VSCODE_ARCH) @@ -121,23 +132,17 @@ steps: ELECTRON_SKIP_BINARY_DOWNLOAD: 1 PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1 GITHUB_TOKEN: "$(github-distro-mixin-password)" - VSCODE_HOST_MOUNT: "/mnt/vss/_work/1/s" - ${{ if or(eq(parameters.VSCODE_ARCH, 'x64'), eq(parameters.VSCODE_ARCH, 'arm64')) }}: - VSCODE_REMOTE_DEPENDENCIES_CONTAINER_NAME: vscodehub.azurecr.io/vscode-linux-build-agent:centos7-devtoolset8-$(VSCODE_ARCH) - ${{ if eq(parameters.VSCODE_ARCH, 'armhf') }}: - VSCODE_REMOTE_DEPENDENCIES_CONTAINER_NAME: vscodehub.azurecr.io/vscode-linux-build-agent:bionic-arm32v7 displayName: Install dependencies (non-OSS) condition: and(succeeded(), ne(variables.NODE_MODULES_RESTORED, 'true')) - - ${{ if or(eq(parameters.VSCODE_ARCH, 'x64'), eq(parameters.VSCODE_ARCH, 'arm64')) }}: - - script: | - set -e + - script: | + set -e - EXPECTED_GLIBC_VERSION="2.17" \ - EXPECTED_GLIBCXX_VERSION="3.4.19" \ - ./build/azure-pipelines/linux/verify-glibc-requirements.sh - condition: and(succeeded(), ne(variables.NODE_MODULES_RESTORED, 'true')) - displayName: Check GLIBC and GLIBCXX dependencies in remote/node_modules + EXPECTED_GLIBC_VERSION="2.28" \ + EXPECTED_GLIBCXX_VERSION="3.4.25" \ + ./build/azure-pipelines/linux/verify-glibc-requirements.sh + condition: and(succeeded(), ne(variables.NODE_MODULES_RESTORED, 'true')) + displayName: Check GLIBC and GLIBCXX dependencies in remote/node_modules - script: node build/azure-pipelines/distro/mixin-npm condition: and(succeeded(), ne(variables.NODE_MODULES_RESTORED, 'true')) @@ -180,7 +185,7 @@ steps: - script: node build/azure-pipelines/distro/mixin-quality displayName: Mixin distro quality - - template: ../common/install-builtin-extensions.yml + - template: ../common/install-builtin-extensions.yml@self - ${{ if ne(parameters.VSCODE_QUALITY, 'oss') }}: - script: | @@ -218,7 +223,6 @@ steps: - script: | set -e - export VSCODE_NODE_GLIBC='-glibc-2.17' yarn gulp vscode-reh-linux-$(VSCODE_ARCH)-min-ci mv ../vscode-reh-linux-$(VSCODE_ARCH) ../vscode-server-linux-$(VSCODE_ARCH) # TODO@joaomoreno ARCHIVE_PATH=".build/linux/server/vscode-server-linux-$(VSCODE_ARCH).tar.gz" @@ -231,7 +235,6 @@ steps: - script: | set -e - export VSCODE_NODE_GLIBC='-glibc-2.17' yarn gulp vscode-reh-web-linux-$(VSCODE_ARCH)-min-ci mv ../vscode-reh-web-linux-$(VSCODE_ARCH) ../vscode-server-linux-$(VSCODE_ARCH)-web # TODO@joaomoreno ARCHIVE_PATH=".build/linux/web/vscode-server-linux-$(VSCODE_ARCH)-web.tar.gz" @@ -249,12 +252,14 @@ steps: displayName: Transpile client and extensions - ${{ if or(eq(parameters.VSCODE_RUN_UNIT_TESTS, true), eq(parameters.VSCODE_RUN_INTEGRATION_TESTS, true), eq(parameters.VSCODE_RUN_SMOKE_TESTS, true)) }}: - - template: product-build-linux-test.yml + - template: product-build-linux-test.yml@self parameters: VSCODE_QUALITY: ${{ parameters.VSCODE_QUALITY }} VSCODE_RUN_UNIT_TESTS: ${{ parameters.VSCODE_RUN_UNIT_TESTS }} VSCODE_RUN_INTEGRATION_TESTS: ${{ parameters.VSCODE_RUN_INTEGRATION_TESTS }} VSCODE_RUN_SMOKE_TESTS: ${{ parameters.VSCODE_RUN_SMOKE_TESTS }} + ${{ if ne(parameters.VSCODE_QUALITY, 'oss') }}: + PUBLISH_TASK_NAME: 1ES.PublishPipelineArtifact@1 - ${{ if and(ne(parameters.VSCODE_CIBUILD, true), ne(parameters.VSCODE_QUALITY, 'oss')) }}: - script: | @@ -308,53 +313,60 @@ steps: condition: and(succeededOrFailed(), notIn(variables['Agent.JobStatus'], 'Succeeded', 'SucceededWithIssues')) displayName: Generate artifact prefix - - task: AzureArtifacts.manifest-generator-task.manifest-generator-task.ManifestGeneratorTask@0 - displayName: Generate SBOM (client) - inputs: - BuildDropPath: $(agent.builddirectory)/VSCode-linux-$(VSCODE_ARCH) - PackageName: Visual Studio Code - - - task: AzureArtifacts.manifest-generator-task.manifest-generator-task.ManifestGeneratorTask@0 - displayName: Generate SBOM (server) + - task: 1ES.PublishPipelineArtifact@1 inputs: - BuildComponentPath: $(Build.SourcesDirectory)/remote - BuildDropPath: $(agent.builddirectory)/vscode-server-linux-$(VSCODE_ARCH) - PackageName: Visual Studio Code Server - - - publish: $(agent.builddirectory)/VSCode-linux-$(VSCODE_ARCH)/_manifest - displayName: Publish SBOM (client) - artifact: $(ARTIFACT_PREFIX)sbom_vscode_client_linux_$(VSCODE_ARCH) - - - publish: $(agent.builddirectory)/vscode-server-linux-$(VSCODE_ARCH)/_manifest - displayName: Publish SBOM (server) - artifact: $(ARTIFACT_PREFIX)sbom_vscode_server_linux_$(VSCODE_ARCH) - - - publish: $(CLIENT_PATH) - artifact: $(ARTIFACT_PREFIX)vscode_client_linux_$(VSCODE_ARCH)_archive-unsigned + targetPath: $(CLIENT_PATH) + artifactName: $(ARTIFACT_PREFIX)vscode_client_linux_$(VSCODE_ARCH)_archive-unsigned + sbomBuildDropPath: $(Agent.BuildDirectory)/VSCode-linux-$(VSCODE_ARCH) + sbomPackageName: "VS Code Linux $(VSCODE_ARCH) (unsigned)" + sbomPackageVersion: $(Build.SourceVersion) condition: and(succeededOrFailed(), ne(variables['CLIENT_PATH'], '')) displayName: Publish client archive - - publish: $(SERVER_PATH) - artifact: $(ARTIFACT_PREFIX)vscode_server_linux_$(VSCODE_ARCH)_archive-unsigned + - task: 1ES.PublishPipelineArtifact@1 + inputs: + targetPath: $(SERVER_PATH) + artifactName: $(ARTIFACT_PREFIX)vscode_server_linux_$(VSCODE_ARCH)_archive-unsigned + sbomBuildDropPath: $(Agent.BuildDirectory)/vscode-server-linux-$(VSCODE_ARCH) + sbomPackageName: "VS Code Linux $(VSCODE_ARCH) Server" + sbomPackageVersion: $(Build.SourceVersion) condition: and(succeededOrFailed(), ne(variables['SERVER_PATH'], '')) displayName: Publish server archive - - publish: $(WEB_PATH) - artifact: $(ARTIFACT_PREFIX)vscode_web_linux_$(VSCODE_ARCH)_archive-unsigned + - task: 1ES.PublishPipelineArtifact@1 + inputs: + targetPath: $(WEB_PATH) + artifactName: $(ARTIFACT_PREFIX)vscode_web_linux_$(VSCODE_ARCH)_archive-unsigned + sbomBuildDropPath: $(Agent.BuildDirectory)/vscode-server-linux-$(VSCODE_ARCH)-web + sbomPackageName: "VS Code Linux $(VSCODE_ARCH) Web" + sbomPackageVersion: $(Build.SourceVersion) condition: and(succeededOrFailed(), ne(variables['WEB_PATH'], '')) displayName: Publish web server archive - - publish: $(DEB_PATH) - artifact: $(ARTIFACT_PREFIX)vscode_client_linux_$(VSCODE_ARCH)_deb-package + - task: 1ES.PublishPipelineArtifact@1 + inputs: + targetPath: $(DEB_PATH) + artifactName: $(ARTIFACT_PREFIX)vscode_client_linux_$(VSCODE_ARCH)_deb-package + sbomBuildDropPath: .build/linux/deb + sbomPackageName: "VS Code Linux $(VSCODE_ARCH) DEB" + sbomPackageVersion: $(Build.SourceVersion) condition: and(succeededOrFailed(), ne(variables['DEB_PATH'], '')) displayName: Publish deb package - - publish: $(RPM_PATH) - artifact: $(ARTIFACT_PREFIX)vscode_client_linux_$(VSCODE_ARCH)_rpm-package + - task: 1ES.PublishPipelineArtifact@1 + inputs: + targetPath: $(RPM_PATH) + artifactName: $(ARTIFACT_PREFIX)vscode_client_linux_$(VSCODE_ARCH)_rpm-package + sbomBuildDropPath: .build/linux/rpm + sbomPackageName: "VS Code Linux $(VSCODE_ARCH) RPM" + sbomPackageVersion: $(Build.SourceVersion) condition: and(succeededOrFailed(), ne(variables['RPM_PATH'], '')) displayName: Publish rpm package - - publish: $(SNAP_PATH) - artifact: $(ARTIFACT_PREFIX)snap-$(VSCODE_ARCH) + - task: 1ES.PublishPipelineArtifact@1 + inputs: + targetPath: $(SNAP_PATH) + artifactName: $(ARTIFACT_PREFIX)snap-$(VSCODE_ARCH) + sbomEnabled: false condition: and(succeededOrFailed(), ne(variables['SNAP_PATH'], '')) displayName: Publish snap pre-package diff --git a/build/azure-pipelines/linux/setup-env.sh b/build/azure-pipelines/linux/setup-env.sh new file mode 100755 index 0000000000000..e42a6b12b1fc0 --- /dev/null +++ b/build/azure-pipelines/linux/setup-env.sh @@ -0,0 +1,70 @@ +#!/usr/bin/env bash + +set -e + +SYSROOT_ARCH=$VSCODE_ARCH +if [ "$SYSROOT_ARCH" == "x64" ]; then + SYSROOT_ARCH="amd64" +fi + +export VSCODE_SYSROOT_DIR=$PWD/.build/sysroots +SYSROOT_ARCH="$SYSROOT_ARCH" node -e '(async () => { const { getVSCodeSysroot } = require("./build/linux/debian/install-sysroot.js"); await getVSCodeSysroot(process.env["SYSROOT_ARCH"]); })()' + +if [ "$npm_config_arch" == "x64" ]; then + if [ "$(echo "$@" | grep -c -- "--only-remote")" -eq 0 ]; then + # Download clang based on chromium revision used by vscode + curl -s https://raw.githubusercontent.com/chromium/chromium/120.0.6099.268/tools/clang/scripts/update.py | python - --output-dir=$PWD/.build/CR_Clang --host-os=linux + + # Download libcxx headers and objects from upstream electron releases + DEBUG=libcxx-fetcher \ + VSCODE_LIBCXX_OBJECTS_DIR=$PWD/.build/libcxx-objects \ + VSCODE_LIBCXX_HEADERS_DIR=$PWD/.build/libcxx_headers \ + VSCODE_LIBCXXABI_HEADERS_DIR=$PWD/.build/libcxxabi_headers \ + VSCODE_ARCH="$npm_config_arch" \ + node build/linux/libcxx-fetcher.js + + # Set compiler toolchain + # Flags for the client build are based on + # https://source.chromium.org/chromium/chromium/src/+/refs/tags/120.0.6099.268:build/config/arm.gni + # https://source.chromium.org/chromium/chromium/src/+/refs/tags/120.0.6099.268:build/config/compiler/BUILD.gn + # https://source.chromium.org/chromium/chromium/src/+/refs/tags/120.0.6099.268:build/config/c++/BUILD.gn + export CC="$PWD/.build/CR_Clang/bin/clang --gcc-toolchain=$VSCODE_SYSROOT_DIR/x86_64-linux-gnu" + export CXX="$PWD/.build/CR_Clang/bin/clang++ --gcc-toolchain=$VSCODE_SYSROOT_DIR/x86_64-linux-gnu" + export CXXFLAGS="-nostdinc++ -D__NO_INLINE__ -I$PWD/.build/libcxx_headers -isystem$PWD/.build/libcxx_headers/include -isystem$PWD/.build/libcxxabi_headers/include -fPIC -flto=thin -fsplit-lto-unit -D_LIBCPP_ABI_NAMESPACE=Cr --sysroot=$VSCODE_SYSROOT_DIR/x86_64-linux-gnu/x86_64-linux-gnu/sysroot" + export LDFLAGS="-stdlib=libc++ --sysroot=$VSCODE_SYSROOT_DIR/x86_64-linux-gnu/x86_64-linux-gnu/sysroot -fuse-ld=lld -flto=thin -L$PWD/.build/libcxx-objects -lc++abi -L$VSCODE_SYSROOT_DIR/x86_64-linux-gnu/x86_64-linux-gnu/sysroot/usr/lib/x86_64-linux-gnu -L$VSCODE_SYSROOT_DIR/x86_64-linux-gnu/x86_64-linux-gnu/sysroot/lib/x86_64-linux-gnu -Wl,--lto-O0" + + # Set compiler toolchain for remote server + export VSCODE_REMOTE_CC=$VSCODE_SYSROOT_DIR/x86_64-linux-gnu/bin/x86_64-linux-gnu-gcc + export VSCODE_REMOTE_CXX=$VSCODE_SYSROOT_DIR/x86_64-linux-gnu/bin/x86_64-linux-gnu-g++ + export VSCODE_REMOTE_CXXFLAGS="--sysroot=$VSCODE_SYSROOT_DIR/x86_64-linux-gnu/x86_64-linux-gnu/sysroot" + export VSCODE_REMOTE_LDFLAGS="--sysroot=$VSCODE_SYSROOT_DIR/x86_64-linux-gnu/x86_64-linux-gnu/sysroot -L$VSCODE_SYSROOT_DIR/x86_64-linux-gnu/x86_64-linux-gnu/sysroot/usr/lib/x86_64-linux-gnu -L$VSCODE_SYSROOT_DIR/x86_64-linux-gnu/x86_64-linux-gnu/sysroot/lib/x86_64-linux-gnu" + fi +elif [ "$npm_config_arch" == "arm64" ]; then + if [ "$(echo "$@" | grep -c -- "--only-remote")" -eq 0 ]; then + # Set compiler toolchain for client native modules + export CC=$VSCODE_SYSROOT_DIR/aarch64-linux-gnu/bin/aarch64-linux-gnu-gcc + export CXX=$VSCODE_SYSROOT_DIR/aarch64-linux-gnu/bin/aarch64-linux-gnu-g++ + export CXXFLAGS="--sysroot=$VSCODE_SYSROOT_DIR/aarch64-linux-gnu/aarch64-linux-gnu/sysroot" + export LDFLAGS="--sysroot=$VSCODE_SYSROOT_DIR/aarch64-linux-gnu/aarch64-linux-gnu/sysroot -L$VSCODE_SYSROOT_DIR/aarch64-linux-gnu/aarch64-linux-gnu/sysroot/usr/lib/aarch64-linux-gnu -L$VSCODE_SYSROOT_DIR/aarch64-linux-gnu/aarch64-linux-gnu/sysroot/lib/aarch64-linux-gnu" + + # Set compiler toolchain for remote server + export VSCODE_REMOTE_CC=$VSCODE_SYSROOT_DIR/aarch64-linux-gnu/bin/aarch64-linux-gnu-gcc + export VSCODE_REMOTE_CXX=$VSCODE_SYSROOT_DIR/aarch64-linux-gnu/bin/aarch64-linux-gnu-g++ + export VSCODE_REMOTE_CXXFLAGS="--sysroot=$VSCODE_SYSROOT_DIR/aarch64-linux-gnu/aarch64-linux-gnu/sysroot" + export VSCODE_REMOTE_LDFLAGS="--sysroot=$VSCODE_SYSROOT_DIR/aarch64-linux-gnu/aarch64-linux-gnu/sysroot -L$VSCODE_SYSROOT_DIR/aarch64-linux-gnu/aarch64-linux-gnu/sysroot/usr/lib/aarch64-linux-gnu -L$VSCODE_SYSROOT_DIR/aarch64-linux-gnu/aarch64-linux-gnu/sysroot/lib/aarch64-linux-gnu" + fi +elif [ "$npm_config_arch" == "arm" ]; then + if [ "$(echo "$@" | grep -c -- "--only-remote")" -eq 0 ]; then + # Set compiler toolchain for client native modules + export CC=$VSCODE_SYSROOT_DIR/arm-rpi-linux-gnueabihf/bin/arm-rpi-linux-gnueabihf-gcc + export CXX=$VSCODE_SYSROOT_DIR/arm-rpi-linux-gnueabihf/bin/arm-rpi-linux-gnueabihf-g++ + export CXXFLAGS="--sysroot=$VSCODE_SYSROOT_DIR/arm-rpi-linux-gnueabihf/arm-rpi-linux-gnueabihf/sysroot" + export LDFLAGS="--sysroot=$VSCODE_SYSROOT_DIR/arm-rpi-linux-gnueabihf/arm-rpi-linux-gnueabihf/sysroot -L$VSCODE_SYSROOT_DIR/arm-rpi-linux-gnueabihf/arm-rpi-linux-gnueabihf/sysroot/usr/lib/arm-linux-gnueabihf -L$VSCODE_SYSROOT_DIR/arm-rpi-linux-gnueabihf/arm-rpi-linux-gnueabihf/sysroot/lib/arm-linux-gnueabihf" + + # Set compiler toolchain for remote server + export VSCODE_REMOTE_CC=$VSCODE_SYSROOT_DIR/arm-rpi-linux-gnueabihf/bin/arm-rpi-linux-gnueabihf-gcc + export VSCODE_REMOTE_CXX=$VSCODE_SYSROOT_DIR/arm-rpi-linux-gnueabihf/bin/arm-rpi-linux-gnueabihf-g++ + export VSCODE_REMOTE_CXXFLAGS="--sysroot=$VSCODE_SYSROOT_DIR/arm-rpi-linux-gnueabihf/arm-rpi-linux-gnueabihf/sysroot" + export VSCODE_REMOTE_LDFLAGS="--sysroot=$VSCODE_SYSROOT_DIR/arm-rpi-linux-gnueabihf/arm-rpi-linux-gnueabihf/sysroot -L$VSCODE_SYSROOT_DIR/arm-rpi-linux-gnueabihf/arm-rpi-linux-gnueabihf/sysroot/usr/lib/arm-linux-gnueabihf -L$VSCODE_SYSROOT_DIR/arm-rpi-linux-gnueabihf/arm-rpi-linux-gnueabihf/sysroot/lib/arm-linux-gnueabihf" + fi +fi diff --git a/build/azure-pipelines/linux/snap-build-linux.yml b/build/azure-pipelines/linux/snap-build-linux.yml index 6fc16833921f1..033058163f904 100644 --- a/build/azure-pipelines/linux/snap-build-linux.yml +++ b/build/azure-pipelines/linux/snap-build-linux.yml @@ -46,24 +46,28 @@ steps: *) SNAPCRAFT_TARGET_ARGS="--target-arch $(VSCODE_ARCH)" ;; esac (cd $SNAP_ROOT/code-* && sudo --preserve-env snapcraft snap $SNAPCRAFT_TARGET_ARGS --output "$SNAP_PATH") - - # Export SNAP_PATH - echo "##vso[task.setvariable variable=SNAP_PATH]$SNAP_PATH" displayName: Prepare for publish - - script: mkdir -p $(agent.builddirectory)/vscode-snap-linux-$(VSCODE_ARCH) - displayName: Make folder for SBOM + - script: | + set -e + SNAP_ROOT="$(pwd)/.build/linux/snap/$(VSCODE_ARCH)" + SNAP_EXTRACTED_PATH=$(find $SNAP_ROOT -maxdepth 1 -type d -name 'code-*') + SNAP_PATH=$(find $SNAP_ROOT -maxdepth 1 -type f -name '*.snap') - - task: AzureArtifacts.manifest-generator-task.manifest-generator-task.ManifestGeneratorTask@0 - displayName: Generate SBOM - inputs: - BuildDropPath: $(agent.builddirectory)/vscode-snap-linux-$(VSCODE_ARCH) - PackageName: Visual Studio Code Snap + # SBOM tool doesn't like recursive symlinks + sudo find $SNAP_EXTRACTED_PATH -type l -delete - - publish: $(agent.builddirectory)/vscode-snap-linux-$(VSCODE_ARCH)/_manifest - displayName: Publish SBOM - artifact: $(ARTIFACT_PREFIX)sbom_vscode_client_linux_snap_$(VSCODE_ARCH) + echo "##vso[task.setvariable variable=SNAP_EXTRACTED_PATH]$SNAP_EXTRACTED_PATH" + echo "##vso[task.setvariable variable=SNAP_PATH]$SNAP_PATH" + target: + container: host + displayName: Find host snap path & prepare for SBOM - - publish: $(SNAP_PATH) - artifact: vscode_client_linux_$(VSCODE_ARCH)_snap + - task: 1ES.PublishPipelineArtifact@1 + inputs: + targetPath: $(SNAP_PATH) + artifactName: vscode_client_linux_$(VSCODE_ARCH)_snap + sbomBuildDropPath: $(SNAP_EXTRACTED_PATH) + sbomPackageName: "VS Code Linux $(VSCODE_ARCH) SNAP" + sbomPackageVersion: $(Build.SourceVersion) displayName: Publish snap package diff --git a/build/azure-pipelines/product-build-pr.yml b/build/azure-pipelines/product-build-pr.yml index b50360707305a..7dce4d2026586 100644 --- a/build/azure-pipelines/product-build-pr.yml +++ b/build/azure-pipelines/product-build-pr.yml @@ -31,7 +31,7 @@ jobs: variables: VSCODE_ARCH: x64 steps: - - template: product-compile.yml + - template: product-compile.yml@self parameters: VSCODE_QUALITY: ${{ variables.VSCODE_QUALITY }} @@ -44,7 +44,7 @@ jobs: NPM_ARCH: x64 DISPLAY: ":10" steps: - - template: linux/product-build-linux.yml + - template: linux/product-build-linux.yml@self parameters: VSCODE_ARCH: x64 VSCODE_QUALITY: ${{ variables.VSCODE_QUALITY }} @@ -62,7 +62,7 @@ jobs: NPM_ARCH: x64 DISPLAY: ":10" steps: - - template: linux/product-build-linux.yml + - template: linux/product-build-linux.yml@self parameters: VSCODE_ARCH: x64 VSCODE_QUALITY: ${{ variables.VSCODE_QUALITY }} @@ -80,7 +80,7 @@ jobs: NPM_ARCH: x64 DISPLAY: ":10" steps: - - template: linux/product-build-linux.yml + - template: linux/product-build-linux.yml@self parameters: VSCODE_ARCH: x64 VSCODE_QUALITY: ${{ variables.VSCODE_QUALITY }} @@ -94,7 +94,7 @@ jobs: pool: 1es-oss-ubuntu-20.04-x64 timeoutInMinutes: 30 steps: - - template: cli/test.yml + - template: cli/test.yml@self - job: Windowsx64UnitTests displayName: Windows (Unit Tests) @@ -104,7 +104,7 @@ jobs: VSCODE_ARCH: x64 NPM_ARCH: x64 steps: - - template: win32/product-build-win32.yml + - template: win32/product-build-win32.yml@self parameters: VSCODE_QUALITY: ${{ variables.VSCODE_QUALITY }} VSCODE_ARCH: x64 @@ -121,7 +121,7 @@ jobs: VSCODE_ARCH: x64 NPM_ARCH: x64 steps: - - template: win32/product-build-win32.yml + - template: win32/product-build-win32.yml@self parameters: VSCODE_QUALITY: ${{ variables.VSCODE_QUALITY }} VSCODE_ARCH: x64 @@ -138,7 +138,7 @@ jobs: # VSCODE_ARCH: x64 # NPM_ARCH: x64 # steps: - # - template: win32/product-build-win32.yml + # - template: win32/product-build-win32.yml@self # parameters: # VSCODE_QUALITY: ${{ variables.VSCODE_QUALITY }} # VSCODE_ARCH: x64 @@ -154,7 +154,7 @@ jobs: variables: VSCODE_ARCH: x64 steps: - - template: oss/product-build-pr-cache-linux.yml + - template: oss/product-build-pr-cache-linux.yml@self - job: Windowsx64MaintainNodeModulesCache displayName: Windows (Maintain node_modules cache) @@ -163,7 +163,7 @@ jobs: variables: VSCODE_ARCH: x64 steps: - - template: oss/product-build-pr-cache-win32.yml + - template: oss/product-build-pr-cache-win32.yml@self # - job: macOSUnitTest # displayName: macOS (Unit Tests) @@ -174,7 +174,7 @@ jobs: # BUILDSECMON_OPT_IN: true # VSCODE_ARCH: x64 # steps: - # - template: darwin/product-build-darwin.yml + # - template: darwin/product-build-darwin.yml@self # parameters: # VSCODE_QUALITY: ${{ variables.VSCODE_QUALITY }} # VSCODE_RUN_UNIT_TESTS: true @@ -189,7 +189,7 @@ jobs: # BUILDSECMON_OPT_IN: true # VSCODE_ARCH: x64 # steps: - # - template: darwin/product-build-darwin.yml + # - template: darwin/product-build-darwin.yml@self # parameters: # VSCODE_QUALITY: ${{ variables.VSCODE_QUALITY }} # VSCODE_RUN_UNIT_TESTS: false @@ -204,7 +204,7 @@ jobs: # BUILDSECMON_OPT_IN: true # VSCODE_ARCH: x64 # steps: - # - template: darwin/product-build-darwin.yml + # - template: darwin/product-build-darwin.yml@self # parameters: # VSCODE_QUALITY: ${{ variables.VSCODE_QUALITY }} # VSCODE_RUN_UNIT_TESTS: false diff --git a/build/azure-pipelines/product-build.yml b/build/azure-pipelines/product-build.yml index 54a37e9b1fa38..31fa2fa65dad1 100644 --- a/build/azure-pipelines/product-build.yml +++ b/build/azure-pipelines/product-build.yml @@ -40,14 +40,26 @@ parameters: displayName: "🎯 Linux x64" type: boolean default: true + - name: VSCODE_BUILD_LINUX_X64_LEGACY_SERVER + displayName: "🎯 Linux x64 Legacy Server" + type: boolean + default: true - name: VSCODE_BUILD_LINUX_ARM64 displayName: "🎯 Linux arm64" type: boolean default: true + - name: VSCODE_BUILD_LINUX_ARM64_LEGACY_SERVER + displayName: "🎯 Linux arm64 Legacy Server" + type: boolean + default: true - name: VSCODE_BUILD_LINUX_ARMHF displayName: "🎯 Linux armhf" type: boolean default: true + - name: VSCODE_BUILD_LINUX_ARMHF_LEGACY_SERVER + displayName: "🎯 Linux armhf Legacy Server" + type: boolean + default: true - name: VSCODE_BUILD_ALPINE displayName: "🎯 Alpine x64" type: boolean @@ -102,6 +114,8 @@ variables: value: ${{ or(eq(parameters.VSCODE_BUILD_WIN32, true), eq(parameters.VSCODE_BUILD_WIN32_ARM64, true)) }} - name: VSCODE_BUILD_STAGE_LINUX value: ${{ or(eq(parameters.VSCODE_BUILD_LINUX, true), eq(parameters.VSCODE_BUILD_LINUX_ARMHF, true), eq(parameters.VSCODE_BUILD_LINUX_ARM64, true)) }} + - name: VSCODE_BUILD_STAGE_LINUX_LEGACY_SERVER + value: ${{ or(eq(parameters.VSCODE_BUILD_LINUX_X64_LEGACY_SERVER, true), eq(parameters.VSCODE_BUILD_LINUX_ARMHF_LEGACY_SERVER, true), eq(parameters.VSCODE_BUILD_LINUX_ARM64_LEGACY_SERVER, true)) }} - name: VSCODE_BUILD_STAGE_ALPINE value: ${{ or(eq(parameters.VSCODE_BUILD_ALPINE, true), eq(parameters.VSCODE_BUILD_ALPINE_ARM64, true)) }} - name: VSCODE_BUILD_STAGE_MACOS @@ -142,526 +156,635 @@ variables: name: "$(Date:yyyyMMdd).$(Rev:r) (${{ parameters.VSCODE_QUALITY }})" resources: - containers: - - container: snapcraft - image: vscodehub.azurecr.io/vscode-linux-build-agent:snapcraft-x64 - endpoint: VSCodeHub pipelines: - pipeline: vscode-7pm-kick-off source: 'VS Code 7PM Kick-Off' trigger: true - -stages: - - stage: Compile - jobs: - - job: Compile - pool: 1es-ubuntu-20.04-x64 - variables: - VSCODE_ARCH: x64 - steps: - - template: product-compile.yml - parameters: - VSCODE_QUALITY: ${{ variables.VSCODE_QUALITY }} - - - stage: CompileCLI - dependsOn: [] - jobs: - - ${{ if eq(parameters.VSCODE_BUILD_LINUX, true) }}: - - job: CLILinuxX64 - pool: 1es-ubuntu-20.04-x64 - steps: - - template: ./linux/cli-build-linux.yml - parameters: - VSCODE_CHECK_ONLY: ${{ variables.VSCODE_CIBUILD }} - VSCODE_QUALITY: ${{ variables.VSCODE_QUALITY }} - VSCODE_BUILD_LINUX: ${{ parameters.VSCODE_BUILD_LINUX }} - - - ${{ if and(eq(variables['VSCODE_CIBUILD'], false), or(eq(parameters.VSCODE_BUILD_LINUX_ARMHF, true), eq(parameters.VSCODE_BUILD_LINUX_ARM64, true))) }}: - - job: CLILinuxGnuARM - pool: 1es-ubuntu-20.04-x64 - steps: - - template: ./linux/cli-build-linux.yml - parameters: - VSCODE_QUALITY: ${{ variables.VSCODE_QUALITY }} - VSCODE_BUILD_LINUX_ARMHF: ${{ parameters.VSCODE_BUILD_LINUX_ARMHF }} - VSCODE_BUILD_LINUX_ARM64: ${{ parameters.VSCODE_BUILD_LINUX_ARM64 }} - - - ${{ if and(eq(variables['VSCODE_CIBUILD'], false), eq(parameters.VSCODE_BUILD_ALPINE, true)) }}: - - job: CLIAlpineX64 - pool: 1es-ubuntu-20.04-x64 - steps: - - template: ./alpine/cli-build-alpine.yml - parameters: - VSCODE_QUALITY: ${{ variables.VSCODE_QUALITY }} - VSCODE_BUILD_ALPINE: ${{ parameters.VSCODE_BUILD_ALPINE }} - - - ${{ if and(eq(variables['VSCODE_CIBUILD'], false), eq(parameters.VSCODE_BUILD_ALPINE_ARM64, true)) }}: - - job: CLIAlpineARM64 - pool: 1es-ubuntu-20.04-arm64 - steps: - - bash: sudo apt update && sudo apt install -y unzip - displayName: Install unzip - - template: ./alpine/cli-build-alpine.yml - parameters: - VSCODE_QUALITY: ${{ variables.VSCODE_QUALITY }} - VSCODE_BUILD_ALPINE_ARM64: ${{ parameters.VSCODE_BUILD_ALPINE_ARM64 }} - - - ${{ if eq(parameters.VSCODE_BUILD_MACOS, true) }}: - - job: CLIMacOSX64 - pool: - vmImage: macOS-11 - steps: - - template: ./darwin/cli-build-darwin.yml - parameters: - VSCODE_CHECK_ONLY: ${{ variables.VSCODE_CIBUILD }} - VSCODE_QUALITY: ${{ variables.VSCODE_QUALITY }} - VSCODE_BUILD_MACOS: ${{ parameters.VSCODE_BUILD_MACOS }} - - - ${{ if and(eq(variables['VSCODE_CIBUILD'], false), eq(parameters.VSCODE_BUILD_MACOS_ARM64, true)) }}: - - job: CLIMacOSARM64 - pool: - vmImage: macOS-11 - steps: - - template: ./darwin/cli-build-darwin.yml - parameters: - VSCODE_QUALITY: ${{ variables.VSCODE_QUALITY }} - VSCODE_BUILD_MACOS_ARM64: ${{ parameters.VSCODE_BUILD_MACOS_ARM64 }} - - - ${{ if eq(parameters.VSCODE_BUILD_WIN32, true) }}: - - job: CLIWindowsX64 - pool: 1es-windows-2019-x64 - steps: - - template: ./win32/cli-build-win32.yml - parameters: - VSCODE_CHECK_ONLY: ${{ variables.VSCODE_CIBUILD }} - VSCODE_QUALITY: ${{ variables.VSCODE_QUALITY }} - VSCODE_BUILD_WIN32: ${{ parameters.VSCODE_BUILD_WIN32 }} - - - ${{ if and(eq(variables['VSCODE_CIBUILD'], false), eq(parameters.VSCODE_BUILD_WIN32_ARM64, true)) }}: - - job: CLIWindowsARM64 - pool: 1es-windows-2019-x64 - steps: - - template: ./win32/cli-build-win32.yml - parameters: - VSCODE_QUALITY: ${{ variables.VSCODE_QUALITY }} - VSCODE_BUILD_WIN32_ARM64: ${{ parameters.VSCODE_BUILD_WIN32_ARM64 }} - - - ${{ if and(eq(parameters.VSCODE_COMPILE_ONLY, false), eq(variables['VSCODE_BUILD_STAGE_WINDOWS'], true)) }}: - - stage: Windows - dependsOn: - - Compile - - CompileCLI - pool: 1es-windows-2019-x64 - jobs: - - ${{ if eq(variables['VSCODE_CIBUILD'], true) }}: - - job: WindowsUnitTests - displayName: Unit Tests - timeoutInMinutes: 60 + repositories: + - repository: 1ESPipelines + type: git + name: 1ESPipelineTemplates/1ESPipelineTemplates + ref: refs/heads/joao/disable-tsa-linux-arm64 + +extends: + template: v1/1ES.Official.PipelineTemplate.yml@1esPipelines + parameters: + sdl: + tsa: + enabled: true + config: + codebaseName: 'devdiv_$(Build.Repository.Name)' + serviceTreeID: '79c048b2-322f-4ed5-a1ea-252a1250e4b3' + instanceUrl: 'https://devdiv.visualstudio.com/defaultcollection' + projectName: 'DevDiv' + areaPath: "DevDiv\\VS Code (compliance tracking only)\\Visual Studio Code Client" + notificationAliases: ['monacotools@microsoft.com'] + validateToolOutput: None + allTools: true + credscan: + suppressionsFile: $(Build.SourcesDirectory)/build/azure-pipelines/config/CredScanSuppressions.json + sourceAnalysisPool: 1es-windows-2022-x64 + containers: + snapcraft: + image: vscodehub.azurecr.io/vscode-linux-build-agent:snapcraft-x64 + ubuntu-2004-arm64: + image: onebranch.azurecr.io/linux/ubuntu-2004-arm64:latest + authenticatedContainerRegistries: + - registry: onebranch.azurecr.io + tenant: AME + identity: 1ESPipelineIdentity + stages: + - stage: Compile + jobs: + - job: Compile + timeoutInMinutes: 90 + pool: + name: 1es-ubuntu-20.04-x64 + os: linux variables: VSCODE_ARCH: x64 steps: - - template: win32/product-build-win32.yml + - template: build/azure-pipelines/product-compile.yml@self parameters: VSCODE_QUALITY: ${{ variables.VSCODE_QUALITY }} + + - stage: CompileCLI + dependsOn: [] + jobs: + - ${{ if eq(parameters.VSCODE_BUILD_LINUX, true) }}: + - job: CLILinuxX64 + pool: + name: 1es-ubuntu-20.04-x64 + os: linux + steps: + - template: build/azure-pipelines/linux/cli-build-linux.yml@self + parameters: + VSCODE_CHECK_ONLY: ${{ variables.VSCODE_CIBUILD }} + VSCODE_QUALITY: ${{ variables.VSCODE_QUALITY }} + VSCODE_BUILD_LINUX: ${{ parameters.VSCODE_BUILD_LINUX }} + + - ${{ if and(eq(variables['VSCODE_CIBUILD'], false), or(eq(parameters.VSCODE_BUILD_LINUX_ARMHF, true), eq(parameters.VSCODE_BUILD_LINUX_ARM64, true))) }}: + - job: CLILinuxGnuARM + pool: + name: 1es-ubuntu-20.04-x64 + os: linux + steps: + - template: build/azure-pipelines/linux/cli-build-linux.yml@self + parameters: + VSCODE_QUALITY: ${{ variables.VSCODE_QUALITY }} + VSCODE_BUILD_LINUX_ARMHF: ${{ parameters.VSCODE_BUILD_LINUX_ARMHF }} + VSCODE_BUILD_LINUX_ARM64: ${{ parameters.VSCODE_BUILD_LINUX_ARM64 }} + + - ${{ if and(eq(variables['VSCODE_CIBUILD'], false), eq(parameters.VSCODE_BUILD_ALPINE, true)) }}: + - job: CLIAlpineX64 + pool: + name: 1es-ubuntu-20.04-x64 + os: linux + steps: + - template: build/azure-pipelines/alpine/cli-build-alpine.yml@self + parameters: + VSCODE_QUALITY: ${{ variables.VSCODE_QUALITY }} + VSCODE_BUILD_ALPINE: ${{ parameters.VSCODE_BUILD_ALPINE }} + + - ${{ if and(eq(variables['VSCODE_CIBUILD'], false), eq(parameters.VSCODE_BUILD_ALPINE_ARM64, true)) }}: + - job: CLIAlpineARM64 + pool: + name: 1es-mariner-2.0-arm64 + os: linux + hostArchitecture: arm64 + container: ubuntu-2004-arm64 + steps: + - template: build/azure-pipelines/alpine/cli-build-alpine.yml@self + parameters: + VSCODE_QUALITY: ${{ variables.VSCODE_QUALITY }} + VSCODE_BUILD_ALPINE_ARM64: ${{ parameters.VSCODE_BUILD_ALPINE_ARM64 }} + + - ${{ if eq(parameters.VSCODE_BUILD_MACOS, true) }}: + - job: CLIMacOSX64 + pool: + name: Azure Pipelines + image: macOS-11 + os: macOS + steps: + - template: build/azure-pipelines/darwin/cli-build-darwin.yml@self + parameters: + VSCODE_CHECK_ONLY: ${{ variables.VSCODE_CIBUILD }} + VSCODE_QUALITY: ${{ variables.VSCODE_QUALITY }} + VSCODE_BUILD_MACOS: ${{ parameters.VSCODE_BUILD_MACOS }} + + - ${{ if and(eq(variables['VSCODE_CIBUILD'], false), eq(parameters.VSCODE_BUILD_MACOS_ARM64, true)) }}: + - job: CLIMacOSARM64 + pool: + name: Azure Pipelines + image: macOS-11 + os: macOS + steps: + - template: build/azure-pipelines/darwin/cli-build-darwin.yml@self + parameters: + VSCODE_QUALITY: ${{ variables.VSCODE_QUALITY }} + VSCODE_BUILD_MACOS_ARM64: ${{ parameters.VSCODE_BUILD_MACOS_ARM64 }} + + - ${{ if eq(parameters.VSCODE_BUILD_WIN32, true) }}: + - job: CLIWindowsX64 + pool: + name: 1es-windows-2019-x64 + os: windows + steps: + - template: build/azure-pipelines/win32/cli-build-win32.yml@self + parameters: + VSCODE_CHECK_ONLY: ${{ variables.VSCODE_CIBUILD }} + VSCODE_QUALITY: ${{ variables.VSCODE_QUALITY }} + VSCODE_BUILD_WIN32: ${{ parameters.VSCODE_BUILD_WIN32 }} + + - ${{ if and(eq(variables['VSCODE_CIBUILD'], false), eq(parameters.VSCODE_BUILD_WIN32_ARM64, true)) }}: + - job: CLIWindowsARM64 + pool: + name: 1es-windows-2019-x64 + os: windows + steps: + - template: build/azure-pipelines/win32/cli-build-win32.yml@self + parameters: + VSCODE_QUALITY: ${{ variables.VSCODE_QUALITY }} + VSCODE_BUILD_WIN32_ARM64: ${{ parameters.VSCODE_BUILD_WIN32_ARM64 }} + + - ${{ if and(eq(parameters.VSCODE_COMPILE_ONLY, false), eq(variables['VSCODE_BUILD_STAGE_WINDOWS'], true)) }}: + - stage: Windows + dependsOn: + - Compile + - CompileCLI + pool: + name: 1es-windows-2019-x64 + os: windows + jobs: + - ${{ if eq(variables['VSCODE_CIBUILD'], true) }}: + - job: WindowsUnitTests + displayName: Unit Tests + timeoutInMinutes: 60 + variables: VSCODE_ARCH: x64 - VSCODE_CIBUILD: ${{ variables.VSCODE_CIBUILD }} - VSCODE_RUN_UNIT_TESTS: true - VSCODE_RUN_INTEGRATION_TESTS: false - VSCODE_RUN_SMOKE_TESTS: false - - job: WindowsIntegrationTests - displayName: Integration Tests - timeoutInMinutes: 60 - variables: - VSCODE_ARCH: x64 - steps: - - template: win32/product-build-win32.yml - parameters: - VSCODE_QUALITY: ${{ variables.VSCODE_QUALITY }} + steps: + - template: build/azure-pipelines/win32/product-build-win32.yml@self + parameters: + VSCODE_QUALITY: ${{ variables.VSCODE_QUALITY }} + VSCODE_ARCH: x64 + VSCODE_CIBUILD: ${{ variables.VSCODE_CIBUILD }} + VSCODE_RUN_UNIT_TESTS: true + VSCODE_RUN_INTEGRATION_TESTS: false + VSCODE_RUN_SMOKE_TESTS: false + - job: WindowsIntegrationTests + displayName: Integration Tests + timeoutInMinutes: 60 + variables: VSCODE_ARCH: x64 - VSCODE_CIBUILD: ${{ variables.VSCODE_CIBUILD }} - VSCODE_RUN_UNIT_TESTS: false - VSCODE_RUN_INTEGRATION_TESTS: true - VSCODE_RUN_SMOKE_TESTS: false - - job: WindowsSmokeTests - displayName: Smoke Tests - timeoutInMinutes: 60 - variables: - VSCODE_ARCH: x64 - steps: - - template: win32/product-build-win32.yml - parameters: - VSCODE_QUALITY: ${{ variables.VSCODE_QUALITY }} + steps: + - template: build/azure-pipelines/win32/product-build-win32.yml@self + parameters: + VSCODE_QUALITY: ${{ variables.VSCODE_QUALITY }} + VSCODE_ARCH: x64 + VSCODE_CIBUILD: ${{ variables.VSCODE_CIBUILD }} + VSCODE_RUN_UNIT_TESTS: false + VSCODE_RUN_INTEGRATION_TESTS: true + VSCODE_RUN_SMOKE_TESTS: false + - job: WindowsSmokeTests + displayName: Smoke Tests + timeoutInMinutes: 60 + variables: VSCODE_ARCH: x64 - VSCODE_CIBUILD: ${{ variables.VSCODE_CIBUILD }} - VSCODE_RUN_UNIT_TESTS: false - VSCODE_RUN_INTEGRATION_TESTS: false - VSCODE_RUN_SMOKE_TESTS: true - - - ${{ if and(eq(variables['VSCODE_CIBUILD'], false), eq(parameters.VSCODE_BUILD_WIN32, true)) }}: - - job: Windows - timeoutInMinutes: 120 - variables: - VSCODE_ARCH: x64 - steps: - - template: win32/product-build-win32.yml - parameters: - VSCODE_QUALITY: ${{ variables.VSCODE_QUALITY }} + steps: + - template: build/azure-pipelines/win32/product-build-win32.yml@self + parameters: + VSCODE_QUALITY: ${{ variables.VSCODE_QUALITY }} + VSCODE_ARCH: x64 + VSCODE_CIBUILD: ${{ variables.VSCODE_CIBUILD }} + VSCODE_RUN_UNIT_TESTS: false + VSCODE_RUN_INTEGRATION_TESTS: false + VSCODE_RUN_SMOKE_TESTS: true + + - ${{ if and(eq(variables['VSCODE_CIBUILD'], false), eq(parameters.VSCODE_BUILD_WIN32, true)) }}: + - job: Windows + timeoutInMinutes: 120 + variables: VSCODE_ARCH: x64 - VSCODE_CIBUILD: ${{ variables.VSCODE_CIBUILD }} - VSCODE_RUN_UNIT_TESTS: ${{ eq(parameters.VSCODE_STEP_ON_IT, false) }} - VSCODE_RUN_INTEGRATION_TESTS: ${{ eq(parameters.VSCODE_STEP_ON_IT, false) }} - VSCODE_RUN_SMOKE_TESTS: ${{ eq(parameters.VSCODE_STEP_ON_IT, false) }} - - - job: WindowsCLISign - timeoutInMinutes: 90 - steps: - - template: win32/product-build-win32-cli-sign.yml - parameters: - VSCODE_BUILD_WIN32: ${{ parameters.VSCODE_BUILD_WIN32 }} - VSCODE_BUILD_WIN32_ARM64: ${{ parameters.VSCODE_BUILD_WIN32_ARM64 }} - - - ${{ if and(eq(variables['VSCODE_CIBUILD'], false), eq(parameters.VSCODE_BUILD_WIN32_ARM64, true)) }}: - - job: WindowsARM64 - timeoutInMinutes: 90 - variables: - VSCODE_ARCH: arm64 - steps: - - template: win32/product-build-win32.yml - parameters: - VSCODE_QUALITY: ${{ variables.VSCODE_QUALITY }} + steps: + - template: build/azure-pipelines/win32/product-build-win32.yml@self + parameters: + VSCODE_QUALITY: ${{ variables.VSCODE_QUALITY }} + VSCODE_ARCH: x64 + VSCODE_CIBUILD: ${{ variables.VSCODE_CIBUILD }} + VSCODE_RUN_UNIT_TESTS: ${{ eq(parameters.VSCODE_STEP_ON_IT, false) }} + VSCODE_RUN_INTEGRATION_TESTS: ${{ eq(parameters.VSCODE_STEP_ON_IT, false) }} + VSCODE_RUN_SMOKE_TESTS: ${{ eq(parameters.VSCODE_STEP_ON_IT, false) }} + + - job: WindowsCLISign + timeoutInMinutes: 90 + steps: + - template: build/azure-pipelines/win32/product-build-win32-cli-sign.yml@self + parameters: + VSCODE_BUILD_WIN32: ${{ parameters.VSCODE_BUILD_WIN32 }} + VSCODE_BUILD_WIN32_ARM64: ${{ parameters.VSCODE_BUILD_WIN32_ARM64 }} + + - ${{ if and(eq(variables['VSCODE_CIBUILD'], false), eq(parameters.VSCODE_BUILD_WIN32_ARM64, true)) }}: + - job: WindowsARM64 + timeoutInMinutes: 90 + variables: VSCODE_ARCH: arm64 - VSCODE_CIBUILD: ${{ variables.VSCODE_CIBUILD }} - VSCODE_RUN_UNIT_TESTS: false - VSCODE_RUN_INTEGRATION_TESTS: false - VSCODE_RUN_SMOKE_TESTS: false - - - ${{ if and(eq(parameters.VSCODE_COMPILE_ONLY, false), eq(variables['VSCODE_BUILD_STAGE_LINUX'], true)) }}: - - stage: Linux - dependsOn: - - Compile - - CompileCLI - pool: 1es-ubuntu-20.04-x64 - jobs: - - ${{ if eq(variables['VSCODE_CIBUILD'], true) }}: - - job: Linuxx64UnitTest - displayName: Unit Tests - variables: - VSCODE_ARCH: x64 - NPM_ARCH: x64 - DISPLAY: ":10" - steps: - - template: linux/product-build-linux.yml - parameters: + steps: + - template: build/azure-pipelines/win32/product-build-win32.yml@self + parameters: + VSCODE_QUALITY: ${{ variables.VSCODE_QUALITY }} + VSCODE_ARCH: arm64 + VSCODE_CIBUILD: ${{ variables.VSCODE_CIBUILD }} + VSCODE_RUN_UNIT_TESTS: false + VSCODE_RUN_INTEGRATION_TESTS: false + VSCODE_RUN_SMOKE_TESTS: false + + - ${{ if and(eq(parameters.VSCODE_COMPILE_ONLY, false), eq(variables['VSCODE_BUILD_STAGE_LINUX'], true)) }}: + - stage: Linux + dependsOn: + - Compile + - CompileCLI + pool: + name: 1es-ubuntu-20.04-x64 + os: linux + jobs: + - ${{ if eq(variables['VSCODE_CIBUILD'], true) }}: + - job: Linuxx64UnitTest + displayName: Unit Tests + variables: VSCODE_ARCH: x64 - VSCODE_QUALITY: ${{ variables.VSCODE_QUALITY }} - VSCODE_CIBUILD: ${{ variables.VSCODE_CIBUILD }} - VSCODE_RUN_UNIT_TESTS: true - VSCODE_RUN_INTEGRATION_TESTS: false - VSCODE_RUN_SMOKE_TESTS: false - - job: Linuxx64IntegrationTest - displayName: Integration Tests - variables: - VSCODE_ARCH: x64 - NPM_ARCH: x64 - DISPLAY: ":10" - steps: - - template: linux/product-build-linux.yml - parameters: + NPM_ARCH: x64 + DISPLAY: ":10" + steps: + - template: build/azure-pipelines/linux/product-build-linux.yml@self + parameters: + VSCODE_ARCH: x64 + VSCODE_QUALITY: ${{ variables.VSCODE_QUALITY }} + VSCODE_CIBUILD: ${{ variables.VSCODE_CIBUILD }} + VSCODE_RUN_UNIT_TESTS: true + VSCODE_RUN_INTEGRATION_TESTS: false + VSCODE_RUN_SMOKE_TESTS: false + - job: Linuxx64IntegrationTest + displayName: Integration Tests + variables: VSCODE_ARCH: x64 - VSCODE_QUALITY: ${{ variables.VSCODE_QUALITY }} - VSCODE_CIBUILD: ${{ variables.VSCODE_CIBUILD }} - VSCODE_RUN_UNIT_TESTS: false - VSCODE_RUN_INTEGRATION_TESTS: true - VSCODE_RUN_SMOKE_TESTS: false - - job: Linuxx64SmokeTest - displayName: Smoke Tests - variables: - VSCODE_ARCH: x64 - NPM_ARCH: x64 - DISPLAY: ":10" - steps: - - template: linux/product-build-linux.yml - parameters: + NPM_ARCH: x64 + DISPLAY: ":10" + steps: + - template: build/azure-pipelines/linux/product-build-linux.yml@self + parameters: + VSCODE_ARCH: x64 + VSCODE_QUALITY: ${{ variables.VSCODE_QUALITY }} + VSCODE_CIBUILD: ${{ variables.VSCODE_CIBUILD }} + VSCODE_RUN_UNIT_TESTS: false + VSCODE_RUN_INTEGRATION_TESTS: true + VSCODE_RUN_SMOKE_TESTS: false + - job: Linuxx64SmokeTest + displayName: Smoke Tests + variables: VSCODE_ARCH: x64 - VSCODE_QUALITY: ${{ variables.VSCODE_QUALITY }} - VSCODE_CIBUILD: ${{ variables.VSCODE_CIBUILD }} - VSCODE_RUN_UNIT_TESTS: false - VSCODE_RUN_INTEGRATION_TESTS: false - VSCODE_RUN_SMOKE_TESTS: true - - - ${{ if and(eq(variables['VSCODE_CIBUILD'], false), eq(parameters.VSCODE_BUILD_LINUX, true)) }}: - - job: Linuxx64 - variables: - VSCODE_ARCH: x64 - NPM_ARCH: x64 - DISPLAY: ":10" - steps: - - template: linux/product-build-linux.yml - parameters: + NPM_ARCH: x64 + DISPLAY: ":10" + steps: + - template: build/azure-pipelines/linux/product-build-linux.yml@self + parameters: + VSCODE_ARCH: x64 + VSCODE_QUALITY: ${{ variables.VSCODE_QUALITY }} + VSCODE_CIBUILD: ${{ variables.VSCODE_CIBUILD }} + VSCODE_RUN_UNIT_TESTS: false + VSCODE_RUN_INTEGRATION_TESTS: false + VSCODE_RUN_SMOKE_TESTS: true + + - ${{ if and(eq(variables['VSCODE_CIBUILD'], false), eq(parameters.VSCODE_BUILD_LINUX, true)) }}: + - job: Linuxx64 + timeoutInMinutes: 90 + variables: VSCODE_ARCH: x64 - VSCODE_QUALITY: ${{ variables.VSCODE_QUALITY }} - VSCODE_CIBUILD: ${{ variables.VSCODE_CIBUILD }} - VSCODE_RUN_UNIT_TESTS: ${{ eq(parameters.VSCODE_STEP_ON_IT, false) }} - VSCODE_RUN_INTEGRATION_TESTS: ${{ eq(parameters.VSCODE_STEP_ON_IT, false) }} - VSCODE_RUN_SMOKE_TESTS: ${{ eq(parameters.VSCODE_STEP_ON_IT, false) }} - - - ${{ if and(eq(variables['VSCODE_CIBUILD'], false), eq(parameters.VSCODE_BUILD_LINUX, true)) }}: - - job: LinuxSnap - dependsOn: - - Linuxx64 - container: snapcraft - variables: - VSCODE_ARCH: x64 - steps: - - template: linux/snap-build-linux.yml + NPM_ARCH: x64 + DISPLAY: ":10" + steps: + - template: build/azure-pipelines/linux/product-build-linux.yml@self + parameters: + VSCODE_ARCH: x64 + VSCODE_QUALITY: ${{ variables.VSCODE_QUALITY }} + VSCODE_CIBUILD: ${{ variables.VSCODE_CIBUILD }} + VSCODE_RUN_UNIT_TESTS: ${{ eq(parameters.VSCODE_STEP_ON_IT, false) }} + VSCODE_RUN_INTEGRATION_TESTS: ${{ eq(parameters.VSCODE_STEP_ON_IT, false) }} + VSCODE_RUN_SMOKE_TESTS: ${{ eq(parameters.VSCODE_STEP_ON_IT, false) }} + + - ${{ if and(eq(variables['VSCODE_CIBUILD'], false), eq(parameters.VSCODE_BUILD_LINUX, true)) }}: + - job: LinuxSnap + dependsOn: + - Linuxx64 + container: snapcraft + variables: + VSCODE_ARCH: x64 + steps: + - template: build/azure-pipelines/linux/snap-build-linux.yml@self - - ${{ if and(eq(variables['VSCODE_CIBUILD'], false), eq(parameters.VSCODE_BUILD_LINUX_ARMHF, true)) }}: - - job: LinuxArmhf - variables: - VSCODE_ARCH: armhf - NPM_ARCH: arm - steps: - - template: linux/product-build-linux.yml - parameters: + - ${{ if and(eq(variables['VSCODE_CIBUILD'], false), eq(parameters.VSCODE_BUILD_LINUX_ARMHF, true)) }}: + - job: LinuxArmhf + variables: VSCODE_ARCH: armhf - VSCODE_QUALITY: ${{ variables.VSCODE_QUALITY }} - VSCODE_CIBUILD: ${{ variables.VSCODE_CIBUILD }} - VSCODE_RUN_UNIT_TESTS: false - VSCODE_RUN_INTEGRATION_TESTS: false - VSCODE_RUN_SMOKE_TESTS: false - - - ${{ if and(eq(variables['VSCODE_CIBUILD'], false), eq(parameters.VSCODE_BUILD_LINUX_ARM64, true)) }}: - - job: LinuxArm64 - variables: - VSCODE_ARCH: arm64 - NPM_ARCH: arm64 - steps: - - template: linux/product-build-linux.yml - parameters: + NPM_ARCH: arm + steps: + - template: build/azure-pipelines/linux/product-build-linux.yml@self + parameters: + VSCODE_ARCH: armhf + VSCODE_QUALITY: ${{ variables.VSCODE_QUALITY }} + VSCODE_CIBUILD: ${{ variables.VSCODE_CIBUILD }} + VSCODE_RUN_UNIT_TESTS: false + VSCODE_RUN_INTEGRATION_TESTS: false + VSCODE_RUN_SMOKE_TESTS: false + + - ${{ if and(eq(variables['VSCODE_CIBUILD'], false), eq(parameters.VSCODE_BUILD_LINUX_ARM64, true)) }}: + - job: LinuxArm64 + variables: VSCODE_ARCH: arm64 - VSCODE_QUALITY: ${{ variables.VSCODE_QUALITY }} - VSCODE_CIBUILD: ${{ variables.VSCODE_CIBUILD }} - VSCODE_RUN_UNIT_TESTS: false - VSCODE_RUN_INTEGRATION_TESTS: false - VSCODE_RUN_SMOKE_TESTS: false - - - ${{ if and(eq(variables['VSCODE_CIBUILD'], false), eq(parameters.VSCODE_COMPILE_ONLY, false), eq(variables['VSCODE_BUILD_STAGE_ALPINE'], true)) }}: - - stage: Alpine - dependsOn: - - Compile - - CompileCLI - pool: 1es-ubuntu-20.04-x64 - jobs: - - ${{ if eq(parameters.VSCODE_BUILD_ALPINE, true) }}: - - job: LinuxAlpine - variables: - VSCODE_ARCH: x64 - NPM_ARCH: x64 - steps: - - template: alpine/product-build-alpine.yml - - - ${{ if eq(parameters.VSCODE_BUILD_ALPINE_ARM64, true) }}: - - job: LinuxAlpineArm64 - timeoutInMinutes: 120 - variables: - VSCODE_ARCH: arm64 - NPM_ARCH: arm64 - steps: - - template: alpine/product-build-alpine.yml - - - ${{ if and(eq(parameters.VSCODE_COMPILE_ONLY, false), eq(variables['VSCODE_BUILD_STAGE_MACOS'], true)) }}: - - stage: macOS - dependsOn: - - Compile - - CompileCLI - pool: - vmImage: macOS-11 - variables: - BUILDSECMON_OPT_IN: true - jobs: - - ${{ if eq(variables['VSCODE_CIBUILD'], true) }}: - - job: macOSUnitTest - displayName: Unit Tests - timeoutInMinutes: 90 - variables: - VSCODE_ARCH: x64 - steps: - - template: darwin/product-build-darwin.yml - parameters: - VSCODE_QUALITY: ${{ variables.VSCODE_QUALITY }} - VSCODE_CIBUILD: ${{ variables.VSCODE_CIBUILD }} - VSCODE_RUN_UNIT_TESTS: true - VSCODE_RUN_INTEGRATION_TESTS: false - VSCODE_RUN_SMOKE_TESTS: false - - job: macOSIntegrationTest - displayName: Integration Tests - timeoutInMinutes: 90 - variables: - VSCODE_ARCH: x64 - steps: - - template: darwin/product-build-darwin.yml - parameters: - VSCODE_QUALITY: ${{ variables.VSCODE_QUALITY }} - VSCODE_CIBUILD: ${{ variables.VSCODE_CIBUILD }} - VSCODE_RUN_UNIT_TESTS: false - VSCODE_RUN_INTEGRATION_TESTS: true - VSCODE_RUN_SMOKE_TESTS: false - - job: macOSSmokeTest - displayName: Smoke Tests - timeoutInMinutes: 90 - variables: - VSCODE_ARCH: x64 - steps: - - template: darwin/product-build-darwin.yml - parameters: - VSCODE_QUALITY: ${{ variables.VSCODE_QUALITY }} - VSCODE_CIBUILD: ${{ variables.VSCODE_CIBUILD }} - VSCODE_RUN_UNIT_TESTS: false - VSCODE_RUN_INTEGRATION_TESTS: false - VSCODE_RUN_SMOKE_TESTS: true + NPM_ARCH: arm64 + steps: + - template: build/azure-pipelines/linux/product-build-linux.yml@self + parameters: + VSCODE_ARCH: arm64 + VSCODE_QUALITY: ${{ variables.VSCODE_QUALITY }} + VSCODE_CIBUILD: ${{ variables.VSCODE_CIBUILD }} + VSCODE_RUN_UNIT_TESTS: false + VSCODE_RUN_INTEGRATION_TESTS: false + VSCODE_RUN_SMOKE_TESTS: false + + - ${{ if and(eq(variables['VSCODE_CIBUILD'], false), eq(parameters.VSCODE_COMPILE_ONLY, false), eq(variables['VSCODE_BUILD_STAGE_LINUX_LEGACY_SERVER'], true)) }}: + - stage: LinuxLegacyServer + dependsOn: + - Compile + pool: + name: 1es-ubuntu-20.04-x64 + os: linux + jobs: + - ${{ if eq(parameters.VSCODE_BUILD_LINUX_X64_LEGACY_SERVER, true) }}: + - job: Linuxx64LegacyServer + variables: + VSCODE_ARCH: x64 + NPM_ARCH: x64 + DISPLAY: ":10" + steps: + - template: build/azure-pipelines/linux/product-build-linux-legacy-server.yml@self + parameters: + VSCODE_ARCH: x64 + VSCODE_QUALITY: ${{ variables.VSCODE_QUALITY }} + VSCODE_RUN_INTEGRATION_TESTS: ${{ eq(parameters.VSCODE_STEP_ON_IT, false) }} + + - ${{ if eq(parameters.VSCODE_BUILD_LINUX_ARMHF_LEGACY_SERVER, true) }}: + - job: LinuxArmhfLegacyServer + variables: + VSCODE_ARCH: armhf + NPM_ARCH: arm + steps: + - template: build/azure-pipelines/linux/product-build-linux-legacy-server.yml@self + parameters: + VSCODE_ARCH: armhf + VSCODE_QUALITY: ${{ variables.VSCODE_QUALITY }} + VSCODE_RUN_INTEGRATION_TESTS: false + + - ${{ if eq(parameters.VSCODE_BUILD_LINUX_ARM64_LEGACY_SERVER, true) }}: + - job: LinuxArm64LegacyServer + variables: + VSCODE_ARCH: arm64 + NPM_ARCH: arm64 + steps: + - template: build/azure-pipelines/linux/product-build-linux-legacy-server.yml@self + parameters: + VSCODE_ARCH: arm64 + VSCODE_QUALITY: ${{ variables.VSCODE_QUALITY }} + VSCODE_RUN_INTEGRATION_TESTS: false + + - ${{ if and(eq(variables['VSCODE_CIBUILD'], false), eq(parameters.VSCODE_COMPILE_ONLY, false), eq(variables['VSCODE_BUILD_STAGE_ALPINE'], true)) }}: + - stage: Alpine + dependsOn: + - Compile + - CompileCLI + pool: + name: 1es-ubuntu-20.04-x64 + os: linux + jobs: + - ${{ if eq(parameters.VSCODE_BUILD_ALPINE, true) }}: + - job: LinuxAlpine + variables: + VSCODE_ARCH: x64 + NPM_ARCH: x64 + steps: + - template: build/azure-pipelines/alpine/product-build-alpine.yml@self + + - ${{ if eq(parameters.VSCODE_BUILD_ALPINE_ARM64, true) }}: + - job: LinuxAlpineArm64 + timeoutInMinutes: 120 + variables: + VSCODE_ARCH: arm64 + NPM_ARCH: arm64 + steps: + - template: build/azure-pipelines/alpine/product-build-alpine.yml@self + + - ${{ if and(eq(parameters.VSCODE_COMPILE_ONLY, false), eq(variables['VSCODE_BUILD_STAGE_MACOS'], true)) }}: + - stage: macOS + dependsOn: + - Compile + - CompileCLI + pool: + name: Azure Pipelines + image: macOS-11 + os: macOS + variables: + BUILDSECMON_OPT_IN: true + jobs: + - ${{ if eq(variables['VSCODE_CIBUILD'], true) }}: + - job: macOSUnitTest + displayName: Unit Tests + timeoutInMinutes: 90 + variables: + VSCODE_ARCH: x64 + steps: + - template: build/azure-pipelines/darwin/product-build-darwin.yml@self + parameters: + VSCODE_QUALITY: ${{ variables.VSCODE_QUALITY }} + VSCODE_CIBUILD: ${{ variables.VSCODE_CIBUILD }} + VSCODE_RUN_UNIT_TESTS: true + VSCODE_RUN_INTEGRATION_TESTS: false + VSCODE_RUN_SMOKE_TESTS: false + - job: macOSIntegrationTest + displayName: Integration Tests + timeoutInMinutes: 90 + variables: + VSCODE_ARCH: x64 + steps: + - template: build/azure-pipelines/darwin/product-build-darwin.yml@self + parameters: + VSCODE_QUALITY: ${{ variables.VSCODE_QUALITY }} + VSCODE_CIBUILD: ${{ variables.VSCODE_CIBUILD }} + VSCODE_RUN_UNIT_TESTS: false + VSCODE_RUN_INTEGRATION_TESTS: true + VSCODE_RUN_SMOKE_TESTS: false + - job: macOSSmokeTest + displayName: Smoke Tests + timeoutInMinutes: 90 + variables: + VSCODE_ARCH: x64 + steps: + - template: build/azure-pipelines/darwin/product-build-darwin.yml@self + parameters: + VSCODE_QUALITY: ${{ variables.VSCODE_QUALITY }} + VSCODE_CIBUILD: ${{ variables.VSCODE_CIBUILD }} + VSCODE_RUN_UNIT_TESTS: false + VSCODE_RUN_INTEGRATION_TESTS: false + VSCODE_RUN_SMOKE_TESTS: true + + - ${{ if and(eq(variables['VSCODE_CIBUILD'], false), eq(parameters.VSCODE_BUILD_MACOS, true)) }}: + - job: macOS + timeoutInMinutes: 90 + variables: + VSCODE_ARCH: x64 + steps: + - template: build/azure-pipelines/darwin/product-build-darwin.yml@self + parameters: + VSCODE_QUALITY: ${{ variables.VSCODE_QUALITY }} + VSCODE_CIBUILD: ${{ variables.VSCODE_CIBUILD }} + VSCODE_RUN_UNIT_TESTS: false + VSCODE_RUN_INTEGRATION_TESTS: false + VSCODE_RUN_SMOKE_TESTS: false + + - ${{ if eq(parameters.VSCODE_STEP_ON_IT, false) }}: + - job: macOSTest + timeoutInMinutes: 90 + variables: + VSCODE_ARCH: x64 + steps: + - template: build/azure-pipelines/darwin/product-build-darwin.yml@self + parameters: + VSCODE_QUALITY: ${{ variables.VSCODE_QUALITY }} + VSCODE_CIBUILD: ${{ variables.VSCODE_CIBUILD }} + VSCODE_RUN_UNIT_TESTS: ${{ eq(parameters.VSCODE_STEP_ON_IT, false) }} + VSCODE_RUN_INTEGRATION_TESTS: ${{ eq(parameters.VSCODE_STEP_ON_IT, false) }} + VSCODE_RUN_SMOKE_TESTS: ${{ eq(parameters.VSCODE_STEP_ON_IT, false) }} + + - job: macOSSign + dependsOn: + - macOS + timeoutInMinutes: 90 + variables: + VSCODE_ARCH: x64 + steps: + - template: build/azure-pipelines/darwin/product-build-darwin-sign.yml@self + + - job: macOSCLISign + timeoutInMinutes: 90 + steps: + - template: build/azure-pipelines/darwin/product-build-darwin-cli-sign.yml@self + parameters: + VSCODE_BUILD_MACOS: ${{ parameters.VSCODE_BUILD_MACOS }} + VSCODE_BUILD_MACOS_ARM64: ${{ parameters.VSCODE_BUILD_MACOS_ARM64 }} + + - ${{ if and(eq(variables['VSCODE_CIBUILD'], false), eq(parameters.VSCODE_BUILD_MACOS_ARM64, true)) }}: + - job: macOSARM64 + timeoutInMinutes: 90 + variables: + VSCODE_ARCH: arm64 + steps: + - template: build/azure-pipelines/darwin/product-build-darwin.yml@self + parameters: + VSCODE_QUALITY: ${{ variables.VSCODE_QUALITY }} + VSCODE_CIBUILD: ${{ variables.VSCODE_CIBUILD }} + VSCODE_RUN_UNIT_TESTS: false + VSCODE_RUN_INTEGRATION_TESTS: false + VSCODE_RUN_SMOKE_TESTS: false + + - job: macOSARM64Sign + dependsOn: + - macOSARM64 + timeoutInMinutes: 90 + variables: + VSCODE_ARCH: arm64 + steps: + - template: build/azure-pipelines/darwin/product-build-darwin-sign.yml@self + + - ${{ if and(eq(variables['VSCODE_CIBUILD'], false), eq(variables['VSCODE_BUILD_MACOS_UNIVERSAL'], true)) }}: + - job: macOSUniversal + dependsOn: + - macOS + - macOSARM64 + timeoutInMinutes: 90 + variables: + VSCODE_ARCH: universal + steps: + - template: build/azure-pipelines/darwin/product-build-darwin-universal.yml@self + + - job: macOSUniversalSign + dependsOn: + - macOSUniversal + timeoutInMinutes: 90 + variables: + VSCODE_ARCH: universal + steps: + - template: build/azure-pipelines/darwin/product-build-darwin-sign.yml@self + + - ${{ if and(eq(variables['VSCODE_CIBUILD'], false), eq(parameters.VSCODE_COMPILE_ONLY, false), eq(variables['VSCODE_BUILD_STAGE_WEB'], true)) }}: + - stage: Web + dependsOn: + - Compile + pool: + name: 1es-ubuntu-20.04-x64 + os: linux + jobs: + - ${{ if eq(parameters.VSCODE_BUILD_WEB, true) }}: + - job: Web + variables: + VSCODE_ARCH: x64 + steps: + - template: build/azure-pipelines/web/product-build-web.yml@self - - ${{ if and(eq(variables['VSCODE_CIBUILD'], false), eq(parameters.VSCODE_BUILD_MACOS, true)) }}: - - job: macOS - timeoutInMinutes: 90 - variables: - VSCODE_ARCH: x64 - steps: - - template: darwin/product-build-darwin.yml - parameters: - VSCODE_QUALITY: ${{ variables.VSCODE_QUALITY }} - VSCODE_CIBUILD: ${{ variables.VSCODE_CIBUILD }} - VSCODE_RUN_UNIT_TESTS: false - VSCODE_RUN_INTEGRATION_TESTS: false - VSCODE_RUN_SMOKE_TESTS: false - - - ${{ if eq(parameters.VSCODE_STEP_ON_IT, false) }}: - - job: macOSTest - timeoutInMinutes: 90 - variables: - VSCODE_ARCH: x64 + - ${{ if eq(variables['VSCODE_PUBLISH'], 'true') }}: + - stage: Publish + dependsOn: [] + pool: + name: 1es-windows-2019-x64 + os: windows + variables: + - name: BUILDS_API_URL + value: $(System.CollectionUri)$(System.TeamProject)/_apis/build/builds/$(Build.BuildId)/ + jobs: + - job: PublishBuild + timeoutInMinutes: 180 + displayName: Publish Build steps: - - template: darwin/product-build-darwin.yml - parameters: - VSCODE_QUALITY: ${{ variables.VSCODE_QUALITY }} - VSCODE_CIBUILD: ${{ variables.VSCODE_CIBUILD }} - VSCODE_RUN_UNIT_TESTS: ${{ eq(parameters.VSCODE_STEP_ON_IT, false) }} - VSCODE_RUN_INTEGRATION_TESTS: ${{ eq(parameters.VSCODE_STEP_ON_IT, false) }} - VSCODE_RUN_SMOKE_TESTS: ${{ eq(parameters.VSCODE_STEP_ON_IT, false) }} - - - job: macOSSign - dependsOn: - - macOS - timeoutInMinutes: 90 - variables: - VSCODE_ARCH: x64 - steps: - - template: darwin/product-build-darwin-sign.yml - - - job: macOSCLISign - timeoutInMinutes: 90 - steps: - - template: darwin/product-build-darwin-cli-sign.yml - parameters: - VSCODE_BUILD_MACOS: ${{ parameters.VSCODE_BUILD_MACOS }} - VSCODE_BUILD_MACOS_ARM64: ${{ parameters.VSCODE_BUILD_MACOS_ARM64 }} - - - ${{ if and(eq(variables['VSCODE_CIBUILD'], false), eq(parameters.VSCODE_BUILD_MACOS_ARM64, true)) }}: - - job: macOSARM64 - timeoutInMinutes: 90 - variables: - VSCODE_ARCH: arm64 - steps: - - template: darwin/product-build-darwin.yml - parameters: - VSCODE_QUALITY: ${{ variables.VSCODE_QUALITY }} - VSCODE_CIBUILD: ${{ variables.VSCODE_CIBUILD }} - VSCODE_RUN_UNIT_TESTS: false - VSCODE_RUN_INTEGRATION_TESTS: false - VSCODE_RUN_SMOKE_TESTS: false - - - job: macOSARM64Sign - dependsOn: - - macOSARM64 - timeoutInMinutes: 90 - variables: - VSCODE_ARCH: arm64 - steps: - - template: darwin/product-build-darwin-sign.yml - - - ${{ if and(eq(variables['VSCODE_CIBUILD'], false), eq(variables['VSCODE_BUILD_MACOS_UNIVERSAL'], true)) }}: - - job: macOSUniversal - dependsOn: - - macOS - - macOSARM64 - timeoutInMinutes: 90 - variables: - VSCODE_ARCH: universal - steps: - - template: darwin/product-build-darwin-universal.yml - - - job: macOSUniversalSign + - template: build/azure-pipelines/product-publish.yml@self + + - ${{ if and(parameters.VSCODE_RELEASE, eq(variables['VSCODE_PRIVATE_BUILD'], false)) }}: + - stage: ApproveRelease + dependsOn: [] # run in parallel to compile stage + pool: + name: 1es-ubuntu-20.04-x64 + os: linux + jobs: + - deployment: ApproveRelease + displayName: "Approve Release" + environment: "vscode" + variables: + skipComponentGovernanceDetection: true + strategy: + runOnce: + deploy: + steps: + - checkout: none + + - ${{ if or(and(parameters.VSCODE_RELEASE, eq(variables['VSCODE_PRIVATE_BUILD'], false)), and(in(parameters.VSCODE_QUALITY, 'insider', 'exploration'), eq(variables['VSCODE_SCHEDULEDBUILD'], true))) }}: + - stage: Release dependsOn: - - macOSUniversal - timeoutInMinutes: 90 - variables: - VSCODE_ARCH: universal - steps: - - template: darwin/product-build-darwin-sign.yml - - - ${{ if and(eq(variables['VSCODE_CIBUILD'], false), eq(parameters.VSCODE_COMPILE_ONLY, false), eq(variables['VSCODE_BUILD_STAGE_WEB'], true)) }}: - - stage: Web - dependsOn: - - Compile - pool: 1es-ubuntu-20.04-x64 - jobs: - - ${{ if eq(parameters.VSCODE_BUILD_WEB, true) }}: - - job: Web - variables: - VSCODE_ARCH: x64 - steps: - - template: web/product-build-web.yml - - - ${{ if eq(variables['VSCODE_PUBLISH'], 'true') }}: - - stage: Publish - dependsOn: [] - pool: 1es-windows-2019-x64 - variables: - - name: BUILDS_API_URL - value: $(System.CollectionUri)$(System.TeamProject)/_apis/build/builds/$(Build.BuildId)/ - jobs: - - job: PublishBuild - timeoutInMinutes: 180 - displayName: Publish Build - steps: - - template: product-publish.yml - - - ${{ if and(parameters.VSCODE_RELEASE, eq(variables['VSCODE_PRIVATE_BUILD'], false)) }}: - - stage: ApproveRelease - dependsOn: [] # run in parallel to compile stage - pool: 1es-ubuntu-20.04-x64 - jobs: - - deployment: ApproveRelease - displayName: "Approve Release" - environment: "vscode" - variables: - skipComponentGovernanceDetection: true - strategy: - runOnce: - deploy: - steps: - - checkout: none - - - ${{ if or(and(parameters.VSCODE_RELEASE, eq(variables['VSCODE_PRIVATE_BUILD'], false)), and(in(parameters.VSCODE_QUALITY, 'insider', 'exploration'), eq(variables['VSCODE_SCHEDULEDBUILD'], true))) }}: - - stage: Release - dependsOn: - - Publish - - ${{ if and(parameters.VSCODE_RELEASE, eq(variables['VSCODE_PRIVATE_BUILD'], false)) }}: - - ApproveRelease - pool: 1es-ubuntu-20.04-x64 - jobs: - - job: ReleaseBuild - displayName: Release Build - steps: - - template: product-release.yml - parameters: - VSCODE_RELEASE: ${{ parameters.VSCODE_RELEASE }} + - Publish + - ${{ if and(parameters.VSCODE_RELEASE, eq(variables['VSCODE_PRIVATE_BUILD'], false)) }}: + - ApproveRelease + pool: + name: 1es-ubuntu-20.04-x64 + os: linux + jobs: + - job: ReleaseBuild + displayName: Release Build + steps: + - template: build/azure-pipelines/product-release.yml@self + parameters: + VSCODE_RELEASE: ${{ parameters.VSCODE_RELEASE }} diff --git a/build/azure-pipelines/product-compile.yml b/build/azure-pipelines/product-compile.yml index ac95819f7a91f..5fd12caf01740 100644 --- a/build/azure-pipelines/product-compile.yml +++ b/build/azure-pipelines/product-compile.yml @@ -10,7 +10,7 @@ steps: nodejsMirror: https://github.com/joaomoreno/node-mirror/releases/download - ${{ if ne(parameters.VSCODE_QUALITY, 'oss') }}: - - template: ./distro/download-distro.yml + - template: ./distro/download-distro.yml@self - task: AzureKeyVault@1 displayName: "Azure Key Vault: Get Secrets" @@ -98,7 +98,7 @@ steps: - script: node build/azure-pipelines/distro/mixin-quality displayName: Mixin distro quality - - template: common/install-builtin-extensions.yml + - template: common/install-builtin-extensions.yml@self - ${{ if eq(parameters.VSCODE_QUALITY, 'oss') }}: - script: yarn npm-run-all -lp core-ci-pr extensions-ci-pr hygiene eslint valid-layers-check vscode-dts-compile-check tsec-compile-check @@ -146,10 +146,11 @@ steps: - script: tar -cz --ignore-failed-read --exclude='.build/node_modules_cache' --exclude='.build/node_modules_list.txt' --exclude='.build/distro' -f $(Build.ArtifactStagingDirectory)/compilation.tar.gz .build out-* test/integration/browser/out test/smoke/out test/automation/out displayName: Compress compilation artifact - - task: PublishPipelineArtifact@1 + - task: 1ES.PublishPipelineArtifact@1 inputs: targetPath: $(Build.ArtifactStagingDirectory)/compilation.tar.gz artifactName: Compilation + sbomEnabled: false displayName: Publish compilation artifact - script: yarn download-builtin-extensions-cg diff --git a/build/azure-pipelines/product-onebranch.yml b/build/azure-pipelines/product-onebranch.yml deleted file mode 100644 index 6241e0c0ee49e..0000000000000 --- a/build/azure-pipelines/product-onebranch.yml +++ /dev/null @@ -1,46 +0,0 @@ -trigger: none -pr: none - -variables: - LinuxContainerImage: "onebranch.azurecr.io/linux/ubuntu-2004:latest" - -resources: - repositories: - - repository: templates - type: git - name: OneBranch.Pipelines/GovernedTemplates - ref: refs/heads/main - - - repository: distro - type: github - name: microsoft/vscode-distro - ref: refs/heads/distro - endpoint: Monaco - -extends: - template: v2/OneBranch.NonOfficial.CrossPlat.yml@templates - parameters: - git: - fetchDepth: 1 - lfs: true - retryCount: 3 - - globalSdl: - policheck: - break: true - credscan: - suppressionsFile: $(Build.SourcesDirectory)\build\azure-pipelines\config\CredScanSuppressions.json - - stages: - - stage: Compile - - jobs: - - job: Compile - pool: - type: linux - - variables: - ob_outputDirectory: '$(Build.SourcesDirectory)' - - steps: - - checkout: distro diff --git a/build/azure-pipelines/product-publish.yml b/build/azure-pipelines/product-publish.yml index 1cf0209aa6306..2c57e131c1a7a 100644 --- a/build/azure-pipelines/product-publish.yml +++ b/build/azure-pipelines/product-publish.yml @@ -101,8 +101,11 @@ steps: displayName: Process artifacts retryCountOnTaskFailure: 3 - - publish: $(Pipeline.Workspace)/artifacts_processed_$(System.StageAttempt)/artifacts_processed_$(System.StageAttempt).txt - artifact: artifacts_processed_$(System.StageAttempt) + - task: 1ES.PublishPipelineArtifact@1 + inputs: + targetPath: $(Pipeline.Workspace)/artifacts_processed_$(System.StageAttempt)/artifacts_processed_$(System.StageAttempt).txt + artifactName: artifacts_processed_$(System.StageAttempt) + sbomEnabled: false displayName: Publish the artifacts processed for this stage attempt condition: always() @@ -113,6 +116,7 @@ steps: $stages = @( if ($env:VSCODE_BUILD_STAGE_WINDOWS -eq 'True') { 'Windows' } if ($env:VSCODE_BUILD_STAGE_LINUX -eq 'True') { 'Linux' } + if ($env:VSCODE_BUILD_STAGE_LINUX_LEGACY_SERVER -eq 'True') { 'LinuxLegacyServer' } if ($env:VSCODE_BUILD_STAGE_ALPINE -eq 'True') { 'Alpine' } if ($env:VSCODE_BUILD_STAGE_MACOS -eq 'True') { 'macOS' } if ($env:VSCODE_BUILD_STAGE_WEB -eq 'True') { 'Web' } diff --git a/build/azure-pipelines/web/product-build-web.yml b/build/azure-pipelines/web/product-build-web.yml index 1cff98f82e190..72ded6bcc11ab 100644 --- a/build/azure-pipelines/web/product-build-web.yml +++ b/build/azure-pipelines/web/product-build-web.yml @@ -5,7 +5,7 @@ steps: versionFilePath: .nvmrc nodejsMirror: https://github.com/joaomoreno/node-mirror/releases/download - - template: ../distro/download-distro.yml + - template: ../distro/download-distro.yml@self - task: AzureKeyVault@1 displayName: "Azure Key Vault: Get Secrets" @@ -94,7 +94,7 @@ steps: - script: node build/azure-pipelines/distro/mixin-quality displayName: Mixin distro quality - - template: ../common/install-builtin-extensions.yml + - template: ../common/install-builtin-extensions.yml@self - script: | set -e @@ -153,17 +153,12 @@ steps: condition: and(succeededOrFailed(), notIn(variables['Agent.JobStatus'], 'Succeeded', 'SucceededWithIssues')) displayName: Generate artifact prefix - - task: AzureArtifacts.manifest-generator-task.manifest-generator-task.ManifestGeneratorTask@0 - displayName: Generate SBOM + - task: 1ES.PublishPipelineArtifact@1 inputs: - BuildDropPath: $(agent.builddirectory)/vscode-web - PackageName: Visual Studio Code Web - - - publish: $(agent.builddirectory)/vscode-web/_manifest - displayName: Publish SBOM (client) - artifact: $(ARTIFACT_PREFIX)sbom_vscode_web - - - publish: $(WEB_PATH) - artifact: $(ARTIFACT_PREFIX)vscode_web_linux_standalone_archive-unsigned + targetPath: $(WEB_PATH) + artifactName: $(ARTIFACT_PREFIX)vscode_web_linux_standalone_archive-unsigned + sbomBuildDropPath: $(Agent.BuildDirectory)/vscode-web + sbomPackageName: "VS Code Web" + sbomPackageVersion: $(Build.SourceVersion) condition: and(succeededOrFailed(), ne(variables['WEB_PATH'], '')) displayName: Publish web archive diff --git a/build/azure-pipelines/win32/cli-build-win32.yml b/build/azure-pipelines/win32/cli-build-win32.yml index 1210b1555c000..19409272ff07b 100644 --- a/build/azure-pipelines/win32/cli-build-win32.yml +++ b/build/azure-pipelines/win32/cli-build-win32.yml @@ -19,7 +19,7 @@ steps: nodejsMirror: https://github.com/joaomoreno/node-mirror/releases/download - ${{ if ne(parameters.VSCODE_QUALITY, 'oss') }}: - - template: ../cli/cli-apply-patches.yml + - template: ../cli/cli-apply-patches.yml@self - task: Npm@1 displayName: Download openssl prebuilt @@ -35,7 +35,7 @@ steps: tar -xvzf $(Build.ArtifactStagingDirectory)/vscode-internal-openssl-prebuilt-0.0.11.tgz --strip-components=1 --directory=$(Build.ArtifactStagingDirectory)/openssl displayName: Extract openssl prebuilt - - template: ../cli/install-rust-win32.yml + - template: ../cli/install-rust-win32.yml@self parameters: targets: - ${{ if eq(parameters.VSCODE_BUILD_WIN32, true) }}: @@ -44,7 +44,7 @@ steps: - aarch64-pc-windows-msvc - ${{ if eq(parameters.VSCODE_BUILD_WIN32, true) }}: - - template: ../cli/cli-compile.yml + - template: ../cli/cli-compile.yml@self parameters: VSCODE_QUALITY: ${{ parameters.VSCODE_QUALITY }} VSCODE_CLI_TARGET: x86_64-pc-windows-msvc @@ -56,7 +56,7 @@ steps: RUSTFLAGS: "-C target-feature=+crt-static" - ${{ if eq(parameters.VSCODE_BUILD_WIN32_ARM64, true) }}: - - template: ../cli/cli-compile.yml + - template: ../cli/cli-compile.yml@self parameters: VSCODE_QUALITY: ${{ parameters.VSCODE_QUALITY }} VSCODE_CLI_TARGET: aarch64-pc-windows-msvc @@ -67,14 +67,23 @@ steps: OPENSSL_INCLUDE_DIR: $(Build.ArtifactStagingDirectory)/openssl/arm64-windows-static/include RUSTFLAGS: "-C target-feature=+crt-static" - - ${{ if eq(parameters.VSCODE_BUILD_WIN32_ARM64, true) }}: - - template: ../cli/cli-publish.yml - parameters: - VSCODE_CLI_ARTIFACT: unsigned_vscode_cli_win32_arm64_cli - VSCODE_CHECK_ONLY: ${{ parameters.VSCODE_CHECK_ONLY }} + - ${{ if not(parameters.VSCODE_CHECK_ONLY) }}: + - ${{ if eq(parameters.VSCODE_BUILD_WIN32_ARM64, true) }}: + - task: 1ES.PublishPipelineArtifact@1 + inputs: + targetPath: $(Build.ArtifactStagingDirectory)/unsigned_vscode_cli_win32_arm64_cli.zip + artifactName: unsigned_vscode_cli_win32_arm64_cli + sbomBuildDropPath: $(Build.ArtifactStagingDirectory)/cli + sbomPackageName: "VS Code Windows arm64 CLI (unsigned)" + sbomPackageVersion: $(Build.SourceVersion) + displayName: Publish unsigned_vscode_cli_win32_arm64_cli artifact - - ${{ if eq(parameters.VSCODE_BUILD_WIN32, true) }}: - - template: ../cli/cli-publish.yml - parameters: - VSCODE_CLI_ARTIFACT: unsigned_vscode_cli_win32_x64_cli - VSCODE_CHECK_ONLY: ${{ parameters.VSCODE_CHECK_ONLY }} + - ${{ if eq(parameters.VSCODE_BUILD_WIN32, true) }}: + - task: 1ES.PublishPipelineArtifact@1 + inputs: + targetPath: $(Build.ArtifactStagingDirectory)/unsigned_vscode_cli_win32_x64_cli.zip + artifactName: unsigned_vscode_cli_win32_x64_cli + sbomBuildDropPath: $(Build.ArtifactStagingDirectory)/cli + sbomPackageName: "VS Code Windows x64 CLI (unsigned)" + sbomPackageVersion: $(Build.SourceVersion) + displayName: Publish unsigned_vscode_cli_win32_x64_cli artifact diff --git a/build/azure-pipelines/win32/product-build-win32-cli-sign.yml b/build/azure-pipelines/win32/product-build-win32-cli-sign.yml index 75b855288b0f7..3b5668d008245 100644 --- a/build/azure-pipelines/win32/product-build-win32-cli-sign.yml +++ b/build/azure-pipelines/win32/product-build-win32-cli-sign.yml @@ -44,7 +44,7 @@ steps: workingDirectory: build displayName: Install build dependencies - - template: ../cli/cli-win32-sign.yml + - template: ../cli/cli-win32-sign.yml@self parameters: VSCODE_CLI_ARTIFACTS: - ${{ if eq(parameters.VSCODE_BUILD_WIN32, true) }}: diff --git a/build/azure-pipelines/win32/product-build-win32-test.yml b/build/azure-pipelines/win32/product-build-win32-test.yml index cc9867ef4fc09..a3b251b71ac17 100644 --- a/build/azure-pipelines/win32/product-build-win32-test.yml +++ b/build/azure-pipelines/win32/product-build-win32-test.yml @@ -9,6 +9,9 @@ parameters: type: boolean - name: VSCODE_RUN_SMOKE_TESTS type: boolean + - name: PUBLISH_TASK_NAME + type: string + default: PublishPipelineArtifact@0 steps: - powershell: yarn npm-run-all -lp "electron $(VSCODE_ARCH)" "playwright-install" @@ -162,7 +165,7 @@ steps: condition: succeededOrFailed() - ${{ if or(eq(parameters.VSCODE_RUN_INTEGRATION_TESTS, true), eq(parameters.VSCODE_RUN_SMOKE_TESTS, true)) }}: - - task: PublishPipelineArtifact@0 + - task: ${{ parameters.PUBLISH_TASK_NAME }} inputs: targetPath: .build\crashes ${{ if and(eq(parameters.VSCODE_RUN_INTEGRATION_TESTS, true), eq(parameters.VSCODE_RUN_SMOKE_TESTS, false)) }}: @@ -171,13 +174,14 @@ steps: artifactName: crash-dump-windows-$(VSCODE_ARCH)-smoke-$(System.JobAttempt) ${{ else }}: artifactName: crash-dump-windows-$(VSCODE_ARCH)-$(System.JobAttempt) + sbomEnabled: false displayName: "Publish Crash Reports" continueOnError: true condition: failed() # In order to properly symbolify above crash reports # (if any), we need the compiled native modules too - - task: PublishPipelineArtifact@0 + - task: ${{ parameters.PUBLISH_TASK_NAME }} inputs: targetPath: node_modules ${{ if and(eq(parameters.VSCODE_RUN_INTEGRATION_TESTS, true), eq(parameters.VSCODE_RUN_SMOKE_TESTS, false)) }}: @@ -186,11 +190,12 @@ steps: artifactName: node-modules-windows-$(VSCODE_ARCH)-smoke-$(System.JobAttempt) ${{ else }}: artifactName: node-modules-windows-$(VSCODE_ARCH)-$(System.JobAttempt) + sbomEnabled: false displayName: "Publish Node Modules" continueOnError: true condition: failed() - - task: PublishPipelineArtifact@0 + - task: ${{ parameters.PUBLISH_TASK_NAME }} inputs: targetPath: .build\logs ${{ if and(eq(parameters.VSCODE_RUN_INTEGRATION_TESTS, true), eq(parameters.VSCODE_RUN_SMOKE_TESTS, false)) }}: @@ -199,6 +204,7 @@ steps: artifactName: logs-windows-$(VSCODE_ARCH)-smoke-$(System.JobAttempt) ${{ else }}: artifactName: logs-windows-$(VSCODE_ARCH)-$(System.JobAttempt) + sbomEnabled: false displayName: "Publish Log Files" continueOnError: true condition: succeededOrFailed() diff --git a/build/azure-pipelines/win32/product-build-win32.yml b/build/azure-pipelines/win32/product-build-win32.yml index ed316e721bc21..3c92499b2a671 100644 --- a/build/azure-pipelines/win32/product-build-win32.yml +++ b/build/azure-pipelines/win32/product-build-win32.yml @@ -30,7 +30,7 @@ steps: addToPath: true - ${{ if ne(parameters.VSCODE_QUALITY, 'oss') }}: - - template: ../distro/download-distro.yml + - template: ../distro/download-distro.yml@self - task: AzureKeyVault@1 displayName: "Azure Key Vault: Get Secrets" @@ -127,7 +127,7 @@ steps: - powershell: node build/azure-pipelines/distro/mixin-quality displayName: Mixin distro quality - - template: ../common/install-builtin-extensions.yml + - template: ../common/install-builtin-extensions.yml@self - ${{ if and(ne(parameters.VSCODE_CIBUILD, true), ne(parameters.VSCODE_QUALITY, 'oss')) }}: - powershell: node build\lib\policies @@ -180,13 +180,15 @@ steps: condition: and(succeeded(), ne(variables['VSCODE_ARCH'], 'arm64')) - ${{ if or(eq(parameters.VSCODE_RUN_UNIT_TESTS, true), eq(parameters.VSCODE_RUN_INTEGRATION_TESTS, true), eq(parameters.VSCODE_RUN_SMOKE_TESTS, true)) }}: - - template: product-build-win32-test.yml + - template: product-build-win32-test.yml@self parameters: VSCODE_QUALITY: ${{ parameters.VSCODE_QUALITY }} VSCODE_ARCH: ${{ parameters.VSCODE_ARCH }} VSCODE_RUN_UNIT_TESTS: ${{ parameters.VSCODE_RUN_UNIT_TESTS }} VSCODE_RUN_INTEGRATION_TESTS: ${{ parameters.VSCODE_RUN_INTEGRATION_TESTS }} VSCODE_RUN_SMOKE_TESTS: ${{ parameters.VSCODE_RUN_SMOKE_TESTS }} + ${{ if ne(parameters.VSCODE_QUALITY, 'oss') }}: + PUBLISH_TASK_NAME: 1ES.PublishPipelineArtifact@1 - ${{ if ne(parameters.VSCODE_CIBUILD, true) }}: - ${{ if ne(parameters.VSCODE_QUALITY, 'oss') }}: @@ -299,50 +301,52 @@ steps: condition: and(succeededOrFailed(), notIn(variables['Agent.JobStatus'], 'Succeeded', 'SucceededWithIssues')) displayName: Generate artifact prefix - - task: AzureArtifacts.manifest-generator-task.manifest-generator-task.ManifestGeneratorTask@0 - displayName: Generate SBOM (client) + - task: 1ES.PublishPipelineArtifact@1 inputs: - BuildDropPath: $(agent.builddirectory)/VSCode-win32-$(VSCODE_ARCH) - PackageName: Visual Studio Code - - - task: AzureArtifacts.manifest-generator-task.manifest-generator-task.ManifestGeneratorTask@0 - displayName: Generate SBOM (server) - inputs: - BuildComponentPath: $(Build.SourcesDirectory)/remote - BuildDropPath: $(agent.builddirectory)/vscode-server-win32-$(VSCODE_ARCH) - PackageName: Visual Studio Code Server - condition: and(succeeded(), ne(variables['VSCODE_ARCH'], 'arm64')) - - - publish: $(agent.builddirectory)/VSCode-win32-$(VSCODE_ARCH)/_manifest - displayName: Publish SBOM (client) - artifact: $(ARTIFACT_PREFIX)sbom_vscode_client_win32_$(VSCODE_ARCH) - - - publish: $(agent.builddirectory)/vscode-server-win32-$(VSCODE_ARCH)/_manifest - displayName: Publish SBOM (server) - artifact: $(ARTIFACT_PREFIX)sbom_vscode_server_win32_$(VSCODE_ARCH) - condition: and(succeeded(), ne(variables['VSCODE_ARCH'], 'arm64')) - - - publish: $(CLIENT_PATH) - artifact: $(ARTIFACT_PREFIX)vscode_client_win32_$(VSCODE_ARCH)_archive + targetPath: $(CLIENT_PATH) + artifactName: $(ARTIFACT_PREFIX)vscode_client_win32_$(VSCODE_ARCH)_archive + sbomBuildDropPath: $(Agent.BuildDirectory)/VSCode-win32-$(VSCODE_ARCH) + sbomPackageName: "VS Code Windows $(VSCODE_ARCH)" + sbomPackageVersion: $(Build.SourceVersion) condition: and(succeededOrFailed(), ne(variables['CLIENT_PATH'], '')) displayName: Publish archive - - publish: $(SERVER_PATH) - artifact: $(ARTIFACT_PREFIX)vscode_server_win32_$(VSCODE_ARCH)_archive + - task: 1ES.PublishPipelineArtifact@1 + inputs: + targetPath: $(SERVER_PATH) + artifactName: $(ARTIFACT_PREFIX)vscode_server_win32_$(VSCODE_ARCH)_archive + sbomBuildDropPath: $(Agent.BuildDirectory)/vscode-server-win32-$(VSCODE_ARCH) + sbomPackageName: "VS Code Windows $(VSCODE_ARCH) Server" + sbomPackageVersion: $(Build.SourceVersion) condition: and(succeededOrFailed(), ne(variables['SERVER_PATH'], ''), ne(variables['VSCODE_ARCH'], 'arm64')) displayName: Publish server archive - - publish: $(WEB_PATH) - artifact: $(ARTIFACT_PREFIX)vscode_web_win32_$(VSCODE_ARCH)_archive + - task: 1ES.PublishPipelineArtifact@1 + inputs: + targetPath: $(WEB_PATH) + artifactName: $(ARTIFACT_PREFIX)vscode_web_win32_$(VSCODE_ARCH)_archive + sbomBuildDropPath: $(Agent.BuildDirectory)/vscode-server-win32-$(VSCODE_ARCH)-web + sbomPackageName: "VS Code Windows $(VSCODE_ARCH) Web" + sbomPackageVersion: $(Build.SourceVersion) condition: and(succeededOrFailed(), ne(variables['WEB_PATH'], ''), ne(variables['VSCODE_ARCH'], 'arm64')) displayName: Publish web server archive - - publish: $(SYSTEM_SETUP_PATH) - artifact: $(ARTIFACT_PREFIX)vscode_client_win32_$(VSCODE_ARCH)_setup + - task: 1ES.PublishPipelineArtifact@1 + inputs: + targetPath: $(SYSTEM_SETUP_PATH) + artifactName: $(ARTIFACT_PREFIX)vscode_client_win32_$(VSCODE_ARCH)_setup + sbomBuildDropPath: $(Agent.BuildDirectory)/VSCode-win32-$(VSCODE_ARCH) + sbomPackageName: "VS Code Windows $(VSCODE_ARCH) System Setup" + sbomPackageVersion: $(Build.SourceVersion) condition: and(succeededOrFailed(), ne(variables['SYSTEM_SETUP_PATH'], '')) displayName: Publish system setup - - publish: $(USER_SETUP_PATH) - artifact: $(ARTIFACT_PREFIX)vscode_client_win32_$(VSCODE_ARCH)_user-setup + - task: 1ES.PublishPipelineArtifact@1 + inputs: + targetPath: $(USER_SETUP_PATH) + artifactName: $(ARTIFACT_PREFIX)vscode_client_win32_$(VSCODE_ARCH)_user-setup + sbomBuildDropPath: $(Agent.BuildDirectory)/VSCode-win32-$(VSCODE_ARCH) + sbomPackageName: "VS Code Windows $(VSCODE_ARCH) User Setup" + sbomPackageVersion: $(Build.SourceVersion) condition: and(succeededOrFailed(), ne(variables['USER_SETUP_PATH'], '')) displayName: Publish user setup diff --git a/build/checksums/electron.txt b/build/checksums/electron.txt index 2eaf08f2c005b..86f78d0adea96 100644 --- a/build/checksums/electron.txt +++ b/build/checksums/electron.txt @@ -1,75 +1,75 @@ -27fcfc6982b3b1b56cbb611c5060bdbee35fe64aab6c8a24e19ac133d1bbaf05 *chromedriver-v27.3.1-darwin-arm64.zip -56ddf9df850b3612e8aa61204e6334400a89502bb8acabcc9873218317831d0d *chromedriver-v27.3.1-darwin-x64.zip -c070bc178589426112c4ff740712ad3ce5da672f717a93ef74c3d53f23173e08 *chromedriver-v27.3.1-linux-arm64.zip -84f072cca93660bc5eb2f261d71c6db207e7a979ecede92c0f0c5d64cbdfc907 *chromedriver-v27.3.1-linux-armv7l.zip -a722b30273d94d370434604949c4bccb38f3c725e14429d9f6dc9f33455414bf *chromedriver-v27.3.1-linux-x64.zip -8ca0fe4e3daeef42002213c2bf957539c77010e16a5772a0a79e167cf803bb85 *chromedriver-v27.3.1-mas-arm64.zip -52bba4265e0e18f6958de225b9726b262efafebdfee846749f4d83c400046a74 *chromedriver-v27.3.1-mas-x64.zip -b1b9dfd2d5c962b87827b0f4f72ab955cdee14358e9914476b30de603b8834ce *chromedriver-v27.3.1-win32-arm64.zip -a74ae6f90b8d0ae0e29a53a254d25a9ca09eaadeb0bc546fea0aff838345a494 *chromedriver-v27.3.1-win32-ia32.zip -bf37f6947e2e0dc19a99320a39aa23cf644dcf075d7d9ba2394364681aaafb60 *chromedriver-v27.3.1-win32-x64.zip -b25fb8ae02721a89614aeb4db621a797fca385340c8b6be64724785a2e734d23 *electron-api.json -e8394d2c23c878b4bd774f85a1f468547f4c9e4669e14b19850f685ca7ab3c76 *electron-v27.3.1-darwin-arm64-dsym-snapshot.zip -f346aeb98ef1c059b6fc035f837ba997e98d2a2b9e06076ec882df877ae1d6be *electron-v27.3.1-darwin-arm64-dsym.zip -a66a6656acc7217a3f8627a384d9ca5ba3d3cb1b7e144e25bb82344eb211612b *electron-v27.3.1-darwin-arm64-symbols.zip -0c8b1c3bf1fcb52be3c0517603fe69329050a2efb1c61ab5e5988a54aa693ebd *electron-v27.3.1-darwin-arm64.zip -92086b977a81d5e4787bdcff9e4b684807edbf71093cdc54c1d2169b96c0165d *electron-v27.3.1-darwin-x64-dsym-snapshot.zip -57c0d9dd591d2abfa56865323745e411351f639049eefa4506116cbe421baca5 *electron-v27.3.1-darwin-x64-dsym.zip -069c76522f1c4316065932a90067f02b7f6e8f144133c2ba09da8d97a06c4c0f *electron-v27.3.1-darwin-x64-symbols.zip -e2360124f0fdb3ff5e6a644d0e139a9546947ee2f69e51f3d310c3f2cbcd19ee *electron-v27.3.1-darwin-x64.zip -e6fdce1f521511b13ec4425d8a7e51077e83faa4bbfa18026598037c6688965c *electron-v27.3.1-linux-arm64-debug.zip -3304fcfbf8aa45f3a174d404174c34123b8f777259eae7fe26a9d82104068a89 *electron-v27.3.1-linux-arm64-symbols.zip -9337cfe3f9256fecc806554c9fac3be780fd20c868efe621aaca2c1455c5ff64 *electron-v27.3.1-linux-arm64.zip -e6fdce1f521511b13ec4425d8a7e51077e83faa4bbfa18026598037c6688965c *electron-v27.3.1-linux-armv7l-debug.zip -7bb138d548e621b62ece75f43b26c5e287127b720a2fcf1b24fde75178000848 *electron-v27.3.1-linux-armv7l-symbols.zip -4956df2e23ae6bdebda8a1fbbee40828ca1170ce61b8d4504f1e30ed1102052a *electron-v27.3.1-linux-armv7l.zip -b8e7cd591db6ffad32f7dac90e05404a675d4f2a5e1e375dfce7b4a5c3b0064b *electron-v27.3.1-linux-x64-debug.zip -5c692dce572cf1b8ad57a0633280ee61096256b7291701a1c3e357f48479fb7b *electron-v27.3.1-linux-x64-symbols.zip -9daf5c5dc2050b9f37a5ec6d91d586ac156824bfe9a07ca53872c1b293280ca1 *electron-v27.3.1-linux-x64.zip -959529b81b9517df24baed089fb92fd1730b4eefaddcd37c864da6b05f63ecbe *electron-v27.3.1-mas-arm64-dsym-snapshot.zip -603c4955dddd4f8211c6fab3e633242255c1bf408326bf66567bfa00bed21493 *electron-v27.3.1-mas-arm64-dsym.zip -8181e85363bcc89863c0de76b29d47b68beb4f53d79583875a74178f5e12ef1d *electron-v27.3.1-mas-arm64-symbols.zip -c577088087c137e60884d857335a6720547c14eac894884d9daa07f1662a43a5 *electron-v27.3.1-mas-arm64.zip -6ce1f58f7bfa0b1aafd1aea2bfbad83df3a86a7b922b5d57f00689c7dd573f42 *electron-v27.3.1-mas-x64-dsym-snapshot.zip -1efe461ecca71b7efd96a34187b4810969c2d2bece7d02db2ad2821acd44e130 *electron-v27.3.1-mas-x64-dsym.zip -eabfbd4ee150f60b7cf10c126343cc078a3df2b8e63938f5795766fa8d837c93 *electron-v27.3.1-mas-x64-symbols.zip -2e0f6a468d388ca47eba3ae8cd79f5f422d3a36813b46d09fc7a0ae93bcbb68c *electron-v27.3.1-mas-x64.zip -c086cee2cfe73567d35e3b373c39f562e9169dc1f234caa6d006a878ff43b2e4 *electron-v27.3.1-win32-arm64-pdb.zip -c064dd5aa63b1506cca2cc6cb2fda6478839c194f62b3b90782b3dc8246bd55c *electron-v27.3.1-win32-arm64-symbols.zip -c9f31ae6408aa6936b5d683eda601773789185890375cd097e61e924d4fed77a *electron-v27.3.1-win32-arm64-toolchain-profile.zip -09caae1d93087cebcefec8bfc01496213e8db95d4563151d46aec3f05f31ec30 *electron-v27.3.1-win32-arm64.zip -c1dbf42222a5bfd86850fec114436e24960275547cf529e4f5ff2729ce680801 *electron-v27.3.1-win32-ia32-pdb.zip -8d3db6570266cc571823e7b2498e694e428134ecf2a11211dbd8f1b1d9ddfe02 *electron-v27.3.1-win32-ia32-symbols.zip -c9f31ae6408aa6936b5d683eda601773789185890375cd097e61e924d4fed77a *electron-v27.3.1-win32-ia32-toolchain-profile.zip -686daa2e3cd9751d411b781af88ff84b91b23ddcf4879bfa5f4417e2e45ca4eb *electron-v27.3.1-win32-ia32.zip -7c1ec060a7ca71c3018b18c66607aad3f648257bdc055f4672341abcf275f1d8 *electron-v27.3.1-win32-x64-pdb.zip -7ab64686ed233b2aef2443d72997a36a2eb67cbdc1321a419111d573292956bc *electron-v27.3.1-win32-x64-symbols.zip -c9f31ae6408aa6936b5d683eda601773789185890375cd097e61e924d4fed77a *electron-v27.3.1-win32-x64-toolchain-profile.zip -20d54521540d1d9fa441d1e977555dbec4958bcd76de57f23b4534077361a865 *electron-v27.3.1-win32-x64.zip -482a6452d0d2ccda8afbfc19326fe0d59118491c216938a192bfa1ad7d168493 *electron.d.ts -ac54ec270dacdd7ca2f806b3c75047875535e1722e251ec81b4f3ab9c574470b *ffmpeg-v27.3.1-darwin-arm64.zip -091699a1fe0506f54b2b990c3e85058dae0fffffb818d22b41ffc2496e155fa4 *ffmpeg-v27.3.1-darwin-x64.zip -be517ba93c5b5f36d2e55af148213f4c2fc43177563825601bee6f88dd4f7b05 *ffmpeg-v27.3.1-linux-arm64.zip -926d0da25ffcea3d05a6cbcae15e5d7729d93bc43394ae4439747669d2210e1d *ffmpeg-v27.3.1-linux-armv7l.zip -6f9c0ef52af14828ad547a80b17f8c63cac51a18b8d5769a2f33e4fa6cccfc7e *ffmpeg-v27.3.1-linux-x64.zip -e26c3c7097642012acf1178367c6815cf90ac588864e99206d89230409f67c01 *ffmpeg-v27.3.1-mas-arm64.zip -8dc1775e034c1a2c25a150690c384d3479d26f020a0ed533e1691a01a62ab0a5 *ffmpeg-v27.3.1-mas-x64.zip -94832b30166038225703810fec94b2f854fab7ca3fc7f5531219d2de5b9fa4e2 *ffmpeg-v27.3.1-win32-arm64.zip -7e5d9c0b4db4e1370219a74c4484f9d15d18c0c54501003280eb61f8dc3b84b2 *ffmpeg-v27.3.1-win32-ia32.zip -f45d1f9cf5e5e7bff93406d70b2bee58ae5b3836f1950fb47abb6520d0547aac *ffmpeg-v27.3.1-win32-x64.zip -ea4a6308be24bfc818526f1830f084961b5bd9277bbac16e5753fa4d60aad01e *hunspell_dictionaries.zip -9c05221181a2ae7d6f437ec356878ab44b71a958995f4f104667fef20a8076ef *libcxx-objects-v27.3.1-linux-arm64.zip -bef8d7930d31993196f7b0916eece6fac1d84fe9ed2b0a63fb484778949c1ee3 *libcxx-objects-v27.3.1-linux-armv7l.zip -17b1e3d3d9e5abdcedc3c22ce879becdff316c2e2a89369c6f2657e9051da6a6 *libcxx-objects-v27.3.1-linux-x64.zip -46313e591349d7a3a8390d96560d7799afad1f3774d588488a6ee80756172f7d *libcxx_headers.zip -44e6410776454067a546bf5d2525dd33baacd919481dc43708f327009f8b52bd *libcxxabi_headers.zip -870072bdd1956b404265ecfebcbe1a4fbaa2219fd61dd881c817f4f4f8130684 *mksnapshot-v27.3.1-darwin-arm64.zip -d76067c1f28fa4baebed723cc9f6374bd31d4baf0d6e7e19c36bfff6d214dd91 *mksnapshot-v27.3.1-darwin-x64.zip -e403e621c2af0a468301297c11790b370a77a0ba6f7f3fa4af731f005b6ffb96 *mksnapshot-v27.3.1-linux-arm64-x64.zip -6f7ef9ce138ff5c6d4c7df449ac717613e3001756b765f0e4e7662af1387c754 *mksnapshot-v27.3.1-linux-armv7l-x64.zip -a794b49b20677cd68b8616229180687adec3f46c425cc4c805c4cdb9c4b6ec72 *mksnapshot-v27.3.1-linux-x64.zip -6a61292fd8e3fb86c36c364ea32be2e78b8947e9abf22a8a7749bad40e75a5f5 *mksnapshot-v27.3.1-mas-arm64.zip -d3b1ae83b6244a4ce83e6eab16f8cd121c187ee21a5e37cac4253cfd4e53f6b4 *mksnapshot-v27.3.1-mas-x64.zip -6916414f51fa30cce966e49ea252a73facf24ebac86f068daa2f37930c48d58b *mksnapshot-v27.3.1-win32-arm64-x64.zip -ef37d3c0d9ef36038adef1b81ffbf302c3b819456ffea705c05c3def09710723 *mksnapshot-v27.3.1-win32-ia32.zip -95a222425bb72a8b1a42f37161eb90ff6869268f539387b47b54904df27f2f68 *mksnapshot-v27.3.1-win32-x64.zip +69b40637a88ad4c17877b3d665b39ad0e11928aa71b19ef45f5b76250d1c9786 *chromedriver-v28.2.8-darwin-arm64.zip +3a9ce6179228245f2c7878c4238e10d51c77dc20642922a226ccc235a20f5a29 *chromedriver-v28.2.8-darwin-x64.zip +7f6470ea5d86dbe68fcc3fccfefd3b7135ba3468ef54b0235bf57cedeabf433d *chromedriver-v28.2.8-linux-arm64.zip +4bfe709d58b237f5c5a7618b2abecf533dac9415d327e763ad6cf622218517cc *chromedriver-v28.2.8-linux-armv7l.zip +7558ee413f96f88b9b9ad5787dd433adcfaf56411fdf052826d39d204ebaba9d *chromedriver-v28.2.8-linux-x64.zip +9814583b075d969c32afb6e929b4bf7956b0223fded996c91341388b8f638dd6 *chromedriver-v28.2.8-mas-arm64.zip +82d11c6606db9aea355b1e410083c72bd1e39abb9e34a839c16b16b75364ea0d *chromedriver-v28.2.8-mas-x64.zip +4803a5335a40ba208136094f5adfde2c4272761d34e0e9e9f4febc2ef676c3ad *chromedriver-v28.2.8-win32-arm64.zip +7b079f47869f7e96a5829f6fb7eff032394f76218b39a2aaf73cc93ce8a68050 *chromedriver-v28.2.8-win32-ia32.zip +2aedd176d4f72b29cd1914364e813756d52f53558df32e3429996b820edc994d *chromedriver-v28.2.8-win32-x64.zip +ae1a521aa36053a3b60b318d7bc093ec7579af6aa8b02bffe1f9e70d6922b726 *electron-api.json +a916f0cc438258f42f43955157565e7eca14966266f3fb123c8c736bece97daa *electron-v28.2.8-darwin-arm64-dsym-snapshot.zip +3c31d0a105b0632f15aa8adc68f06dc8ca47b1fdf1e62d1436ac43af117a22fb *electron-v28.2.8-darwin-arm64-dsym.zip +dab03f1cd7b499552d503bcca2fc1c3f40a1d2c463655ca3ace20778f08e9b04 *electron-v28.2.8-darwin-arm64-symbols.zip +2965d8c8d64fb6c51f5a283a246de653bfae22fe4bf9adf6c04592afabf62f04 *electron-v28.2.8-darwin-arm64.zip +03511a34d94d27eb576ab20e3a432c082a32a298475c7a85a329e029dddc55e4 *electron-v28.2.8-darwin-x64-dsym-snapshot.zip +96089786bd2723786673561c9b6f9a154928de663f2411f10153e6c985703eef *electron-v28.2.8-darwin-x64-dsym.zip +872789c3c218ab8f98be83c7781e3e6ef0114bd39780d65eaae77e99dbbda1de *electron-v28.2.8-darwin-x64-symbols.zip +a7889addd37254f842798bdd3ca34752b75acf6d8dd456cdeb2d75590c0a9ceb *electron-v28.2.8-darwin-x64.zip +fb90b8c903407ae575f9c8f727376519c0b35ed6f01dec55b177285b5db864e3 *electron-v28.2.8-linux-arm64-debug.zip +591248f7c94a6d7c4a4d8b2fcf63c8e4347018a65e1f68ed90e5549a587062c8 *electron-v28.2.8-linux-arm64-symbols.zip +6183db1029cebd9e0fb0e4f2d24a80b0274c5265756e66cb9fa0a480b92c98ea *electron-v28.2.8-linux-arm64.zip +fb90b8c903407ae575f9c8f727376519c0b35ed6f01dec55b177285b5db864e3 *electron-v28.2.8-linux-armv7l-debug.zip +87c4c534cd1d447b9d4632585a0d79c9d31114bd39ca63df1f2384afae3aa6b7 *electron-v28.2.8-linux-armv7l-symbols.zip +2a772b65815a0d47a756eed52f76cd9f27a8c277d7998bfcfe93b84a346eb255 *electron-v28.2.8-linux-armv7l.zip +773aa1f0bbe2b79765bf498958565f63957f8ec2e42327978a143dcbbc7f1bea *electron-v28.2.8-linux-x64-debug.zip +f8cbc6f2b719cc2f623afcfde8cb1d42614708793621a7a97b328015366b9b8f *electron-v28.2.8-linux-x64-symbols.zip +e7d17ee311299dfef3d2916987a513c4c1b66ad2e417c15fa5d29699602bd6cb *electron-v28.2.8-linux-x64.zip +5f0179fd7bf3927381bde24c9fb372fe95328be0500918cd6ee7f9503fae1ef5 *electron-v28.2.8-mas-arm64-dsym-snapshot.zip +e9810019f1d7b1b5a93fd1aee8adda5a872ebfb170de6d55cdd55162b923432d *electron-v28.2.8-mas-arm64-dsym.zip +4781376244c7df89d119575e2788ad43fae4387d850ef672665688081b30997c *electron-v28.2.8-mas-arm64-symbols.zip +a3932199781970e0b2fdb805d6556287ca877b35ac19384da00474140e14c41f *electron-v28.2.8-mas-arm64.zip +326cde32079496e0d976c5b65e85e5ce208eea3d8d23cd92c9e25f0fa6b30f40 *electron-v28.2.8-mas-x64-dsym-snapshot.zip +59a2b3d28dba45ee3016f8ab49a71b0c55f99ef046476183bc36890c9d335a71 *electron-v28.2.8-mas-x64-dsym.zip +313ff88f568c39079a1b7a1011f77fa03890cb9bb53649a489643311303cc3b8 *electron-v28.2.8-mas-x64-symbols.zip +41ab9f3addea5066d7e0ace28ebaead7128a2073931473c847aa9133b7df9248 *electron-v28.2.8-mas-x64.zip +179de6dd4835216bcd3e8bb9eb4d4b54013df865f52dbf0d5214726fc31cba9a *electron-v28.2.8-win32-arm64-pdb.zip +8628dec571206001420c1d8655904883d5de7e772d51ab2101b002c22e0dd25c *electron-v28.2.8-win32-arm64-symbols.zip +c9f31ae6408aa6936b5d683eda601773789185890375cd097e61e924d4fed77a *electron-v28.2.8-win32-arm64-toolchain-profile.zip +bb2a2a466d14c32c06ff09c42b3d1413f19fdc8a49a445d07d289fa453c268d3 *electron-v28.2.8-win32-arm64.zip +1d1efc3a1d17072bc76a4a63c8236a896d46f6f3badacd50bc5824149196d56f *electron-v28.2.8-win32-ia32-pdb.zip +9ddb1520de421a7c636160d01432c9bf111e6ef4b9a3be41b185c702c72353ac *electron-v28.2.8-win32-ia32-symbols.zip +c9f31ae6408aa6936b5d683eda601773789185890375cd097e61e924d4fed77a *electron-v28.2.8-win32-ia32-toolchain-profile.zip +38e22f9b0a32e0fc26e81905214e244c0a5d5c19e13c8ca2329ac75b62881472 *electron-v28.2.8-win32-ia32.zip +8168296e0454377e0113a7d0f87535d3d0e0c1a8538e8079ee1aae9c7223bb02 *electron-v28.2.8-win32-x64-pdb.zip +a276e9e748fa7db970e7dcce6f4ae571d8615a44e5208c0fa3c03de08774a4aa *electron-v28.2.8-win32-x64-symbols.zip +c9f31ae6408aa6936b5d683eda601773789185890375cd097e61e924d4fed77a *electron-v28.2.8-win32-x64-toolchain-profile.zip +079cc98f7933992ac7154e21e160d4a4c6b3541c26b56fc6f8438e9eabc369b9 *electron-v28.2.8-win32-x64.zip +f838e4a7c24518c5fa25d4a23acf869737cfa88761019cea4f83ebfb302363ec *electron.d.ts +4450bcc66cece4ff2373563e0123799f95645fa155577a8f380211b29e8b4ec9 *ffmpeg-v28.2.8-darwin-arm64.zip +152e3ed53098d24f356d7ec640d19efc57f7f34c39d8b8278f2586985d4a99a1 *ffmpeg-v28.2.8-darwin-x64.zip +8e108e533811febcc51f377ac8604d506663453e41c02dc818517e1ea9a4e8d5 *ffmpeg-v28.2.8-linux-arm64.zip +51ecd03435f56a2ced31b1c9dbf281955ba82a814ca0214a4292bdc711e5a45c *ffmpeg-v28.2.8-linux-armv7l.zip +acc9dc3765f68b7563045e2d0df11bbef6b41be0a1c34bbf9fa778f36eefb42f *ffmpeg-v28.2.8-linux-x64.zip +15a2a4a28a66e65122eb4f2bd796ccd5b6ed45420a034878affd002fc8c290dc *ffmpeg-v28.2.8-mas-arm64.zip +2dfe2f524c5220f50c7b6fe08605a67631b5520e0c82842e1f41f677cac17643 *ffmpeg-v28.2.8-mas-x64.zip +313e2979f0df88715159c0737bfbb5ae1d5c79fb9820e94d2a93ba71d3324ecd *ffmpeg-v28.2.8-win32-arm64.zip +9e73bc07563aefa8b9625676939a410b35a823d961b96da0e8edd90d7e5fb47b *ffmpeg-v28.2.8-win32-ia32.zip +1b11042defc8a3f403e5567fa4a4b8c59b224f3b7b52d44d6c7197b96af7b53b *ffmpeg-v28.2.8-win32-x64.zip +1e2e9480d4228f6bbc731ff7ee413b9e97656c36b15418d20681a76d82902b86 *hunspell_dictionaries.zip +8c8b967cf4c78ed9bbf4921b2c616257f45b137412eb3bc64176066c3e47bbe8 *libcxx-objects-v28.2.8-linux-arm64.zip +56af259535ccfaac295b82ce68686f9582265cb2ebe2783852f518c0fabc8a1e *libcxx-objects-v28.2.8-linux-armv7l.zip +b590e001dc98e32e5952ca69573e6f1bcec5e2f2d99052d1089ab72084cccea1 *libcxx-objects-v28.2.8-linux-x64.zip +c0634d5c92f0a2983b17c866f7d3694cb75f6e78cd07b10d9488ef46acc66a50 *libcxx_headers.zip +99ee16441d9eb2b92a05d5a5c9b9dc4cdfab33cb09595e9d78fd2ba503dead5b *libcxxabi_headers.zip +a95de1da301d641caaafaea9869c4c7834c254f818ac0c10d97402b2220c8be3 *mksnapshot-v28.2.8-darwin-arm64.zip +e5ef6b35d7cd807f93babfedbbde513ab6053ad9fb80b0f7abc1bfda414daaa1 *mksnapshot-v28.2.8-darwin-x64.zip +eeb6c5b7962af8d5cfaa97b2cf96d312d0ad57a3abb3e00774d50ea2e005bb9b *mksnapshot-v28.2.8-linux-arm64-x64.zip +0adacd0767469f90400b1f17ba8ac3ccb33cfeb11a8ef54d70bc8adb7cc306dc *mksnapshot-v28.2.8-linux-armv7l-x64.zip +5242817f1f26e10804e7e2446d0a8a64e8b2958cdba01e79d89db883d9d960d0 *mksnapshot-v28.2.8-linux-x64.zip +0ecb67673508c10f4fe08e7cb80300b9a8f507f50994c79caf302ff78ef748ca *mksnapshot-v28.2.8-mas-arm64.zip +19429da56077f12de4d4563f49c55f4f1f0fe61f66863804640fc55e65ee98f9 *mksnapshot-v28.2.8-mas-x64.zip +c7b47ae63c2f6eb07b06379206e6f215fbcb2b9a49faa72ca850bf8f9b998c4c *mksnapshot-v28.2.8-win32-arm64-x64.zip +0032660a9f8575a153951f29adae49a18e400b40906eec803fe7e3d2e970503d *mksnapshot-v28.2.8-win32-ia32.zip +2c71c9a2bd4441e580dc3083073e712fba94e0236415c8ab35320da52f492508 *mksnapshot-v28.2.8-win32-x64.zip diff --git a/build/checksums/nodejs.txt b/build/checksums/nodejs.txt index 9ed8af5842a6a..13aa4c7e87bfb 100644 --- a/build/checksums/nodejs.txt +++ b/build/checksums/nodejs.txt @@ -1,6 +1,6 @@ -18ca716ea57522b90473777cb9f878467f77fdf826d37beb15a0889fdd74533e node-v18.17.1-darwin-arm64.tar.gz -b3e083d2715f07ec3f00438401fb58faa1e0bdf3c7bde9f38b75ed17809d92fa node-v18.17.1-darwin-x64.tar.gz -8f5203f5c6dc44ea50ac918b7ecbdb1c418e4f3d9376d8232a1ef9ff38f9c480 node-v18.17.1-linux-arm64.tar.gz -1ab79868859b2d37148c6d8ecee3abb5ee55b88731ab5df01928ed4f6f9bfbad node-v18.17.1-linux-armv7l.tar.gz -2cb75f2bc04b0a3498733fbee779b2f76fe3f655188b4ac69ef2887b6721da2d node-v18.17.1-linux-x64.tar.gz -afb45186ad4f4217c2fc1dfc2239ff5ab016ef0ba5fc329bc6aa8fd10c7ecc88 win-x64/node.exe +9f982cc91b28778dd8638e4f94563b0c2a1da7aba62beb72bd427721035ab553 node-v18.18.2-darwin-arm64.tar.gz +5bb8da908ed590e256a69bf2862238c8a67bc4600119f2f7721ca18a7c810c0f node-v18.18.2-darwin-x64.tar.gz +0c9a6502b66310cb26e12615b57304e91d92ac03d4adcb91c1906351d7928f0d node-v18.18.2-linux-arm64.tar.gz +7a3b34a6fdb9514bc2374114ec6df3c36113dc5075c38b22763aa8f106783737 node-v18.18.2-linux-armv7l.tar.gz +a44c3e7f8bf91e852c928e5d8bd67ca316b35e27eec1d8acbe3b9dbe03688dab node-v18.18.2-linux-x64.tar.gz +54884183ff5108874c091746465e8156ae0acc68af589cc10bc41b3927db0f4a win-x64/node.exe diff --git a/build/gulpfile.extensions.js b/build/gulpfile.extensions.js index bcdb206606b24..559049597787e 100644 --- a/build/gulpfile.extensions.js +++ b/build/gulpfile.extensions.js @@ -22,74 +22,72 @@ const commit = getVersion(root); const plumber = require('gulp-plumber'); const ext = require('./lib/extensions'); -const extensionsPath = path.join(path.dirname(__dirname), 'extensions'); - // To save 250ms for each gulp startup, we are caching the result here // const compilations = glob.sync('**/tsconfig.json', { // cwd: extensionsPath, // ignore: ['**/out/**', '**/node_modules/**'] // }); const compilations = [ - 'authentication-proxy/tsconfig.json', - 'configuration-editing/build/tsconfig.json', - 'configuration-editing/tsconfig.json', - 'css-language-features/client/tsconfig.json', - 'css-language-features/server/tsconfig.json', - 'debug-auto-launch/tsconfig.json', - 'debug-server-ready/tsconfig.json', - 'emmet/tsconfig.json', - 'extension-editing/tsconfig.json', - 'git/tsconfig.json', - 'git-base/tsconfig.json', - 'github-authentication/tsconfig.json', - 'github/tsconfig.json', - 'grunt/tsconfig.json', - 'gulp/tsconfig.json', - 'html-language-features/client/tsconfig.json', - 'html-language-features/server/tsconfig.json', - 'ipynb/tsconfig.json', - 'jake/tsconfig.json', - 'json-language-features/client/tsconfig.json', - 'json-language-features/server/tsconfig.json', - 'markdown-language-features/preview-src/tsconfig.json', - 'markdown-language-features/server/tsconfig.json', - 'markdown-language-features/tsconfig.json', - 'markdown-math/tsconfig.json', - 'media-preview/tsconfig.json', - 'merge-conflict/tsconfig.json', - 'microsoft-authentication/tsconfig.json', - 'notebook-renderers/tsconfig.json', - 'npm/tsconfig.json', - 'php-language-features/tsconfig.json', - 'search-result/tsconfig.json', - 'references-view/tsconfig.json', - 'simple-browser/tsconfig.json', - 'tunnel-forwarding/tsconfig.json', - 'typescript-language-features/test-workspace/tsconfig.json', - 'typescript-language-features/web/tsconfig.json', - 'typescript-language-features/tsconfig.json', - 'vscode-api-tests/tsconfig.json', - 'vscode-colorize-tests/tsconfig.json', - 'vscode-test-resolver/tsconfig.json' + 'extensions/configuration-editing/tsconfig.json', + 'extensions/css-language-features/client/tsconfig.json', + 'extensions/css-language-features/server/tsconfig.json', + 'extensions/debug-auto-launch/tsconfig.json', + 'extensions/debug-server-ready/tsconfig.json', + 'extensions/emmet/tsconfig.json', + 'extensions/extension-editing/tsconfig.json', + 'extensions/git/tsconfig.json', + 'extensions/git-base/tsconfig.json', + 'extensions/github/tsconfig.json', + 'extensions/github-authentication/tsconfig.json', + 'extensions/grunt/tsconfig.json', + 'extensions/gulp/tsconfig.json', + 'extensions/html-language-features/client/tsconfig.json', + 'extensions/html-language-features/server/tsconfig.json', + 'extensions/ipynb/tsconfig.json', + 'extensions/jake/tsconfig.json', + 'extensions/json-language-features/client/tsconfig.json', + 'extensions/json-language-features/server/tsconfig.json', + 'extensions/markdown-language-features/preview-src/tsconfig.json', + 'extensions/markdown-language-features/server/tsconfig.json', + 'extensions/markdown-language-features/tsconfig.json', + 'extensions/markdown-math/tsconfig.json', + 'extensions/media-preview/tsconfig.json', + 'extensions/merge-conflict/tsconfig.json', + 'extensions/microsoft-authentication/tsconfig.json', + 'extensions/notebook-renderers/tsconfig.json', + 'extensions/npm/tsconfig.json', + 'extensions/php-language-features/tsconfig.json', + 'extensions/references-view/tsconfig.json', + 'extensions/search-result/tsconfig.json', + 'extensions/simple-browser/tsconfig.json', + 'extensions/tunnel-forwarding/tsconfig.json', + 'extensions/typescript-language-features/test-workspace/tsconfig.json', + 'extensions/typescript-language-features/web/tsconfig.json', + 'extensions/typescript-language-features/tsconfig.json', + 'extensions/vscode-api-tests/tsconfig.json', + 'extensions/vscode-colorize-tests/tsconfig.json', + 'extensions/vscode-test-resolver/tsconfig.json', + + '.vscode/extensions/vscode-selfhost-test-provider/tsconfig.json', ]; const getBaseUrl = out => `https://ticino.blob.core.windows.net/sourcemaps/${commit}/${out}`; const tasks = compilations.map(function (tsconfigFile) { - const absolutePath = path.join(extensionsPath, tsconfigFile); - const relativeDirname = path.dirname(tsconfigFile); + const absolutePath = path.join(root, tsconfigFile); + const relativeDirname = path.dirname(tsconfigFile.replace(/^(.*\/)?extensions\//i, '')); const overrideOptions = {}; overrideOptions.sourceMap = true; const name = relativeDirname.replace(/\//g, '-'); - const root = path.join('extensions', relativeDirname); - const srcBase = path.join(root, 'src'); + const srcRoot = path.dirname(tsconfigFile); + const srcBase = path.join(srcRoot, 'src'); const src = path.join(srcBase, '**'); - const srcOpts = { cwd: path.dirname(__dirname), base: srcBase }; + const srcOpts = { cwd: root, base: srcBase, dot: true }; - const out = path.join(root, 'out'); + const out = path.join(srcRoot, 'out'); const baseUrl = getBaseUrl(out); let headerId, headerOut; @@ -116,7 +114,7 @@ const tasks = compilations.map(function (tsconfigFile) { const pipeline = function () { const input = es.through(); - const tsFilter = filter(['**/*.ts', '!**/lib/lib*.d.ts', '!**/node_modules/**'], { restore: true }); + const tsFilter = filter(['**/*.ts', '!**/lib/lib*.d.ts', '!**/node_modules/**'], { restore: true, dot: true }); const output = input .pipe(plumber({ errorHandler: function (err) { @@ -134,12 +132,13 @@ const tasks = compilations.map(function (tsconfigFile) { sourceMappingURL: !build ? null : f => `${baseUrl}/${f.relative}.map`, addComment: !!build, includeContent: !!build, - sourceRoot: '../src' + // note: trailing slash is important, else the source URLs in V8's file coverage are incorrect + sourceRoot: '../src/', })) .pipe(tsFilter.restore) .pipe(build ? nlsDev.bundleMetaDataFiles(headerId, headerOut) : es.through()) // Filter out *.nls.json file. We needed them only to bundle meta data file. - .pipe(filter(['**', '!**/*.nls.json'])) + .pipe(filter(['**', '!**/*.nls.json'], { dot: true })) .pipe(reporter.end(emitError)); return es.duplex(input, output); @@ -271,6 +270,7 @@ exports.watchWebExtensionsTask = watchWebExtensionsTask; * @param {boolean} isWatch */ async function buildWebExtensions(isWatch) { + const extensionsPath = path.join(root, 'extensions'); const webpackConfigLocations = await nodeUtil.promisify(glob)( path.join(extensionsPath, '**', 'extension-browser.webpack.config.js'), { ignore: ['**/node_modules'] } diff --git a/build/gulpfile.reh.js b/build/gulpfile.reh.js index 595d0ce1f434b..c2b81d0cf7c7e 100644 --- a/build/gulpfile.reh.js +++ b/build/gulpfile.reh.js @@ -373,7 +373,13 @@ function packageTask(type, platform, arch, sourceFolderName, destinationFolderNa ); } - if (platform === 'linux' || platform === 'alpine') { + if (platform === 'linux' && process.env['VSCODE_NODE_GLIBC'] === '-glibc-2.17') { + result = es.merge(result, + gulp.src(`resources/server/bin/helpers/check-requirements-linux-legacy.sh`, { base: '.' }) + .pipe(rename(`bin/helpers/check-requirements.sh`)) + .pipe(util.setExecutableBit()) + ); + } else if (platform === 'linux' || platform === 'alpine') { result = es.merge(result, gulp.src(`resources/server/bin/helpers/check-requirements-linux.sh`, { base: '.' }) .pipe(rename(`bin/helpers/check-requirements.sh`)) diff --git a/build/gulpfile.vscode.js b/build/gulpfile.vscode.js index bfd5c896e2f93..e1507e0424f8f 100644 --- a/build/gulpfile.vscode.js +++ b/build/gulpfile.vscode.js @@ -281,7 +281,7 @@ function packageTask(platform, arch, sourceFolderName, destinationFolderName, op '**/node-pty/lib/worker/conoutSocketWorker.js', '**/node-pty/lib/shared/conout.js', '**/*.wasm', - '**/node-vsce-sign/bin/*', + '**/@vscode/vsce-sign/bin/*', ], 'node_modules.asar')); let all = es.merge( diff --git a/build/lib/asar.js b/build/lib/asar.js index cadb9ab974d38..31845f2f2dd3f 100644 --- a/build/lib/asar.js +++ b/build/lib/asar.js @@ -4,7 +4,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ Object.defineProperty(exports, "__esModule", { value: true }); -exports.createAsar = void 0; +exports.createAsar = createAsar; const path = require("path"); const es = require("event-stream"); const pickle = require('chromium-pickle-js'); @@ -115,5 +115,4 @@ function createAsar(folderPath, unpackGlobs, destFilename) { } }); } -exports.createAsar = createAsar; //# sourceMappingURL=asar.js.map \ No newline at end of file diff --git a/build/lib/builtInExtensions.js b/build/lib/builtInExtensions.js index 1b0adc48d4ce4..463ce16e18df3 100644 --- a/build/lib/builtInExtensions.js +++ b/build/lib/builtInExtensions.js @@ -4,7 +4,8 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ Object.defineProperty(exports, "__esModule", { value: true }); -exports.getBuiltInExtensions = exports.getExtensionStream = void 0; +exports.getExtensionStream = getExtensionStream; +exports.getBuiltInExtensions = getBuiltInExtensions; const fs = require("fs"); const path = require("path"); const os = require("os"); @@ -58,7 +59,6 @@ function getExtensionStream(extension) { } return getExtensionDownloadStream(extension); } -exports.getExtensionStream = getExtensionStream; function syncMarketplaceExtension(extension) { const galleryServiceUrl = productjson.extensionsGallery?.serviceUrl; const source = ansiColors.blue(galleryServiceUrl ? '[marketplace]' : '[github]'); @@ -127,7 +127,6 @@ function getBuiltInExtensions() { .on('end', resolve); }); } -exports.getBuiltInExtensions = getBuiltInExtensions; if (require.main === module) { getBuiltInExtensions().then(() => process.exit(0)).catch(err => { console.error(err); diff --git a/build/lib/bundle.js b/build/lib/bundle.js index 5d3ee9d5118ac..61d9f0156241b 100644 --- a/build/lib/bundle.js +++ b/build/lib/bundle.js @@ -4,7 +4,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ Object.defineProperty(exports, "__esModule", { value: true }); -exports.bundle = void 0; +exports.bundle = bundle; const fs = require("fs"); const path = require("path"); const vm = require("vm"); @@ -78,7 +78,6 @@ function bundle(entryPoints, config, callback) { }); }, (err) => callback(err, null)); } -exports.bundle = bundle; function emitEntryPoints(modules, entryPoints) { const modulesMap = {}; modules.forEach((m) => { diff --git a/build/lib/compilation.js b/build/lib/compilation.js index 35bc464d34aac..b44cbefe78a95 100644 --- a/build/lib/compilation.js +++ b/build/lib/compilation.js @@ -4,7 +4,10 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ Object.defineProperty(exports, "__esModule", { value: true }); -exports.watchApiProposalNamesTask = exports.compileApiProposalNamesTask = exports.watchTask = exports.compileTask = exports.transpileTask = void 0; +exports.watchApiProposalNamesTask = exports.compileApiProposalNamesTask = void 0; +exports.transpileTask = transpileTask; +exports.compileTask = compileTask; +exports.watchTask = watchTask; const es = require("event-stream"); const fs = require("fs"); const gulp = require("gulp"); @@ -20,6 +23,7 @@ const ts = require("typescript"); const File = require("vinyl"); const task = require("./task"); const index_1 = require("./mangle/index"); +const postcss_1 = require("./postcss"); const watch = require('./watch'); // --- gulp-tsb: compile and transpile -------------------------------- const reporter = (0, reporter_1.createReporter)(); @@ -57,13 +61,12 @@ function createCompile(src, build, emitError, transpileOnly) { const isRuntimeJs = (f) => f.path.endsWith('.js') && !f.path.includes('fixtures'); const isCSS = (f) => f.path.endsWith('.css') && !f.path.includes('fixtures'); const noDeclarationsFilter = util.filter(data => !(/\.d\.ts$/.test(data.path))); - const postcss = require('gulp-postcss'); const postcssNesting = require('postcss-nesting'); const input = es.through(); const output = input .pipe(util.$if(isUtf8Test, bom())) // this is required to preserve BOM in test files that loose it otherwise .pipe(util.$if(!build && isRuntimeJs, util.appendOwnPathSourceURL())) - .pipe(util.$if(isCSS, postcss([postcssNesting()]))) + .pipe(util.$if(isCSS, (0, postcss_1.gulpPostcss)([postcssNesting()], err => reporter(String(err))))) .pipe(tsFilter) .pipe(util.loadSourcemaps()) .pipe(compilation(token)) @@ -96,10 +99,9 @@ function transpileTask(src, out, swc) { task.taskName = `transpile-${path.basename(src)}`; return task; } -exports.transpileTask = transpileTask; function compileTask(src, out, build, options = {}) { const task = () => { - if (os.totalmem() < 4000000000) { + if (os.totalmem() < 4_000_000_000) { throw new Error('compilation requires 4GB of RAM'); } const compile = createCompile(src, build, true, false); @@ -137,7 +139,6 @@ function compileTask(src, out, build, options = {}) { task.taskName = `compile-${path.basename(src)}`; return task; } -exports.compileTask = compileTask; function watchTask(out, build) { const task = () => { const compile = createCompile('src', build, false, false); @@ -153,7 +154,6 @@ function watchTask(out, build) { task.taskName = `watch-${path.basename(out)}`; return task; } -exports.watchTask = watchTask; const REPO_SRC_FOLDER = path.join(__dirname, '../../src'); class MonacoGenerator { _isWatch; diff --git a/build/lib/compilation.ts b/build/lib/compilation.ts index 94bfe6e832d0a..b88d0d290031b 100644 --- a/build/lib/compilation.ts +++ b/build/lib/compilation.ts @@ -19,6 +19,7 @@ import * as File from 'vinyl'; import * as task from './task'; import { Mangler } from './mangle/index'; import { RawSourceMap } from 'source-map'; +import { gulpPostcss } from './postcss'; const watch = require('./watch'); @@ -67,14 +68,13 @@ function createCompile(src: string, build: boolean, emitError: boolean, transpil const isCSS = (f: File) => f.path.endsWith('.css') && !f.path.includes('fixtures'); const noDeclarationsFilter = util.filter(data => !(/\.d\.ts$/.test(data.path))); - const postcss = require('gulp-postcss') as typeof import('gulp-postcss'); const postcssNesting = require('postcss-nesting'); const input = es.through(); const output = input .pipe(util.$if(isUtf8Test, bom())) // this is required to preserve BOM in test files that loose it otherwise .pipe(util.$if(!build && isRuntimeJs, util.appendOwnPathSourceURL())) - .pipe(util.$if(isCSS, postcss([postcssNesting()]))) + .pipe(util.$if(isCSS, gulpPostcss([postcssNesting()], err => reporter(String(err))))) .pipe(tsFilter) .pipe(util.loadSourcemaps()) .pipe(compilation(token)) diff --git a/build/lib/dependencies.js b/build/lib/dependencies.js index 64087a9ac17be..1f2dd75d68ccd 100644 --- a/build/lib/dependencies.js +++ b/build/lib/dependencies.js @@ -4,7 +4,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ Object.defineProperty(exports, "__esModule", { value: true }); -exports.getProductionDependencies = void 0; +exports.getProductionDependencies = getProductionDependencies; const fs = require("fs"); const path = require("path"); const cp = require("child_process"); @@ -69,7 +69,6 @@ function getProductionDependencies(folderPath) { } return [...new Set(result)]; } -exports.getProductionDependencies = getProductionDependencies; if (require.main === module) { console.log(JSON.stringify(getProductionDependencies(root), null, ' ')); } diff --git a/build/lib/extensions.js b/build/lib/extensions.js index c81568c7275f8..6a6c0a7b4cd87 100644 --- a/build/lib/extensions.js +++ b/build/lib/extensions.js @@ -4,7 +4,14 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ Object.defineProperty(exports, "__esModule", { value: true }); -exports.buildExtensionMedia = exports.webpackExtensions = exports.translatePackageJSON = exports.scanBuiltinExtensions = exports.packageMarketplaceExtensionsStream = exports.packageLocalExtensionsStream = exports.fromGithub = exports.fromMarketplace = void 0; +exports.fromMarketplace = fromMarketplace; +exports.fromGithub = fromGithub; +exports.packageLocalExtensionsStream = packageLocalExtensionsStream; +exports.packageMarketplaceExtensionsStream = packageMarketplaceExtensionsStream; +exports.scanBuiltinExtensions = scanBuiltinExtensions; +exports.translatePackageJSON = translatePackageJSON; +exports.webpackExtensions = webpackExtensions; +exports.buildExtensionMedia = buildExtensionMedia; const es = require("event-stream"); const fs = require("fs"); const cp = require("child_process"); @@ -213,7 +220,6 @@ function fromMarketplace(serviceUrl, { name: extensionName, version, sha256, met .pipe(json({ __metadata: metadata })) .pipe(packageJsonFilter.restore); } -exports.fromMarketplace = fromMarketplace; function fromGithub({ name, version, repo, sha256, metadata }) { const json = require('gulp-json-editor'); fancyLog('Downloading extension from GH:', ansiColors.yellow(`${name}@${version}`), '...'); @@ -232,7 +238,6 @@ function fromGithub({ name, version, repo, sha256, metadata }) { .pipe(json({ __metadata: metadata })) .pipe(packageJsonFilter.restore); } -exports.fromGithub = fromGithub; const excludedExtensions = [ 'vscode-api-tests', 'vscode-colorize-tests', @@ -306,7 +311,6 @@ function packageLocalExtensionsStream(forWeb, disableMangle) { return (result .pipe(util2.setExecutableBit(['**/*.sh']))); } -exports.packageLocalExtensionsStream = packageLocalExtensionsStream; function packageMarketplaceExtensionsStream(forWeb) { const marketplaceExtensionsDescriptions = [ ...builtInExtensions.filter(({ name }) => (forWeb ? !marketplaceWebExtensionsExclude.has(name) : true)), @@ -325,7 +329,6 @@ function packageMarketplaceExtensionsStream(forWeb) { return (marketplaceExtensionsStream .pipe(util2.setExecutableBit(['**/*.sh']))); } -exports.packageMarketplaceExtensionsStream = packageMarketplaceExtensionsStream; function scanBuiltinExtensions(extensionsRoot, exclude = []) { const scannedExtensions = []; try { @@ -361,7 +364,6 @@ function scanBuiltinExtensions(extensionsRoot, exclude = []) { return scannedExtensions; } } -exports.scanBuiltinExtensions = scanBuiltinExtensions; function translatePackageJSON(packageJSON, packageNLSPath) { const CharCode_PC = '%'.charCodeAt(0); const packageNls = JSON.parse(fs.readFileSync(packageNLSPath).toString()); @@ -385,7 +387,6 @@ function translatePackageJSON(packageJSON, packageNLSPath) { translate(packageJSON); return packageJSON; } -exports.translatePackageJSON = translatePackageJSON; const extensionsPath = path.join(root, 'extensions'); // Additional projects to run esbuild on. These typically build code for webviews const esbuildMediaScripts = [ @@ -459,7 +460,6 @@ async function webpackExtensions(taskName, isWatch, webpackConfigLocations) { } }); } -exports.webpackExtensions = webpackExtensions; async function esbuildExtensions(taskName, isWatch, scripts) { function reporter(stdError, script) { const matches = (stdError || '').match(/\> (.+): error: (.+)?/g); @@ -500,5 +500,4 @@ async function buildExtensionMedia(isWatch, outputRoot) { outputRoot: outputRoot ? path.join(root, outputRoot, path.dirname(p)) : undefined }))); } -exports.buildExtensionMedia = buildExtensionMedia; //# sourceMappingURL=extensions.js.map \ No newline at end of file diff --git a/build/lib/fetch.js b/build/lib/fetch.js index ba23e78257c00..2fed63bca0e3f 100644 --- a/build/lib/fetch.js +++ b/build/lib/fetch.js @@ -4,7 +4,9 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ Object.defineProperty(exports, "__esModule", { value: true }); -exports.fetchGithub = exports.fetchUrl = exports.fetchUrls = void 0; +exports.fetchUrls = fetchUrls; +exports.fetchUrl = fetchUrl; +exports.fetchGithub = fetchGithub; const es = require("event-stream"); const VinylFile = require("vinyl"); const log = require("fancy-log"); @@ -30,7 +32,6 @@ function fetchUrls(urls, options) { }); })); } -exports.fetchUrls = fetchUrls; async function fetchUrl(url, options, retries = 10, retryDelay = 1000) { const verbose = !!options.verbose ?? (!!process.env['CI'] || !!process.env['BUILD_ARTIFACTSTAGINGDIRECTORY']); try { @@ -94,7 +95,6 @@ async function fetchUrl(url, options, retries = 10, retryDelay = 1000) { throw e; } } -exports.fetchUrl = fetchUrl; const ghApiHeaders = { Accept: 'application/vnd.github.v3+json', 'User-Agent': 'VSCode Build', @@ -135,5 +135,4 @@ function fetchGithub(repo, options) { } })); } -exports.fetchGithub = fetchGithub; //# sourceMappingURL=fetch.js.map \ No newline at end of file diff --git a/build/lib/getVersion.js b/build/lib/getVersion.js index abf05e932105f..b50ead538a25c 100644 --- a/build/lib/getVersion.js +++ b/build/lib/getVersion.js @@ -4,7 +4,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ Object.defineProperty(exports, "__esModule", { value: true }); -exports.getVersion = void 0; +exports.getVersion = getVersion; const git = require("./git"); function getVersion(root) { let version = process.env['BUILD_SOURCEVERSION']; @@ -13,5 +13,4 @@ function getVersion(root) { } return version; } -exports.getVersion = getVersion; //# sourceMappingURL=getVersion.js.map \ No newline at end of file diff --git a/build/lib/git.js b/build/lib/git.js index a8e712ed070b1..798a408bdb91a 100644 --- a/build/lib/git.js +++ b/build/lib/git.js @@ -1,6 +1,6 @@ "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); -exports.getVersion = void 0; +exports.getVersion = getVersion; /*--------------------------------------------------------------------------------------------- * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. @@ -51,5 +51,4 @@ function getVersion(repo) { } return refs[ref]; } -exports.getVersion = getVersion; //# sourceMappingURL=git.js.map \ No newline at end of file diff --git a/build/lib/i18n.js b/build/lib/i18n.js index 1844af139c5e6..c33994987f0d2 100644 --- a/build/lib/i18n.js +++ b/build/lib/i18n.js @@ -4,7 +4,14 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ Object.defineProperty(exports, "__esModule", { value: true }); -exports.prepareIslFiles = exports.prepareI18nPackFiles = exports.createXlfFilesForIsl = exports.createXlfFilesForExtensions = exports.EXTERNAL_EXTENSIONS = exports.createXlfFilesForCoreBundle = exports.getResource = exports.processNlsFiles = exports.XLF = exports.Line = exports.extraLanguages = exports.defaultLanguages = void 0; +exports.EXTERNAL_EXTENSIONS = exports.XLF = exports.Line = exports.extraLanguages = exports.defaultLanguages = void 0; +exports.processNlsFiles = processNlsFiles; +exports.getResource = getResource; +exports.createXlfFilesForCoreBundle = createXlfFilesForCoreBundle; +exports.createXlfFilesForExtensions = createXlfFilesForExtensions; +exports.createXlfFilesForIsl = createXlfFilesForIsl; +exports.prepareI18nPackFiles = prepareI18nPackFiles; +exports.prepareIslFiles = prepareIslFiles; const path = require("path"); const fs = require("fs"); const event_stream_1 = require("event-stream"); @@ -423,7 +430,6 @@ function processNlsFiles(opts) { this.queue(file); }); } -exports.processNlsFiles = processNlsFiles; const editorProject = 'vscode-editor', workbenchProject = 'vscode-workbench', extensionsProject = 'vscode-extensions', setupProject = 'vscode-setup', serverProject = 'vscode-server'; function getResource(sourceFile) { let resource; @@ -458,7 +464,6 @@ function getResource(sourceFile) { } throw new Error(`Could not identify the XLF bundle for ${sourceFile}`); } -exports.getResource = getResource; function createXlfFilesForCoreBundle() { return (0, event_stream_1.through)(function (file) { const basename = path.basename(file.path); @@ -506,7 +511,6 @@ function createXlfFilesForCoreBundle() { } }); } -exports.createXlfFilesForCoreBundle = createXlfFilesForCoreBundle; function createL10nBundleForExtension(extensionFolderName, prefixWithBuildFolder) { const prefix = prefixWithBuildFolder ? '.build/' : ''; return gulp @@ -653,7 +657,6 @@ function createXlfFilesForExtensions() { } }); } -exports.createXlfFilesForExtensions = createXlfFilesForExtensions; function createXlfFilesForIsl() { return (0, event_stream_1.through)(function (file) { let projectName, resourceFile; @@ -704,7 +707,6 @@ function createXlfFilesForIsl() { this.queue(xlfFile); }); } -exports.createXlfFilesForIsl = createXlfFilesForIsl; function createI18nFile(name, messages) { const result = Object.create(null); result[''] = [ @@ -793,7 +795,6 @@ function prepareI18nPackFiles(resultingTranslationPaths) { }); }); } -exports.prepareI18nPackFiles = prepareI18nPackFiles; function prepareIslFiles(language, innoSetupConfig) { const parsePromises = []; return (0, event_stream_1.through)(function (xlf) { @@ -816,7 +817,6 @@ function prepareIslFiles(language, innoSetupConfig) { }); }); } -exports.prepareIslFiles = prepareIslFiles; function createIslFile(name, messages, language, innoSetup) { const content = []; let originalContent; diff --git a/build/lib/i18n.resources.json b/build/lib/i18n.resources.json index cf74aac44be46..b080b05f102cf 100644 --- a/build/lib/i18n.resources.json +++ b/build/lib/i18n.resources.json @@ -62,6 +62,10 @@ "name": "vs/workbench/contrib/mappedEdits", "project": "vscode-workbench" }, + { + "name": "vs/workbench/contrib/markdown", + "project": "vscode-workbench" + }, { "name": "vs/workbench/contrib/comments", "project": "vscode-workbench" @@ -327,7 +331,7 @@ "project": "vscode-workbench" }, { - "name": "vs/workbench/contrib/audioCues", + "name": "vs/workbench/contrib/accessibilitySignals", "project": "vscode-workbench" }, { @@ -338,6 +342,10 @@ "name": "vs/workbench/contrib/bracketPairColorizer2Telemetry", "project": "vscode-workbench" }, + { + "name": "vs/workbench/contrib/scrollLocking", + "project": "vscode-workbench" + }, { "name": "vs/workbench/contrib/remoteTunnel", "project": "vscode-workbench" @@ -549,6 +557,10 @@ { "name": "vs/workbench/contrib/accountEntitlements", "project": "vscode-workbench" + }, + { + "name": "vs/workbench/contrib/authentication", + "project": "vscode-workbench" } ] } diff --git a/build/lib/mangleTypeScript.js b/build/lib/mangleTypeScript.js deleted file mode 100644 index 45b50148d127b..0000000000000 --- a/build/lib/mangleTypeScript.js +++ /dev/null @@ -1,676 +0,0 @@ -"use strict"; -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ -Object.defineProperty(exports, "__esModule", { value: true }); -exports.Mangler = void 0; -const ts = require("typescript"); -const path = require("path"); -const fs = require("fs"); -const process_1 = require("process"); -const source_map_1 = require("source-map"); -const url_1 = require("url"); -class ShortIdent { - static _keywords = new Set(['await', 'break', 'case', 'catch', 'class', 'const', 'continue', 'debugger', - 'default', 'delete', 'do', 'else', 'export', 'extends', 'false', 'finally', 'for', 'function', 'if', - 'import', 'in', 'instanceof', 'let', 'new', 'null', 'return', 'static', 'super', 'switch', 'this', 'throw', - 'true', 'try', 'typeof', 'var', 'void', 'while', 'with', 'yield']); - static _alphabet = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890$_'.split(''); - _value = 0; - _isNameTaken; - prefix; - constructor(prefix, isNameTaken) { - this.prefix = prefix; - this._isNameTaken = name => ShortIdent._keywords.has(name) || /^[_0-9]/.test(name) || isNameTaken(name); - } - next(localIsNameTaken) { - const candidate = this.prefix + ShortIdent.convert(this._value); - this._value++; - if (this._isNameTaken(candidate) || localIsNameTaken?.(candidate)) { - // try again - return this.next(localIsNameTaken); - } - return candidate; - } - static convert(n) { - const base = this._alphabet.length; - let result = ''; - do { - const rest = n % base; - result += this._alphabet[rest]; - n = (n / base) | 0; - } while (n > 0); - return result; - } -} -var FieldType; -(function (FieldType) { - FieldType[FieldType["Public"] = 0] = "Public"; - FieldType[FieldType["Protected"] = 1] = "Protected"; - FieldType[FieldType["Private"] = 2] = "Private"; -})(FieldType || (FieldType = {})); -class ClassData { - fileName; - node; - fields = new Map(); - replacements; - parent; - children; - constructor(fileName, node) { - // analyse all fields (properties and methods). Find usages of all protected and - // private ones and keep track of all public ones (to prevent naming collisions) - this.fileName = fileName; - this.node = node; - const candidates = []; - for (const member of node.members) { - if (ts.isMethodDeclaration(member)) { - // method `foo() {}` - candidates.push(member); - } - else if (ts.isPropertyDeclaration(member)) { - // property `foo = 234` - candidates.push(member); - } - else if (ts.isGetAccessor(member)) { - // getter: `get foo() { ... }` - candidates.push(member); - } - else if (ts.isSetAccessor(member)) { - // setter: `set foo() { ... }` - candidates.push(member); - } - else if (ts.isConstructorDeclaration(member)) { - // constructor-prop:`constructor(private foo) {}` - for (const param of member.parameters) { - if (hasModifier(param, ts.SyntaxKind.PrivateKeyword) - || hasModifier(param, ts.SyntaxKind.ProtectedKeyword) - || hasModifier(param, ts.SyntaxKind.PublicKeyword) - || hasModifier(param, ts.SyntaxKind.ReadonlyKeyword)) { - candidates.push(param); - } - } - } - } - for (const member of candidates) { - const ident = ClassData._getMemberName(member); - if (!ident) { - continue; - } - const type = ClassData._getFieldType(member); - this.fields.set(ident, { type, pos: member.name.getStart() }); - } - } - static _getMemberName(node) { - if (!node.name) { - return undefined; - } - const { name } = node; - let ident = name.getText(); - if (name.kind === ts.SyntaxKind.ComputedPropertyName) { - if (name.expression.kind !== ts.SyntaxKind.StringLiteral) { - // unsupported: [Symbol.foo] or [abc + 'field'] - return; - } - // ['foo'] - ident = name.expression.getText().slice(1, -1); - } - return ident; - } - static _getFieldType(node) { - if (hasModifier(node, ts.SyntaxKind.PrivateKeyword)) { - return 2 /* FieldType.Private */; - } - else if (hasModifier(node, ts.SyntaxKind.ProtectedKeyword)) { - return 1 /* FieldType.Protected */; - } - else { - return 0 /* FieldType.Public */; - } - } - static _shouldMangle(type) { - return type === 2 /* FieldType.Private */ - || type === 1 /* FieldType.Protected */; - } - static makeImplicitPublicActuallyPublic(data, reportViolation) { - // TS-HACK - // A subtype can make an inherited protected field public. To prevent accidential - // mangling of public fields we mark the original (protected) fields as public... - for (const [name, info] of data.fields) { - if (info.type !== 0 /* FieldType.Public */) { - continue; - } - let parent = data.parent; - while (parent) { - if (parent.fields.get(name)?.type === 1 /* FieldType.Protected */) { - const parentPos = parent.node.getSourceFile().getLineAndCharacterOfPosition(parent.fields.get(name).pos); - const infoPos = data.node.getSourceFile().getLineAndCharacterOfPosition(info.pos); - reportViolation(name, `'${name}' from ${parent.fileName}:${parentPos.line + 1}`, `${data.fileName}:${infoPos.line + 1}`); - parent.fields.get(name).type = 0 /* FieldType.Public */; - } - parent = parent.parent; - } - } - } - static fillInReplacement(data) { - if (data.replacements) { - // already done - return; - } - // fill in parents first - if (data.parent) { - ClassData.fillInReplacement(data.parent); - } - data.replacements = new Map(); - const identPool = new ShortIdent('', name => { - // locally taken - if (data._isNameTaken(name)) { - return true; - } - // parents - let parent = data.parent; - while (parent) { - if (parent._isNameTaken(name)) { - return true; - } - parent = parent.parent; - } - // children - if (data.children) { - const stack = [...data.children]; - while (stack.length) { - const node = stack.pop(); - if (node._isNameTaken(name)) { - return true; - } - if (node.children) { - stack.push(...node.children); - } - } - } - return false; - }); - for (const [name, info] of data.fields) { - if (ClassData._shouldMangle(info.type)) { - const shortName = identPool.next(); - data.replacements.set(name, shortName); - } - } - } - // a name is taken when a field that doesn't get mangled exists or - // when the name is already in use for replacement - _isNameTaken(name) { - if (this.fields.has(name) && !ClassData._shouldMangle(this.fields.get(name).type)) { - // public field - return true; - } - if (this.replacements) { - for (const shortName of this.replacements.values()) { - if (shortName === name) { - // replaced already (happens wih super types) - return true; - } - } - } - if (isNameTakenInFile(this.node, name)) { - return true; - } - return false; - } - lookupShortName(name) { - let value = this.replacements.get(name); - let parent = this.parent; - while (parent) { - if (parent.replacements.has(name) && parent.fields.get(name)?.type === 1 /* FieldType.Protected */) { - value = parent.replacements.get(name) ?? value; - } - parent = parent.parent; - } - return value; - } - // --- parent chaining - addChild(child) { - this.children ??= []; - this.children.push(child); - child.parent = this; - } -} -function isNameTakenInFile(node, name) { - const identifiers = node.getSourceFile().identifiers; - if (identifiers instanceof Map) { - if (identifiers.has(name)) { - return true; - } - } - return false; -} -const fileIdents = new class { - idents = new ShortIdent('$', () => false); - next(file) { - return this.idents.next(name => isNameTakenInFile(file, name)); - } -}; -const skippedFiles = [ - // Build - 'css.build.ts', - 'nls.build.ts', - // Monaco - 'editorCommon.ts', - 'editorOptions.ts', - 'editorZoom.ts', - 'standaloneEditor.ts', - 'standaloneLanguages.ts', - // Generated - 'extensionsApiProposals.ts', - // Module passed around as type - 'pfs.ts', -]; -class DeclarationData { - fileName; - node; - replacementName; - constructor(fileName, node) { - this.fileName = fileName; - this.node = node; - this.replacementName = fileIdents.next(node.getSourceFile()); - } - get locations() { - return [{ - fileName: this.fileName, - offset: this.node.name.getStart() - }]; - } - shouldMangle(newName) { - // New name is longer the existing one :'( - if (newName.length >= this.node.name.getText().length) { - return false; - } - // Don't mangle functions we've explicitly opted out - if (this.node.getFullText().includes('@skipMangle')) { - return false; - } - // Don't mangle functions in the monaco editor API. - if (skippedFiles.some(file => this.node.getSourceFile().fileName.endsWith(file))) { - return false; - } - return true; - } -} -class ConstData { - fileName; - statement; - decl; - service; - replacementName; - constructor(fileName, statement, decl, service) { - this.fileName = fileName; - this.statement = statement; - this.decl = decl; - this.service = service; - this.replacementName = fileIdents.next(statement.getSourceFile()); - } - get locations() { - // If the const aliases any types, we need to rename those too - const definitionResult = this.service.getDefinitionAndBoundSpan(this.decl.getSourceFile().fileName, this.decl.name.getStart()); - if (definitionResult?.definitions && definitionResult.definitions.length > 1) { - return definitionResult.definitions.map(x => ({ fileName: x.fileName, offset: x.textSpan.start })); - } - return [{ fileName: this.fileName, offset: this.decl.name.getStart() }]; - } - shouldMangle(newName) { - // New name is longer the existing one :'( - if (newName.length >= this.decl.name.getText().length) { - return false; - } - // Don't mangle functions we've explicitly opted out - if (this.statement.getFullText().includes('@skipMangle')) { - return false; - } - // Don't mangle functions in some files - if (skippedFiles.some(file => this.decl.getSourceFile().fileName.endsWith(file))) { - return false; - } - return true; - } -} -class StaticLanguageServiceHost { - projectPath; - _cmdLine; - _scriptSnapshots = new Map(); - constructor(projectPath) { - this.projectPath = projectPath; - const existingOptions = {}; - const parsed = ts.readConfigFile(projectPath, ts.sys.readFile); - if (parsed.error) { - throw parsed.error; - } - this._cmdLine = ts.parseJsonConfigFileContent(parsed.config, ts.sys, path.dirname(projectPath), existingOptions); - if (this._cmdLine.errors.length > 0) { - throw parsed.error; - } - } - getCompilationSettings() { - return this._cmdLine.options; - } - getScriptFileNames() { - return this._cmdLine.fileNames; - } - getScriptVersion(_fileName) { - return '1'; - } - getProjectVersion() { - return '1'; - } - getScriptSnapshot(fileName) { - let result = this._scriptSnapshots.get(fileName); - if (result === undefined) { - const content = ts.sys.readFile(fileName); - if (content === undefined) { - return undefined; - } - result = ts.ScriptSnapshot.fromString(content); - this._scriptSnapshots.set(fileName, result); - } - return result; - } - getCurrentDirectory() { - return path.dirname(this.projectPath); - } - getDefaultLibFileName(options) { - return ts.getDefaultLibFilePath(options); - } - directoryExists = ts.sys.directoryExists; - getDirectories = ts.sys.getDirectories; - fileExists = ts.sys.fileExists; - readFile = ts.sys.readFile; - readDirectory = ts.sys.readDirectory; - // this is necessary to make source references work. - realpath = ts.sys.realpath; -} -/** - * TypeScript2TypeScript transformer that mangles all private and protected fields - * - * 1. Collect all class fields (properties, methods) - * 2. Collect all sub and super-type relations between classes - * 3. Compute replacement names for each field - * 4. Lookup rename locations for these fields - * 5. Prepare and apply edits - */ -class Mangler { - projectPath; - log; - allClassDataByKey = new Map(); - allExportedDeclarationsByKey = new Map(); - service; - constructor(projectPath, log = () => { }) { - this.projectPath = projectPath; - this.log = log; - this.service = ts.createLanguageService(new StaticLanguageServiceHost(projectPath)); - } - computeNewFileContents(strictImplicitPublicHandling) { - // STEP find all classes and their field info. Find all exported consts and functions. - const visit = (node) => { - if (ts.isClassDeclaration(node) || ts.isClassExpression(node)) { - const anchor = node.name ?? node; - const key = `${node.getSourceFile().fileName}|${anchor.getStart()}`; - if (this.allClassDataByKey.has(key)) { - throw new Error('DUPE?'); - } - this.allClassDataByKey.set(key, new ClassData(node.getSourceFile().fileName, node)); - } - if (ts.isClassDeclaration(node) && hasModifier(node, ts.SyntaxKind.ExportKeyword)) { - if (node.name) { - const anchor = node.name; - const key = `${node.getSourceFile().fileName}|${anchor.getStart()}`; - if (this.allExportedDeclarationsByKey.has(key)) { - throw new Error('DUPE?'); - } - this.allExportedDeclarationsByKey.set(key, new DeclarationData(node.getSourceFile().fileName, node)); - } - } - if (ts.isFunctionDeclaration(node) - && ts.isSourceFile(node.parent) - && hasModifier(node, ts.SyntaxKind.ExportKeyword)) { - if (node.name && node.body) { // On named function and not on the overload - const anchor = node.name; - const key = `${node.getSourceFile().fileName}|${anchor.getStart()}`; - if (this.allExportedDeclarationsByKey.has(key)) { - throw new Error('DUPE?'); - } - this.allExportedDeclarationsByKey.set(key, new DeclarationData(node.getSourceFile().fileName, node)); - } - } - if (ts.isVariableStatement(node) - && ts.isSourceFile(node.parent) - && hasModifier(node, ts.SyntaxKind.ExportKeyword)) { - for (const decl of node.declarationList.declarations) { - const key = `${decl.getSourceFile().fileName}|${decl.name.getStart()}`; - if (this.allExportedDeclarationsByKey.has(key)) { - throw new Error('DUPE?'); - } - this.allExportedDeclarationsByKey.set(key, new ConstData(node.getSourceFile().fileName, node, decl, this.service)); - } - } - ts.forEachChild(node, visit); - }; - for (const file of this.service.getProgram().getSourceFiles()) { - if (!file.isDeclarationFile) { - ts.forEachChild(file, visit); - } - } - this.log(`Done collecting. Classes: ${this.allClassDataByKey.size}. Exported const/fn: ${this.allExportedDeclarationsByKey.size}`); - // STEP: connect sub and super-types - const setupParents = (data) => { - const extendsClause = data.node.heritageClauses?.find(h => h.token === ts.SyntaxKind.ExtendsKeyword); - if (!extendsClause) { - // no EXTENDS-clause - return; - } - const info = this.service.getDefinitionAtPosition(data.fileName, extendsClause.types[0].expression.getEnd()); - if (!info || info.length === 0) { - // throw new Error('SUPER type not found'); - return; - } - if (info.length !== 1) { - // inherits from declared/library type - return; - } - const [definition] = info; - const key = `${definition.fileName}|${definition.textSpan.start}`; - const parent = this.allClassDataByKey.get(key); - if (!parent) { - // throw new Error(`SUPER type not found: ${key}`); - return; - } - parent.addChild(data); - }; - for (const data of this.allClassDataByKey.values()) { - setupParents(data); - } - // STEP: make implicit public (actually protected) field really public - const violations = new Map(); - let violationsCauseFailure = false; - for (const data of this.allClassDataByKey.values()) { - ClassData.makeImplicitPublicActuallyPublic(data, (name, what, why) => { - const arr = violations.get(what); - if (arr) { - arr.push(why); - } - else { - violations.set(what, [why]); - } - if (strictImplicitPublicHandling && !strictImplicitPublicHandling.has(name)) { - violationsCauseFailure = true; - } - }); - } - for (const [why, whys] of violations) { - this.log(`WARN: ${why} became PUBLIC because of: ${whys.join(' , ')}`); - } - if (violationsCauseFailure) { - const message = 'Protected fields have been made PUBLIC. This hurts minification and is therefore not allowed. Review the WARN messages further above'; - this.log(`ERROR: ${message}`); - throw new Error(message); - } - // STEP: compute replacement names for each class - for (const data of this.allClassDataByKey.values()) { - ClassData.fillInReplacement(data); - } - this.log(`Done creating class replacements`); - const editsByFile = new Map(); - const appendEdit = (fileName, edit) => { - const edits = editsByFile.get(fileName); - if (!edits) { - editsByFile.set(fileName, [edit]); - } - else { - edits.push(edit); - } - }; - const appendRename = (newText, loc) => { - appendEdit(loc.fileName, { - newText: (loc.prefixText || '') + newText + (loc.suffixText || ''), - offset: loc.textSpan.start, - length: loc.textSpan.length - }); - }; - for (const data of this.allClassDataByKey.values()) { - if (hasModifier(data.node, ts.SyntaxKind.DeclareKeyword)) { - continue; - } - fields: for (const [name, info] of data.fields) { - if (!ClassData._shouldMangle(info.type)) { - continue fields; - } - // TS-HACK: protected became public via 'some' child - // and because of that we might need to ignore this now - let parent = data.parent; - while (parent) { - if (parent.fields.get(name)?.type === 0 /* FieldType.Public */) { - continue fields; - } - parent = parent.parent; - } - const newText = data.lookupShortName(name); - const locations = this.service.findRenameLocations(data.fileName, info.pos, false, false, true) ?? []; - for (const loc of locations) { - appendRename(newText, loc); - } - } - } - for (const data of this.allExportedDeclarationsByKey.values()) { - if (!data.shouldMangle(data.replacementName)) { - continue; - } - const newText = data.replacementName; - for (const { fileName, offset } of data.locations) { - const locations = this.service.findRenameLocations(fileName, offset, false, false, true) ?? []; - for (const loc of locations) { - appendRename(newText, loc); - } - } - } - this.log(`Done preparing edits: ${editsByFile.size} files`); - // STEP: apply all rename edits (per file) - const result = new Map(); - let savedBytes = 0; - for (const item of this.service.getProgram().getSourceFiles()) { - const { mapRoot, sourceRoot } = this.service.getProgram().getCompilerOptions(); - const projectDir = path.dirname(this.projectPath); - const sourceMapRoot = mapRoot ?? (0, url_1.pathToFileURL)(sourceRoot ?? projectDir).toString(); - // source maps - let generator; - let newFullText; - const edits = editsByFile.get(item.fileName); - if (!edits) { - // just copy - newFullText = item.getFullText(); - } - else { - // source map generator - const relativeFileName = normalize(path.relative(projectDir, item.fileName)); - const mappingsByLine = new Map(); - // apply renames - edits.sort((a, b) => b.offset - a.offset); - const characters = item.getFullText().split(''); - let lastEdit; - for (const edit of edits) { - if (lastEdit && lastEdit.offset === edit.offset) { - // - if (lastEdit.length !== edit.length || lastEdit.newText !== edit.newText) { - this.log('ERROR: Overlapping edit', item.fileName, edit.offset, edits); - throw new Error('OVERLAPPING edit'); - } - else { - continue; - } - } - lastEdit = edit; - const mangledName = characters.splice(edit.offset, edit.length, edit.newText).join(''); - savedBytes += mangledName.length - edit.newText.length; - // source maps - const pos = item.getLineAndCharacterOfPosition(edit.offset); - let mappings = mappingsByLine.get(pos.line); - if (!mappings) { - mappings = []; - mappingsByLine.set(pos.line, mappings); - } - mappings.unshift({ - source: relativeFileName, - original: { line: pos.line + 1, column: pos.character }, - generated: { line: pos.line + 1, column: pos.character }, - name: mangledName - }, { - source: relativeFileName, - original: { line: pos.line + 1, column: pos.character + edit.length }, - generated: { line: pos.line + 1, column: pos.character + edit.newText.length }, - }); - } - // source map generation, make sure to get mappings per line correct - generator = new source_map_1.SourceMapGenerator({ file: path.basename(item.fileName), sourceRoot: sourceMapRoot }); - generator.setSourceContent(relativeFileName, item.getFullText()); - for (const [, mappings] of mappingsByLine) { - let lineDelta = 0; - for (const mapping of mappings) { - generator.addMapping({ - ...mapping, - generated: { line: mapping.generated.line, column: mapping.generated.column - lineDelta } - }); - lineDelta += mapping.original.column - mapping.generated.column; - } - } - newFullText = characters.join(''); - } - result.set(item.fileName, { out: newFullText, sourceMap: generator?.toString() }); - } - this.log(`Done: ${savedBytes / 1000}kb saved`); - return result; - } -} -exports.Mangler = Mangler; -// --- ast utils -function hasModifier(node, kind) { - const modifiers = ts.canHaveModifiers(node) ? ts.getModifiers(node) : undefined; - return Boolean(modifiers?.find(mode => mode.kind === kind)); -} -function normalize(path) { - return path.replace(/\\/g, '/'); -} -async function _run() { - const projectPath = path.join(__dirname, '../../src/tsconfig.json'); - const projectBase = path.dirname(projectPath); - const newProjectBase = path.join(path.dirname(projectBase), path.basename(projectBase) + '2'); - fs.cpSync(projectBase, newProjectBase, { recursive: true }); - for await (const [fileName, contents] of new Mangler(projectPath, console.log).computeNewFileContents(new Set(['saveState']))) { - const newFilePath = path.join(newProjectBase, path.relative(projectBase, fileName)); - await fs.promises.mkdir(path.dirname(newFilePath), { recursive: true }); - await fs.promises.writeFile(newFilePath, contents.out); - if (contents.sourceMap) { - await fs.promises.writeFile(newFilePath + '.map', contents.sourceMap); - } - } -} -if (__filename === process_1.argv[1]) { - _run(); -} -//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoibWFuZ2xlVHlwZVNjcmlwdC5qcyIsInNvdXJjZVJvb3QiOiIiLCJzb3VyY2VzIjpbIm1hbmdsZVR5cGVTY3JpcHQudHMiXSwibmFtZXMiOltdLCJtYXBwaW5ncyI6IjtBQUFBOzs7Z0dBR2dHOzs7QUFFaEcsaUNBQWlDO0FBQ2pDLDZCQUE2QjtBQUM3Qix5QkFBeUI7QUFDekIscUNBQStCO0FBQy9CLDJDQUF5RDtBQUN6RCw2QkFBb0M7QUFFcEMsTUFBTSxVQUFVO0lBRVAsTUFBTSxDQUFDLFNBQVMsR0FBRyxJQUFJLEdBQUcsQ0FBQyxDQUFDLE9BQU8sRUFBRSxPQUFPLEVBQUUsTUFBTSxFQUFFLE9BQU8sRUFBRSxPQUFPLEVBQUUsT0FBTyxFQUFFLFVBQVUsRUFBRSxVQUFVO1FBQzlHLFNBQVMsRUFBRSxRQUFRLEVBQUUsSUFBSSxFQUFFLE1BQU0sRUFBRSxRQUFRLEVBQUUsU0FBUyxFQUFFLE9BQU8sRUFBRSxTQUFTLEVBQUUsS0FBSyxFQUFFLFVBQVUsRUFBRSxJQUFJO1FBQ25HLFFBQVEsRUFBRSxJQUFJLEVBQUUsWUFBWSxFQUFFLEtBQUssRUFBRSxLQUFLLEVBQUUsTUFBTSxFQUFFLFFBQVEsRUFBRSxRQUFRLEVBQUUsT0FBTyxFQUFFLFFBQVEsRUFBRSxNQUFNLEVBQUUsT0FBTztRQUMxRyxNQUFNLEVBQUUsS0FBSyxFQUFFLFFBQVEsRUFBRSxLQUFLLEVBQUUsTUFBTSxFQUFFLE9BQU8sRUFBRSxNQUFNLEVBQUUsT0FBTyxDQUFDLENBQUMsQ0FBQztJQUU1RCxNQUFNLENBQUMsU0FBUyxHQUFHLGtFQUFrRSxDQUFDLEtBQUssQ0FBQyxFQUFFLENBQUMsQ0FBQztJQUVoRyxNQUFNLEdBQUcsQ0FBQyxDQUFDO0lBQ0YsWUFBWSxDQUE0QjtJQUN4QyxNQUFNLENBQVM7SUFFaEMsWUFBWSxNQUFjLEVBQUUsV0FBc0M7UUFDakUsSUFBSSxDQUFDLE1BQU0sR0FBRyxNQUFNLENBQUM7UUFDckIsSUFBSSxDQUFDLFlBQVksR0FBRyxJQUFJLENBQUMsRUFBRSxDQUFDLFVBQVUsQ0FBQyxTQUFTLENBQUMsR0FBRyxDQUFDLElBQUksQ0FBQyxJQUFJLFNBQVMsQ0FBQyxJQUFJLENBQUMsSUFBSSxDQUFDLElBQUksV0FBVyxDQUFDLElBQUksQ0FBQyxDQUFDO0lBQ3pHLENBQUM7SUFFRCxJQUFJLENBQUMsZ0JBQTRDO1FBQ2hELE1BQU0sU0FBUyxHQUFHLElBQUksQ0FBQyxNQUFNLEdBQUcsVUFBVSxDQUFDLE9BQU8sQ0FBQyxJQUFJLENBQUMsTUFBTSxDQUFDLENBQUM7UUFDaEUsSUFBSSxDQUFDLE1BQU0sRUFBRSxDQUFDO1FBQ2QsSUFBSSxJQUFJLENBQUMsWUFBWSxDQUFDLFNBQVMsQ0FBQyxJQUFJLGdCQUFnQixFQUFFLENBQUMsU0FBUyxDQUFDLEVBQUU7WUFDbEUsWUFBWTtZQUNaLE9BQU8sSUFBSSxDQUFDLElBQUksQ0FBQyxnQkFBZ0IsQ0FBQyxDQUFDO1NBQ25DO1FBQ0QsT0FBTyxTQUFTLENBQUM7SUFDbEIsQ0FBQztJQUVPLE1BQU0sQ0FBQyxPQUFPLENBQUMsQ0FBUztRQUMvQixNQUFNLElBQUksR0FBRyxJQUFJLENBQUMsU0FBUyxDQUFDLE1BQU0sQ0FBQztRQUNuQyxJQUFJLE1BQU0sR0FBRyxFQUFFLENBQUM7UUFDaEIsR0FBRztZQUNGLE1BQU0sSUFBSSxHQUFHLENBQUMsR0FBRyxJQUFJLENBQUM7WUFDdEIsTUFBTSxJQUFJLElBQUksQ0FBQyxTQUFTLENBQUMsSUFBSSxDQUFDLENBQUM7WUFDL0IsQ0FBQyxHQUFHLENBQUMsQ0FBQyxHQUFHLElBQUksQ0FBQyxHQUFHLENBQUMsQ0FBQztTQUNuQixRQUFRLENBQUMsR0FBRyxDQUFDLEVBQUU7UUFDaEIsT0FBTyxNQUFNLENBQUM7SUFDZixDQUFDOztBQUdGLElBQVcsU0FJVjtBQUpELFdBQVcsU0FBUztJQUNuQiw2Q0FBTSxDQUFBO0lBQ04sbURBQVMsQ0FBQTtJQUNULCtDQUFPLENBQUE7QUFDUixDQUFDLEVBSlUsU0FBUyxLQUFULFNBQVMsUUFJbkI7QUFFRCxNQUFNLFNBQVM7SUFVSjtJQUNBO0lBVFYsTUFBTSxHQUFHLElBQUksR0FBRyxFQUE0QyxDQUFDO0lBRXJELFlBQVksQ0FBa0M7SUFFdEQsTUFBTSxDQUF3QjtJQUM5QixRQUFRLENBQTBCO0lBRWxDLFlBQ1UsUUFBZ0IsRUFDaEIsSUFBOEM7UUFFdkQsZ0ZBQWdGO1FBQ2hGLGdGQUFnRjtRQUp2RSxhQUFRLEdBQVIsUUFBUSxDQUFRO1FBQ2hCLFNBQUksR0FBSixJQUFJLENBQTBDO1FBS3ZELE1BQU0sVUFBVSxHQUE0QixFQUFFLENBQUM7UUFDL0MsS0FBSyxNQUFNLE1BQU0sSUFBSSxJQUFJLENBQUMsT0FBTyxFQUFFO1lBQ2xDLElBQUksRUFBRSxDQUFDLG1CQUFtQixDQUFDLE1BQU0sQ0FBQyxFQUFFO2dCQUNuQyxvQkFBb0I7Z0JBQ3BCLFVBQVUsQ0FBQyxJQUFJLENBQUMsTUFBTSxDQUFDLENBQUM7YUFFeEI7aUJBQU0sSUFBSSxFQUFFLENBQUMscUJBQXFCLENBQUMsTUFBTSxDQUFDLEVBQUU7Z0JBQzVDLHVCQUF1QjtnQkFDdkIsVUFBVSxDQUFDLElBQUksQ0FBQyxNQUFNLENBQUMsQ0FBQzthQUV4QjtpQkFBTSxJQUFJLEVBQUUsQ0FBQyxhQUFhLENBQUMsTUFBTSxDQUFDLEVBQUU7Z0JBQ3BDLDhCQUE4QjtnQkFDOUIsVUFBVSxDQUFDLElBQUksQ0FBQyxNQUFNLENBQUMsQ0FBQzthQUV4QjtpQkFBTSxJQUFJLEVBQUUsQ0FBQyxhQUFhLENBQUMsTUFBTSxDQUFDLEVBQUU7Z0JBQ3BDLDhCQUE4QjtnQkFDOUIsVUFBVSxDQUFDLElBQUksQ0FBQyxNQUFNLENBQUMsQ0FBQzthQUV4QjtpQkFBTSxJQUFJLEVBQUUsQ0FBQyx3QkFBd0IsQ0FBQyxNQUFNLENBQUMsRUFBRTtnQkFDL0MsaURBQWlEO2dCQUNqRCxLQUFLLE1BQU0sS0FBSyxJQUFJLE1BQU0sQ0FBQyxVQUFVLEVBQUU7b0JBQ3RDLElBQUksV0FBVyxDQUFDLEtBQUssRUFBRSxFQUFFLENBQUMsVUFBVSxDQUFDLGNBQWMsQ0FBQzsyQkFDaEQsV0FBVyxDQUFDLEtBQUssRUFBRSxFQUFFLENBQUMsVUFBVSxDQUFDLGdCQUFnQixDQUFDOzJCQUNsRCxXQUFXLENBQUMsS0FBSyxFQUFFLEVBQUUsQ0FBQyxVQUFVLENBQUMsYUFBYSxDQUFDOzJCQUMvQyxXQUFXLENBQUMsS0FBSyxFQUFFLEVBQUUsQ0FBQyxVQUFVLENBQUMsZUFBZSxDQUFDLEVBQ25EO3dCQUNELFVBQVUsQ0FBQyxJQUFJLENBQUMsS0FBSyxDQUFDLENBQUM7cUJBQ3ZCO2lCQUNEO2FBQ0Q7U0FDRDtRQUNELEtBQUssTUFBTSxNQUFNLElBQUksVUFBVSxFQUFFO1lBQ2hDLE1BQU0sS0FBSyxHQUFHLFNBQVMsQ0FBQyxjQUFjLENBQUMsTUFBTSxDQUFDLENBQUM7WUFDL0MsSUFBSSxDQUFDLEtBQUssRUFBRTtnQkFDWCxTQUFTO2FBQ1Q7WUFDRCxNQUFNLElBQUksR0FBRyxTQUFTLENBQUMsYUFBYSxDQUFDLE1BQU0sQ0FBQyxDQUFDO1lBQzdDLElBQUksQ0FBQyxNQUFNLENBQUMsR0FBRyxDQUFDLEtBQUssRUFBRSxFQUFFLElBQUksRUFBRSxHQUFHLEVBQUUsTUFBTSxDQUFDLElBQUssQ0FBQyxRQUFRLEVBQUUsRUFBRSxDQUFDLENBQUM7U0FDL0Q7SUFDRixDQUFDO0lBRU8sTUFBTSxDQUFDLGNBQWMsQ0FBQyxJQUF5QjtRQUN0RCxJQUFJLENBQUMsSUFBSSxDQUFDLElBQUksRUFBRTtZQUNmLE9BQU8sU0FBUyxDQUFDO1NBQ2pCO1FBQ0QsTUFBTSxFQUFFLElBQUksRUFBRSxHQUFHLElBQUksQ0FBQztRQUN0QixJQUFJLEtBQUssR0FBRyxJQUFJLENBQUMsT0FBTyxFQUFFLENBQUM7UUFDM0IsSUFBSSxJQUFJLENBQUMsSUFBSSxLQUFLLEVBQUUsQ0FBQyxVQUFVLENBQUMsb0JBQW9CLEVBQUU7WUFDckQsSUFBSSxJQUFJLENBQUMsVUFBVSxDQUFDLElBQUksS0FBSyxFQUFFLENBQUMsVUFBVSxDQUFDLGFBQWEsRUFBRTtnQkFDekQsK0NBQStDO2dCQUMvQyxPQUFPO2FBQ1A7WUFDRCxVQUFVO1lBQ1YsS0FBSyxHQUFHLElBQUksQ0FBQyxVQUFVLENBQUMsT0FBTyxFQUFFLENBQUMsS0FBSyxDQUFDLENBQUMsRUFBRSxDQUFDLENBQUMsQ0FBQyxDQUFDO1NBQy9DO1FBRUQsT0FBTyxLQUFLLENBQUM7SUFDZCxDQUFDO0lBRU8sTUFBTSxDQUFDLGFBQWEsQ0FBQyxJQUFhO1FBQ3pDLElBQUksV0FBVyxDQUFDLElBQUksRUFBRSxFQUFFLENBQUMsVUFBVSxDQUFDLGNBQWMsQ0FBQyxFQUFFO1lBQ3BELGlDQUF5QjtTQUN6QjthQUFNLElBQUksV0FBVyxDQUFDLElBQUksRUFBRSxFQUFFLENBQUMsVUFBVSxDQUFDLGdCQUFnQixDQUFDLEVBQUU7WUFDN0QsbUNBQTJCO1NBQzNCO2FBQU07WUFDTixnQ0FBd0I7U0FDeEI7SUFDRixDQUFDO0lBRUQsTUFBTSxDQUFDLGFBQWEsQ0FBQyxJQUFlO1FBQ25DLE9BQU8sSUFBSSw4QkFBc0I7ZUFDN0IsSUFBSSxnQ0FBd0IsQ0FDOUI7SUFDSCxDQUFDO0lBRUQsTUFBTSxDQUFDLGdDQUFnQyxDQUFDLElBQWUsRUFBRSxlQUFrRTtRQUMxSCxVQUFVO1FBQ1YsaUZBQWlGO1FBQ2pGLGlGQUFpRjtRQUNqRixLQUFLLE1BQU0sQ0FBQyxJQUFJLEVBQUUsSUFBSSxDQUFDLElBQUksSUFBSSxDQUFDLE1BQU0sRUFBRTtZQUN2QyxJQUFJLElBQUksQ0FBQyxJQUFJLDZCQUFxQixFQUFFO2dCQUNuQyxTQUFTO2FBQ1Q7WUFDRCxJQUFJLE1BQU0sR0FBMEIsSUFBSSxDQUFDLE1BQU0sQ0FBQztZQUNoRCxPQUFPLE1BQU0sRUFBRTtnQkFDZCxJQUFJLE1BQU0sQ0FBQyxNQUFNLENBQUMsR0FBRyxDQUFDLElBQUksQ0FBQyxFQUFFLElBQUksZ0NBQXdCLEVBQUU7b0JBQzFELE1BQU0sU0FBUyxHQUFHLE1BQU0sQ0FBQyxJQUFJLENBQUMsYUFBYSxFQUFFLENBQUMsNkJBQTZCLENBQUMsTUFBTSxDQUFDLE1BQU0sQ0FBQyxHQUFHLENBQUMsSUFBSSxDQUFFLENBQUMsR0FBRyxDQUFDLENBQUM7b0JBQzFHLE1BQU0sT0FBTyxHQUFHLElBQUksQ0FBQyxJQUFJLENBQUMsYUFBYSxFQUFFLENBQUMsNkJBQTZCLENBQUMsSUFBSSxDQUFDLEdBQUcsQ0FBQyxDQUFDO29CQUNsRixlQUFlLENBQUMsSUFBSSxFQUFFLElBQUksSUFBSSxVQUFVLE1BQU0sQ0FBQyxRQUFRLElBQUksU0FBUyxDQUFDLElBQUksR0FBRyxDQUFDLEVBQUUsRUFBRSxHQUFHLElBQUksQ0FBQyxRQUFRLElBQUksT0FBTyxDQUFDLElBQUksR0FBRyxDQUFDLEVBQUUsQ0FBQyxDQUFDO29CQUV6SCxNQUFNLENBQUMsTUFBTSxDQUFDLEdBQUcsQ0FBQyxJQUFJLENBQUUsQ0FBQyxJQUFJLDJCQUFtQixDQUFDO2lCQUNqRDtnQkFDRCxNQUFNLEdBQUcsTUFBTSxDQUFDLE1BQU0sQ0FBQzthQUN2QjtTQUNEO0lBQ0YsQ0FBQztJQUVELE1BQU0sQ0FBQyxpQkFBaUIsQ0FBQyxJQUFlO1FBRXZDLElBQUksSUFBSSxDQUFDLFlBQVksRUFBRTtZQUN0QixlQUFlO1lBQ2YsT0FBTztTQUNQO1FBRUQsd0JBQXdCO1FBQ3hCLElBQUksSUFBSSxDQUFDLE1BQU0sRUFBRTtZQUNoQixTQUFTLENBQUMsaUJBQWlCLENBQUMsSUFBSSxDQUFDLE1BQU0sQ0FBQyxDQUFDO1NBQ3pDO1FBRUQsSUFBSSxDQUFDLFlBQVksR0FBRyxJQUFJLEdBQUcsRUFBRSxDQUFDO1FBRTlCLE1BQU0sU0FBUyxHQUFHLElBQUksVUFBVSxDQUFDLEVBQUUsRUFBRSxJQUFJLENBQUMsRUFBRTtZQUUzQyxnQkFBZ0I7WUFDaEIsSUFBSSxJQUFJLENBQUMsWUFBWSxDQUFDLElBQUksQ0FBQyxFQUFFO2dCQUM1QixPQUFPLElBQUksQ0FBQzthQUNaO1lBRUQsVUFBVTtZQUNWLElBQUksTUFBTSxHQUEwQixJQUFJLENBQUMsTUFBTSxDQUFDO1lBQ2hELE9BQU8sTUFBTSxFQUFFO2dCQUNkLElBQUksTUFBTSxDQUFDLFlBQVksQ0FBQyxJQUFJLENBQUMsRUFBRTtvQkFDOUIsT0FBTyxJQUFJLENBQUM7aUJBQ1o7Z0JBQ0QsTUFBTSxHQUFHLE1BQU0sQ0FBQyxNQUFNLENBQUM7YUFDdkI7WUFFRCxXQUFXO1lBQ1gsSUFBSSxJQUFJLENBQUMsUUFBUSxFQUFFO2dCQUNsQixNQUFNLEtBQUssR0FBRyxDQUFDLEdBQUcsSUFBSSxDQUFDLFFBQVEsQ0FBQyxDQUFDO2dCQUNqQyxPQUFPLEtBQUssQ0FBQyxNQUFNLEVBQUU7b0JBQ3BCLE1BQU0sSUFBSSxHQUFHLEtBQUssQ0FBQyxHQUFHLEVBQUcsQ0FBQztvQkFDMUIsSUFBSSxJQUFJLENBQUMsWUFBWSxDQUFDLElBQUksQ0FBQyxFQUFFO3dCQUM1QixPQUFPLElBQUksQ0FBQztxQkFDWjtvQkFDRCxJQUFJLElBQUksQ0FBQyxRQUFRLEVBQUU7d0JBQ2xCLEtBQUssQ0FBQyxJQUFJLENBQUMsR0FBRyxJQUFJLENBQUMsUUFBUSxDQUFDLENBQUM7cUJBQzdCO2lCQUNEO2FBQ0Q7WUFFRCxPQUFPLEtBQUssQ0FBQztRQUNkLENBQUMsQ0FBQyxDQUFDO1FBRUgsS0FBSyxNQUFNLENBQUMsSUFBSSxFQUFFLElBQUksQ0FBQyxJQUFJLElBQUksQ0FBQyxNQUFNLEVBQUU7WUFDdkMsSUFBSSxTQUFTLENBQUMsYUFBYSxDQUFDLElBQUksQ0FBQyxJQUFJLENBQUMsRUFBRTtnQkFDdkMsTUFBTSxTQUFTLEdBQUcsU0FBUyxDQUFDLElBQUksRUFBRSxDQUFDO2dCQUNuQyxJQUFJLENBQUMsWUFBWSxDQUFDLEdBQUcsQ0FBQyxJQUFJLEVBQUUsU0FBUyxDQUFDLENBQUM7YUFDdkM7U0FDRDtJQUNGLENBQUM7SUFFRCxrRUFBa0U7SUFDbEUsa0RBQWtEO0lBQzFDLFlBQVksQ0FBQyxJQUFZO1FBQ2hDLElBQUksSUFBSSxDQUFDLE1BQU0sQ0FBQyxHQUFHLENBQUMsSUFBSSxDQUFDLElBQUksQ0FBQyxTQUFTLENBQUMsYUFBYSxDQUFDLElBQUksQ0FBQyxNQUFNLENBQUMsR0FBRyxDQUFDLElBQUksQ0FBRSxDQUFDLElBQUksQ0FBQyxFQUFFO1lBQ25GLGVBQWU7WUFDZixPQUFPLElBQUksQ0FBQztTQUNaO1FBQ0QsSUFBSSxJQUFJLENBQUMsWUFBWSxFQUFFO1lBQ3RCLEtBQUssTUFBTSxTQUFTLElBQUksSUFBSSxDQUFDLFlBQVksQ0FBQyxNQUFNLEVBQUUsRUFBRTtnQkFDbkQsSUFBSSxTQUFTLEtBQUssSUFBSSxFQUFFO29CQUN2Qiw2Q0FBNkM7b0JBQzdDLE9BQU8sSUFBSSxDQUFDO2lCQUNaO2FBQ0Q7U0FDRDtRQUVELElBQUksaUJBQWlCLENBQUMsSUFBSSxDQUFDLElBQUksRUFBRSxJQUFJLENBQUMsRUFBRTtZQUN2QyxPQUFPLElBQUksQ0FBQztTQUNaO1FBRUQsT0FBTyxLQUFLLENBQUM7SUFDZCxDQUFDO0lBRUQsZUFBZSxDQUFDLElBQVk7UUFDM0IsSUFBSSxLQUFLLEdBQUcsSUFBSSxDQUFDLFlBQWEsQ0FBQyxHQUFHLENBQUMsSUFBSSxDQUFFLENBQUM7UUFDMUMsSUFBSSxNQUFNLEdBQUcsSUFBSSxDQUFDLE1BQU0sQ0FBQztRQUN6QixPQUFPLE1BQU0sRUFBRTtZQUNkLElBQUksTUFBTSxDQUFDLFlBQWEsQ0FBQyxHQUFHLENBQUMsSUFBSSxDQUFDLElBQUksTUFBTSxDQUFDLE1BQU0sQ0FBQyxHQUFHLENBQUMsSUFBSSxDQUFDLEVBQUUsSUFBSSxnQ0FBd0IsRUFBRTtnQkFDNUYsS0FBSyxHQUFHLE1BQU0sQ0FBQyxZQUFhLENBQUMsR0FBRyxDQUFDLElBQUksQ0FBRSxJQUFJLEtBQUssQ0FBQzthQUNqRDtZQUNELE1BQU0sR0FBRyxNQUFNLENBQUMsTUFBTSxDQUFDO1NBQ3ZCO1FBQ0QsT0FBTyxLQUFLLENBQUM7SUFDZCxDQUFDO0lBRUQsc0JBQXNCO0lBRXRCLFFBQVEsQ0FBQyxLQUFnQjtRQUN4QixJQUFJLENBQUMsUUFBUSxLQUFLLEVBQUUsQ0FBQztRQUNyQixJQUFJLENBQUMsUUFBUSxDQUFDLElBQUksQ0FBQyxLQUFLLENBQUMsQ0FBQztRQUMxQixLQUFLLENBQUMsTUFBTSxHQUFHLElBQUksQ0FBQztJQUNyQixDQUFDO0NBQ0Q7QUFFRCxTQUFTLGlCQUFpQixDQUFDLElBQWEsRUFBRSxJQUFZO0lBQ3JELE1BQU0sV0FBVyxHQUFTLElBQUksQ0FBQyxhQUFhLEVBQUcsQ0FBQyxXQUFXLENBQUM7SUFDNUQsSUFBSSxXQUFXLFlBQVksR0FBRyxFQUFFO1FBQy9CLElBQUksV0FBVyxDQUFDLEdBQUcsQ0FBQyxJQUFJLENBQUMsRUFBRTtZQUMxQixPQUFPLElBQUksQ0FBQztTQUNaO0tBQ0Q7SUFDRCxPQUFPLEtBQUssQ0FBQztBQUNkLENBQUM7QUFFRCxNQUFNLFVBQVUsR0FBRyxJQUFJO0lBQ0wsTUFBTSxHQUFHLElBQUksVUFBVSxDQUFDLEdBQUcsRUFBRSxHQUFHLEVBQUUsQ0FBQyxLQUFLLENBQUMsQ0FBQztJQUUzRCxJQUFJLENBQUMsSUFBbUI7UUFDdkIsT0FBTyxJQUFJLENBQUMsTUFBTSxDQUFDLElBQUksQ0FBQyxJQUFJLENBQUMsRUFBRSxDQUFDLGlCQUFpQixDQUFDLElBQUksRUFBRSxJQUFJLENBQUMsQ0FBQyxDQUFDO0lBQ2hFLENBQUM7Q0FDRCxDQUFDO0FBRUYsTUFBTSxZQUFZLEdBQUc7SUFDcEIsUUFBUTtJQUNSLGNBQWM7SUFDZCxjQUFjO0lBRWQsU0FBUztJQUNULGlCQUFpQjtJQUNqQixrQkFBa0I7SUFDbEIsZUFBZTtJQUNmLHFCQUFxQjtJQUNyQix3QkFBd0I7SUFFeEIsWUFBWTtJQUNaLDJCQUEyQjtJQUUzQiwrQkFBK0I7SUFDL0IsUUFBUTtDQUNSLENBQUM7QUFFRixNQUFNLGVBQWU7SUFLVjtJQUNBO0lBSkQsZUFBZSxDQUFTO0lBRWpDLFlBQ1UsUUFBZ0IsRUFDaEIsSUFBa0Q7UUFEbEQsYUFBUSxHQUFSLFFBQVEsQ0FBUTtRQUNoQixTQUFJLEdBQUosSUFBSSxDQUE4QztRQUUzRCxJQUFJLENBQUMsZUFBZSxHQUFHLFVBQVUsQ0FBQyxJQUFJLENBQUMsSUFBSSxDQUFDLGFBQWEsRUFBRSxDQUFDLENBQUM7SUFDOUQsQ0FBQztJQUVELElBQUksU0FBUztRQUNaLE9BQU8sQ0FBQztnQkFDUCxRQUFRLEVBQUUsSUFBSSxDQUFDLFFBQVE7Z0JBQ3ZCLE1BQU0sRUFBRSxJQUFJLENBQUMsSUFBSSxDQUFDLElBQUssQ0FBQyxRQUFRLEVBQUU7YUFDbEMsQ0FBQyxDQUFDO0lBQ0osQ0FBQztJQUVELFlBQVksQ0FBQyxPQUFlO1FBQzNCLDBDQUEwQztRQUMxQyxJQUFJLE9BQU8sQ0FBQyxNQUFNLElBQUksSUFBSSxDQUFDLElBQUksQ0FBQyxJQUFLLENBQUMsT0FBTyxFQUFFLENBQUMsTUFBTSxFQUFFO1lBQ3ZELE9BQU8sS0FBSyxDQUFDO1NBQ2I7UUFFRCxvREFBb0Q7UUFDcEQsSUFBSSxJQUFJLENBQUMsSUFBSSxDQUFDLFdBQVcsRUFBRSxDQUFDLFFBQVEsQ0FBQyxhQUFhLENBQUMsRUFBRTtZQUNwRCxPQUFPLEtBQUssQ0FBQztTQUNiO1FBRUQsbURBQW1EO1FBQ25ELElBQUksWUFBWSxDQUFDLElBQUksQ0FBQyxJQUFJLENBQUMsRUFBRSxDQUFDLElBQUksQ0FBQyxJQUFJLENBQUMsYUFBYSxFQUFFLENBQUMsUUFBUSxDQUFDLFFBQVEsQ0FBQyxJQUFJLENBQUMsQ0FBQyxFQUFFO1lBQ2pGLE9BQU8sS0FBSyxDQUFDO1NBQ2I7UUFFRCxPQUFPLElBQUksQ0FBQztJQUNiLENBQUM7Q0FDRDtBQUVELE1BQU0sU0FBUztJQUtKO0lBQ0E7SUFDQTtJQUNRO0lBTlQsZUFBZSxDQUFTO0lBRWpDLFlBQ1UsUUFBZ0IsRUFDaEIsU0FBK0IsRUFDL0IsSUFBNEIsRUFDcEIsT0FBMkI7UUFIbkMsYUFBUSxHQUFSLFFBQVEsQ0FBUTtRQUNoQixjQUFTLEdBQVQsU0FBUyxDQUFzQjtRQUMvQixTQUFJLEdBQUosSUFBSSxDQUF3QjtRQUNwQixZQUFPLEdBQVAsT0FBTyxDQUFvQjtRQUU1QyxJQUFJLENBQUMsZUFBZSxHQUFHLFVBQVUsQ0FBQyxJQUFJLENBQUMsU0FBUyxDQUFDLGFBQWEsRUFBRSxDQUFDLENBQUM7SUFDbkUsQ0FBQztJQUVELElBQUksU0FBUztRQUNaLDhEQUE4RDtRQUM5RCxNQUFNLGdCQUFnQixHQUFHLElBQUksQ0FBQyxPQUFPLENBQUMseUJBQXlCLENBQUMsSUFBSSxDQUFDLElBQUksQ0FBQyxhQUFhLEVBQUUsQ0FBQyxRQUFRLEVBQUUsSUFBSSxDQUFDLElBQUksQ0FBQyxJQUFJLENBQUMsUUFBUSxFQUFFLENBQUMsQ0FBQztRQUMvSCxJQUFJLGdCQUFnQixFQUFFLFdBQVcsSUFBSSxnQkFBZ0IsQ0FBQyxXQUFXLENBQUMsTUFBTSxHQUFHLENBQUMsRUFBRTtZQUM3RSxPQUFPLGdCQUFnQixDQUFDLFdBQVcsQ0FBQyxHQUFHLENBQUMsQ0FBQyxDQUFDLEVBQUUsQ0FBQyxDQUFDLEVBQUUsUUFBUSxFQUFFLENBQUMsQ0FBQyxRQUFRLEVBQUUsTUFBTSxFQUFFLENBQUMsQ0FBQyxRQUFRLENBQUMsS0FBSyxFQUFFLENBQUMsQ0FBQyxDQUFDO1NBQ25HO1FBRUQsT0FBTyxDQUFDLEVBQUUsUUFBUSxFQUFFLElBQUksQ0FBQyxRQUFRLEVBQUUsTUFBTSxFQUFFLElBQUksQ0FBQyxJQUFJLENBQUMsSUFBSSxDQUFDLFFBQVEsRUFBRSxFQUFFLENBQUMsQ0FBQztJQUN6RSxDQUFDO0lBRUQsWUFBWSxDQUFDLE9BQWU7UUFDM0IsMENBQTBDO1FBQzFDLElBQUksT0FBTyxDQUFDLE1BQU0sSUFBSSxJQUFJLENBQUMsSUFBSSxDQUFDLElBQUksQ0FBQyxPQUFPLEVBQUUsQ0FBQyxNQUFNLEVBQUU7WUFDdEQsT0FBTyxLQUFLLENBQUM7U0FDYjtRQUVELG9EQUFvRDtRQUNwRCxJQUFJLElBQUksQ0FBQyxTQUFTLENBQUMsV0FBVyxFQUFFLENBQUMsUUFBUSxDQUFDLGFBQWEsQ0FBQyxFQUFFO1lBQ3pELE9BQU8sS0FBSyxDQUFDO1NBQ2I7UUFFRCx1Q0FBdUM7UUFDdkMsSUFBSSxZQUFZLENBQUMsSUFBSSxDQUFDLElBQUksQ0FBQyxFQUFFLENBQUMsSUFBSSxDQUFDLElBQUksQ0FBQyxhQUFhLEVBQUUsQ0FBQyxRQUFRLENBQUMsUUFBUSxDQUFDLElBQUksQ0FBQyxDQUFDLEVBQUU7WUFDakYsT0FBTyxLQUFLLENBQUM7U0FDYjtRQUVELE9BQU8sSUFBSSxDQUFDO0lBQ2IsQ0FBQztDQUNEO0FBRUQsTUFBTSx5QkFBeUI7SUFLVDtJQUhKLFFBQVEsQ0FBdUI7SUFDL0IsZ0JBQWdCLEdBQW9DLElBQUksR0FBRyxFQUFFLENBQUM7SUFFL0UsWUFBcUIsV0FBbUI7UUFBbkIsZ0JBQVcsR0FBWCxXQUFXLENBQVE7UUFDdkMsTUFBTSxlQUFlLEdBQWdDLEVBQUUsQ0FBQztRQUN4RCxNQUFNLE1BQU0sR0FBRyxFQUFFLENBQUMsY0FBYyxDQUFDLFdBQVcsRUFBRSxFQUFFLENBQUMsR0FBRyxDQUFDLFFBQVEsQ0FBQyxDQUFDO1FBQy9ELElBQUksTUFBTSxDQUFDLEtBQUssRUFBRTtZQUNqQixNQUFNLE1BQU0sQ0FBQyxLQUFLLENBQUM7U0FDbkI7UUFDRCxJQUFJLENBQUMsUUFBUSxHQUFHLEVBQUUsQ0FBQywwQkFBMEIsQ0FBQyxNQUFNLENBQUMsTUFBTSxFQUFFLEVBQUUsQ0FBQyxHQUFHLEVBQUUsSUFBSSxDQUFDLE9BQU8sQ0FBQyxXQUFXLENBQUMsRUFBRSxlQUFlLENBQUMsQ0FBQztRQUNqSCxJQUFJLElBQUksQ0FBQyxRQUFRLENBQUMsTUFBTSxDQUFDLE1BQU0sR0FBRyxDQUFDLEVBQUU7WUFDcEMsTUFBTSxNQUFNLENBQUMsS0FBSyxDQUFDO1NBQ25CO0lBQ0YsQ0FBQztJQUNELHNCQUFzQjtRQUNyQixPQUFPLElBQUksQ0FBQyxRQUFRLENBQUMsT0FBTyxDQUFDO0lBQzlCLENBQUM7SUFDRCxrQkFBa0I7UUFDakIsT0FBTyxJQUFJLENBQUMsUUFBUSxDQUFDLFNBQVMsQ0FBQztJQUNoQyxDQUFDO0lBQ0QsZ0JBQWdCLENBQUMsU0FBaUI7UUFDakMsT0FBTyxHQUFHLENBQUM7SUFDWixDQUFDO0lBQ0QsaUJBQWlCO1FBQ2hCLE9BQU8sR0FBRyxDQUFDO0lBQ1osQ0FBQztJQUNELGlCQUFpQixDQUFDLFFBQWdCO1FBQ2pDLElBQUksTUFBTSxHQUFtQyxJQUFJLENBQUMsZ0JBQWdCLENBQUMsR0FBRyxDQUFDLFFBQVEsQ0FBQyxDQUFDO1FBQ2pGLElBQUksTUFBTSxLQUFLLFNBQVMsRUFBRTtZQUN6QixNQUFNLE9BQU8sR0FBRyxFQUFFLENBQUMsR0FBRyxDQUFDLFFBQVEsQ0FBQyxRQUFRLENBQUMsQ0FBQztZQUMxQyxJQUFJLE9BQU8sS0FBSyxTQUFTLEVBQUU7Z0JBQzFCLE9BQU8sU0FBUyxDQUFDO2FBQ2pCO1lBQ0QsTUFBTSxHQUFHLEVBQUUsQ0FBQyxjQUFjLENBQUMsVUFBVSxDQUFDLE9BQU8sQ0FBQyxDQUFDO1lBQy9DLElBQUksQ0FBQyxnQkFBZ0IsQ0FBQyxHQUFHLENBQUMsUUFBUSxFQUFFLE1BQU0sQ0FBQyxDQUFDO1NBQzVDO1FBQ0QsT0FBTyxNQUFNLENBQUM7SUFDZixDQUFDO0lBQ0QsbUJBQW1CO1FBQ2xCLE9BQU8sSUFBSSxDQUFDLE9BQU8sQ0FBQyxJQUFJLENBQUMsV0FBVyxDQUFDLENBQUM7SUFDdkMsQ0FBQztJQUNELHFCQUFxQixDQUFDLE9BQTJCO1FBQ2hELE9BQU8sRUFBRSxDQUFDLHFCQUFxQixDQUFDLE9BQU8sQ0FBQyxDQUFDO0lBQzFDLENBQUM7SUFDRCxlQUFlLEdBQUcsRUFBRSxDQUFDLEdBQUcsQ0FBQyxlQUFlLENBQUM7SUFDekMsY0FBYyxHQUFHLEVBQUUsQ0FBQyxHQUFHLENBQUMsY0FBYyxDQUFDO0lBQ3ZDLFVBQVUsR0FBRyxFQUFFLENBQUMsR0FBRyxDQUFDLFVBQVUsQ0FBQztJQUMvQixRQUFRLEdBQUcsRUFBRSxDQUFDLEdBQUcsQ0FBQyxRQUFRLENBQUM7SUFDM0IsYUFBYSxHQUFHLEVBQUUsQ0FBQyxHQUFHLENBQUMsYUFBYSxDQUFDO0lBQ3JDLG9EQUFvRDtJQUNwRCxRQUFRLEdBQUcsRUFBRSxDQUFDLEdBQUcsQ0FBQyxRQUFRLENBQUM7Q0FDM0I7QUFPRDs7Ozs7Ozs7R0FRRztBQUNILE1BQWEsT0FBTztJQU9FO0lBQThCO0lBTGxDLGlCQUFpQixHQUFHLElBQUksR0FBRyxFQUFxQixDQUFDO0lBQ2pELDRCQUE0QixHQUFHLElBQUksR0FBRyxFQUF1QyxDQUFDO0lBRTlFLE9BQU8sQ0FBcUI7SUFFN0MsWUFBcUIsV0FBbUIsRUFBVyxNQUEwQixHQUFHLEVBQUUsR0FBRyxDQUFDO1FBQWpFLGdCQUFXLEdBQVgsV0FBVyxDQUFRO1FBQVcsUUFBRyxHQUFILEdBQUcsQ0FBZ0M7UUFDckYsSUFBSSxDQUFDLE9BQU8sR0FBRyxFQUFFLENBQUMscUJBQXFCLENBQUMsSUFBSSx5QkFBeUIsQ0FBQyxXQUFXLENBQUMsQ0FBQyxDQUFDO0lBQ3JGLENBQUM7SUFFRCxzQkFBc0IsQ0FBQyw0QkFBMEM7UUFFaEUsc0ZBQXNGO1FBRXRGLE1BQU0sS0FBSyxHQUFHLENBQUMsSUFBYSxFQUFRLEVBQUU7WUFDckMsSUFBSSxFQUFFLENBQUMsa0JBQWtCLENBQUMsSUFBSSxDQUFDLElBQUksRUFBRSxDQUFDLGlCQUFpQixDQUFDLElBQUksQ0FBQyxFQUFFO2dCQUM5RCxNQUFNLE1BQU0sR0FBRyxJQUFJLENBQUMsSUFBSSxJQUFJLElBQUksQ0FBQztnQkFDakMsTUFBTSxHQUFHLEdBQUcsR0FBRyxJQUFJLENBQUMsYUFBYSxFQUFFLENBQUMsUUFBUSxJQUFJLE1BQU0sQ0FBQyxRQUFRLEVBQUUsRUFBRSxDQUFDO2dCQUNwRSxJQUFJLElBQUksQ0FBQyxpQkFBaUIsQ0FBQyxHQUFHLENBQUMsR0FBRyxDQUFDLEVBQUU7b0JBQ3BDLE1BQU0sSUFBSSxLQUFLLENBQUMsT0FBTyxDQUFDLENBQUM7aUJBQ3pCO2dCQUNELElBQUksQ0FBQyxpQkFBaUIsQ0FBQyxHQUFHLENBQUMsR0FBRyxFQUFFLElBQUksU0FBUyxDQUFDLElBQUksQ0FBQyxhQUFhLEVBQUUsQ0FBQyxRQUFRLEVBQUUsSUFBSSxDQUFDLENBQUMsQ0FBQzthQUNwRjtZQUVELElBQUksRUFBRSxDQUFDLGtCQUFrQixDQUFDLElBQUksQ0FBQyxJQUFJLFdBQVcsQ0FBQyxJQUFJLEVBQUUsRUFBRSxDQUFDLFVBQVUsQ0FBQyxhQUFhLENBQUMsRUFBRTtnQkFDbEYsSUFBSSxJQUFJLENBQUMsSUFBSSxFQUFFO29CQUNkLE1BQU0sTUFBTSxHQUFHLElBQUksQ0FBQyxJQUFJLENBQUM7b0JBQ3pCLE1BQU0sR0FBRyxHQUFHLEdBQUcsSUFBSSxDQUFDLGFBQWEsRUFBRSxDQUFDLFFBQVEsSUFBSSxNQUFNLENBQUMsUUFBUSxFQUFFLEVBQUUsQ0FBQztvQkFDcEUsSUFBSSxJQUFJLENBQUMsNEJBQTRCLENBQUMsR0FBRyxDQUFDLEdBQUcsQ0FBQyxFQUFFO3dCQUMvQyxNQUFNLElBQUksS0FBSyxDQUFDLE9BQU8sQ0FBQyxDQUFDO3FCQUN6QjtvQkFDRCxJQUFJLENBQUMsNEJBQTRCLENBQUMsR0FBRyxDQUFDLEdBQUcsRUFBRSxJQUFJLGVBQWUsQ0FBQyxJQUFJLENBQUMsYUFBYSxFQUFFLENBQUMsUUFBUSxFQUFFLElBQUksQ0FBQyxDQUFDLENBQUM7aUJBQ3JHO2FBQ0Q7WUFFRCxJQUFJLEVBQUUsQ0FBQyxxQkFBcUIsQ0FBQyxJQUFJLENBQUM7bUJBQzlCLEVBQUUsQ0FBQyxZQUFZLENBQUMsSUFBSSxDQUFDLE1BQU0sQ0FBQzttQkFDNUIsV0FBVyxDQUFDLElBQUksRUFBRSxFQUFFLENBQUMsVUFBVSxDQUFDLGFBQWEsQ0FBQyxFQUNoRDtnQkFDRCxJQUFJLElBQUksQ0FBQyxJQUFJLElBQUksSUFBSSxDQUFDLElBQUksRUFBRSxFQUFFLDRDQUE0QztvQkFDekUsTUFBTSxNQUFNLEdBQUcsSUFBSSxDQUFDLElBQUksQ0FBQztvQkFDekIsTUFBTSxHQUFHLEdBQUcsR0FBRyxJQUFJLENBQUMsYUFBYSxFQUFFLENBQUMsUUFBUSxJQUFJLE1BQU0sQ0FBQyxRQUFRLEVBQUUsRUFBRSxDQUFDO29CQUNwRSxJQUFJLElBQUksQ0FBQyw0QkFBNEIsQ0FBQyxHQUFHLENBQUMsR0FBRyxDQUFDLEVBQUU7d0JBQy9DLE1BQU0sSUFBSSxLQUFLLENBQUMsT0FBTyxDQUFDLENBQUM7cUJBQ3pCO29CQUNELElBQUksQ0FBQyw0QkFBNEIsQ0FBQyxHQUFHLENBQUMsR0FBRyxFQUFFLElBQUksZUFBZSxDQUFDLElBQUksQ0FBQyxhQUFhLEVBQUUsQ0FBQyxRQUFRLEVBQUUsSUFBSSxDQUFDLENBQUMsQ0FBQztpQkFDckc7YUFDRDtZQUVELElBQUksRUFBRSxDQUFDLG1CQUFtQixDQUFDLElBQUksQ0FBQzttQkFDNUIsRUFBRSxDQUFDLFlBQVksQ0FBQyxJQUFJLENBQUMsTUFBTSxDQUFDO21CQUM1QixXQUFXLENBQUMsSUFBSSxFQUFFLEVBQUUsQ0FBQyxVQUFVLENBQUMsYUFBYSxDQUFDLEVBQ2hEO2dCQUNELEtBQUssTUFBTSxJQUFJLElBQUksSUFBSSxDQUFDLGVBQWUsQ0FBQyxZQUFZLEVBQUU7b0JBQ3JELE1BQU0sR0FBRyxHQUFHLEdBQUcsSUFBSSxDQUFDLGFBQWEsRUFBRSxDQUFDLFFBQVEsSUFBSSxJQUFJLENBQUMsSUFBSSxDQUFDLFFBQVEsRUFBRSxFQUFFLENBQUM7b0JBQ3ZFLElBQUksSUFBSSxDQUFDLDRCQUE0QixDQUFDLEdBQUcsQ0FBQyxHQUFHLENBQUMsRUFBRTt3QkFDL0MsTUFBTSxJQUFJLEtBQUssQ0FBQyxPQUFPLENBQUMsQ0FBQztxQkFDekI7b0JBQ0QsSUFBSSxDQUFDLDRCQUE0QixDQUFDLEdBQUcsQ0FBQyxHQUFHLEVBQUUsSUFBSSxTQUFTLENBQUMsSUFBSSxDQUFDLGFBQWEsRUFBRSxDQUFDLFFBQVEsRUFBRSxJQUFJLEVBQUUsSUFBSSxFQUFFLElBQUksQ0FBQyxPQUFPLENBQUMsQ0FBQyxDQUFDO2lCQUNuSDthQUNEO1lBRUQsRUFBRSxDQUFDLFlBQVksQ0FBQyxJQUFJLEVBQUUsS0FBSyxDQUFDLENBQUM7UUFDOUIsQ0FBQyxDQUFDO1FBRUYsS0FBSyxNQUFNLElBQUksSUFBSSxJQUFJLENBQUMsT0FBTyxDQUFDLFVBQVUsRUFBRyxDQUFDLGNBQWMsRUFBRSxFQUFFO1lBQy9ELElBQUksQ0FBQyxJQUFJLENBQUMsaUJBQWlCLEVBQUU7Z0JBQzVCLEVBQUUsQ0FBQyxZQUFZLENBQUMsSUFBSSxFQUFFLEtBQUssQ0FBQyxDQUFDO2FBQzdCO1NBQ0Q7UUFDRCxJQUFJLENBQUMsR0FBRyxDQUFDLDZCQUE2QixJQUFJLENBQUMsaUJBQWlCLENBQUMsSUFBSSx3QkFBd0IsSUFBSSxDQUFDLDRCQUE0QixDQUFDLElBQUksRUFBRSxDQUFDLENBQUM7UUFHbkkscUNBQXFDO1FBRXJDLE1BQU0sWUFBWSxHQUFHLENBQUMsSUFBZSxFQUFFLEVBQUU7WUFDeEMsTUFBTSxhQUFhLEdBQUcsSUFBSSxDQUFDLElBQUksQ0FBQyxlQUFlLEVBQUUsSUFBSSxDQUFDLENBQUMsQ0FBQyxFQUFFLENBQUMsQ0FBQyxDQUFDLEtBQUssS0FBSyxFQUFFLENBQUMsVUFBVSxDQUFDLGNBQWMsQ0FBQyxDQUFDO1lBQ3JHLElBQUksQ0FBQyxhQUFhLEVBQUU7Z0JBQ25CLG9CQUFvQjtnQkFDcEIsT0FBTzthQUNQO1lBRUQsTUFBTSxJQUFJLEdBQUcsSUFBSSxDQUFDLE9BQU8sQ0FBQyx1QkFBdUIsQ0FBQyxJQUFJLENBQUMsUUFBUSxFQUFFLGFBQWEsQ0FBQyxLQUFLLENBQUMsQ0FBQyxDQUFDLENBQUMsVUFBVSxDQUFDLE1BQU0sRUFBRSxDQUFDLENBQUM7WUFDN0csSUFBSSxDQUFDLElBQUksSUFBSSxJQUFJLENBQUMsTUFBTSxLQUFLLENBQUMsRUFBRTtnQkFDL0IsMkNBQTJDO2dCQUMzQyxPQUFPO2FBQ1A7WUFFRCxJQUFJLElBQUksQ0FBQyxNQUFNLEtBQUssQ0FBQyxFQUFFO2dCQUN0QixzQ0FBc0M7Z0JBQ3RDLE9BQU87YUFDUDtZQUVELE1BQU0sQ0FBQyxVQUFVLENBQUMsR0FBRyxJQUFJLENBQUM7WUFDMUIsTUFBTSxHQUFHLEdBQUcsR0FBRyxVQUFVLENBQUMsUUFBUSxJQUFJLFVBQVUsQ0FBQyxRQUFRLENBQUMsS0FBSyxFQUFFLENBQUM7WUFDbEUsTUFBTSxNQUFNLEdBQUcsSUFBSSxDQUFDLGlCQUFpQixDQUFDLEdBQUcsQ0FBQyxHQUFHLENBQUMsQ0FBQztZQUMvQyxJQUFJLENBQUMsTUFBTSxFQUFFO2dCQUNaLG1EQUFtRDtnQkFDbkQsT0FBTzthQUNQO1lBQ0QsTUFBTSxDQUFDLFFBQVEsQ0FBQyxJQUFJLENBQUMsQ0FBQztRQUN2QixDQUFDLENBQUM7UUFDRixLQUFLLE1BQU0sSUFBSSxJQUFJLElBQUksQ0FBQyxpQkFBaUIsQ0FBQyxNQUFNLEVBQUUsRUFBRTtZQUNuRCxZQUFZLENBQUMsSUFBSSxDQUFDLENBQUM7U0FDbkI7UUFFRCx1RUFBdUU7UUFDdkUsTUFBTSxVQUFVLEdBQUcsSUFBSSxHQUFHLEVBQW9CLENBQUM7UUFDL0MsSUFBSSxzQkFBc0IsR0FBRyxLQUFLLENBQUM7UUFDbkMsS0FBSyxNQUFNLElBQUksSUFBSSxJQUFJLENBQUMsaUJBQWlCLENBQUMsTUFBTSxFQUFFLEVBQUU7WUFDbkQsU0FBUyxDQUFDLGdDQUFnQyxDQUFDLElBQUksRUFBRSxDQUFDLElBQVksRUFBRSxJQUFJLEVBQUUsR0FBRyxFQUFFLEVBQUU7Z0JBQzVFLE1BQU0sR0FBRyxHQUFHLFVBQVUsQ0FBQyxHQUFHLENBQUMsSUFBSSxDQUFDLENBQUM7Z0JBQ2pDLElBQUksR0FBRyxFQUFFO29CQUNSLEdBQUcsQ0FBQyxJQUFJLENBQUMsR0FBRyxDQUFDLENBQUM7aUJBQ2Q7cUJBQU07b0JBQ04sVUFBVSxDQUFDLEdBQUcsQ0FBQyxJQUFJLEVBQUUsQ0FBQyxHQUFHLENBQUMsQ0FBQyxDQUFDO2lCQUM1QjtnQkFFRCxJQUFJLDRCQUE0QixJQUFJLENBQUMsNEJBQTRCLENBQUMsR0FBRyxDQUFDLElBQUksQ0FBQyxFQUFFO29CQUM1RSxzQkFBc0IsR0FBRyxJQUFJLENBQUM7aUJBQzlCO1lBQ0YsQ0FBQyxDQUFDLENBQUM7U0FDSDtRQUNELEtBQUssTUFBTSxDQUFDLEdBQUcsRUFBRSxJQUFJLENBQUMsSUFBSSxVQUFVLEVBQUU7WUFDckMsSUFBSSxDQUFDLEdBQUcsQ0FBQyxTQUFTLEdBQUcsOEJBQThCLElBQUksQ0FBQyxJQUFJLENBQUMsS0FBSyxDQUFDLEVBQUUsQ0FBQyxDQUFDO1NBQ3ZFO1FBQ0QsSUFBSSxzQkFBc0IsRUFBRTtZQUMzQixNQUFNLE9BQU8sR0FBRyxzSUFBc0ksQ0FBQztZQUN2SixJQUFJLENBQUMsR0FBRyxDQUFDLFVBQVUsT0FBTyxFQUFFLENBQUMsQ0FBQztZQUM5QixNQUFNLElBQUksS0FBSyxDQUFDLE9BQU8sQ0FBQyxDQUFDO1NBQ3pCO1FBRUQsaURBQWlEO1FBQ2pELEtBQUssTUFBTSxJQUFJLElBQUksSUFBSSxDQUFDLGlCQUFpQixDQUFDLE1BQU0sRUFBRSxFQUFFO1lBQ25ELFNBQVMsQ0FBQyxpQkFBaUIsQ0FBQyxJQUFJLENBQUMsQ0FBQztTQUNsQztRQUNELElBQUksQ0FBQyxHQUFHLENBQUMsa0NBQWtDLENBQUMsQ0FBQztRQUk3QyxNQUFNLFdBQVcsR0FBRyxJQUFJLEdBQUcsRUFBa0IsQ0FBQztRQUU5QyxNQUFNLFVBQVUsR0FBRyxDQUFDLFFBQWdCLEVBQUUsSUFBVSxFQUFFLEVBQUU7WUFDbkQsTUFBTSxLQUFLLEdBQUcsV0FBVyxDQUFDLEdBQUcsQ0FBQyxRQUFRLENBQUMsQ0FBQztZQUN4QyxJQUFJLENBQUMsS0FBSyxFQUFFO2dCQUNYLFdBQVcsQ0FBQyxHQUFHLENBQUMsUUFBUSxFQUFFLENBQUMsSUFBSSxDQUFDLENBQUMsQ0FBQzthQUNsQztpQkFBTTtnQkFDTixLQUFLLENBQUMsSUFBSSxDQUFDLElBQUksQ0FBQyxDQUFDO2FBQ2pCO1FBQ0YsQ0FBQyxDQUFDO1FBQ0YsTUFBTSxZQUFZLEdBQUcsQ0FBQyxPQUFlLEVBQUUsR0FBc0IsRUFBRSxFQUFFO1lBQ2hFLFVBQVUsQ0FBQyxHQUFHLENBQUMsUUFBUSxFQUFFO2dCQUN4QixPQUFPLEVBQUUsQ0FBQyxHQUFHLENBQUMsVUFBVSxJQUFJLEVBQUUsQ0FBQyxHQUFHLE9BQU8sR0FBRyxDQUFDLEdBQUcsQ0FBQyxVQUFVLElBQUksRUFBRSxDQUFDO2dCQUNsRSxNQUFNLEVBQUUsR0FBRyxDQUFDLFFBQVEsQ0FBQyxLQUFLO2dCQUMxQixNQUFNLEVBQUUsR0FBRyxDQUFDLFFBQVEsQ0FBQyxNQUFNO2FBQzNCLENBQUMsQ0FBQztRQUNKLENBQUMsQ0FBQztRQUVGLEtBQUssTUFBTSxJQUFJLElBQUksSUFBSSxDQUFDLGlCQUFpQixDQUFDLE1BQU0sRUFBRSxFQUFFO1lBRW5ELElBQUksV0FBVyxDQUFDLElBQUksQ0FBQyxJQUFJLEVBQUUsRUFBRSxDQUFDLFVBQVUsQ0FBQyxjQUFjLENBQUMsRUFBRTtnQkFDekQsU0FBUzthQUNUO1lBRUQsTUFBTSxFQUFFLEtBQUssTUFBTSxDQUFDLElBQUksRUFBRSxJQUFJLENBQUMsSUFBSSxJQUFJLENBQUMsTUFBTSxFQUFFO2dCQUMvQyxJQUFJLENBQUMsU0FBUyxDQUFDLGFBQWEsQ0FBQyxJQUFJLENBQUMsSUFBSSxDQUFDLEVBQUU7b0JBQ3hDLFNBQVMsTUFBTSxDQUFDO2lCQUNoQjtnQkFFRCxvREFBb0Q7Z0JBQ3BELHVEQUF1RDtnQkFDdkQsSUFBSSxNQUFNLEdBQUcsSUFBSSxDQUFDLE1BQU0sQ0FBQztnQkFDekIsT0FBTyxNQUFNLEVBQUU7b0JBQ2QsSUFBSSxNQUFNLENBQUMsTUFBTSxDQUFDLEdBQUcsQ0FBQyxJQUFJLENBQUMsRUFBRSxJQUFJLDZCQUFxQixFQUFFO3dCQUN2RCxTQUFTLE1BQU0sQ0FBQztxQkFDaEI7b0JBQ0QsTUFBTSxHQUFHLE1BQU0sQ0FBQyxNQUFNLENBQUM7aUJBQ3ZCO2dCQUVELE1BQU0sT0FBTyxHQUFHLElBQUksQ0FBQyxlQUFlLENBQUMsSUFBSSxDQUFDLENBQUM7Z0JBQzNDLE1BQU0sU0FBUyxHQUFHLElBQUksQ0FBQyxPQUFPLENBQUMsbUJBQW1CLENBQUMsSUFBSSxDQUFDLFFBQVEsRUFBRSxJQUFJLENBQUMsR0FBRyxFQUFFLEtBQUssRUFBRSxLQUFLLEVBQUUsSUFBSSxDQUFDLElBQUksRUFBRSxDQUFDO2dCQUN0RyxLQUFLLE1BQU0sR0FBRyxJQUFJLFNBQVMsRUFBRTtvQkFDNUIsWUFBWSxDQUFDLE9BQU8sRUFBRSxHQUFHLENBQUMsQ0FBQztpQkFDM0I7YUFDRDtTQUNEO1FBRUQsS0FBSyxNQUFNLElBQUksSUFBSSxJQUFJLENBQUMsNEJBQTRCLENBQUMsTUFBTSxFQUFFLEVBQUU7WUFDOUQsSUFBSSxDQUFDLElBQUksQ0FBQyxZQUFZLENBQUMsSUFBSSxDQUFDLGVBQWUsQ0FBQyxFQUFFO2dCQUM3QyxTQUFTO2FBQ1Q7WUFFRCxNQUFNLE9BQU8sR0FBRyxJQUFJLENBQUMsZUFBZSxDQUFDO1lBQ3JDLEtBQUssTUFBTSxFQUFFLFFBQVEsRUFBRSxNQUFNLEVBQUUsSUFBSSxJQUFJLENBQUMsU0FBUyxFQUFFO2dCQUNsRCxNQUFNLFNBQVMsR0FBRyxJQUFJLENBQUMsT0FBTyxDQUFDLG1CQUFtQixDQUFDLFFBQVEsRUFBRSxNQUFNLEVBQUUsS0FBSyxFQUFFLEtBQUssRUFBRSxJQUFJLENBQUMsSUFBSSxFQUFFLENBQUM7Z0JBQy9GLEtBQUssTUFBTSxHQUFHLElBQUksU0FBUyxFQUFFO29CQUM1QixZQUFZLENBQUMsT0FBTyxFQUFFLEdBQUcsQ0FBQyxDQUFDO2lCQUMzQjthQUNEO1NBQ0Q7UUFFRCxJQUFJLENBQUMsR0FBRyxDQUFDLHlCQUF5QixXQUFXLENBQUMsSUFBSSxRQUFRLENBQUMsQ0FBQztRQUU1RCwwQ0FBMEM7UUFDMUMsTUFBTSxNQUFNLEdBQUcsSUFBSSxHQUFHLEVBQXdCLENBQUM7UUFDL0MsSUFBSSxVQUFVLEdBQUcsQ0FBQyxDQUFDO1FBRW5CLEtBQUssTUFBTSxJQUFJLElBQUksSUFBSSxDQUFDLE9BQU8sQ0FBQyxVQUFVLEVBQUcsQ0FBQyxjQUFjLEVBQUUsRUFBRTtZQUUvRCxNQUFNLEVBQUUsT0FBTyxFQUFFLFVBQVUsRUFBRSxHQUFHLElBQUksQ0FBQyxPQUFPLENBQUMsVUFBVSxFQUFHLENBQUMsa0JBQWtCLEVBQUUsQ0FBQztZQUNoRixNQUFNLFVBQVUsR0FBRyxJQUFJLENBQUMsT0FBTyxDQUFDLElBQUksQ0FBQyxXQUFXLENBQUMsQ0FBQztZQUNsRCxNQUFNLGFBQWEsR0FBRyxPQUFPLElBQUksSUFBQSxtQkFBYSxFQUFDLFVBQVUsSUFBSSxVQUFVLENBQUMsQ0FBQyxRQUFRLEVBQUUsQ0FBQztZQUVwRixjQUFjO1lBQ2QsSUFBSSxTQUF5QyxDQUFDO1lBRTlDLElBQUksV0FBbUIsQ0FBQztZQUN4QixNQUFNLEtBQUssR0FBRyxXQUFXLENBQUMsR0FBRyxDQUFDLElBQUksQ0FBQyxRQUFRLENBQUMsQ0FBQztZQUM3QyxJQUFJLENBQUMsS0FBSyxFQUFFO2dCQUNYLFlBQVk7Z0JBQ1osV0FBVyxHQUFHLElBQUksQ0FBQyxXQUFXLEVBQUUsQ0FBQzthQUVqQztpQkFBTTtnQkFDTix1QkFBdUI7Z0JBQ3ZCLE1BQU0sZ0JBQWdCLEdBQUcsU0FBUyxDQUFDLElBQUksQ0FBQyxRQUFRLENBQUMsVUFBVSxFQUFFLElBQUksQ0FBQyxRQUFRLENBQUMsQ0FBQyxDQUFDO2dCQUM3RSxNQUFNLGNBQWMsR0FBRyxJQUFJLEdBQUcsRUFBcUIsQ0FBQztnQkFFcEQsZ0JBQWdCO2dCQUNoQixLQUFLLENBQUMsSUFBSSxDQUFDLENBQUMsQ0FBQyxFQUFFLENBQUMsRUFBRSxFQUFFLENBQUMsQ0FBQyxDQUFDLE1BQU0sR0FBRyxDQUFDLENBQUMsTUFBTSxDQUFDLENBQUM7Z0JBQzFDLE1BQU0sVUFBVSxHQUFHLElBQUksQ0FBQyxXQUFXLEVBQUUsQ0FBQyxLQUFLLENBQUMsRUFBRSxDQUFDLENBQUM7Z0JBRWhELElBQUksUUFBMEIsQ0FBQztnQkFFL0IsS0FBSyxNQUFNLElBQUksSUFBSSxLQUFLLEVBQUU7b0JBQ3pCLElBQUksUUFBUSxJQUFJLFFBQVEsQ0FBQyxNQUFNLEtBQUssSUFBSSxDQUFDLE1BQU0sRUFBRTt3QkFDaEQsRUFBRTt3QkFDRixJQUFJLFFBQVEsQ0FBQyxNQUFNLEtBQUssSUFBSSxDQUFDLE1BQU0sSUFBSSxRQUFRLENBQUMsT0FBTyxLQUFLLElBQUksQ0FBQyxPQUFPLEVBQUU7NEJBQ3pFLElBQUksQ0FBQyxHQUFHLENBQUMseUJBQXlCLEVBQUUsSUFBSSxDQUFDLFFBQVEsRUFBRSxJQUFJLENBQUMsTUFBTSxFQUFFLEtBQUssQ0FBQyxDQUFDOzRCQUN2RSxNQUFNLElBQUksS0FBSyxDQUFDLGtCQUFrQixDQUFDLENBQUM7eUJBQ3BDOzZCQUFNOzRCQUNOLFNBQVM7eUJBQ1Q7cUJBQ0Q7b0JBQ0QsUUFBUSxHQUFHLElBQUksQ0FBQztvQkFDaEIsTUFBTSxXQUFXLEdBQUcsVUFBVSxDQUFDLE1BQU0sQ0FBQyxJQUFJLENBQUMsTUFBTSxFQUFFLElBQUksQ0FBQyxNQUFNLEVBQUUsSUFBSSxDQUFDLE9BQU8sQ0FBQyxDQUFDLElBQUksQ0FBQyxFQUFFLENBQUMsQ0FBQztvQkFDdkYsVUFBVSxJQUFJLFdBQVcsQ0FBQyxNQUFNLEdBQUcsSUFBSSxDQUFDLE9BQU8sQ0FBQyxNQUFNLENBQUM7b0JBRXZELGNBQWM7b0JBQ2QsTUFBTSxHQUFHLEdBQUcsSUFBSSxDQUFDLDZCQUE2QixDQUFDLElBQUksQ0FBQyxNQUFNLENBQUMsQ0FBQztvQkFHNUQsSUFBSSxRQUFRLEdBQUcsY0FBYyxDQUFDLEdBQUcsQ0FBQyxHQUFHLENBQUMsSUFBSSxDQUFDLENBQUM7b0JBQzVDLElBQUksQ0FBQyxRQUFRLEVBQUU7d0JBQ2QsUUFBUSxHQUFHLEVBQUUsQ0FBQzt3QkFDZCxjQUFjLENBQUMsR0FBRyxDQUFDLEdBQUcsQ0FBQyxJQUFJLEVBQUUsUUFBUSxDQUFDLENBQUM7cUJBQ3ZDO29CQUNELFFBQVEsQ0FBQyxPQUFPLENBQUM7d0JBQ2hCLE1BQU0sRUFBRSxnQkFBZ0I7d0JBQ3hCLFFBQVEsRUFBRSxFQUFFLElBQUksRUFBRSxHQUFHLENBQUMsSUFBSSxHQUFHLENBQUMsRUFBRSxNQUFNLEVBQUUsR0FBRyxDQUFDLFNBQVMsRUFBRTt3QkFDdkQsU0FBUyxFQUFFLEVBQUUsSUFBSSxFQUFFLEdBQUcsQ0FBQyxJQUFJLEdBQUcsQ0FBQyxFQUFFLE1BQU0sRUFBRSxHQUFHLENBQUMsU0FBUyxFQUFFO3dCQUN4RCxJQUFJLEVBQUUsV0FBVztxQkFDakIsRUFBRTt3QkFDRixNQUFNLEVBQUUsZ0JBQWdCO3dCQUN4QixRQUFRLEVBQUUsRUFBRSxJQUFJLEVBQUUsR0FBRyxDQUFDLElBQUksR0FBRyxDQUFDLEVBQUUsTUFBTSxFQUFFLEdBQUcsQ0FBQyxTQUFTLEdBQUcsSUFBSSxDQUFDLE1BQU0sRUFBRTt3QkFDckUsU0FBUyxFQUFFLEVBQUUsSUFBSSxFQUFFLEdBQUcsQ0FBQyxJQUFJLEdBQUcsQ0FBQyxFQUFFLE1BQU0sRUFBRSxHQUFHLENBQUMsU0FBUyxHQUFHLElBQUksQ0FBQyxPQUFPLENBQUMsTUFBTSxFQUFFO3FCQUM5RSxDQUFDLENBQUM7aUJBQ0g7Z0JBRUQsb0VBQW9FO2dCQUNwRSxTQUFTLEdBQUcsSUFBSSwrQkFBa0IsQ0FBQyxFQUFFLElBQUksRUFBRSxJQUFJLENBQUMsUUFBUSxDQUFDLElBQUksQ0FBQyxRQUFRLENBQUMsRUFBRSxVQUFVLEVBQUUsYUFBYSxFQUFFLENBQUMsQ0FBQztnQkFDdEcsU0FBUyxDQUFDLGdCQUFnQixDQUFDLGdCQUFnQixFQUFFLElBQUksQ0FBQyxXQUFXLEVBQUUsQ0FBQyxDQUFDO2dCQUNqRSxLQUFLLE1BQU0sQ0FBQyxFQUFFLFFBQVEsQ0FBQyxJQUFJLGNBQWMsRUFBRTtvQkFDMUMsSUFBSSxTQUFTLEdBQUcsQ0FBQyxDQUFDO29CQUNsQixLQUFLLE1BQU0sT0FBTyxJQUFJLFFBQVEsRUFBRTt3QkFDL0IsU0FBUyxDQUFDLFVBQVUsQ0FBQzs0QkFDcEIsR0FBRyxPQUFPOzRCQUNWLFNBQVMsRUFBRSxFQUFFLElBQUksRUFBRSxPQUFPLENBQUMsU0FBUyxDQUFDLElBQUksRUFBRSxNQUFNLEVBQUUsT0FBTyxDQUFDLFNBQVMsQ0FBQyxNQUFNLEdBQUcsU0FBUyxFQUFFO3lCQUN6RixDQUFDLENBQUM7d0JBQ0gsU0FBUyxJQUFJLE9BQU8sQ0FBQyxRQUFRLENBQUMsTUFBTSxHQUFHLE9BQU8sQ0FBQyxTQUFTLENBQUMsTUFBTSxDQUFDO3FCQUNoRTtpQkFDRDtnQkFFRCxXQUFXLEdBQUcsVUFBVSxDQUFDLElBQUksQ0FBQyxFQUFFLENBQUMsQ0FBQzthQUNsQztZQUNELE1BQU0sQ0FBQyxHQUFHLENBQUMsSUFBSSxDQUFDLFFBQVEsRUFBRSxFQUFFLEdBQUcsRUFBRSxXQUFXLEVBQUUsU0FBUyxFQUFFLFNBQVMsRUFBRSxRQUFRLEVBQUUsRUFBRSxDQUFDLENBQUM7U0FDbEY7UUFFRCxJQUFJLENBQUMsR0FBRyxDQUFDLFNBQVMsVUFBVSxHQUFHLElBQUksVUFBVSxDQUFDLENBQUM7UUFDL0MsT0FBTyxNQUFNLENBQUM7SUFDZixDQUFDO0NBQ0Q7QUFuU0QsMEJBbVNDO0FBRUQsZ0JBQWdCO0FBRWhCLFNBQVMsV0FBVyxDQUFDLElBQWEsRUFBRSxJQUFtQjtJQUN0RCxNQUFNLFNBQVMsR0FBRyxFQUFFLENBQUMsZ0JBQWdCLENBQUMsSUFBSSxDQUFDLENBQUMsQ0FBQyxDQUFDLEVBQUUsQ0FBQyxZQUFZLENBQUMsSUFBSSxDQUFDLENBQUMsQ0FBQyxDQUFDLFNBQVMsQ0FBQztJQUNoRixPQUFPLE9BQU8sQ0FBQyxTQUFTLEVBQUUsSUFBSSxDQUFDLElBQUksQ0FBQyxFQUFFLENBQUMsSUFBSSxDQUFDLElBQUksS0FBSyxJQUFJLENBQUMsQ0FBQyxDQUFDO0FBQzdELENBQUM7QUFFRCxTQUFTLFNBQVMsQ0FBQyxJQUFZO0lBQzlCLE9BQU8sSUFBSSxDQUFDLE9BQU8sQ0FBQyxLQUFLLEVBQUUsR0FBRyxDQUFDLENBQUM7QUFDakMsQ0FBQztBQUVELEtBQUssVUFBVSxJQUFJO0lBRWxCLE1BQU0sV0FBVyxHQUFHLElBQUksQ0FBQyxJQUFJLENBQUMsU0FBUyxFQUFFLHlCQUF5QixDQUFDLENBQUM7SUFDcEUsTUFBTSxXQUFXLEdBQUcsSUFBSSxDQUFDLE9BQU8sQ0FBQyxXQUFXLENBQUMsQ0FBQztJQUM5QyxNQUFNLGNBQWMsR0FBRyxJQUFJLENBQUMsSUFBSSxDQUFDLElBQUksQ0FBQyxPQUFPLENBQUMsV0FBVyxDQUFDLEVBQUUsSUFBSSxDQUFDLFFBQVEsQ0FBQyxXQUFXLENBQUMsR0FBRyxHQUFHLENBQUMsQ0FBQztJQUU5RixFQUFFLENBQUMsTUFBTSxDQUFDLFdBQVcsRUFBRSxjQUFjLEVBQUUsRUFBRSxTQUFTLEVBQUUsSUFBSSxFQUFFLENBQUMsQ0FBQztJQUU1RCxJQUFJLEtBQUssRUFBRSxNQUFNLENBQUMsUUFBUSxFQUFFLFFBQVEsQ0FBQyxJQUFJLElBQUksT0FBTyxDQUFDLFdBQVcsRUFBRSxPQUFPLENBQUMsR0FBRyxDQUFDLENBQUMsc0JBQXNCLENBQUMsSUFBSSxHQUFHLENBQUMsQ0FBQyxXQUFXLENBQUMsQ0FBQyxDQUFDLEVBQUU7UUFDOUgsTUFBTSxXQUFXLEdBQUcsSUFBSSxDQUFDLElBQUksQ0FBQyxjQUFjLEVBQUUsSUFBSSxDQUFDLFFBQVEsQ0FBQyxXQUFXLEVBQUUsUUFBUSxDQUFDLENBQUMsQ0FBQztRQUNwRixNQUFNLEVBQUUsQ0FBQyxRQUFRLENBQUMsS0FBSyxDQUFDLElBQUksQ0FBQyxPQUFPLENBQUMsV0FBVyxDQUFDLEVBQUUsRUFBRSxTQUFTLEVBQUUsSUFBSSxFQUFFLENBQUMsQ0FBQztRQUN4RSxNQUFNLEVBQUUsQ0FBQyxRQUFRLENBQUMsU0FBUyxDQUFDLFdBQVcsRUFBRSxRQUFRLENBQUMsR0FBRyxDQUFDLENBQUM7UUFDdkQsSUFBSSxRQUFRLENBQUMsU0FBUyxFQUFFO1lBQ3ZCLE1BQU0sRUFBRSxDQUFDLFFBQVEsQ0FBQyxTQUFTLENBQUMsV0FBVyxHQUFHLE1BQU0sRUFBRSxRQUFRLENBQUMsU0FBUyxDQUFDLENBQUM7U0FDdEU7S0FDRDtBQUNGLENBQUM7QUFFRCxJQUFJLFVBQVUsS0FBSyxjQUFJLENBQUMsQ0FBQyxDQUFDLEVBQUU7SUFDM0IsSUFBSSxFQUFFLENBQUM7Q0FDUCJ9 \ No newline at end of file diff --git a/build/lib/monaco-api.js b/build/lib/monaco-api.js index 6512b6ae88686..2052806c46bc7 100644 --- a/build/lib/monaco-api.js +++ b/build/lib/monaco-api.js @@ -4,7 +4,9 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ Object.defineProperty(exports, "__esModule", { value: true }); -exports.execute = exports.run3 = exports.DeclarationResolver = exports.FSProvider = exports.RECIPE_PATH = void 0; +exports.DeclarationResolver = exports.FSProvider = exports.RECIPE_PATH = void 0; +exports.run3 = run3; +exports.execute = execute; const fs = require("fs"); const path = require("path"); const fancyLog = require("fancy-log"); @@ -559,7 +561,6 @@ function run3(resolver) { const sourceFileGetter = (moduleId) => resolver.getDeclarationSourceFile(moduleId); return _run(resolver.ts, sourceFileGetter); } -exports.run3 = run3; class TypeScriptLanguageServiceHost { _ts; _libs; @@ -623,5 +624,4 @@ function execute() { } return r; } -exports.execute = execute; //# sourceMappingURL=monaco-api.js.map \ No newline at end of file diff --git a/build/lib/nls.js b/build/lib/nls.js index 982f74bcf4da9..48ca84f243317 100644 --- a/build/lib/nls.js +++ b/build/lib/nls.js @@ -4,7 +4,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ Object.defineProperty(exports, "__esModule", { value: true }); -exports.nls = void 0; +exports.nls = nls; const lazy = require("lazy.js"); const event_stream_1 = require("event-stream"); const File = require("vinyl"); @@ -74,7 +74,6 @@ function nls() { })); return (0, event_stream_1.duplex)(input, output); } -exports.nls = nls; function isImportNode(ts, node) { return node.kind === ts.SyntaxKind.ImportDeclaration || node.kind === ts.SyntaxKind.ImportEqualsDeclaration; } diff --git a/build/lib/optimize.js b/build/lib/optimize.js index 9dff0859acced..d48235ebf15a1 100644 --- a/build/lib/optimize.js +++ b/build/lib/optimize.js @@ -4,7 +4,10 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ Object.defineProperty(exports, "__esModule", { value: true }); -exports.minifyTask = exports.optimizeTask = exports.optimizeLoaderTask = exports.loaderConfig = void 0; +exports.loaderConfig = loaderConfig; +exports.optimizeLoaderTask = optimizeLoaderTask; +exports.optimizeTask = optimizeTask; +exports.minifyTask = minifyTask; const es = require("event-stream"); const gulp = require("gulp"); const concat = require("gulp-concat"); @@ -18,6 +21,7 @@ const bundle = require("./bundle"); const i18n_1 = require("./i18n"); const stats_1 = require("./stats"); const util = require("./util"); +const postcss_1 = require("./postcss"); const REPO_ROOT_PATH = path.join(__dirname, '../..'); function log(prefix, message) { fancyLog(ansiColors.cyan('[' + prefix + ']'), message); @@ -33,7 +37,6 @@ function loaderConfig() { result['vs/css'] = { inlineResources: true }; return result; } -exports.loaderConfig = loaderConfig; const IS_OUR_COPYRIGHT_REGEXP = /Copyright \(C\) Microsoft Corporation/i; function loaderPlugin(src, base, amdModuleId) { return (gulp @@ -223,7 +226,6 @@ function optimizeManualTask(options) { function optimizeLoaderTask(src, out, bundleLoader, bundledFileHeader = '', externalLoaderInfo) { return () => loader(src, bundledFileHeader, bundleLoader, externalLoaderInfo).pipe(gulp.dest(out)); } -exports.optimizeLoaderTask = optimizeLoaderTask; function optimizeTask(opts) { return function () { const optimizers = [optimizeAMDTask(opts.amd)]; @@ -236,13 +238,11 @@ function optimizeTask(opts) { return es.merge(...optimizers).pipe(gulp.dest(opts.out)); }; } -exports.optimizeTask = optimizeTask; function minifyTask(src, sourceMapBaseUrl) { const esbuild = require('esbuild'); const sourceMappingURL = sourceMapBaseUrl ? ((f) => `${sourceMapBaseUrl}/${f.relative}.map`) : undefined; return cb => { const cssnano = require('cssnano'); - const postcss = require('gulp-postcss'); const sourcemaps = require('gulp-sourcemaps'); const svgmin = require('gulp-svgmin'); const jsFilter = filter('**/*.js', { restore: true }); @@ -271,7 +271,7 @@ function minifyTask(src, sourceMapBaseUrl) { cb(undefined, f); } }, cb); - }), jsFilter.restore, cssFilter, postcss([cssnano({ preset: 'default' })]), cssFilter.restore, svgFilter, svgmin(), svgFilter.restore, sourcemaps.mapSources((sourcePath) => { + }), jsFilter.restore, cssFilter, (0, postcss_1.gulpPostcss)([cssnano({ preset: 'default' })]), cssFilter.restore, svgFilter, svgmin(), svgFilter.restore, sourcemaps.mapSources((sourcePath) => { if (sourcePath === 'bootstrap-fork.js') { return 'bootstrap-fork.orig.js'; } @@ -284,5 +284,4 @@ function minifyTask(src, sourceMapBaseUrl) { }), gulp.dest(src + '-min'), (err) => cb(err)); }; } -exports.minifyTask = minifyTask; //# sourceMappingURL=optimize.js.map \ No newline at end of file diff --git a/build/lib/optimize.ts b/build/lib/optimize.ts index aebe22a7e0a43..5b6dee9bf6555 100644 --- a/build/lib/optimize.ts +++ b/build/lib/optimize.ts @@ -16,6 +16,7 @@ import * as bundle from './bundle'; import { Language, processNlsFiles } from './i18n'; import { createStatsStream } from './stats'; import * as util from './util'; +import { gulpPostcss } from './postcss'; const REPO_ROOT_PATH = path.join(__dirname, '../..'); @@ -381,7 +382,6 @@ export function minifyTask(src: string, sourceMapBaseUrl?: string): (cb: any) => return cb => { const cssnano = require('cssnano') as typeof import('cssnano'); - const postcss = require('gulp-postcss') as typeof import('gulp-postcss'); const sourcemaps = require('gulp-sourcemaps') as typeof import('gulp-sourcemaps'); const svgmin = require('gulp-svgmin') as typeof import('gulp-svgmin'); @@ -420,7 +420,7 @@ export function minifyTask(src: string, sourceMapBaseUrl?: string): (cb: any) => }), jsFilter.restore, cssFilter, - postcss([cssnano({ preset: 'default' })]), + gulpPostcss([cssnano({ preset: 'default' })]), cssFilter.restore, svgFilter, svgmin(), diff --git a/build/lib/postcss.js b/build/lib/postcss.js new file mode 100644 index 0000000000000..356015ab1596d --- /dev/null +++ b/build/lib/postcss.js @@ -0,0 +1,36 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.gulpPostcss = gulpPostcss; +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +const postcss = require("postcss"); +const es = require("event-stream"); +function gulpPostcss(plugins, handleError) { + const instance = postcss(plugins); + return es.map((file, callback) => { + if (file.isNull()) { + return callback(null, file); + } + if (file.isStream()) { + return callback(new Error('Streaming not supported')); + } + instance + .process(file.contents.toString(), { from: file.path }) + .then((result) => { + file.contents = Buffer.from(result.css); + callback(null, file); + }) + .catch((error) => { + if (handleError) { + handleError(error); + callback(); + } + else { + callback(error); + } + }); + }); +} +//# sourceMappingURL=postcss.js.map \ No newline at end of file diff --git a/build/lib/postcss.ts b/build/lib/postcss.ts new file mode 100644 index 0000000000000..cf3121e221e72 --- /dev/null +++ b/build/lib/postcss.ts @@ -0,0 +1,36 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +import * as postcss from 'postcss'; +import * as File from 'vinyl'; +import * as es from 'event-stream'; + +export function gulpPostcss(plugins: postcss.AcceptedPlugin[], handleError?: (err: Error) => void) { + const instance = postcss(plugins); + + return es.map((file: File, callback: (error?: any, file?: any) => void) => { + if (file.isNull()) { + return callback(null, file); + } + + if (file.isStream()) { + return callback(new Error('Streaming not supported')); + } + + instance + .process(file.contents.toString(), { from: file.path }) + .then((result) => { + file.contents = Buffer.from(result.css); + callback(null, file); + }) + .catch((error) => { + if (handleError) { + handleError(error); + callback(); + } else { + callback(error); + } + }); + }); +} diff --git a/build/lib/reporter.js b/build/lib/reporter.js index 305d736428741..9d4a1b4fd7966 100644 --- a/build/lib/reporter.js +++ b/build/lib/reporter.js @@ -4,7 +4,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ Object.defineProperty(exports, "__esModule", { value: true }); -exports.createReporter = void 0; +exports.createReporter = createReporter; const es = require("event-stream"); const fancyLog = require("fancy-log"); const ansiColors = require("ansi-colors"); @@ -99,5 +99,4 @@ function createReporter(id) { }; return result; } -exports.createReporter = createReporter; //# sourceMappingURL=reporter.js.map \ No newline at end of file diff --git a/build/lib/standalone.js b/build/lib/standalone.js index 4ddf88ed22382..dbc47db0833fe 100644 --- a/build/lib/standalone.js +++ b/build/lib/standalone.js @@ -4,7 +4,8 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ Object.defineProperty(exports, "__esModule", { value: true }); -exports.createESMSourcesAndResources2 = exports.extractEditor = void 0; +exports.extractEditor = extractEditor; +exports.createESMSourcesAndResources2 = createESMSourcesAndResources2; const fs = require("fs"); const path = require("path"); const tss = require("./treeshaking"); @@ -111,7 +112,6 @@ function extractEditor(options) { 'vs/nls.mock.ts', ].forEach(copyFile); } -exports.extractEditor = extractEditor; function createESMSourcesAndResources2(options) { const ts = require('typescript'); const SRC_FOLDER = path.join(REPO_ROOT, options.srcFolder); @@ -251,7 +251,6 @@ function createESMSourcesAndResources2(options) { } } } -exports.createESMSourcesAndResources2 = createESMSourcesAndResources2; function transportCSS(module, enqueue, write) { if (!/\.css/.test(module)) { return false; diff --git a/build/lib/stats.js b/build/lib/stats.js index d923bb809dafa..e089cb0c1b449 100644 --- a/build/lib/stats.js +++ b/build/lib/stats.js @@ -4,7 +4,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ Object.defineProperty(exports, "__esModule", { value: true }); -exports.createStatsStream = void 0; +exports.createStatsStream = createStatsStream; const es = require("event-stream"); const fancyLog = require("fancy-log"); const ansiColors = require("ansi-colors"); @@ -73,5 +73,4 @@ function createStatsStream(group, log) { this.emit('end'); }); } -exports.createStatsStream = createStatsStream; //# sourceMappingURL=stats.js.map \ No newline at end of file diff --git a/build/lib/stylelint/validateVariableNames.js b/build/lib/stylelint/validateVariableNames.js index 2367fb94c2e4d..57b2aad957f8c 100644 --- a/build/lib/stylelint/validateVariableNames.js +++ b/build/lib/stylelint/validateVariableNames.js @@ -4,7 +4,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ Object.defineProperty(exports, "__esModule", { value: true }); -exports.getVariableNameValidator = void 0; +exports.getVariableNameValidator = getVariableNameValidator; const fs_1 = require("fs"); const path = require("path"); const RE_VAR_PROP = /var\(\s*(--([\w\-\.]+))/g; @@ -30,5 +30,4 @@ function getVariableNameValidator() { } }; } -exports.getVariableNameValidator = getVariableNameValidator; //# sourceMappingURL=validateVariableNames.js.map \ No newline at end of file diff --git a/build/lib/stylelint/vscode-known-variables.json b/build/lib/stylelint/vscode-known-variables.json index f6d81111246c5..497957a39c5ed 100644 --- a/build/lib/stylelint/vscode-known-variables.json +++ b/build/lib/stylelint/vscode-known-variables.json @@ -11,7 +11,9 @@ "--vscode-activityBar-inactiveForeground", "--vscode-activityBarBadge-background", "--vscode-activityBarBadge-foreground", + "--vscode-activityBarTop-activeBackground", "--vscode-activityBarTop-activeBorder", + "--vscode-activityBarTop-background", "--vscode-activityBarTop-dropBorder", "--vscode-activityBarTop-foreground", "--vscode-activityBarTop-inactiveForeground", @@ -43,10 +45,10 @@ "--vscode-charts-yellow", "--vscode-chat-avatarBackground", "--vscode-chat-avatarForeground", + "--vscode-chat-requestBackground", "--vscode-chat-requestBorder", "--vscode-chat-slashCommandBackground", "--vscode-chat-slashCommandForeground", - "--vscode-chat-list-background", "--vscode-checkbox-background", "--vscode-checkbox-border", "--vscode-checkbox-foreground", @@ -259,6 +261,10 @@ "--vscode-editorMarkerNavigationInfo-headerBackground", "--vscode-editorMarkerNavigationWarning-background", "--vscode-editorMarkerNavigationWarning-headerBackground", + "--vscode-editorMultiCursor-primary-background", + "--vscode-editorMultiCursor-primary-foreground", + "--vscode-editorMultiCursor-secondary-background", + "--vscode-editorMultiCursor-secondary-foreground", "--vscode-editorOverviewRuler-addedForeground", "--vscode-editorOverviewRuler-background", "--vscode-editorOverviewRuler-border", @@ -485,6 +491,9 @@ "--vscode-panelTitle-activeBorder", "--vscode-panelTitle-activeForeground", "--vscode-panelTitle-inactiveForeground", + "--vscode-panelStickyScroll-background", + "--vscode-panelStickyScroll-border", + "--vscode-panelStickyScroll-shadow", "--vscode-peekView-border", "--vscode-peekViewEditor-background", "--vscode-peekViewEditor-matchHighlightBackground", @@ -555,10 +564,15 @@ "--vscode-sideBar-border", "--vscode-sideBar-dropBackground", "--vscode-sideBar-foreground", + "--vscode-sideBarActivityBarTop-border", "--vscode-sideBarSectionHeader-background", "--vscode-sideBarSectionHeader-border", "--vscode-sideBarSectionHeader-foreground", + "--vscode-sideBarTitle-background", "--vscode-sideBarTitle-foreground", + "--vscode-sideBarStickyScroll-background", + "--vscode-sideBarStickyScroll-border", + "--vscode-sideBarStickyScroll-shadow", "--vscode-sideBySideEditor-horizontalBorder", "--vscode-sideBySideEditor-verticalBorder", "--vscode-simpleFindWidget-sashBorder", @@ -699,11 +713,17 @@ "--vscode-testing-coveredBorder", "--vscode-testing-coveredGutterBackground", "--vscode-testing-iconErrored", + "--vscode-testing-iconErrored-retired", "--vscode-testing-iconFailed", + "--vscode-testing-iconFailed-retired", "--vscode-testing-iconPassed", + "--vscode-testing-iconPassed-retired", "--vscode-testing-iconQueued", + "--vscode-testing-iconQueued-retired", "--vscode-testing-iconSkipped", + "--vscode-testing-iconSkipped-retired", "--vscode-testing-iconUnset", + "--vscode-testing-iconUnset-retired", "--vscode-testing-message-error-decorationForeground", "--vscode-testing-message-error-lineBackground", "--vscode-testing-message-info-decorationForeground", @@ -748,8 +768,7 @@ "--vscode-widget-border", "--vscode-widget-shadow", "--vscode-window-activeBorder", - "--vscode-window-inactiveBorder", - "--vscode-multiDiffEditor-headerBackground" + "--vscode-window-inactiveBorder" ], "others": [ "--background-dark", @@ -781,6 +800,7 @@ "--testMessageDecorationFontFamily", "--testMessageDecorationFontSize", "--title-border-bottom-color", + "--vscode-chat-list-background", "--vscode-editorCodeLens-fontFamily", "--vscode-editorCodeLens-fontFamilyDefault", "--vscode-editorCodeLens-fontFeatureSettings", @@ -790,10 +810,10 @@ "--vscode-hover-maxWidth", "--vscode-hover-sourceWhiteSpace", "--vscode-hover-whiteSpace", - "--vscode-inline-chat-cropped", - "--vscode-inline-chat-expanded", "--vscode-inline-chat-quick-voice-height", "--vscode-inline-chat-quick-voice-width", + "--vscode-editor-dictation-widget-height", + "--vscode-editor-dictation-widget-width", "--vscode-interactive-session-foreground", "--vscode-interactive-result-editor-background-color", "--vscode-repl-font-family", @@ -802,6 +822,7 @@ "--vscode-repl-line-height", "--vscode-sash-hover-size", "--vscode-sash-size", + "--vscode-testing-coverage-lineHeight", "--vscode-editorStickyScroll-scrollableWidth", "--vscode-editorStickyScroll-foldingOpacityTransition", "--window-border-color", diff --git a/build/lib/task.js b/build/lib/task.js index 6b040a756982d..597b2a0d39761 100644 --- a/build/lib/task.js +++ b/build/lib/task.js @@ -4,7 +4,9 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ Object.defineProperty(exports, "__esModule", { value: true }); -exports.define = exports.parallel = exports.series = void 0; +exports.series = series; +exports.parallel = parallel; +exports.define = define; const fancyLog = require("fancy-log"); const ansiColors = require("ansi-colors"); function _isPromise(p) { @@ -67,7 +69,6 @@ function series(...tasks) { result._tasks = tasks; return result; } -exports.series = series; function parallel(...tasks) { const result = async () => { await Promise.all(tasks.map(t => _execute(t))); @@ -75,7 +76,6 @@ function parallel(...tasks) { result._tasks = tasks; return result; } -exports.parallel = parallel; function define(name, task) { if (task._tasks) { // This is a composite task @@ -94,5 +94,4 @@ function define(name, task) { task.displayName = name; return task; } -exports.define = define; //# sourceMappingURL=task.js.map \ No newline at end of file diff --git a/build/lib/treeshaking.js b/build/lib/treeshaking.js index 51c610ecda263..c8e95511877f8 100644 --- a/build/lib/treeshaking.js +++ b/build/lib/treeshaking.js @@ -4,7 +4,9 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ Object.defineProperty(exports, "__esModule", { value: true }); -exports.shake = exports.toStringShakeLevel = exports.ShakeLevel = void 0; +exports.ShakeLevel = void 0; +exports.toStringShakeLevel = toStringShakeLevel; +exports.shake = shake; const fs = require("fs"); const path = require("path"); const TYPESCRIPT_LIB_FOLDER = path.dirname(require.resolve('typescript/lib/lib.d.ts')); @@ -24,7 +26,6 @@ function toStringShakeLevel(shakeLevel) { return 'ClassMembers (2)'; } } -exports.toStringShakeLevel = toStringShakeLevel; function printDiagnostics(options, diagnostics) { for (const diag of diagnostics) { let result = ''; @@ -61,7 +62,6 @@ function shake(options) { markNodes(ts, languageService, options); return generateResult(ts, languageService, options.shakeLevel); } -exports.shake = shake; //#region Discovery, LanguageService & Setup function createTypeScriptLanguageService(ts, options) { // Discover referenced files diff --git a/build/lib/tsb/builder.js b/build/lib/tsb/builder.js index e87945ea9cc43..fc74bfa8acc63 100644 --- a/build/lib/tsb/builder.js +++ b/build/lib/tsb/builder.js @@ -4,7 +4,8 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ Object.defineProperty(exports, "__esModule", { value: true }); -exports.createTypeScriptBuilder = exports.CancellationToken = void 0; +exports.CancellationToken = void 0; +exports.createTypeScriptBuilder = createTypeScriptBuilder; const fs = require("fs"); const path = require("path"); const crypto = require("crypto"); @@ -364,7 +365,6 @@ function createTypeScriptBuilder(config, projectFile, cmd) { languageService: service }; } -exports.createTypeScriptBuilder = createTypeScriptBuilder; class ScriptSnapshot { _text; _mtime; diff --git a/build/lib/tsb/index.js b/build/lib/tsb/index.js index 47f26bc8178d3..8b8116d5a4958 100644 --- a/build/lib/tsb/index.js +++ b/build/lib/tsb/index.js @@ -4,7 +4,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ Object.defineProperty(exports, "__esModule", { value: true }); -exports.create = void 0; +exports.create = create; const Vinyl = require("vinyl"); const through = require("through"); const builder = require("./builder"); @@ -132,5 +132,4 @@ function create(projectPath, existingOptions, config, onError = _defaultOnError) }; return result; } -exports.create = create; //# sourceMappingURL=index.js.map \ No newline at end of file diff --git a/build/lib/util.js b/build/lib/util.js index 388ef5df94893..ed52776c2c0d1 100644 --- a/build/lib/util.js +++ b/build/lib/util.js @@ -4,7 +4,29 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ Object.defineProperty(exports, "__esModule", { value: true }); -exports.buildWebNodePaths = exports.createExternalLoaderConfig = exports.acquireWebNodePaths = exports.getElectronVersion = exports.streamToPromise = exports.versionStringToNumber = exports.filter = exports.rebase = exports.ensureDir = exports.rreddir = exports.rimraf = exports.rewriteSourceMappingURL = exports.appendOwnPathSourceURL = exports.$if = exports.stripSourceMappingURL = exports.loadSourcemaps = exports.cleanNodeModules = exports.skipDirectories = exports.toFileUri = exports.setExecutableBit = exports.fixWin32DirectoryPermissions = exports.debounce = exports.incremental = void 0; +exports.incremental = incremental; +exports.debounce = debounce; +exports.fixWin32DirectoryPermissions = fixWin32DirectoryPermissions; +exports.setExecutableBit = setExecutableBit; +exports.toFileUri = toFileUri; +exports.skipDirectories = skipDirectories; +exports.cleanNodeModules = cleanNodeModules; +exports.loadSourcemaps = loadSourcemaps; +exports.stripSourceMappingURL = stripSourceMappingURL; +exports.$if = $if; +exports.appendOwnPathSourceURL = appendOwnPathSourceURL; +exports.rewriteSourceMappingURL = rewriteSourceMappingURL; +exports.rimraf = rimraf; +exports.rreddir = rreddir; +exports.ensureDir = ensureDir; +exports.rebase = rebase; +exports.filter = filter; +exports.versionStringToNumber = versionStringToNumber; +exports.streamToPromise = streamToPromise; +exports.getElectronVersion = getElectronVersion; +exports.acquireWebNodePaths = acquireWebNodePaths; +exports.createExternalLoaderConfig = createExternalLoaderConfig; +exports.buildWebNodePaths = buildWebNodePaths; const es = require("event-stream"); const _debounce = require("debounce"); const _filter = require("gulp-filter"); @@ -54,7 +76,6 @@ function incremental(streamProvider, initial, supportsCancellation) { }); return es.duplex(input, output); } -exports.incremental = incremental; function debounce(task, duration = 500) { const input = es.through(); const output = es.through(); @@ -83,7 +104,6 @@ function debounce(task, duration = 500) { }); return es.duplex(input, output); } -exports.debounce = debounce; function fixWin32DirectoryPermissions() { if (!/win32/.test(process.platform)) { return es.through(); @@ -95,7 +115,6 @@ function fixWin32DirectoryPermissions() { return f; }); } -exports.fixWin32DirectoryPermissions = fixWin32DirectoryPermissions; function setExecutableBit(pattern) { const setBit = es.mapSync(f => { if (!f.stat) { @@ -115,7 +134,6 @@ function setExecutableBit(pattern) { .pipe(filter.restore); return es.duplex(input, output); } -exports.setExecutableBit = setExecutableBit; function toFileUri(filePath) { const match = filePath.match(/^([a-z])\:(.*)$/i); if (match) { @@ -123,7 +141,6 @@ function toFileUri(filePath) { } return 'file://' + filePath.replace(/\\/g, '/'); } -exports.toFileUri = toFileUri; function skipDirectories() { return es.mapSync(f => { if (!f.isDirectory()) { @@ -131,7 +148,6 @@ function skipDirectories() { } }); } -exports.skipDirectories = skipDirectories; function cleanNodeModules(rulePath) { const rules = fs.readFileSync(rulePath, 'utf8') .split(/\r?\n/g) @@ -143,7 +159,6 @@ function cleanNodeModules(rulePath) { const output = es.merge(input.pipe(_filter(['**', ...excludes])), input.pipe(_filter(includes))); return es.duplex(input, output); } -exports.cleanNodeModules = cleanNodeModules; function loadSourcemaps() { const input = es.through(); const output = input @@ -185,7 +200,6 @@ function loadSourcemaps() { })); return es.duplex(input, output); } -exports.loadSourcemaps = loadSourcemaps; function stripSourceMappingURL() { const input = es.through(); const output = input @@ -196,7 +210,6 @@ function stripSourceMappingURL() { })); return es.duplex(input, output); } -exports.stripSourceMappingURL = stripSourceMappingURL; /** Splits items in the stream based on the predicate, sending them to onTrue if true, or onFalse otherwise */ function $if(test, onTrue, onFalse = es.through()) { if (typeof test === 'boolean') { @@ -204,7 +217,6 @@ function $if(test, onTrue, onFalse = es.through()) { } return ternaryStream(test, onTrue, onFalse); } -exports.$if = $if; /** Operator that appends the js files' original path a sourceURL, so debug locations map */ function appendOwnPathSourceURL() { const input = es.through(); @@ -218,7 +230,6 @@ function appendOwnPathSourceURL() { })); return es.duplex(input, output); } -exports.appendOwnPathSourceURL = appendOwnPathSourceURL; function rewriteSourceMappingURL(sourceMappingURLBase) { const input = es.through(); const output = input @@ -230,7 +241,6 @@ function rewriteSourceMappingURL(sourceMappingURLBase) { })); return es.duplex(input, output); } -exports.rewriteSourceMappingURL = rewriteSourceMappingURL; function rimraf(dir) { const result = () => new Promise((c, e) => { let retries = 0; @@ -250,7 +260,6 @@ function rimraf(dir) { result.taskName = `clean-${path.basename(dir).toLowerCase()}`; return result; } -exports.rimraf = rimraf; function _rreaddir(dirPath, prepend, result) { const entries = fs.readdirSync(dirPath, { withFileTypes: true }); for (const entry of entries) { @@ -267,7 +276,6 @@ function rreddir(dirPath) { _rreaddir(dirPath, '', result); return result; } -exports.rreddir = rreddir; function ensureDir(dirPath) { if (fs.existsSync(dirPath)) { return; @@ -275,14 +283,12 @@ function ensureDir(dirPath) { ensureDir(path.dirname(dirPath)); fs.mkdirSync(dirPath); } -exports.ensureDir = ensureDir; function rebase(count) { return rename(f => { const parts = f.dirname ? f.dirname.split(/[\/\\]/) : []; f.dirname = parts.slice(count).join(path.sep); }); } -exports.rebase = rebase; function filter(fn) { const result = es.through(function (data) { if (fn(data)) { @@ -295,7 +301,6 @@ function filter(fn) { result.restore = es.through(); return result; } -exports.filter = filter; function versionStringToNumber(versionStr) { const semverRegex = /(\d+)\.(\d+)\.(\d+)/; const match = versionStr.match(semverRegex); @@ -304,21 +309,18 @@ function versionStringToNumber(versionStr) { } return parseInt(match[1], 10) * 1e4 + parseInt(match[2], 10) * 1e2 + parseInt(match[3], 10); } -exports.versionStringToNumber = versionStringToNumber; function streamToPromise(stream) { return new Promise((c, e) => { stream.on('error', err => e(err)); stream.on('end', () => c()); }); } -exports.streamToPromise = streamToPromise; function getElectronVersion() { const yarnrc = fs.readFileSync(path.join(root, '.yarnrc'), 'utf8'); const electronVersion = /^target "(.*)"$/m.exec(yarnrc)[1]; const msBuildId = /^ms_build_id "(.*)"$/m.exec(yarnrc)[1]; return { electronVersion, msBuildId }; } -exports.getElectronVersion = getElectronVersion; function acquireWebNodePaths() { const root = path.join(__dirname, '..', '..'); const webPackageJSON = path.join(root, '/remote/web', 'package.json'); @@ -367,7 +369,6 @@ function acquireWebNodePaths() { nodePaths['@microsoft/applicationinsights-core-js'] = 'browser/applicationinsights-core-js.min.js'; return nodePaths; } -exports.acquireWebNodePaths = acquireWebNodePaths; function createExternalLoaderConfig(webEndpoint, commit, quality) { if (!webEndpoint || !commit || !quality) { return undefined; @@ -384,7 +385,6 @@ function createExternalLoaderConfig(webEndpoint, commit, quality) { }; return externalLoaderConfig; } -exports.createExternalLoaderConfig = createExternalLoaderConfig; function buildWebNodePaths(outDir) { const result = () => new Promise((resolve, _) => { const root = path.join(__dirname, '..', '..'); @@ -405,5 +405,4 @@ function buildWebNodePaths(outDir) { result.taskName = 'build-web-node-paths'; return result; } -exports.buildWebNodePaths = buildWebNodePaths; //# sourceMappingURL=util.js.map \ No newline at end of file diff --git a/build/linux/debian/calculate-deps.js b/build/linux/debian/calculate-deps.js index 6304df9edda2a..bbcb6bfc3de47 100644 --- a/build/linux/debian/calculate-deps.js +++ b/build/linux/debian/calculate-deps.js @@ -4,7 +4,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ Object.defineProperty(exports, "__esModule", { value: true }); -exports.generatePackageDeps = void 0; +exports.generatePackageDeps = generatePackageDeps; const child_process_1 = require("child_process"); const fs_1 = require("fs"); const os_1 = require("os"); @@ -17,7 +17,6 @@ function generatePackageDeps(files, arch, chromiumSysroot, vscodeSysroot) { dependencies.push(additionalDepsSet); return dependencies; } -exports.generatePackageDeps = generatePackageDeps; // Based on https://source.chromium.org/chromium/chromium/src/+/main:chrome/installer/linux/debian/calculate_package_deps.py. function calculatePackageDeps(binaryPath, arch, chromiumSysroot, vscodeSysroot) { try { diff --git a/build/linux/debian/install-sysroot.js b/build/linux/debian/install-sysroot.js index d637fce3ca6ca..feca7d3fa9d69 100644 --- a/build/linux/debian/install-sysroot.js +++ b/build/linux/debian/install-sysroot.js @@ -4,7 +4,8 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ Object.defineProperty(exports, "__esModule", { value: true }); -exports.getChromiumSysroot = exports.getVSCodeSysroot = void 0; +exports.getVSCodeSysroot = getVSCodeSysroot; +exports.getChromiumSysroot = getChromiumSysroot; const child_process_1 = require("child_process"); const os_1 = require("os"); const fs = require("fs"); @@ -156,7 +157,6 @@ async function getVSCodeSysroot(arch) { fs.writeFileSync(stamp, expectedName); return result; } -exports.getVSCodeSysroot = getVSCodeSysroot; async function getChromiumSysroot(arch) { const sysrootJSONUrl = `https://raw.githubusercontent.com/electron/electron/v${getElectronVersion().electronVersion}/script/sysroots.json`; const sysrootDictLocation = `${(0, os_1.tmpdir)()}/sysroots.json`; @@ -214,5 +214,4 @@ async function getChromiumSysroot(arch) { fs.writeFileSync(stamp, url); return sysroot; } -exports.getChromiumSysroot = getChromiumSysroot; //# sourceMappingURL=install-sysroot.js.map \ No newline at end of file diff --git a/build/linux/debian/types.js b/build/linux/debian/types.js index 2cd177c34a811..ce21d50e1a98f 100644 --- a/build/linux/debian/types.js +++ b/build/linux/debian/types.js @@ -4,9 +4,8 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ Object.defineProperty(exports, "__esModule", { value: true }); -exports.isDebianArchString = void 0; +exports.isDebianArchString = isDebianArchString; function isDebianArchString(s) { return ['amd64', 'armhf', 'arm64'].includes(s); } -exports.isDebianArchString = isDebianArchString; //# sourceMappingURL=types.js.map \ No newline at end of file diff --git a/build/linux/dependencies-generator.js b/build/linux/dependencies-generator.js index e40ed70901c62..80c247d1129f2 100644 --- a/build/linux/dependencies-generator.js +++ b/build/linux/dependencies-generator.js @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ 'use strict'; Object.defineProperty(exports, "__esModule", { value: true }); -exports.getDependencies = void 0; +exports.getDependencies = getDependencies; const child_process_1 = require("child_process"); const path = require("path"); const install_sysroot_1 = require("./debian/install-sysroot"); @@ -23,7 +23,7 @@ const product = require("../../product.json"); // The reference dependencies, which one has to update when the new dependencies // are valid, are in dep-lists.ts const FAIL_BUILD_FOR_NEW_DEPENDENCIES = true; -// Based on https://source.chromium.org/chromium/chromium/src/+/refs/tags/118.0.5993.159:chrome/installer/linux/BUILD.gn;l=64-80 +// Based on https://source.chromium.org/chromium/chromium/src/+/refs/tags/120.0.6099.268:chrome/installer/linux/BUILD.gn;l=64-80 // and the Linux Archive build // Shared library dependencies that we already bundle. const bundledDeps = [ @@ -92,7 +92,6 @@ async function getDependencies(packageType, buildDir, applicationName, arch) { } return sortedDependencies; } -exports.getDependencies = getDependencies; // Based on https://source.chromium.org/chromium/chromium/src/+/main:chrome/installer/linux/rpm/merge_package_deps.py. function mergePackageDeps(inputDeps) { const requires = new Set(); diff --git a/build/linux/dependencies-generator.ts b/build/linux/dependencies-generator.ts index 12bc3c08a64d5..9f1a068b8d7e8 100644 --- a/build/linux/dependencies-generator.ts +++ b/build/linux/dependencies-generator.ts @@ -25,7 +25,7 @@ import product = require('../../product.json'); // are valid, are in dep-lists.ts const FAIL_BUILD_FOR_NEW_DEPENDENCIES: boolean = true; -// Based on https://source.chromium.org/chromium/chromium/src/+/refs/tags/118.0.5993.159:chrome/installer/linux/BUILD.gn;l=64-80 +// Based on https://source.chromium.org/chromium/chromium/src/+/refs/tags/120.0.6099.268:chrome/installer/linux/BUILD.gn;l=64-80 // and the Linux Archive build // Shared library dependencies that we already bundle. const bundledDeps = [ diff --git a/build/linux/libcxx-fetcher.js b/build/linux/libcxx-fetcher.js index 1e195ba1fac05..cfdc9498502e9 100644 --- a/build/linux/libcxx-fetcher.js +++ b/build/linux/libcxx-fetcher.js @@ -4,7 +4,8 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ Object.defineProperty(exports, "__esModule", { value: true }); -exports.downloadLibcxxObjects = exports.downloadLibcxxHeaders = void 0; +exports.downloadLibcxxHeaders = downloadLibcxxHeaders; +exports.downloadLibcxxObjects = downloadLibcxxObjects; // Can be removed once https://github.com/electron/electron-rebuild/pull/703 is available. const fs = require("fs"); const path = require("path"); @@ -29,7 +30,6 @@ async function downloadLibcxxHeaders(outDir, electronVersion, lib_name) { d(`unpacking ${lib_name}_headers from ${headers}`); await extract(headers, { dir: outDir }); } -exports.downloadLibcxxHeaders = downloadLibcxxHeaders; async function downloadLibcxxObjects(outDir, electronVersion, targetArch = 'x64') { if (await fs.existsSync(path.resolve(outDir, 'libc++.a'))) { return; @@ -47,7 +47,6 @@ async function downloadLibcxxObjects(outDir, electronVersion, targetArch = 'x64' d(`unpacking libcxx-objects from ${objects}`); await extract(objects, { dir: outDir }); } -exports.downloadLibcxxObjects = downloadLibcxxObjects; async function main() { const libcxxObjectsDirPath = process.env['VSCODE_LIBCXX_OBJECTS_DIR']; const libcxxHeadersDownloadDir = process.env['VSCODE_LIBCXX_HEADERS_DIR']; diff --git a/build/linux/rpm/calculate-deps.js b/build/linux/rpm/calculate-deps.js index ac870e4a54646..b19e26f18544e 100644 --- a/build/linux/rpm/calculate-deps.js +++ b/build/linux/rpm/calculate-deps.js @@ -4,7 +4,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ Object.defineProperty(exports, "__esModule", { value: true }); -exports.generatePackageDeps = void 0; +exports.generatePackageDeps = generatePackageDeps; const child_process_1 = require("child_process"); const fs_1 = require("fs"); const dep_lists_1 = require("./dep-lists"); @@ -14,7 +14,6 @@ function generatePackageDeps(files) { dependencies.push(additionalDepsSet); return dependencies; } -exports.generatePackageDeps = generatePackageDeps; // Based on https://source.chromium.org/chromium/chromium/src/+/main:chrome/installer/linux/rpm/calculate_package_deps.py. function calculatePackageDeps(binaryPath) { try { diff --git a/build/linux/rpm/dep-lists.js b/build/linux/rpm/dep-lists.js index b9a6e80d5f380..bd84fc146dcb1 100644 --- a/build/linux/rpm/dep-lists.js +++ b/build/linux/rpm/dep-lists.js @@ -81,7 +81,6 @@ exports.referenceGeneratedDepsByArch = { 'libnss3.so(NSS_3.11)(64bit)', 'libnss3.so(NSS_3.12)(64bit)', 'libnss3.so(NSS_3.12.1)(64bit)', - 'libnss3.so(NSS_3.13)(64bit)', 'libnss3.so(NSS_3.2)(64bit)', 'libnss3.so(NSS_3.22)(64bit)', 'libnss3.so(NSS_3.3)(64bit)', @@ -173,7 +172,6 @@ exports.referenceGeneratedDepsByArch = { 'libnss3.so(NSS_3.11)', 'libnss3.so(NSS_3.12)', 'libnss3.so(NSS_3.12.1)', - 'libnss3.so(NSS_3.13)', 'libnss3.so(NSS_3.2)', 'libnss3.so(NSS_3.22)', 'libnss3.so(NSS_3.22)(64bit)', @@ -269,7 +267,6 @@ exports.referenceGeneratedDepsByArch = { 'libnss3.so(NSS_3.11)(64bit)', 'libnss3.so(NSS_3.12)(64bit)', 'libnss3.so(NSS_3.12.1)(64bit)', - 'libnss3.so(NSS_3.13)(64bit)', 'libnss3.so(NSS_3.2)(64bit)', 'libnss3.so(NSS_3.22)(64bit)', 'libnss3.so(NSS_3.3)(64bit)', diff --git a/build/linux/rpm/dep-lists.ts b/build/linux/rpm/dep-lists.ts index 275d88b95a880..82a4fe7698d3f 100644 --- a/build/linux/rpm/dep-lists.ts +++ b/build/linux/rpm/dep-lists.ts @@ -80,7 +80,6 @@ export const referenceGeneratedDepsByArch = { 'libnss3.so(NSS_3.11)(64bit)', 'libnss3.so(NSS_3.12)(64bit)', 'libnss3.so(NSS_3.12.1)(64bit)', - 'libnss3.so(NSS_3.13)(64bit)', 'libnss3.so(NSS_3.2)(64bit)', 'libnss3.so(NSS_3.22)(64bit)', 'libnss3.so(NSS_3.3)(64bit)', @@ -172,7 +171,6 @@ export const referenceGeneratedDepsByArch = { 'libnss3.so(NSS_3.11)', 'libnss3.so(NSS_3.12)', 'libnss3.so(NSS_3.12.1)', - 'libnss3.so(NSS_3.13)', 'libnss3.so(NSS_3.2)', 'libnss3.so(NSS_3.22)', 'libnss3.so(NSS_3.22)(64bit)', @@ -268,7 +266,6 @@ export const referenceGeneratedDepsByArch = { 'libnss3.so(NSS_3.11)(64bit)', 'libnss3.so(NSS_3.12)(64bit)', 'libnss3.so(NSS_3.12.1)(64bit)', - 'libnss3.so(NSS_3.13)(64bit)', 'libnss3.so(NSS_3.2)(64bit)', 'libnss3.so(NSS_3.22)(64bit)', 'libnss3.so(NSS_3.3)(64bit)', diff --git a/build/linux/rpm/types.js b/build/linux/rpm/types.js index 6dba7cf38d135..a20b9c2fe025b 100644 --- a/build/linux/rpm/types.js +++ b/build/linux/rpm/types.js @@ -4,9 +4,8 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ Object.defineProperty(exports, "__esModule", { value: true }); -exports.isRpmArchString = void 0; +exports.isRpmArchString = isRpmArchString; function isRpmArchString(s) { return ['x86_64', 'armv7hl', 'aarch64'].includes(s); } -exports.isRpmArchString = isRpmArchString; //# sourceMappingURL=types.js.map \ No newline at end of file diff --git a/build/npm/dirs.js b/build/npm/dirs.js index faf3a6577a5d8..372d546cd78e3 100644 --- a/build/npm/dirs.js +++ b/build/npm/dirs.js @@ -52,6 +52,7 @@ const dirs = [ 'test/integration/browser', 'test/monaco', 'test/smoke', + '.vscode/extensions/vscode-selfhost-test-provider', ]; if (fs.existsSync(`${__dirname}/../../.build/distro/npm`)) { diff --git a/build/npm/gyp/yarn.lock b/build/npm/gyp/yarn.lock index 3716071611c37..c444d89094fcf 100644 --- a/build/npm/gyp/yarn.lock +++ b/build/npm/gyp/yarn.lock @@ -367,9 +367,9 @@ inherits@2, inherits@^2.0.3: integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== ip@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/ip/-/ip-2.0.0.tgz#4cf4ab182fee2314c75ede1276f8c80b479936da" - integrity sha512-WKa+XuLG1A1R0UWhl2+1XQSi+fZWMsYKffMZTTYsiZaUD8k2yDAj5atimTUD2TZkyCkNEeYE5NhFZmupOGtjYQ== + version "2.0.1" + resolved "https://registry.yarnpkg.com/ip/-/ip-2.0.1.tgz#e8f3595d33a3ea66490204234b77636965307105" + integrity sha512-lJUL9imLTNi1ZfXT+DU6rBBdbiKGBuay9B6xGSPVjUeQwaH1RIGqef8RZkUtHioLmSNpPR5M4HVKJGm1j8FWVQ== is-fullwidth-code-point@^3.0.0: version "3.0.0" diff --git a/build/package.json b/build/package.json index b4ea8d06993c0..a92ae344d67d8 100644 --- a/build/package.json +++ b/build/package.json @@ -19,7 +19,6 @@ "@types/gulp-filter": "^3.0.32", "@types/gulp-gzip": "^0.0.31", "@types/gulp-json-editor": "^2.2.31", - "@types/gulp-postcss": "^8.0.6", "@types/gulp-rename": "^0.0.33", "@types/gulp-sourcemaps": "^0.0.32", "@types/mime": "0.0.29", diff --git a/build/win32/Cargo.lock b/build/win32/Cargo.lock index fb5217556906a..18edefc752d97 100644 --- a/build/win32/Cargo.lock +++ b/build/win32/Cargo.lock @@ -109,7 +109,7 @@ dependencies = [ [[package]] name = "inno_updater" -version = "0.10.1" +version = "0.11.0" dependencies = [ "byteorder", "crc", diff --git a/build/win32/Cargo.toml b/build/win32/Cargo.toml index cf3cc9de80be5..3925505c22525 100644 --- a/build/win32/Cargo.toml +++ b/build/win32/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "inno_updater" -version = "0.10.1" +version = "0.11.0" authors = ["Microsoft "] build = "build.rs" diff --git a/build/win32/code.iss b/build/win32/code.iss index f8d231f58583f..fca3d1e9d9b85 100644 --- a/build/win32/code.iss +++ b/build/win32/code.iss @@ -1519,7 +1519,7 @@ begin StopTunnelServiceIfNeeded(); - Exec(ExpandConstant('{app}\tools\inno_updater.exe'), ExpandConstant('"{app}\{#ExeBasename}.exe" ' + BoolToStr(LockFileExists())), '', SW_SHOW, ewWaitUntilTerminated, UpdateResultCode); + Exec(ExpandConstant('{app}\tools\inno_updater.exe'), ExpandConstant('"{app}\{#ExeBasename}.exe" ' + BoolToStr(LockFileExists()) + ' "{cm:UpdatingVisualStudioCode}"'), '', SW_SHOW, ewWaitUntilTerminated, UpdateResultCode); end; if ShouldRestartTunnelService then diff --git a/build/win32/explorer-appx-fetcher.js b/build/win32/explorer-appx-fetcher.js index d618c21674acf..554b449d872cd 100644 --- a/build/win32/explorer-appx-fetcher.js +++ b/build/win32/explorer-appx-fetcher.js @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ 'use strict'; Object.defineProperty(exports, "__esModule", { value: true }); -exports.downloadExplorerAppx = void 0; +exports.downloadExplorerAppx = downloadExplorerAppx; const fs = require("fs"); const debug = require("debug"); const extract = require("extract-zip"); @@ -36,7 +36,6 @@ async function downloadExplorerAppx(outDir, quality = 'stable', targetArch = 'x6 d(`unpacking from ${fileName}`); await extract(artifact, { dir: fs.realpathSync(outDir) }); } -exports.downloadExplorerAppx = downloadExplorerAppx; async function main(outputDir) { const arch = process.env['VSCODE_ARCH']; if (!outputDir) { diff --git a/build/win32/i18n/messages.de.isl b/build/win32/i18n/messages.de.isl index 6a9f29aa9c27e..8d065e6c10ac1 100644 --- a/build/win32/i18n/messages.de.isl +++ b/build/win32/i18n/messages.de.isl @@ -6,4 +6,5 @@ AddToPath=Zu PATH hinzuf RunAfter=%1 nach der Installation ausfhren Other=Andere: SourceFile=%1-Quelldatei -OpenWithCodeContextMenu=Mit %1 ffnen \ No newline at end of file +OpenWithCodeContextMenu=Mit %1 ffnen +UpdatingVisualStudioCode=Visual Studio Code wird aktualisiert... \ No newline at end of file diff --git a/build/win32/i18n/messages.en.isl b/build/win32/i18n/messages.en.isl index 986eba00d3e58..a5cc58201543e 100644 --- a/build/win32/i18n/messages.en.isl +++ b/build/win32/i18n/messages.en.isl @@ -14,3 +14,4 @@ RunAfter=Run %1 after installation Other=Other: SourceFile=%1 Source File OpenWithCodeContextMenu=Open w&ith %1 +UpdatingVisualStudioCode=Updating Visual Studio Code... diff --git a/build/win32/i18n/messages.es.isl b/build/win32/i18n/messages.es.isl index 0ba4d0c44f2b0..66b7534a20770 100644 --- a/build/win32/i18n/messages.es.isl +++ b/build/win32/i18n/messages.es.isl @@ -7,3 +7,4 @@ RunAfter=Ejecutar %1 despu Other=Otros: SourceFile=Archivo de origen %1 OpenWithCodeContextMenu=Abrir &con %1 +UpdatingVisualStudioCode=Actualizando Visual Studio Code... \ No newline at end of file diff --git a/build/win32/i18n/messages.fr.isl b/build/win32/i18n/messages.fr.isl index df14041862513..348d6be00495f 100644 --- a/build/win32/i18n/messages.fr.isl +++ b/build/win32/i18n/messages.fr.isl @@ -6,4 +6,5 @@ AddToPath=Ajouter RunAfter=Excuter %1 aprs l'installation Other=Autre: SourceFile=Fichier source %1 -OpenWithCodeContextMenu=Ouvrir avec %1 \ No newline at end of file +OpenWithCodeContextMenu=Ouvrir avec %1 +UpdatingVisualStudioCode=Mise jour de Visual Studio Code... \ No newline at end of file diff --git a/build/win32/i18n/messages.hu.isl b/build/win32/i18n/messages.hu.isl index b64553da8e6de..ef3862ad35b10 100644 --- a/build/win32/i18n/messages.hu.isl +++ b/build/win32/i18n/messages.hu.isl @@ -6,4 +6,5 @@ AddToPath=Hozz RunAfter=%1 indtsa a telepts utn Other=Egyb: SourceFile=%1 forrsfjl -OpenWithCodeContextMenu=Megnyits a kvetkezvel: %1 \ No newline at end of file +OpenWithCodeContextMenu=Megnyits a kvetkezvel: %1 +UpdatingVisualStudioCode=A Visual Studio Code frisstse... \ No newline at end of file diff --git a/build/win32/i18n/messages.it.isl b/build/win32/i18n/messages.it.isl index 08248c4ce1ba8..bc23825844a3b 100644 --- a/build/win32/i18n/messages.it.isl +++ b/build/win32/i18n/messages.it.isl @@ -6,4 +6,5 @@ AddToPath=Aggiungi a PATH (disponibile dopo il riavvio) RunAfter=Esegui %1 dopo l'installazione Other=Altro: SourceFile=File di origine %1 -OpenWithCodeContextMenu=Apri con %1 \ No newline at end of file +OpenWithCodeContextMenu=Apri con %1 +UpdatingVisualStudioCode=Aggiornamento di Visual Studio Code... \ No newline at end of file diff --git a/build/win32/i18n/messages.ja.isl b/build/win32/i18n/messages.ja.isl index 9675060e94afd..ef10366b46993 100644 --- a/build/win32/i18n/messages.ja.isl +++ b/build/win32/i18n/messages.ja.isl @@ -6,4 +6,5 @@ AddToPath=PATH RunAfter=CXg[ %1 s Other=̑: SourceFile=%1 \[X t@C -OpenWithCodeContextMenu=%1 ŊJ \ No newline at end of file +OpenWithCodeContextMenu=%1 ŊJ +UpdatingVisualStudioCode=Visual Studio Code XVĂ܂... \ No newline at end of file diff --git a/build/win32/i18n/messages.ko.isl b/build/win32/i18n/messages.ko.isl index 5a510558bbd2b..f938c75e289b1 100644 --- a/build/win32/i18n/messages.ko.isl +++ b/build/win32/i18n/messages.ko.isl @@ -6,4 +6,5 @@ AddToPath=PATH RunAfter=ġ %1 Other=Ÿ: SourceFile=%1 -OpenWithCodeContextMenu=%1() \ No newline at end of file +OpenWithCodeContextMenu=%1() +UpdatingVisualStudioCode=Visual Studio Code Ʈ ... \ No newline at end of file diff --git a/build/win32/i18n/messages.pt-br.isl b/build/win32/i18n/messages.pt-br.isl index e327e8fd1a066..e85aede38622a 100644 --- a/build/win32/i18n/messages.pt-br.isl +++ b/build/win32/i18n/messages.pt-br.isl @@ -6,4 +6,5 @@ AddToPath=Adicione em PATH (dispon RunAfter=Executar %1 aps a instalao Other=Outros: SourceFile=Arquivo Fonte %1 -OpenWithCodeContextMenu=Abrir com %1 \ No newline at end of file +OpenWithCodeContextMenu=Abrir com %1 +UpdatingVisualStudioCode=Atualizando o Visual Studio Code... \ No newline at end of file diff --git a/build/win32/i18n/messages.ru.isl b/build/win32/i18n/messages.ru.isl index bca3b864a5f9e..2b1d906e55dd4 100644 --- a/build/win32/i18n/messages.ru.isl +++ b/build/win32/i18n/messages.ru.isl @@ -6,4 +6,5 @@ AddToPath= RunAfter= %1 Other=: SourceFile= %1 -OpenWithCodeContextMenu= %1 \ No newline at end of file +OpenWithCodeContextMenu= %1 +UpdatingVisualStudioCode= Visual Studio Code... \ No newline at end of file diff --git a/build/win32/i18n/messages.tr.isl b/build/win32/i18n/messages.tr.isl index b13e5e27bd2af..5eff39c24a76a 100644 --- a/build/win32/i18n/messages.tr.isl +++ b/build/win32/i18n/messages.tr.isl @@ -6,4 +6,5 @@ AddToPath=PATH'e ekle (yeniden ba RunAfter=Kurulumdan sonra %1 uygulamasn altr. Other=Dier: SourceFile=%1 Kaynak Dosyas -OpenWithCodeContextMenu=%1 le A \ No newline at end of file +OpenWithCodeContextMenu=%1 le A +UpdatingVisualStudioCode=Visual Studio Code gncelleniyor... \ No newline at end of file diff --git a/build/win32/i18n/messages.zh-cn.isl b/build/win32/i18n/messages.zh-cn.isl index 8fa136f6d5a9b..629bf9ea40135 100644 --- a/build/win32/i18n/messages.zh-cn.isl +++ b/build/win32/i18n/messages.zh-cn.isl @@ -6,4 +6,5 @@ AddToPath= RunAfter=װ %1 Other=: SourceFile=%1 Դļ -OpenWithCodeContextMenu=ͨ %1 \ No newline at end of file +OpenWithCodeContextMenu=ͨ %1 +UpdatingVisualStudioCode=ڸ Visual Studio Code... \ No newline at end of file diff --git a/build/win32/i18n/messages.zh-tw.isl b/build/win32/i18n/messages.zh-tw.isl index 40c5fa92d793e..8ed1f5a5061d7 100644 --- a/build/win32/i18n/messages.zh-tw.isl +++ b/build/win32/i18n/messages.zh-tw.isl @@ -6,4 +6,5 @@ AddToPath= RunAfter=w˫ %1 Other=L: SourceFile=%1 ӷɮ -OpenWithCodeContextMenu=H %1 } \ No newline at end of file +OpenWithCodeContextMenu=H %1 } +UpdatingVisualStudioCode=bs Visual Studio Code... \ No newline at end of file diff --git a/build/win32/inno_updater.exe b/build/win32/inno_updater.exe index fa2fd26a466de..b87cbd47f24f5 100644 Binary files a/build/win32/inno_updater.exe and b/build/win32/inno_updater.exe differ diff --git a/build/yarn.lock b/build/yarn.lock index a0aca2e9595a2..b7f69b1140d58 100644 --- a/build/yarn.lock +++ b/build/yarn.lock @@ -489,14 +489,6 @@ "@types/js-beautify" "*" "@types/node" "*" -"@types/gulp-postcss@^8.0.6": - version "8.0.6" - resolved "https://registry.yarnpkg.com/@types/gulp-postcss/-/gulp-postcss-8.0.6.tgz#d314c785876c8a1779154ba1d152125082ecde0f" - integrity sha512-mjGEmTvurqRHFeJQnrgtMC9GtKNkI2+56n92zIzff5UFr2jUfilw1elKRxS7bK0FYRvuEcnMX9JH0AUpCxBrpg== - dependencies: - "@types/node" "*" - "@types/vinyl" "*" - "@types/gulp-rename@^0.0.33": version "0.0.33" resolved "https://registry.yarnpkg.com/@types/gulp-rename/-/gulp-rename-0.0.33.tgz#38d146e97786569f74f5391a1b1f9b5198674b6c" diff --git a/cglicenses.json b/cglicenses.json index d61164acd3d33..6ca8ba33b4dc1 100644 --- a/cglicenses.json +++ b/cglicenses.json @@ -589,5 +589,15 @@ "prependLicenseText": [ "Copyright (c) heap.js authors" ] + }, + { + // Reason: mono-repo where the individual packages are also dual-licensed under MIT and Apache-2.0 + "name": "system-configuration", + "fullLicenseTextUri": "https://raw.githubusercontent.com/mullvad/system-configuration-rs/v0.6.0/system-configuration/LICENSE-MIT" + }, + { + // Reason: mono-repo where the individual packages are also dual-licensed under MIT and Apache-2.0 + "name": "system-configuration-sys", + "fullLicenseTextUri": "https://raw.githubusercontent.com/mullvad/system-configuration-rs/v0.6.0/system-configuration-sys/LICENSE-MIT" } ] diff --git a/cgmanifest.json b/cgmanifest.json index c546a3e27e5d0..891a0b0cb3260 100644 --- a/cgmanifest.json +++ b/cgmanifest.json @@ -6,7 +6,7 @@ "git": { "name": "chromium", "repositoryUrl": "https://chromium.googlesource.com/chromium/src", - "commitHash": "b1f5594cf472956192e71c38ebfc22472d44a03d" + "commitHash": "14d11e5bb9b5b1cd51f7b19546e74a73cab42084" } }, "licenseDetail": [ @@ -40,7 +40,7 @@ "SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE." ], "isOnlyProductionDependency": true, - "version": "118.0.5993.159" + "version": "120.0.6099.291" }, { "component": { @@ -48,7 +48,7 @@ "git": { "name": "ffmpeg", "repositoryUrl": "https://chromium.googlesource.com/chromium/third_party/ffmpeg", - "commitHash": "0ba37733400593b162e5ae9ff26b384cff49c250" + "commitHash": "e1ca3f06adec15150a171bc38f550058b4bbb23b" } }, "isOnlyProductionDependency": true, @@ -516,11 +516,11 @@ "git": { "name": "nodejs", "repositoryUrl": "https://github.com/nodejs/node", - "commitHash": "2e414d5d1082233c3516fca923fe351d5186c80e" + "commitHash": "8a01b3dcb7d08a48bfd3e6bf85ef49faa1454839" } }, "isOnlyProductionDependency": true, - "version": "18.17.1" + "version": "18.18.2" }, { "component": { @@ -528,12 +528,12 @@ "git": { "name": "electron", "repositoryUrl": "https://github.com/electron/electron", - "commitHash": "f6af8959c36eca437f38076c46ab13e910cbfbdb" + "commitHash": "31cd9d1f61714e20f1067d726404600ab7281698" } }, "isOnlyProductionDependency": true, "license": "MIT", - "version": "27.3.1" + "version": "28.2.8" }, { "component": { diff --git a/cli/Cargo.lock b/cli/Cargo.lock index 553df3f8f53d4..cb493fcfee698 100644 --- a/cli/Cargo.lock +++ b/cli/Cargo.lock @@ -492,22 +492,18 @@ dependencies = [ [[package]] name = "crossbeam-channel" -version = "0.5.6" +version = "0.5.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2dd04ddaf88237dc3b8d8f9a3c1004b506b54b3313403944054d23c0870c521" +checksum = "ab3db02a9c5b5121e1e42fbdb1aeb65f5e02624cc58c43f2884c6ccac0b82f95" dependencies = [ - "cfg-if", "crossbeam-utils", ] [[package]] name = "crossbeam-utils" -version = "0.8.16" +version = "0.8.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a22b2d63d4d1dc0b7f1b6b2747dd0088008a9be28b6ddf0b1e7d335e3037294" -dependencies = [ - "cfg-if", -] +checksum = "248e3bacc7dc6baa3b21e405ee045c3047101a49145e7e9eca583ab4c2ca5345" [[package]] name = "crypto-common" @@ -1101,9 +1097,9 @@ dependencies = [ [[package]] name = "indexmap" -version = "1.9.1" +version = "1.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10a35a97730320ffe8e2d410b5d3b69279b98d2c14bdb8b70ea89ecf7888d41e" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" dependencies = [ "autocfg", "hashbrown 0.12.3", @@ -1236,9 +1232,9 @@ checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" [[package]] name = "libc" -version = "0.2.144" +version = "0.2.153" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b00cc1c228a6782d0f076e7b232802e0c5689d41bb5df366f2a6b6621cfdfe1" +checksum = "9c198f91728a82281a64e1f4f9eeb25d82cb32a5de251c6bd1b5154d63a8e7bd" [[package]] name = "libz-sys" @@ -1330,14 +1326,13 @@ dependencies = [ [[package]] name = "mio" -version = "0.8.4" +version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57ee1c23c7c63b0c9250c339ffdc69255f110b298b901b9f6c82547b7b87caaf" +checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c" dependencies = [ "libc", - "log", "wasi 0.11.0+wasi-snapshot-preview1", - "windows-sys 0.36.1", + "windows-sys 0.48.0", ] [[package]] @@ -1551,7 +1546,7 @@ checksum = "ed41783a5bf567688eb38372f2b7a8530f5a607a4b49d38dd7573236c23ca7e2" dependencies = [ "futures-channel", "futures-util", - "indexmap 1.9.1", + "indexmap 1.9.3", "once_cell", "pin-project-lite", "thiserror", @@ -2526,7 +2521,7 @@ dependencies = [ [[package]] name = "tunnels" version = "0.1.0" -source = "git+https://github.com/microsoft/dev-tunnels?rev=4de1ff7979b5758c69218a3f45f6d9784b165072#4de1ff7979b5758c69218a3f45f6d9784b165072" +source = "git+https://github.com/microsoft/dev-tunnels?rev=8cae9b2a24c65c6c1958f5a0e77d72b23b5c6c30#8cae9b2a24c65c6c1958f5a0e77d72b23b5c6c30" dependencies = [ "async-trait", "chrono", diff --git a/cli/Cargo.toml b/cli/Cargo.toml index f51f31e9fb508..db058cd9f7cb5 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -34,7 +34,7 @@ serde_bytes = "0.11.9" chrono = { version = "0.4.26", features = ["serde", "std", "clock"], default-features = false } gethostname = "0.4.3" libc = "0.2.144" -tunnels = { git = "https://github.com/microsoft/dev-tunnels", rev = "4de1ff7979b5758c69218a3f45f6d9784b165072", default-features = false, features = ["connections"] } +tunnels = { git = "https://github.com/microsoft/dev-tunnels", rev = "8cae9b2a24c65c6c1958f5a0e77d72b23b5c6c30", default-features = false, features = ["connections"] } keyring = { version = "2.0.3", default-features = false, features = ["linux-secret-service-rt-tokio-crypto-openssl"] } dialoguer = "0.10.4" hyper = { version = "0.14.26", features = ["server", "http1", "runtime"] } diff --git a/cli/ThirdPartyNotices.txt b/cli/ThirdPartyNotices.txt index 6ec82c8b541ad..f65f8a333979b 100644 --- a/cli/ThirdPartyNotices.txt +++ b/cli/ThirdPartyNotices.txt @@ -3827,7 +3827,7 @@ DEALINGS IN THE SOFTWARE. indexmap 1.9.1 - Apache-2.0 OR MIT indexmap 2.1.0 - Apache-2.0 OR MIT -https://github.com/bluss/indexmap +https://github.com/indexmap-rs/indexmap Copyright (c) 2016--2017 @@ -4232,7 +4232,7 @@ DEALINGS IN THE SOFTWARE. --------------------------------------------------------- -libc 0.2.144 - MIT OR Apache-2.0 +libc 0.2.153 - MIT OR Apache-2.0 https://github.com/rust-lang/libc Copyright (c) 2014-2020 The Rust Project Developers @@ -4597,7 +4597,7 @@ The parts of miniz that are not covered by the unlicense is [some Zip64 code](ht --------------------------------------------------------- -mio 0.8.4 - MIT +mio 0.8.11 - MIT https://github.com/tokio-rs/mio The MIT License (MIT) @@ -6189,6 +6189,34 @@ DEALINGS IN THE SOFTWARE. --------------------------------------------------------- +os_info 3.7.0 - MIT +https://github.com/stanislav-tkach/os_info + +The MIT License (MIT) + +Copyright (c) 2017 Stanislav Tkach + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +--------------------------------------------------------- + +--------------------------------------------------------- + parking 2.0.0 - Apache-2.0 OR MIT https://github.com/smol-rs/parking @@ -8637,7 +8665,7 @@ SOFTWARE. system-configuration 0.5.1 - MIT OR Apache-2.0 https://github.com/mullvad/system-configuration-rs -Copyright (c) 2017 Amagicom AB +Copyright (c) 2024 Mullvad VPN AB Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated @@ -8669,7 +8697,7 @@ DEALINGS IN THE SOFTWARE. system-configuration-sys 0.5.0 - MIT OR Apache-2.0 https://github.com/mullvad/system-configuration-rs -Copyright (c) 2017 Amagicom AB +Copyright (c) 2024 Mullvad VPN AB Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated @@ -8853,7 +8881,7 @@ DEALINGS IN THE SOFTWARE. time 0.3.21 - MIT OR Apache-2.0 https://github.com/time-rs/time -Copyright (c) 2022 Jacob Pratt et al. +Copyright (c) 2024 Jacob Pratt et al. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal @@ -8879,7 +8907,7 @@ SOFTWARE. time-core 0.1.1 - MIT OR Apache-2.0 https://github.com/time-rs/time -Copyright (c) 2022 Jacob Pratt et al. +Copyright (c) 2024 Jacob Pratt et al. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal @@ -9359,7 +9387,7 @@ THE SOFTWARE. --------------------------------------------------------- -tunnels 97233d20448e1c3cb0e0fd9114acf68c7e5c0249 +tunnels 8cae9b2a24c65c6c1958f5a0e77d72b23b5c6c30 https://github.com/microsoft/dev-tunnels MIT License @@ -9613,7 +9641,7 @@ DEALINGS IN THE SOFTWARE. --------------------------------------------------------- -urlencoding 2.1.2 - MIT +urlencoding 2.1.3 - MIT https://github.com/kornelski/rust_urlencoding The MIT License (MIT) @@ -10595,6 +10623,7 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. --------------------------------------------------------- winreg 0.50.0 - MIT +winreg 0.8.0 - MIT https://github.com/gentoo90/winreg-rs The MIT License (MIT) diff --git a/cli/src/auth.rs b/cli/src/auth.rs index ee7117330be11..9d5c9b73fdb2c 100644 --- a/cli/src/auth.rs +++ b/cli/src/auth.rs @@ -60,7 +60,7 @@ impl Display for AuthProvider { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { AuthProvider::Microsoft => write!(f, "Microsoft Account"), - AuthProvider::Github => write!(f, "Github Account"), + AuthProvider::Github => write!(f, "GitHub Account"), } } } @@ -144,7 +144,7 @@ impl StoredCredential { let res = match res { Ok(r) => r, Err(e) => { - warning!(log, "failed to check Github token: {}", e); + warning!(log, "failed to check GitHub token: {}", e); return false; } }; @@ -154,7 +154,7 @@ impl StoredCredential { } let err = StatusError::from_res(res).await; - debug!(log, "github token looks expired: {:?}", err); + debug!(log, "GitHub token looks expired: {:?}", err); true } } @@ -404,7 +404,10 @@ impl Auth { let mut keyring_storage = KeyringStorage::default(); #[cfg(target_os = "linux")] let mut keyring_storage = ThreadKeyringStorage::default(); - let mut file_storage = FileStorage(PersistedState::new(self.file_storage_path.clone())); + let mut file_storage = FileStorage(PersistedState::new_with_mode( + self.file_storage_path.clone(), + 0o600, + )); let native_storage_result = if std::env::var("VSCODE_CLI_USE_FILE_KEYCHAIN").is_ok() || self.file_storage_path.exists() @@ -675,7 +678,7 @@ impl Auth { if !*IS_INTERACTIVE_CLI { info!( self.log, - "Using Github for authentication, run `{} tunnel user login --provider ` option to change this.", + "Using GitHub for authentication, run `{} tunnel user login --provider ` option to change this.", APPLICATION_NAME ); return Ok(AuthProvider::Github); diff --git a/cli/src/bin/code/legacy_args.rs b/cli/src/bin/code/legacy_args.rs index 3f134443641c3..0bd92c92fd3d8 100644 --- a/cli/src/bin/code/legacy_args.rs +++ b/cli/src/bin/code/legacy_args.rs @@ -42,6 +42,9 @@ pub fn try_parse_legacy( } } } else if let Ok(value) = arg.to_value() { + if value == "tunnel" { + return None; + } if let Some(last_arg) = &last_arg { args.get_mut(last_arg) .expect("expected to have last arg") diff --git a/cli/src/commands.rs b/cli/src/commands.rs index d10a52ad774cf..027716947a37b 100644 --- a/cli/src/commands.rs +++ b/cli/src/commands.rs @@ -6,8 +6,8 @@ mod context; pub mod args; +pub mod serve_web; pub mod tunnels; pub mod update; pub mod version; -pub mod serve_web; pub use context::CommandContext; diff --git a/cli/src/commands/args.rs b/cli/src/commands/args.rs index 229d54ad06137..dcdbd808fe7ff 100644 --- a/cli/src/commands/args.rs +++ b/cli/src/commands/args.rs @@ -201,12 +201,18 @@ pub struct ServeWebArgs { /// A secret that must be included with all requests. #[clap(long)] pub connection_token: Option, + /// A file containing a secret that must be included with all requests. + #[clap(long)] + pub connection_token_file: Option, /// Run without a connection token. Only use this if the connection is secured by other means. #[clap(long)] pub without_connection_token: bool, /// If set, the user accepts the server license terms and the server will be started without a user prompt. #[clap(long)] pub accept_server_license_terms: bool, + /// Specifies the path under which the web UI and the code server is provided. + #[clap(long)] + pub server_base_path: Option, /// Specifies the directory that server data is kept in. #[clap(long)] pub server_data_dir: Option, @@ -652,6 +658,33 @@ pub struct TunnelServeArgs { /// If set, the user accepts the server license terms and the server will be started without a user prompt. #[clap(long)] pub accept_server_license_terms: bool, + + /// Requests that extensions be preloaded and installed on connecting servers. + #[clap(long)] + pub install_extension: Vec, + + /// Specifies the directory that server data is kept in. + #[clap(long)] + pub server_data_dir: Option, + + /// Set the root path for extensions. + #[clap(long)] + pub extensions_dir: Option, +} + +impl TunnelServeArgs { + pub fn apply_to_server_args(&self, csa: &mut CodeServerArgs) { + csa.install_extensions + .extend_from_slice(&self.install_extension); + + if let Some(d) = &self.server_data_dir { + csa.server_data_dir = Some(d.clone()); + } + + if let Some(d) = &self.extensions_dir { + csa.extensions_dir = Some(d.clone()); + } + } } #[derive(Args, Debug, Clone)] diff --git a/cli/src/commands/serve_web.rs b/cli/src/commands/serve_web.rs index 959763a431d10..fba927234260a 100644 --- a/cli/src/commands/serve_web.rs +++ b/cli/src/commands/serve_web.rs @@ -5,8 +5,10 @@ use std::collections::HashMap; use std::convert::Infallible; +use std::fs; +use std::io::{Read, Write}; use std::net::{IpAddr, Ipv4Addr, SocketAddr}; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; @@ -76,15 +78,14 @@ pub async fn serve_web(ctx: CommandContext, mut args: ServeWebArgs) -> Result Result>, } + +fn mint_connection_token(path: &Path, prefer_token: Option) -> std::io::Result { + #[cfg(not(windows))] + use std::os::unix::fs::OpenOptionsExt; + + let mut f = fs::OpenOptions::new(); + f.create(true); + f.write(true); + f.read(true); + #[cfg(not(windows))] + f.mode(0o600); + let mut f = f.open(path)?; + + if prefer_token.is_none() { + let mut t = String::new(); + f.read_to_string(&mut t)?; + let t = t.trim(); + if !t.is_empty() { + return Ok(t.to_string()); + } + } + + f.set_len(0)?; + let prefer_token = prefer_token.unwrap_or_else(|| uuid::Uuid::new_v4().to_string()); + f.write_all(prefer_token.as_bytes())?; + Ok(prefer_token) +} diff --git a/cli/src/commands/tunnels.rs b/cli/src/commands/tunnels.rs index f9ae68830751b..044c92fcebd0f 100644 --- a/cli/src/commands/tunnels.rs +++ b/cli/src/commands/tunnels.rs @@ -53,6 +53,7 @@ use crate::{ app_lock::AppMutex, command::new_std_command, errors::{wrap, AnyError, CodeError}, + machine::canonical_exe, prereqs::PreReqChecker, }, }; @@ -230,8 +231,7 @@ pub async fn service( // likewise for license consent legal::require_consent(&ctx.paths, args.accept_server_license_terms)?; - let current_exe = - std::env::current_exe().map_err(|e| wrap(e, "could not get current exe"))?; + let current_exe = canonical_exe().map_err(|e| wrap(e, "could not get current exe"))?; manager .register( @@ -407,7 +407,8 @@ pub async fn serve(ctx: CommandContext, gateway_args: TunnelServeArgs) -> Result legal::require_consent(&paths, gateway_args.accept_server_license_terms)?; - let csa = (&args).into(); + let mut csa = (&args).into(); + gateway_args.apply_to_server_args(&mut csa); let result = serve_with_csa(paths, log, gateway_args, csa, TUNNEL_CLI_LOCK_NAME).await; drop(no_sleep); @@ -497,7 +498,12 @@ fn get_connection_token(tunnel: &ActiveTunnel) -> String { let mut hash = Sha256::new(); hash.update(tunnel.id.as_bytes()); let result = hash.finalize(); - b64::URL_SAFE_NO_PAD.encode(result) + let mut result = b64::URL_SAFE_NO_PAD.encode(result); + if result.starts_with('-') { + result.insert(0, 'a'); // avoid arg parsing issue + } + + result } async fn serve_with_csa( diff --git a/cli/src/singleton.rs b/cli/src/singleton.rs index 635c400fb0f6d..58c6204708178 100644 --- a/cli/src/singleton.rs +++ b/cli/src/singleton.rs @@ -58,6 +58,7 @@ pub async fn acquire_singleton(lock_file: &Path) -> Result, + #[allow(dead_code)] + mode: u32, } impl PersistedStateContainer @@ -58,13 +61,28 @@ where fn save(&mut self, state: T) -> Result<(), WrappedError> { let s = serde_json::to_string(&state).unwrap(); self.state = Some(state); - write(&self.path, s).map_err(|e| { + self.write_state(s).map_err(|e| { wrap( e, format!("error saving launcher state into {}", self.path.display()), ) }) } + + fn write_state(&mut self, s: String) -> std::io::Result<()> { + #[cfg(not(windows))] + use std::os::unix::fs::OpenOptionsExt; + + let mut f = fs::OpenOptions::new(); + f.create(true); + f.write(true); + f.truncate(true); + #[cfg(not(windows))] + f.mode(self.mode); + + let mut f = f.open(&self.path)?; + f.write_all(s.as_bytes()) + } } /// Container that holds some state value that is persisted to disk. @@ -82,8 +100,17 @@ where { /// Creates a new state container that persists to the given path. pub fn new(path: PathBuf) -> PersistedState { + Self::new_with_mode(path, 0o644) + } + + /// Creates a new state container that persists to the given path. + pub fn new_with_mode(path: PathBuf, mode: u32) -> PersistedState { PersistedState { - container: Arc::new(Mutex::new(PersistedStateContainer { path, state: None })), + container: Arc::new(Mutex::new(PersistedStateContainer { + path, + state: None, + mode, + })), } } @@ -217,5 +244,4 @@ impl LauncherPaths { pub fn web_server_storage(&self) -> PathBuf { self.root.join("serve-web") } - } diff --git a/cli/src/tunnels.rs b/cli/src/tunnels.rs index 7378cf34afd6d..452da4dc3e96d 100644 --- a/cli/src/tunnels.rs +++ b/cli/src/tunnels.rs @@ -6,14 +6,13 @@ pub mod code_server; pub mod dev_tunnels; pub mod legal; +pub mod local_forwarding; pub mod paths; pub mod protocol; pub mod shutdown_signal; pub mod singleton_client; pub mod singleton_server; -pub mod local_forwarding; -mod wsl_detect; mod challenge; mod control_server; mod nosleep; @@ -34,8 +33,9 @@ mod service_macos; #[cfg(target_os = "windows")] mod service_windows; mod socket_signal; +mod wsl_detect; -pub use control_server::{serve, serve_stream, Next, ServeStreamParams, AuthRequired}; +pub use control_server::{serve, serve_stream, AuthRequired, Next, ServeStreamParams}; pub use nosleep::SleepInhibitor; pub use service::{ create_service_manager, ServiceContainer, ServiceManager, SERVICE_LOG_FILE_NAME, diff --git a/cli/src/tunnels/code_server.rs b/cli/src/tunnels/code_server.rs index bb854001d541f..ca7649adabb87 100644 --- a/cli/src/tunnels/code_server.rs +++ b/cli/src/tunnels/code_server.rs @@ -15,7 +15,8 @@ use crate::update_service::{ unzip_downloaded_release, Platform, Release, TargetKind, UpdateService, }; use crate::util::command::{ - capture_command, capture_command_and_check_status, kill_tree, new_script_command, + capture_command, capture_command_and_check_status, check_output_status, kill_tree, + new_script_command, }; use crate::util::errors::{wrap, AnyError, CodeError, ExtensionInstallFailed, WrappedError}; use crate::util::http::{self, BoxedHttp}; @@ -56,6 +57,8 @@ pub struct CodeServerArgs { pub log: Option, pub accept_server_license_terms: bool, pub verbose: bool, + pub server_data_dir: Option, + pub extensions_dir: Option, // extension management pub install_extensions: Vec, pub uninstall_extensions: Vec, @@ -143,6 +146,12 @@ impl CodeServerArgs { args.push(format!("--category={}", i)); } } + if let Some(d) = &self.server_data_dir { + args.push(format!("--server-data-dir={}", d)); + } + if let Some(d) = &self.extensions_dir { + args.push(format!("--extensions-dir={}", d)); + } if self.start_server { args.push(String::from("--start-server")); } @@ -424,7 +433,11 @@ impl<'a> ServerBuilder<'a> { .await?; let server_dir = target_dir.join(SERVER_FOLDER_NAME); - unzip_downloaded_release(&archive_path, &server_dir, SilentCopyProgress())?; + unzip_downloaded_release( + &archive_path, + &server_dir, + self.logger.get_download_logger("server inflate progress:"), + )?; if !skip_requirements_check().await { let output = capture_command_and_check_status( @@ -488,6 +501,28 @@ impl<'a> ServerBuilder<'a> { }) } + /// Runs the command that just installs extensions and exits. + pub async fn install_extensions(&self) -> Result<(), AnyError> { + // cmd already has --install-extensions from base + let mut cmd = self.get_base_command(); + let cmd_str = || { + self.server_params + .code_server_args + .command_arguments() + .join(" ") + }; + + let r = cmd.output().await.map_err(|e| CodeError::CommandFailed { + command: cmd_str(), + code: -1, + output: e.to_string(), + })?; + + check_output_status(r, cmd_str)?; + + Ok(()) + } + pub async fn listen_on_default_socket(&self) -> Result { let requested_file = get_socket_name(); self.listen_on_socket(&requested_file).await diff --git a/cli/src/tunnels/control_server.rs b/cli/src/tunnels/control_server.rs index 5f564494b98b7..ec09e4512a06e 100644 --- a/cli/src/tunnels/control_server.rs +++ b/cli/src/tunnels/control_server.rs @@ -6,10 +6,11 @@ use crate::async_pipe::get_socket_rw_stream; use crate::constants::{CONTROL_PORT, PRODUCT_NAME_LONG}; use crate::log; use crate::msgpack_rpc::{new_msgpack_rpc, start_msgpack_rpc, MsgPackCodec, MsgPackSerializer}; +use crate::options::Quality; use crate::rpc::{MaybeSync, RpcBuilder, RpcCaller, RpcDispatcher}; use crate::self_update::SelfUpdate; use crate::state::LauncherPaths; -use crate::tunnels::protocol::{HttpRequestParams, METHOD_CHALLENGE_ISSUE}; +use crate::tunnels::protocol::{HttpRequestParams, PortPrivacy, METHOD_CHALLENGE_ISSUE}; use crate::tunnels::socket_signal::CloseReason; use crate::update_service::{Platform, Release, TargetKind, UpdateService}; use crate::util::command::new_tokio_command; @@ -144,6 +145,31 @@ pub struct ServerTermination { pub tunnel: ActiveTunnel, } +async fn preload_extensions( + log: &log::Logger, + platform: Platform, + mut args: CodeServerArgs, + launcher_paths: LauncherPaths, +) -> Result<(), AnyError> { + args.start_server = false; + + let params_raw = ServerParamsRaw { + commit_id: None, + quality: Quality::Stable, + code_server_args: args.clone(), + headless: true, + platform, + }; + + // cannot use delegated HTTP here since there's no remote connection yet + let http = Arc::new(ReqwestSimpleHttp::new()); + let resolved = params_raw.resolve(log, http.clone()).await?; + let sb = ServerBuilder::new(log, &resolved, &launcher_paths, http.clone()); + + sb.setup().await?; + sb.install_extensions().await +} + // Runs the launcher server. Exits on a ctrl+c or when requested by a user. // Note that client connections may not be closed when this returns; use // `close_all_clients()` on the ServerTermination to make this happen. @@ -160,6 +186,26 @@ pub async fn serve( let (tx, mut rx) = mpsc::channel::(4); let (exit_barrier, signal_exit) = new_barrier(); + if !code_server_args.install_extensions.is_empty() { + info!( + log, + "Preloading extensions using stable server: {:?}", code_server_args.install_extensions + ); + let log = log.clone(); + let code_server_args = code_server_args.clone(); + let launcher_paths = launcher_paths.clone(); + // This is run async to the primary tunnel setup to be speedy. + tokio::spawn(async move { + if let Err(e) = + preload_extensions(&log, platform, code_server_args, launcher_paths).await + { + warning!(log, "Failed to preload extensions: {:?}", e); + } else { + info!(log, "Extension install complete"); + } + }); + } + loop { tokio::select! { Ok(reason) = shutdown_rx.wait() => { @@ -1031,8 +1077,16 @@ async fn handle_forward( let port_forwarding = port_forwarding .as_ref() .ok_or(CodeError::PortForwardingNotAvailable)?; - info!(log, "Forwarding port {}", params.port); - let uri = port_forwarding.forward(params.port).await?; + info!( + log, + "Forwarding port {} (public={})", params.port, params.public + ); + let privacy = match params.public { + true => PortPrivacy::Public, + false => PortPrivacy::Private, + }; + + let uri = port_forwarding.forward(params.port, privacy).await?; Ok(ForwardResult { uri }) } diff --git a/cli/src/tunnels/dev_tunnels.rs b/cli/src/tunnels/dev_tunnels.rs index 94396e8997701..19ee3c2bf42d6 100644 --- a/cli/src/tunnels/dev_tunnels.rs +++ b/cli/src/tunnels/dev_tunnels.rs @@ -562,6 +562,10 @@ impl DevTunnels { let tunnel = match self.get_existing_tunnel_with_name(name).await? { Some(e) => { + if tunnel_has_host_connection(&e) { + return Err(CodeError::TunnelActiveAndInUse(name.to_string()).into()); + } + let loc = TunnelLocator::try_from(&e).unwrap(); info!(self.log, "Adopting existing tunnel (ID={:?})", loc); spanf!( @@ -687,13 +691,7 @@ impl DevTunnels { let recyclable = existing_tunnels .iter() - .filter(|t| { - t.status - .as_ref() - .and_then(|s| s.host_connection_count.as_ref()) - .map(|c| c.get_count()) - .unwrap_or(0) == 0 - }) + .filter(|t| !tunnel_has_host_connection(t)) .choose(&mut rand::thread_rng()); match recyclable { @@ -764,12 +762,9 @@ impl DevTunnels { ) -> Result { let existing_tunnels = self.list_tunnels_with_tag(&[self.tag]).await?; let is_name_free = |n: &str| { - !existing_tunnels.iter().any(|v| { - v.status - .as_ref() - .and_then(|s| s.host_connection_count.as_ref().map(|c| c.get_count())) - .unwrap_or(0) > 0 && v.labels.iter().any(|t| t == n) - }) + !existing_tunnels + .iter() + .any(|v| tunnel_has_host_connection(v) && v.labels.iter().any(|t| t == n)) }; if let Some(machine_name) = preferred_name { @@ -973,7 +968,6 @@ impl ActiveTunnelManager { } /// Adds a port for TCP/IP forwarding. - #[allow(dead_code)] // todo: port forwarding pub async fn add_port_tcp( &self, port_number: u16, @@ -1235,6 +1229,14 @@ fn privacy_to_tunnel_acl(privacy: PortPrivacy) -> TunnelAccessControl { } } +fn tunnel_has_host_connection(tunnel: &Tunnel) -> bool { + tunnel + .status + .as_ref() + .and_then(|s| s.host_connection_count.as_ref().map(|c| c.get_count() > 0)) + .unwrap_or_default() +} + #[cfg(test)] mod test { use super::*; diff --git a/cli/src/tunnels/port_forwarder.rs b/cli/src/tunnels/port_forwarder.rs index 093c1c8176f09..30267e8bc86c3 100644 --- a/cli/src/tunnels/port_forwarder.rs +++ b/cli/src/tunnels/port_forwarder.rs @@ -15,7 +15,7 @@ use crate::{ use super::{dev_tunnels::ActiveTunnel, protocol::PortPrivacy}; pub enum PortForwardingRec { - Forward(u16, oneshot::Sender>), + Forward(u16, PortPrivacy, oneshot::Sender>), Unforward(u16, oneshot::Sender>), } @@ -54,8 +54,9 @@ impl PortForwardingProcessor { /// Processes the incoming forwarding request. pub async fn process(&mut self, req: PortForwardingRec, tunnel: &mut ActiveTunnel) { match req { - PortForwardingRec::Forward(port, tx) => { - tx.send(self.process_forward(port, tunnel).await).ok(); + PortForwardingRec::Forward(port, privacy, tx) => { + tx.send(self.process_forward(port, privacy, tunnel).await) + .ok(); } PortForwardingRec::Unforward(port, tx) => { tx.send(self.process_unforward(port, tunnel).await).ok(); @@ -80,6 +81,7 @@ impl PortForwardingProcessor { async fn process_forward( &mut self, port: u16, + privacy: PortPrivacy, tunnel: &mut ActiveTunnel, ) -> Result { if port == CONTROL_PORT { @@ -87,7 +89,7 @@ impl PortForwardingProcessor { } if !self.forwarded.contains(&port) { - tunnel.add_port_tcp(port, PortPrivacy::Private).await?; + tunnel.add_port_tcp(port, privacy).await?; self.forwarded.insert(port); } @@ -101,9 +103,9 @@ pub struct PortForwarding { } impl PortForwarding { - pub async fn forward(&self, port: u16) -> Result { + pub async fn forward(&self, port: u16, privacy: PortPrivacy) -> Result { let (tx, rx) = oneshot::channel(); - let req = PortForwardingRec::Forward(port, tx); + let req = PortForwardingRec::Forward(port, privacy, tx); if self.tx.send(req).await.is_err() { return Err(ServerHasClosed().into()); diff --git a/cli/src/tunnels/protocol.rs b/cli/src/tunnels/protocol.rs index 6434faadc390f..d26ea978068dd 100644 --- a/cli/src/tunnels/protocol.rs +++ b/cli/src/tunnels/protocol.rs @@ -47,6 +47,8 @@ pub struct HttpHeadersParams { #[derive(Deserialize, Debug)] pub struct ForwardParams { pub port: u16, + #[serde(default)] + pub public: bool, } #[derive(Deserialize, Debug)] diff --git a/cli/src/tunnels/service_linux.rs b/cli/src/tunnels/service_linux.rs index b60d114dc46cb..80599ba3c3239 100644 --- a/cli/src/tunnels/service_linux.rs +++ b/cli/src/tunnels/service_linux.rs @@ -90,6 +90,10 @@ impl ServiceManager for SystemdService { info!(self.log, "Successfully registered service..."); + if let Err(e) = proxy.reload().await { + warning!(self.log, "Error issuing reload(): {}", e); + } + // note: enablement is implicit in recent systemd version, but required for older systems // https://github.com/microsoft/vscode/issues/167489#issuecomment-1331222826 proxy @@ -257,4 +261,7 @@ trait SystemdManagerDbus { #[dbus_proxy(name = "StopUnit")] fn stop_unit(&self, name: String, mode: String) -> zbus::Result; + + #[dbus_proxy(name = "Reload")] + fn reload(&self) -> zbus::Result<()>; } diff --git a/cli/src/tunnels/socket_signal.rs b/cli/src/tunnels/socket_signal.rs index 9036c6ae3f9b6..2227f323852c8 100644 --- a/cli/src/tunnels/socket_signal.rs +++ b/cli/src/tunnels/socket_signal.rs @@ -94,41 +94,42 @@ impl ServerMessageSink { async fn server_message_or_closed( &mut self, - body: Option<&[u8]>, + body_or_end: Option<&[u8]>, ) -> Result<(), mpsc::error::SendError> { let i = self.id; let mut tx = self.tx.take().unwrap(); - let msg = body - .map(|b| self.get_server_msg_content(b)) - .map(|body| RefServerMessageParams { i, body }); - - let r = match &mut tx { - ServerMessageDestination::Channel(tx) => { - tx.send(SocketSignal::from_message(&ToClientRequest { - id: None, - params: match msg { - Some(msg) => ClientRequestMethod::servermsg(msg), - None => ClientRequestMethod::serverclose(ServerClosedParams { i }), - }, - })) - .await - } - ServerMessageDestination::Rpc(caller) => { - match msg { - Some(msg) => caller.notify("servermsg", msg), - None => caller.notify("serverclose", ServerClosedParams { i }), - }; - Ok(()) - } - }; + if let Some(b) = body_or_end { + let body = self.get_server_msg_content(b, false); + let r = + send_data_or_close_if_none(i, &mut tx, Some(RefServerMessageParams { i, body })) + .await; + self.tx = Some(tx); + return r; + } + + let tail = self.get_server_msg_content(&[], true); + if !tail.is_empty() { + let _ = send_data_or_close_if_none( + i, + &mut tx, + Some(RefServerMessageParams { i, body: tail }), + ) + .await; + } + + let r = send_data_or_close_if_none(i, &mut tx, None).await; self.tx = Some(tx); r } - pub(crate) fn get_server_msg_content<'a: 'b, 'b>(&'a mut self, body: &'b [u8]) -> &'b [u8] { + pub(crate) fn get_server_msg_content<'a: 'b, 'b>( + &'a mut self, + body: &'b [u8], + finish: bool, + ) -> &'b [u8] { if let Some(flate) = &mut self.flate { - if let Ok(compressed) = flate.process(body) { + if let Ok(compressed) = flate.process(body, finish) { return compressed; } } @@ -137,6 +138,32 @@ impl ServerMessageSink { } } +async fn send_data_or_close_if_none( + i: u16, + tx: &mut ServerMessageDestination, + msg: Option>, +) -> Result<(), mpsc::error::SendError> { + match tx { + ServerMessageDestination::Channel(tx) => { + tx.send(SocketSignal::from_message(&ToClientRequest { + id: None, + params: match msg { + Some(msg) => ClientRequestMethod::servermsg(msg), + None => ClientRequestMethod::serverclose(ServerClosedParams { i }), + }, + })) + .await + } + ServerMessageDestination::Rpc(caller) => { + match msg { + Some(msg) => caller.notify("servermsg", msg), + None => caller.notify("serverclose", ServerClosedParams { i }), + }; + Ok(()) + } + } +} + impl Drop for ServerMessageSink { fn drop(&mut self) { self.multiplexer.remove(self.id); @@ -162,7 +189,8 @@ impl ClientMessageDecoder { pub fn decode<'a: 'b, 'b>(&'a mut self, message: &'b [u8]) -> std::io::Result<&'b [u8]> { match &mut self.dec { - Some(d) => d.process(message), + // todo@connor4312 do we ever need to actually 'finish' the client message stream? + Some(d) => d.process(message, false), None => Ok(message), } } @@ -175,6 +203,7 @@ trait FlateAlgorithm { &mut self, contents: &[u8], output: &mut [u8], + finish: bool, ) -> Result; } @@ -193,9 +222,15 @@ impl FlateAlgorithm for DecompressFlateAlgorithm { &mut self, contents: &[u8], output: &mut [u8], + finish: bool, ) -> Result { + let mode = match finish { + true => flate2::FlushDecompress::Finish, + false => flate2::FlushDecompress::None, + }; + self.0 - .decompress(contents, output, flate2::FlushDecompress::None) + .decompress(contents, output, mode) .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidInput, e)) } } @@ -215,9 +250,15 @@ impl FlateAlgorithm for CompressFlateAlgorithm { &mut self, contents: &[u8], output: &mut [u8], + finish: bool, ) -> Result { + let mode = match finish { + true => flate2::FlushCompress::Finish, + false => flate2::FlushCompress::Sync, + }; + self.0 - .compress(contents, output, flate2::FlushCompress::Sync) + .compress(contents, output, mode) .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidInput, e)) } } @@ -241,23 +282,25 @@ where } } - pub fn process(&mut self, contents: &[u8]) -> std::io::Result<&[u8]> { + pub fn process(&mut self, contents: &[u8], finish: bool) -> std::io::Result<&[u8]> { let mut out_offset = 0; let mut in_offset = 0; loop { let in_before = self.flate.total_in(); let out_before = self.flate.total_out(); - match self - .flate - .process(&contents[in_offset..], &mut self.output[out_offset..]) - { + match self.flate.process( + &contents[in_offset..], + &mut self.output[out_offset..], + finish, + ) { Ok(flate2::Status::Ok | flate2::Status::BufError) => { let processed_len = in_offset + (self.flate.total_in() - in_before) as usize; let output_len = out_offset + (self.flate.total_out() - out_before) as usize; - if processed_len < contents.len() { + if processed_len < contents.len() || output_len == self.output.len() { // If we filled the output buffer but there's more data to compress, - // extend the output buffer and keep compressing. + // or the output got filled after processing all input, extend + // the output buffer and keep compressing. out_offset = output_len; in_offset = processed_len; if output_len == self.output.len() { @@ -298,7 +341,7 @@ mod tests { // 3000 and 30000 test resizing the buffer for msg_len in [3, 30, 300, 3000, 30000] { let vals = (0..msg_len).map(|v| v as u8).collect::>(); - let compressed = sink.get_server_msg_content(&vals); + let compressed = sink.get_server_msg_content(&vals, false); assert_ne!(compressed, vals); let decompressed = decompress.decode(compressed).unwrap(); assert_eq!(decompressed.len(), vals.len()); diff --git a/cli/src/update_service.rs b/cli/src/update_service.rs index d218e4a133394..4bec13d6e86a3 100644 --- a/cli/src/update_service.rs +++ b/cli/src/update_service.rs @@ -209,8 +209,11 @@ pub enum Platform { LinuxAlpineX64, LinuxAlpineARM64, LinuxX64, + LinuxX64Legacy, LinuxARM64, + LinuxARM64Legacy, LinuxARM32, + LinuxARM32Legacy, DarwinX64, DarwinARM64, WindowsX64, @@ -237,8 +240,11 @@ impl Platform { Platform::LinuxAlpineARM64 => "server-alpine-arm64", Platform::LinuxAlpineX64 => "server-linux-alpine", Platform::LinuxX64 => "server-linux-x64", + Platform::LinuxX64Legacy => "server-linux-legacy-x64", Platform::LinuxARM64 => "server-linux-arm64", + Platform::LinuxARM64Legacy => "server-linux-legacy-arm64", Platform::LinuxARM32 => "server-linux-armhf", + Platform::LinuxARM32Legacy => "server-linux-legacy-armhf", Platform::DarwinX64 => "server-darwin", Platform::DarwinARM64 => "server-darwin-arm64", Platform::WindowsX64 => "server-win32-x64", @@ -253,8 +259,11 @@ impl Platform { Platform::LinuxAlpineARM64 => "cli-alpine-arm64", Platform::LinuxAlpineX64 => "cli-alpine-x64", Platform::LinuxX64 => "cli-linux-x64", + Platform::LinuxX64Legacy => "cli-linux-x64", Platform::LinuxARM64 => "cli-linux-arm64", + Platform::LinuxARM64Legacy => "cli-linux-arm64", Platform::LinuxARM32 => "cli-linux-armhf", + Platform::LinuxARM32Legacy => "cli-linux-armhf", Platform::DarwinX64 => "cli-darwin-x64", Platform::DarwinARM64 => "cli-darwin-arm64", Platform::WindowsARM64 => "cli-win32-arm64", @@ -309,8 +318,11 @@ impl fmt::Display for Platform { Platform::LinuxAlpineARM64 => "LinuxAlpineARM64", Platform::LinuxAlpineX64 => "LinuxAlpineX64", Platform::LinuxX64 => "LinuxX64", + Platform::LinuxX64Legacy => "LinuxX64Legacy", Platform::LinuxARM64 => "LinuxARM64", + Platform::LinuxARM64Legacy => "LinuxARM64Legacy", Platform::LinuxARM32 => "LinuxARM32", + Platform::LinuxARM32Legacy => "LinuxARM32Legacy", Platform::DarwinX64 => "DarwinX64", Platform::DarwinARM64 => "DarwinARM64", Platform::WindowsX64 => "WindowsX64", diff --git a/cli/src/util/errors.rs b/cli/src/util/errors.rs index 03280d12f0af3..fc706199aabe8 100644 --- a/cli/src/util/errors.rs +++ b/cli/src/util/errors.rs @@ -471,7 +471,7 @@ pub enum CodeError { #[error("platform not currently supported: {0}")] UnsupportedPlatform(String), - #[error("This machine does not meet {name}'s prerequisites, expected either...: {bullets}")] + #[error("This machine does not meet {name}'s prerequisites, expected either...\n{bullets}")] PrerequisitesFailed { name: &'static str, bullets: String }, #[error("failed to spawn process: {0:?}")] ProcessSpawnFailed(std::io::Error), @@ -512,6 +512,10 @@ pub enum CodeError { // todo: can be specialized when update service is moved to CodeErrors #[error("Could not check for update: {0}")] UpdateCheckFailed(String), + #[error("Could not write connection token file: {0}")] + CouldNotCreateConnectionTokenFile(std::io::Error), + #[error("A tunnel with the name {0} exists and is in-use. Please pick a different name or stop the existing tunnel.")] + TunnelActiveAndInUse(String), } makeAnyError!( diff --git a/cli/src/util/io.rs b/cli/src/util/io.rs index 95b378c0c65b6..93a7efbfdd935 100644 --- a/cli/src/util/io.rs +++ b/cli/src/util/io.rs @@ -241,11 +241,7 @@ mod tests { let mut rx = tailf(read_file, 32); assert!(rx.try_recv().is_err()); - let mut append_file = OpenOptions::new() - .write(true) - .append(true) - .open(&file_path) - .unwrap(); + let mut append_file = OpenOptions::new().append(true).open(&file_path).unwrap(); writeln!(&mut append_file, "some line").unwrap(); let recv = rx.recv().await; @@ -338,11 +334,7 @@ mod tests { assert!(rx.try_recv().is_err()); - let mut append_file = OpenOptions::new() - .write(true) - .append(true) - .open(&file_path) - .unwrap(); + let mut append_file = OpenOptions::new().append(true).open(&file_path).unwrap(); writeln!(append_file, " is now complete").unwrap(); let recv = rx.recv().await; diff --git a/cli/src/util/machine.rs b/cli/src/util/machine.rs index 4c7b6729e4325..1eb0759f9f9ba 100644 --- a/cli/src/util/machine.rs +++ b/cli/src/util/machine.rs @@ -3,7 +3,11 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -use std::{path::Path, time::Duration}; +use std::{ + ffi::OsString, + path::{Path, PathBuf}, + time::Duration, +}; use sysinfo::{Pid, PidExt, ProcessExt, System, SystemExt}; pub fn process_at_path_exists(pid: u32, name: &Path) -> bool { @@ -71,3 +75,78 @@ pub async fn wait_until_exe_deleted(current_exe: &Path, poll_ms: u64) { tokio::time::sleep(duration).await; } } + +/// Gets the canonical current exe location, referring to the "current" symlink +/// if running inside snap. +pub fn canonical_exe() -> std::io::Result { + canonical_exe_inner( + std::env::current_exe(), + std::env::var_os("SNAP"), + std::env::var_os("SNAP_REVISION"), + ) +} + +#[inline(always)] +#[allow(unused_variables)] +fn canonical_exe_inner( + exe: std::io::Result, + snap: Option, + rev: Option, +) -> std::io::Result { + let exe = exe?; + + #[cfg(target_os = "linux")] + if let (Some(snap), Some(rev)) = (snap, rev) { + if !exe.starts_with(snap) { + return Ok(exe); + } + + let mut out = PathBuf::new(); + for part in exe.iter() { + if part == rev { + out.push("current") + } else { + out.push(part) + } + } + + return Ok(out); + } + + Ok(exe) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + + #[test] + #[cfg(target_os = "linux")] + fn test_canonical_exe_in_snap() { + let exe = canonical_exe_inner( + Ok(PathBuf::from("/snap/my-snap/1234/some/exe")), + Some("/snap/my-snap/1234".into()), + Some("1234".into()), + ) + .unwrap(); + assert_eq!(exe, PathBuf::from("/snap/my-snap/current/some/exe")); + } + + #[test] + fn test_canonical_exe_not_in_snap() { + let exe = canonical_exe_inner( + Ok(PathBuf::from("/not-in-snap")), + Some("/snap/my-snap/1234".into()), + Some("1234".into()), + ) + .unwrap(); + assert_eq!(exe, PathBuf::from("/not-in-snap")); + } + + #[test] + fn test_canonical_exe_not_in_snap2() { + let exe = canonical_exe_inner(Ok(PathBuf::from("/not-in-snap")), None, None).unwrap(); + assert_eq!(exe, PathBuf::from("/not-in-snap")); + } +} diff --git a/cli/src/util/prereqs.rs b/cli/src/util/prereqs.rs index b22fd469facdd..20a5bc94b374e 100644 --- a/cli/src/util/prereqs.rs +++ b/cli/src/util/prereqs.rs @@ -4,7 +4,6 @@ *--------------------------------------------------------------------------------------------*/ use std::cmp::Ordering; -use super::command::capture_command; use crate::constants::QUALITYLESS_SERVER_NAME; use crate::update_service::Platform; use lazy_static::lazy_static; @@ -20,8 +19,10 @@ lazy_static! { static ref GENERIC_VERSION_RE: Regex = Regex::new(r"^([0-9]+)\.([0-9]+)$").unwrap(); static ref LIBSTD_CXX_VERSION_RE: BinRegex = BinRegex::new(r"GLIBCXX_([0-9]+)\.([0-9]+)(?:\.([0-9]+))?").unwrap(); - static ref MIN_CXX_VERSION: SimpleSemver = SimpleSemver::new(3, 4, 19); - static ref MIN_LDD_VERSION: SimpleSemver = SimpleSemver::new(2, 17, 0); + static ref MIN_CXX_VERSION: SimpleSemver = SimpleSemver::new(3, 4, 25); + static ref MIN_LEGACY_CXX_VERSION: SimpleSemver = SimpleSemver::new(3, 4, 19); + static ref MIN_LDD_VERSION: SimpleSemver = SimpleSemver::new(2, 28, 0); + static ref MIN_LEGACY_LDD_VERSION: SimpleSemver = SimpleSemver::new(2, 17, 0); } const NIXOS_TEST_PATH: &str = "/etc/NIXOS"; @@ -63,18 +64,30 @@ impl PreReqChecker { } else { println!("!!! WARNING: Skipping server pre-requisite check !!!"); println!("!!! Server stability is not guaranteed. Proceed at your own risk. !!!"); - (Ok(()), Ok(())) + (Ok(false), Ok(false)) }; - if (gnu_a.is_ok() && gnu_b.is_ok()) || is_nixos { - return Ok(if cfg!(target_arch = "x86_64") { - Platform::LinuxX64 - } else if cfg!(target_arch = "arm") { - Platform::LinuxARM32 - } else { - Platform::LinuxARM64 - }); - } + match (&gnu_a, &gnu_b, is_nixos) { + (Ok(false), Ok(false), _) | (_, _, true) => { + return Ok(if cfg!(target_arch = "x86_64") { + Platform::LinuxX64 + } else if cfg!(target_arch = "arm") { + Platform::LinuxARM32 + } else { + Platform::LinuxARM64 + }); + } + (Ok(_), Ok(_), _) => { + return Ok(if cfg!(target_arch = "x86_64") { + Platform::LinuxX64Legacy + } else if cfg!(target_arch = "arm") { + Platform::LinuxARM32Legacy + } else { + Platform::LinuxARM64Legacy + }); + } + _ => {} + }; if or_musl.is_ok() { return Ok(if cfg!(target_arch = "x86_64") { @@ -126,8 +139,9 @@ async fn check_musl_interpreter() -> Result<(), String> { Ok(()) } -#[allow(dead_code)] -async fn check_glibc_version() -> Result<(), String> { +/// Checks the glibc version, returns "true" if the legacy server is required. +#[cfg(target_os = "linux")] +async fn check_glibc_version() -> Result { #[cfg(target_env = "gnu")] let version = { let v = unsafe { libc::gnu_get_libc_version() }; @@ -137,7 +151,7 @@ async fn check_glibc_version() -> Result<(), String> { }; #[cfg(not(target_env = "gnu"))] let version = { - capture_command("ldd", ["--version"]) + super::command::capture_command("ldd", ["--version"]) .await .ok() .and_then(|o| extract_ldd_version(&o.stdout)) @@ -145,7 +159,9 @@ async fn check_glibc_version() -> Result<(), String> { if let Some(v) = version { return if v >= *MIN_LDD_VERSION { - Ok(()) + Ok(false) + } else if v >= *MIN_LEGACY_LDD_VERSION { + Ok(true) } else { Err(format!( "find GLIBC >= {} (but found {} instead) for GNU environments", @@ -154,7 +170,7 @@ async fn check_glibc_version() -> Result<(), String> { }; } - Ok(()) + Ok(false) } /// Check for nixos to avoid mandating glibc versions. See: @@ -180,8 +196,9 @@ pub async fn skip_requirements_check() -> bool { false } -#[allow(dead_code)] -async fn check_glibcxx_version() -> Result<(), String> { +/// Checks the glibc++ version, returns "true" if the legacy server is required. +#[cfg(target_os = "linux")] +async fn check_glibcxx_version() -> Result { let mut libstdc_path: Option = None; #[cfg(any(target_arch = "x86_64", target_arch = "aarch64"))] @@ -193,7 +210,7 @@ async fn check_glibcxx_version() -> Result<(), String> { if fs::metadata(DEFAULT_LIB_PATH).await.is_ok() { libstdc_path = Some(DEFAULT_LIB_PATH.to_owned()); } else if fs::metadata(LDCONFIG_PATH).await.is_ok() { - libstdc_path = capture_command(LDCONFIG_PATH, ["-p"]) + libstdc_path = super::command::capture_command(LDCONFIG_PATH, ["-p"]) .await .ok() .and_then(|o| extract_libstd_from_ldconfig(&o.stdout)); @@ -211,30 +228,35 @@ async fn check_glibcxx_version() -> Result<(), String> { } } -#[allow(dead_code)] -fn check_for_sufficient_glibcxx_versions(contents: Vec) -> Result<(), String> { - let all_versions: Vec = LIBSTD_CXX_VERSION_RE +#[cfg(target_os = "linux")] +fn check_for_sufficient_glibcxx_versions(contents: Vec) -> Result { + let max_version = LIBSTD_CXX_VERSION_RE .captures_iter(&contents) .map(|m| SimpleSemver { major: m.get(1).map_or(0, |s| u32_from_bytes(s.as_bytes())), minor: m.get(2).map_or(0, |s| u32_from_bytes(s.as_bytes())), patch: m.get(3).map_or(0, |s| u32_from_bytes(s.as_bytes())), }) - .collect(); + .max(); - if !all_versions.iter().any(|v| &*MIN_CXX_VERSION >= v) { - return Err(format!( - "find GLIBCXX >= {} (but found {} instead) for GNU environments", - *MIN_CXX_VERSION, - all_versions - .iter() - .map(String::from) - .collect::>() - .join(", ") - )); + if let Some(max_version) = &max_version { + if max_version >= &*MIN_CXX_VERSION { + return Ok(false); + } + + if max_version >= &*MIN_LEGACY_CXX_VERSION { + return Ok(true); + } } - Ok(()) + Err(format!( + "find GLIBCXX >= {} (but found {} instead) for GNU environments", + *MIN_CXX_VERSION, + max_version + .as_ref() + .map(String::from) + .unwrap_or("none".to_string()) + )) } #[allow(dead_code)] @@ -255,6 +277,7 @@ fn extract_generic_version(output: &str) -> Option { }) } +#[allow(dead_code)] fn extract_libstd_from_ldconfig(output: &[u8]) -> Option { String::from_utf8_lossy(output) .lines() @@ -326,12 +349,12 @@ mod tests { #[test] fn test_extract_libstd_from_ldconfig() { let actual = " - libstoken.so.1 (libc6,x86-64) => /lib/x86_64-linux-gnu/libstoken.so.1 - libstemmer.so.0d (libc6,x86-64) => /lib/x86_64-linux-gnu/libstemmer.so.0d - libstdc++.so.6 (libc6,x86-64) => /lib/x86_64-linux-gnu/libstdc++.so.6 - libstartup-notification-1.so.0 (libc6,x86-64) => /lib/x86_64-linux-gnu/libstartup-notification-1.so.0 - libssl3.so (libc6,x86-64) => /lib/x86_64-linux-gnu/libssl3.so - ".to_owned().into_bytes(); + libstoken.so.1 (libc6,x86-64) => /lib/x86_64-linux-gnu/libstoken.so.1 + libstemmer.so.0d (libc6,x86-64) => /lib/x86_64-linux-gnu/libstemmer.so.0d + libstdc++.so.6 (libc6,x86-64) => /lib/x86_64-linux-gnu/libstdc++.so.6 + libstartup-notification-1.so.0 (libc6,x86-64) => /lib/x86_64-linux-gnu/libstartup-notification-1.so.0 + libssl3.so (libc6,x86-64) => /lib/x86_64-linux-gnu/libssl3.so + ".to_owned().into_bytes(); assert_eq!( extract_libstd_from_ldconfig(&actual), @@ -358,10 +381,10 @@ mod tests { #[test] fn check_for_sufficient_glibcxx_versions() { let actual = "ldd (Ubuntu GLIBC 2.31-0ubuntu9.7) 2.31 - Copyright (C) 2020 Free Software Foundation, Inc. - This is free software; see the source for copying conditions. There is NO - warranty; not even for MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. - Written by Roland McGrath and Ulrich Drepper." + Copyright (C) 2020 Free Software Foundation, Inc. + This is free software; see the source for copying conditions. There is NO + warranty; not even for MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + Written by Roland McGrath and Ulrich Drepper." .to_owned() .into_bytes(); diff --git a/cli/src/util/tar.rs b/cli/src/util/tar.rs index 0a5496411f7f3..fe4d426970021 100644 --- a/cli/src/util/tar.rs +++ b/cli/src/util/tar.rs @@ -13,7 +13,7 @@ use tar::Archive; use super::errors::wrapdbg; use super::io::ReportCopyProgress; -fn should_skip_first_segment(file: &fs::File) -> Result { +fn should_skip_first_segment(file: &fs::File) -> Result<(bool, u64), WrappedError> { // unfortunately, we need to re-read the archive here since you cannot reuse // `.entries()`. But this will generally only look at one or two files, so this // should be acceptably speedy... If not, we could hardcode behavior for @@ -39,17 +39,21 @@ fn should_skip_first_segment(file: &fs::File) -> Result { .to_owned() }; - let mut had_multiple = false; + let mut num_entries = 1; + let mut had_different_prefixes = false; for file in entries.flatten() { - had_multiple = true; - if let Ok(name) = file.path() { - if name.iter().next() != Some(&first_name) { - return Ok(false); + if !had_different_prefixes { + if let Ok(name) = file.path() { + if name.iter().next() != Some(&first_name) { + had_different_prefixes = true; + } } } + + num_entries += 1; } - Ok(had_multiple) // prefix removal is invalid if there's only a single file + Ok((!had_different_prefixes && num_entries > 1, num_entries)) // prefix removal is invalid if there's only a single file } pub fn decompress_tarball( @@ -62,7 +66,11 @@ where { let mut tar_gz = fs::File::open(path) .map_err(|e| wrap(e, format!("error opening file {}", path.display())))?; - let skip_first = should_skip_first_segment(&tar_gz)?; + + let (skip_first, num_entries) = should_skip_first_segment(&tar_gz)?; + let report_progress_every = num_entries / 20; + let mut entries_so_far = 0; + let mut last_reported_at = 0; // reset since skip logic read the tar already: tar_gz @@ -71,12 +79,19 @@ where let tar = GzDecoder::new(tar_gz); let mut archive = Archive::new(tar); - - let results = archive + archive .entries() .map_err(|e| wrap(e, format!("error opening archive {}", path.display())))? .filter_map(|e| e.ok()) - .map(|mut entry| { + .try_for_each::<_, Result<_, WrappedError>>(|mut entry| { + // approximate progress based on where we are in the archive: + entries_so_far += 1; + if entries_so_far - last_reported_at > report_progress_every { + reporter.report_progress(entries_so_far, num_entries); + entries_so_far += 1; + last_reported_at = entries_so_far; + } + let entry_path = entry .path() .map_err(|e| wrap(e, "error reading entry path"))?; @@ -95,12 +110,11 @@ where entry .unpack(&path) .map_err(|e| wrapdbg(e, format!("error unpacking {}", path.display())))?; - Ok(path) - }) - .collect::, WrappedError>>()?; - // Tarballs don't have a way to get the number of entries ahead of time - reporter.report_progress(results.len() as u64, results.len() as u64); + Ok(()) + })?; + + reporter.report_progress(num_entries, num_entries); Ok(()) } diff --git a/cli/src/util/zipper.rs b/cli/src/util/zipper.rs index 0e9939d4db516..69bcf2d23f6d3 100644 --- a/cli/src/util/zipper.rs +++ b/cli/src/util/zipper.rs @@ -55,8 +55,12 @@ where .map_err(|e| wrap(e, format!("failed to open zip archive {}", path.display())))?; let skip_segments_no = usize::from(should_skip_first_segment(&mut archive)); + let report_progress_every = archive.len() / 20; + for i in 0..archive.len() { - reporter.report_progress(i as u64, archive.len() as u64); + if i % report_progress_every == 0 { + reporter.report_progress(i as u64, archive.len() as u64); + } let mut file = archive .by_index(i) .map_err(|e| wrap(e, format!("could not open zip entry {}", i)))?; diff --git a/extensions/configuration-editing/src/settingsDocumentHelper.ts b/extensions/configuration-editing/src/settingsDocumentHelper.ts index 110494fdb3e69..6135df5315a5e 100644 --- a/extensions/configuration-editing/src/settingsDocumentHelper.ts +++ b/extensions/configuration-editing/src/settingsDocumentHelper.ts @@ -36,6 +36,11 @@ export class SettingsDocument { return this.provideLanguageCompletionItems(location, position); } + // workbench.editor.label + if (location.path[0] === 'workbench.editor.label.patterns') { + return this.provideEditorLabelCompletionItems(location, position); + } + // settingsSync.ignoredExtensions if (location.path[0] === 'settingsSync.ignoredExtensions') { let ignoredExtensions = []; @@ -120,7 +125,34 @@ export class SettingsDocument { completions.push(this.newSimpleCompletionItem(getText('remoteName'), range, vscode.l10n.t("e.g. SSH"))); completions.push(this.newSimpleCompletionItem(getText('dirty'), range, vscode.l10n.t("an indicator for when the active editor has unsaved changes"))); completions.push(this.newSimpleCompletionItem(getText('separator'), range, vscode.l10n.t("a conditional separator (' - ') that only shows when surrounded by variables with values"))); + completions.push(this.newSimpleCompletionItem(getText('activeRepositoryName'), range, vscode.l10n.t("the name of the active repository (e.g. vscode)"))); + completions.push(this.newSimpleCompletionItem(getText('activeRepositoryBranchName'), range, vscode.l10n.t("the name of the active branch in the active repository (e.g. main)"))); + + return completions; + } + + private async provideEditorLabelCompletionItems(location: Location, pos: vscode.Position): Promise { + const completions: vscode.CompletionItem[] = []; + + if (!this.isCompletingPropertyValue(location, pos)) { + return completions; + } + + let range = this.document.getWordRangeAtPosition(pos, /\$\{[^"\}]*\}?/); + if (!range || range.start.isEqual(pos) || range.end.isEqual(pos) && this.document.getText(range).endsWith('}')) { + range = new vscode.Range(pos, pos); + } + + const getText = (variable: string) => { + const text = '${' + variable + '}'; + return location.previousNode ? text : JSON.stringify(text); + }; + + completions.push(this.newSimpleCompletionItem(getText('dirname'), range, vscode.l10n.t("The parent folder name of the editor (e.g. myFileFolder)"))); + completions.push(this.newSimpleCompletionItem(getText('dirname(1)'), range, vscode.l10n.t("The nth parent folder name of the editor"))); + completions.push(this.newSimpleCompletionItem(getText('filename'), range, vscode.l10n.t("The file name of the editor without its directory or extension (e.g. myFile)"))); + completions.push(this.newSimpleCompletionItem(getText('extname'), range, vscode.l10n.t("The file extension of the editor (e.g. txt)"))); return completions; } diff --git a/extensions/css-language-features/package.json b/extensions/css-language-features/package.json index 06e58ee064d84..e105c0090eeaa 100644 --- a/extensions/css-language-features/package.json +++ b/extensions/css-language-features/package.json @@ -994,7 +994,7 @@ ] }, "dependencies": { - "vscode-languageclient": "9.0.1", + "vscode-languageclient": "^9.0.1", "vscode-uri": "^3.0.8" }, "devDependencies": { diff --git a/extensions/css-language-features/package.nls.json b/extensions/css-language-features/package.nls.json index 5e9129c84e17e..d6e25a57a43a1 100644 --- a/extensions/css-language-features/package.nls.json +++ b/extensions/css-language-features/package.nls.json @@ -12,7 +12,7 @@ "css.lint.emptyRules.desc": "Do not use empty rulesets.", "css.lint.float.desc": "Avoid using `float`. Floats lead to fragile CSS that is easy to break if one aspect of the layout changes.", "css.lint.fontFaceProperties.desc": "`@font-face` rule must define `src` and `font-family` properties.", - "css.lint.hexColorLength.desc": "Hex colors must consist of three or six hex numbers.", + "css.lint.hexColorLength.desc": "Hex colors must consist of 3, 4, 6 or 8 hex numbers.", "css.lint.idSelector.desc": "Selectors should not contain IDs because these rules are too tightly coupled with the HTML.", "css.lint.ieHack.desc": "IE hacks are only necessary when supporting IE7 and older.", "css.lint.important.desc": "Avoid using `!important`. It is an indication that the specificity of the entire CSS has gotten out of control and needs to be refactored.", @@ -47,7 +47,7 @@ "less.lint.emptyRules.desc": "Do not use empty rulesets.", "less.lint.float.desc": "Avoid using `float`. Floats lead to fragile CSS that is easy to break if one aspect of the layout changes.", "less.lint.fontFaceProperties.desc": "`@font-face` rule must define `src` and `font-family` properties.", - "less.lint.hexColorLength.desc": "Hex colors must consist of three or six hex numbers.", + "less.lint.hexColorLength.desc": "Hex colors must consist of 3, 4, 6 or 8 hex numbers.", "less.lint.idSelector.desc": "Selectors should not contain IDs because these rules are too tightly coupled with the HTML.", "less.lint.ieHack.desc": "IE hacks are only necessary when supporting IE7 and older.", "less.lint.important.desc": "Avoid using `!important`. It is an indication that the specificity of the entire CSS has gotten out of control and needs to be refactored.", @@ -81,7 +81,7 @@ "scss.lint.emptyRules.desc": "Do not use empty rulesets.", "scss.lint.float.desc": "Avoid using `float`. Floats lead to fragile CSS that is easy to break if one aspect of the layout changes.", "scss.lint.fontFaceProperties.desc": "`@font-face` rule must define `src` and `font-family` properties.", - "scss.lint.hexColorLength.desc": "Hex colors must consist of three or six hex numbers.", + "scss.lint.hexColorLength.desc": "Hex colors must consist of 3, 4, 6 or 8 hex numbers.", "scss.lint.idSelector.desc": "Selectors should not contain IDs because these rules are too tightly coupled with the HTML.", "scss.lint.ieHack.desc": "IE hacks are only necessary when supporting IE7 and older.", "scss.lint.important.desc": "Avoid using `!important`. It is an indication that the specificity of the entire CSS has gotten out of control and needs to be refactored.", diff --git a/extensions/css-language-features/server/package.json b/extensions/css-language-features/server/package.json index b5ff75c76860b..0a790771db20c 100644 --- a/extensions/css-language-features/server/package.json +++ b/extensions/css-language-features/server/package.json @@ -11,8 +11,8 @@ "browser": "./dist/browser/cssServerMain", "dependencies": { "@vscode/l10n": "^0.0.18", - "vscode-css-languageservice": "^6.2.12", - "vscode-languageserver": "^9.0.2-next.1", + "vscode-css-languageservice": "^6.2.13", + "vscode-languageserver": "^10.0.0-next.2", "vscode-uri": "^3.0.8" }, "devDependencies": { diff --git a/extensions/css-language-features/server/yarn.lock b/extensions/css-language-features/server/yarn.lock index 2e2a0c9a6ca99..b7f86c89a112e 100644 --- a/extensions/css-language-features/server/yarn.lock +++ b/extensions/css-language-features/server/yarn.lock @@ -24,28 +24,28 @@ undici-types@~5.26.4: resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-5.26.5.tgz#bcd539893d00b56e964fd2657a4866b221a65617" integrity sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA== -vscode-css-languageservice@^6.2.12: - version "6.2.12" - resolved "https://registry.yarnpkg.com/vscode-css-languageservice/-/vscode-css-languageservice-6.2.12.tgz#f8f9f335fb4b433f557c51c62e687b4f62c0c786" - integrity sha512-PS9r7HgNjqzRl3v91sXpCyZPc8UDotNo6gntFNtGCKPhGA9Frk7g/VjX1Mbv3F00pn56D+rxrFzR9ep4cawOgA== +vscode-css-languageservice@^6.2.13: + version "6.2.13" + resolved "https://registry.yarnpkg.com/vscode-css-languageservice/-/vscode-css-languageservice-6.2.13.tgz#c7c2dc7a081a203048d60157c65536767d6d96f8" + integrity sha512-2rKWXfH++Kxd9Z4QuEgd1IF7WmblWWU7DScuyf1YumoGLkY9DW6wF/OTlhOyO2rN63sWHX2dehIpKBbho4ZwvA== dependencies: "@vscode/l10n" "^0.0.18" vscode-languageserver-textdocument "^1.0.11" vscode-languageserver-types "3.17.5" vscode-uri "^3.0.8" -vscode-jsonrpc@8.2.1-next.1: - version "8.2.1-next.1" - resolved "https://registry.yarnpkg.com/vscode-jsonrpc/-/vscode-jsonrpc-8.2.1-next.1.tgz#52e1091907b56759114fabac803b18c44a48f2a9" - integrity sha512-L+DYtdUtqUXGpyMgHqer6IBKvFFhl/1ToiMmCmG85LYHuuX0jllHMz77MYt0RicakoYY+Lq1yLK6Qj3YBqgzDQ== +vscode-jsonrpc@9.0.0-next.2: + version "9.0.0-next.2" + resolved "https://registry.yarnpkg.com/vscode-jsonrpc/-/vscode-jsonrpc-9.0.0-next.2.tgz#29e9741c742c80329bba1c60ce38fd014651ba80" + integrity sha512-meIaXAgChCHzWy45QGU8YpCNyqnZQ/sYeCj32OLDDbUYsCF7AvgpdXx3nnZn9yzr8ed0Od9bW+NGphEmXsqvIQ== -vscode-languageserver-protocol@3.17.6-next.1: - version "3.17.6-next.1" - resolved "https://registry.yarnpkg.com/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.17.6-next.1.tgz#5d87f7f708667cf04dbefb5c860901df7d01ebc1" - integrity sha512-2npXUc8oe/fb9Bjcwm2HTWYZXyCbW4NTo7jkOrEciGO+/LfWbSMgqZ6PwKWgqUkgCbkPxQHNjoMqr9ol/Ehjgg== +vscode-languageserver-protocol@3.17.6-next.3: + version "3.17.6-next.3" + resolved "https://registry.yarnpkg.com/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.17.6-next.3.tgz#09d3e28e9ad12270233d07fa0b69cf1d51d7dfe4" + integrity sha512-H8ATH5SAvc3JzttS+AL6g681PiBOZM/l34WP2JZk4akY3y7NqTP+f9cJ+MhrVBbD3aDS8bdAKewZgbFLW6M8Pg== dependencies: - vscode-jsonrpc "8.2.1-next.1" - vscode-languageserver-types "3.17.6-next.1" + vscode-jsonrpc "9.0.0-next.2" + vscode-languageserver-types "3.17.6-next.3" vscode-languageserver-textdocument@^1.0.11: version "1.0.11" @@ -57,17 +57,17 @@ vscode-languageserver-types@3.17.5: resolved "https://registry.yarnpkg.com/vscode-languageserver-types/-/vscode-languageserver-types-3.17.5.tgz#3273676f0cf2eab40b3f44d085acbb7f08a39d8a" integrity sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg== -vscode-languageserver-types@3.17.6-next.1: - version "3.17.6-next.1" - resolved "https://registry.yarnpkg.com/vscode-languageserver-types/-/vscode-languageserver-types-3.17.6-next.1.tgz#a3d2006d52f7d4026ea67668113ec16c73cd8f1d" - integrity sha512-7xVc/xLtNhKuCKX0mINT6mFUrUuRz0EinhwPGT8Gtsv2hlo+xJb5NKbiGailcWa1/T5e4dr5Pb2MfGchHreHAA== +vscode-languageserver-types@3.17.6-next.3: + version "3.17.6-next.3" + resolved "https://registry.yarnpkg.com/vscode-languageserver-types/-/vscode-languageserver-types-3.17.6-next.3.tgz#f71d6c57f18d921346cfe0c227aabd72eb8cd2f0" + integrity sha512-l5kNFXFRQGuzriXpuBqFpRmkf6f6A4VoU3h95OsVkqIOoi1k7KbwSo600cIdsKSJWrPg/+vX+QMPcMw1oI7ItA== -vscode-languageserver@^9.0.2-next.1: - version "9.0.2-next.1" - resolved "https://registry.yarnpkg.com/vscode-languageserver/-/vscode-languageserver-9.0.2-next.1.tgz#cc9bbd66716346aa761e5bafa19d64559ab4e030" - integrity sha512-xySldxoHIcKXtxoI0LqRX3QcTdOVFt1SeHV0hyPq28p7xGPqWxUPcmTcfIqYdHefXG22nd8DQbGWOEe52yu08A== +vscode-languageserver@^10.0.0-next.2: + version "10.0.0-next.2" + resolved "https://registry.yarnpkg.com/vscode-languageserver/-/vscode-languageserver-10.0.0-next.2.tgz#9a8ac58f72979961497c4fd7f6097561d4134d5f" + integrity sha512-WZdK/XO6EkNU6foYck49NpS35sahWhYFs4hwCGalH/6lhPmdUKABTnWioK/RLZKWqH8E5HdlAHQMfSBIxKBV9Q== dependencies: - vscode-languageserver-protocol "3.17.6-next.1" + vscode-languageserver-protocol "3.17.6-next.3" vscode-uri@^3.0.8: version "3.0.8" diff --git a/extensions/css-language-features/yarn.lock b/extensions/css-language-features/yarn.lock index 794ee50acbead..d01914101caf1 100644 --- a/extensions/css-language-features/yarn.lock +++ b/extensions/css-language-features/yarn.lock @@ -4,50 +4,50 @@ "@types/node@18.x": version "18.15.13" - resolved "https://registry.yarnpkg.com/@types/node/-/node-18.15.13.tgz#f64277c341150c979e42b00e4ac289290c9df469" + resolved "https://registry.npmjs.org/@types/node/-/node-18.15.13.tgz" integrity sha512-N+0kuo9KgrUQ1Sn/ifDXsvg0TTleP7rIy4zOBGECxAljqvqfqpTfzx0Q1NUedOixRMBfe2Whhb056a42cWs26Q== balanced-match@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.0.tgz#89b4d199ab2bee49de164ea02b89ce462d71b767" - integrity sha1-ibTRmasr7kneFk6gK4nORi1xt2c= + version "1.0.2" + resolved "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz" + integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw== brace-expansion@^2.0.1: version "2.0.1" - resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-2.0.1.tgz#1edc459e0f0c548486ecf9fc99f2221364b9a0ae" + resolved "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz" integrity sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA== dependencies: balanced-match "^1.0.0" lru-cache@^6.0.0: version "6.0.0" - resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-6.0.0.tgz#6d6fe6570ebd96aaf90fcad1dafa3b2566db3a94" + resolved "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz" integrity sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA== dependencies: yallist "^4.0.0" minimatch@^5.1.0: version "5.1.6" - resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-5.1.6.tgz#1cfcb8cf5522ea69952cd2af95ae09477f122a96" + resolved "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz" integrity sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g== dependencies: brace-expansion "^2.0.1" semver@^7.3.7: - version "7.5.4" - resolved "https://registry.yarnpkg.com/semver/-/semver-7.5.4.tgz#483986ec4ed38e1c6c48c34894a9182dbff68a6e" - integrity sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA== + version "7.6.0" + resolved "https://registry.npmjs.org/semver/-/semver-7.6.0.tgz" + integrity sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg== dependencies: lru-cache "^6.0.0" vscode-jsonrpc@8.2.0: version "8.2.0" - resolved "https://registry.yarnpkg.com/vscode-jsonrpc/-/vscode-jsonrpc-8.2.0.tgz#f43dfa35fb51e763d17cd94dcca0c9458f35abf9" + resolved "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.0.tgz" integrity sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA== -vscode-languageclient@9.0.1: +vscode-languageclient@^9.0.1: version "9.0.1" - resolved "https://registry.yarnpkg.com/vscode-languageclient/-/vscode-languageclient-9.0.1.tgz#cdfe20267726c8d4db839dc1e9d1816e1296e854" + resolved "https://registry.npmjs.org/vscode-languageclient/-/vscode-languageclient-9.0.1.tgz" integrity sha512-JZiimVdvimEuHh5olxhxkht09m3JzUGwggb5eRUkzzJhZ2KjCN0nh55VfiED9oez9DyF8/fz1g1iBV3h+0Z2EA== dependencies: minimatch "^5.1.0" @@ -56,7 +56,7 @@ vscode-languageclient@9.0.1: vscode-languageserver-protocol@3.17.5: version "3.17.5" - resolved "https://registry.yarnpkg.com/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.17.5.tgz#864a8b8f390835572f4e13bd9f8313d0e3ac4bea" + resolved "https://registry.npmjs.org/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.17.5.tgz" integrity sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg== dependencies: vscode-jsonrpc "8.2.0" @@ -64,15 +64,15 @@ vscode-languageserver-protocol@3.17.5: vscode-languageserver-types@3.17.5: version "3.17.5" - resolved "https://registry.yarnpkg.com/vscode-languageserver-types/-/vscode-languageserver-types-3.17.5.tgz#3273676f0cf2eab40b3f44d085acbb7f08a39d8a" + resolved "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.17.5.tgz" integrity sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg== vscode-uri@^3.0.8: version "3.0.8" - resolved "https://registry.yarnpkg.com/vscode-uri/-/vscode-uri-3.0.8.tgz#1770938d3e72588659a172d0fd4642780083ff9f" + resolved "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.0.8.tgz" integrity sha512-AyFQ0EVmsOZOlAnxoFOGOq1SQDWAB7C6aqMGS23svWAllfOaxbuFvcT8D1i8z3Gyn8fraVeZNNmN6e9bxxXkKw== yallist@^4.0.0: version "4.0.0" - resolved "https://registry.yarnpkg.com/yallist/-/yallist-4.0.0.tgz#9bb92790d9c0effec63be73519e11a35019a3a72" + resolved "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz" integrity sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A== diff --git a/extensions/dart/cgmanifest.json b/extensions/dart/cgmanifest.json index 0086a5158e511..9c90588adf182 100644 --- a/extensions/dart/cgmanifest.json +++ b/extensions/dart/cgmanifest.json @@ -6,7 +6,7 @@ "git": { "name": "dart-lang/dart-syntax-highlight", "repositoryUrl": "https://github.com/dart-lang/dart-syntax-highlight", - "commitHash": "0a6648177bdbb91a4e1a38c16e57ede0ccba4f18" + "commitHash": "272e2f89f85073c04b7e15b582257f76d2489970" } }, "licenseDetail": [ diff --git a/extensions/dart/syntaxes/dart.tmLanguage.json b/extensions/dart/syntaxes/dart.tmLanguage.json index ae4db9698e98a..cc9dee8d2754e 100644 --- a/extensions/dart/syntaxes/dart.tmLanguage.json +++ b/extensions/dart/syntaxes/dart.tmLanguage.json @@ -4,7 +4,7 @@ "If you want to provide a fix or improvement, please create a pull request against the original repository.", "Once accepted there, we are happy to receive an update request." ], - "version": "https://github.com/dart-lang/dart-syntax-highlight/commit/0a6648177bdbb91a4e1a38c16e57ede0ccba4f18", + "version": "https://github.com/dart-lang/dart-syntax-highlight/commit/272e2f89f85073c04b7e15b582257f76d2489970", "name": "Dart", "scopeName": "source.dart", "patterns": [ @@ -308,7 +308,7 @@ }, { "name": "keyword.control.dart", - "match": "(? 0" + "when": "git.closedRepositoryCount > 0" } ], "scm/sourceControl": [ @@ -1875,6 +1895,11 @@ "command": "git.viewAllChanges", "when": "scmProvider == git && scmHistoryItemFileCount != 0 && config.multiDiffEditor.experimental.enabled", "group": "inline@1" + }, + { + "command": "git.viewAllChanges", + "when": "scmProvider == git && scmHistoryItemFileCount != 0 && config.multiDiffEditor.experimental.enabled", + "group": "1_view@1" } ], "scm/incomingChanges/historyItem/context": [ @@ -1882,6 +1907,11 @@ "command": "git.viewCommit", "when": "scmProvider == git && scmHistoryItemFileCount != 0 && config.multiDiffEditor.experimental.enabled", "group": "inline@1" + }, + { + "command": "git.viewCommit", + "when": "scmProvider == git && scmHistoryItemFileCount != 0 && config.multiDiffEditor.experimental.enabled", + "group": "1_view@1" } ], "scm/outgoingChanges": [ @@ -1899,13 +1929,13 @@ "scm/outgoingChanges/context": [ { "command": "git.pushRef", - "group": "1_modification@1", - "when": "scmProvider == git && scmHistoryItemGroupHasUpstream" + "when": "scmProvider == git && scmHistoryItemGroupHasUpstream", + "group": "1_modification@1" }, { "command": "git.publish", - "group": "1_modification@1", - "when": "scmProvider == git && !scmHistoryItemGroupHasUpstream" + "when": "scmProvider == git && !scmHistoryItemGroupHasUpstream", + "group": "1_modification@1" } ], "scm/outgoingChanges/allChanges/context": [ @@ -1913,6 +1943,11 @@ "command": "git.viewAllChanges", "when": "scmProvider == git && scmHistoryItemFileCount != 0 && config.multiDiffEditor.experimental.enabled", "group": "inline@1" + }, + { + "command": "git.viewAllChanges", + "when": "scmProvider == git && scmHistoryItemFileCount != 0 && config.multiDiffEditor.experimental.enabled", + "group": "1_view@1" } ], "scm/outgoingChanges/historyItem/context": [ @@ -1920,6 +1955,11 @@ "command": "git.viewCommit", "when": "scmProvider == git && scmHistoryItemFileCount != 0 && config.multiDiffEditor.experimental.enabled", "group": "inline@1" + }, + { + "command": "git.viewCommit", + "when": "scmProvider == git && scmHistoryItemFileCount != 0 && config.multiDiffEditor.experimental.enabled", + "group": "1_view@1" } ], "editor/title": [ @@ -2020,6 +2060,20 @@ "when": "scmProvider == git && scmResourceGroup == index" } ], + "diffEditor/gutter/hunk": [ + { + "command": "git.diff.stageHunk", + "group": "primary@10", + "when": "diffEditorOriginalUri =~ /^git\\:.*%22ref%22%3A%22~%22%7D$/" + } + ], + "diffEditor/gutter/selection": [ + { + "command": "git.diff.stageSelection", + "group": "primary@10", + "when": "diffEditorOriginalUri =~ /^git\\:.*%22ref%22%3A%22~%22%7D$/" + } + ], "scm/change/title": [ { "command": "git.stageChange", @@ -2644,6 +2698,12 @@ "default": false, "description": "%config.followTagsWhenSync%" }, + "git.replaceTagsWhenPull": { + "type": "boolean", + "scope": "resource", + "default": false, + "description": "%config.replaceTagsWhenPull%" + }, "git.promptToSaveFilesBeforeStash": { "type": "string", "enum": [ @@ -2726,13 +2786,8 @@ "default": false }, "git.inputValidation": { - "type": "string", - "enum": [ - "always", - "warn", - "off" - ], - "default": "off", + "type": "boolean", + "default": false, "description": "%config.inputValidation%" }, "git.inputValidationLength": { diff --git a/extensions/git/package.nls.json b/extensions/git/package.nls.json index 5cc838d25c326..3d0411c53dc8a 100644 --- a/extensions/git/package.nls.json +++ b/extensions/git/package.nls.json @@ -23,6 +23,8 @@ "command.stageSelectedRanges": "Stage Selected Ranges", "command.revertSelectedRanges": "Revert Selected Ranges", "command.stageChange": "Stage Change", + "command.stageSelection": "Stage Selection", + "command.stageBlock": "Stage Block", "command.revertChange": "Revert Change", "command.unstage": "Unstage Changes", "command.unstageAll": "Unstage All Changes", @@ -33,6 +35,7 @@ "command.cleanAllTracked": "Discard All Tracked Changes", "command.cleanAllUntracked": "Discard All Untracked Changes", "command.closeAllDiffEditors": "Close All Diff Editors", + "command.closeAllUnmodifiedEditors": "Close All Unmodified Editors", "command.commit": "Commit", "command.commitAmend": "Commit (Amend)", "command.commitSigned": "Commit (Signed Off)", @@ -176,6 +179,7 @@ "config.decorations.enabled": "Controls whether Git contributes colors and badges to the Explorer and the Open Editors view.", "config.enableStatusBarSync": "Controls whether the Git Sync command appears in the status bar.", "config.followTagsWhenSync": "Push all annotated tags when running the sync command.", + "config.replaceTagsWhenPull": "Automatically replace the local tags with the remote tags in case of a conflict when running the pull command.", "config.promptToSaveFilesBeforeStash": "Controls whether Git should check for unsaved files before stashing changes.", "config.promptToSaveFilesBeforeStash.always": "Check for any unsaved files.", "config.promptToSaveFilesBeforeStash.staged": "Check only for unsaved staged files.", @@ -196,7 +200,7 @@ "config.openAfterClone.prompt": "Always prompt for action.", "config.showInlineOpenFileAction": "Controls whether to show an inline Open File action in the Git changes view.", "config.showPushSuccessNotification": "Controls whether to show a notification when a push is successful.", - "config.inputValidation": "Controls when to show commit message input validation.", + "config.inputValidation": "Controls whether to show commit message input validation diagnostics.", "config.inputValidationLength": "Controls the commit message length threshold for showing a warning.", "config.inputValidationSubjectLength": "Controls the commit message subject length threshold for showing a warning. Unset it to inherit the value of `#git.inputValidationLength#`.", "config.detectSubmodules": "Controls whether to automatically detect Git submodules.", @@ -286,6 +290,10 @@ "colors.ignored": "Color for ignored resources.", "colors.conflict": "Color for resources with conflicts.", "colors.submodule": "Color for submodule resources.", + "colors.incomingAdded": "Color for added incoming resource.", + "colors.incomingDeleted": "Color for deleted incoming resource.", + "colors.incomingRenamed": "Color for renamed incoming resource.", + "colors.incomingModified": "Color for modified incoming resource.", "view.workbench.scm.missing.windows": { "message": "[Download Git for Windows](https://git-scm.com/download/win)\nAfter installing, please [reload](command:workbench.action.reloadWindow) (or [troubleshoot](command:git.showOutput)). Additional source control providers can be installed [from the Marketplace](command:workbench.extensions.search?%22%40category%3A%5C%22scm%20providers%5C%22%22).", "comment": [ diff --git a/extensions/git/src/actionButton.ts b/extensions/git/src/actionButton.ts index d29bfcb23222b..494972276ac0e 100644 --- a/extensions/git/src/actionButton.ts +++ b/extensions/git/src/actionButton.ts @@ -128,7 +128,7 @@ export class ActionButton { command: 'git.commit', title: l10n.t('{0} Continue', '$(check)'), tooltip: this.state.isCommitInProgress ? l10n.t('Continuing Rebase...') : l10n.t('Continue Rebase'), - arguments: [this.repository.sourceControl, ''] + arguments: [this.repository.sourceControl, null] }; } @@ -138,7 +138,7 @@ export class ActionButton { command: 'git.commit', title: l10n.t('{0} Commit', '$(check)'), tooltip: this.state.isCommitInProgress ? l10n.t('Committing Changes...') : l10n.t('Commit Changes'), - arguments: [this.repository.sourceControl, ''] + arguments: [this.repository.sourceControl, null] }; } diff --git a/extensions/git/src/api/api1.ts b/extensions/git/src/api/api1.ts index 1a0a483409c27..5d3f3c453868c 100644 --- a/extensions/git/src/api/api1.ts +++ b/extensions/git/src/api/api1.ts @@ -160,10 +160,6 @@ export class ApiRepository implements Repository { return this.repository.diffBetween(ref1, ref2, path); } - getDiff(): Promise { - return this.repository.getDiff(); - } - hashObject(data: string): Promise { return this.repository.hashObject(data); } @@ -257,7 +253,7 @@ export class ApiRepository implements Repository { } commit(message: string, opts?: CommitOptions): Promise { - return this.repository.commit(message, opts); + return this.repository.commit(message, { ...opts, postCommitCommand: null }); } merge(ref: string): Promise { diff --git a/extensions/git/src/api/git.d.ts b/extensions/git/src/api/git.d.ts index 6a53d89335284..d6d2166e00bf5 100644 --- a/extensions/git/src/api/git.d.ts +++ b/extensions/git/src/api/git.d.ts @@ -143,6 +143,7 @@ export interface LogOptions { readonly reverse?: boolean; readonly sortByAuthorDate?: boolean; readonly shortStats?: boolean; + readonly author?: string; } export interface CommitOptions { @@ -225,8 +226,6 @@ export interface Repository { diffBetween(ref1: string, ref2: string): Promise; diffBetween(ref1: string, ref2: string, path: string): Promise; - getDiff(): Promise; - hashObject(data: string): Promise; createBranch(name: string, checkout: boolean, ref?: string): Promise; diff --git a/extensions/git/src/commands.ts b/extensions/git/src/commands.ts index 979daf3d2ba21..22fa8212d228a 100644 --- a/extensions/git/src/commands.ts +++ b/extensions/git/src/commands.ts @@ -5,14 +5,14 @@ import * as os from 'os'; import * as path from 'path'; -import { Command, commands, Disposable, LineChange, MessageOptions, Position, ProgressLocation, QuickPickItem, Range, SourceControlResourceState, TextDocumentShowOptions, TextEditor, Uri, ViewColumn, window, workspace, WorkspaceEdit, WorkspaceFolder, TimelineItem, env, Selection, TextDocumentContentProvider, InputBoxValidationSeverity, TabInputText, TabInputTextMerge, QuickPickItemKind, TextDocument, LogOutputChannel, l10n, Memento, UIKind, QuickInputButton, ThemeIcon, SourceControlHistoryItem, SourceControl, InputBoxValidationMessage } from 'vscode'; +import { Command, commands, Disposable, LineChange, MessageOptions, Position, ProgressLocation, QuickPickItem, Range, SourceControlResourceState, TextDocumentShowOptions, TextEditor, Uri, ViewColumn, window, workspace, WorkspaceEdit, WorkspaceFolder, TimelineItem, env, Selection, TextDocumentContentProvider, InputBoxValidationSeverity, TabInputText, TabInputTextMerge, QuickPickItemKind, TextDocument, LogOutputChannel, l10n, Memento, UIKind, QuickInputButton, ThemeIcon, SourceControlHistoryItem, SourceControl, InputBoxValidationMessage, Tab, TabInputNotebook } from 'vscode'; import TelemetryReporter from '@vscode/extension-telemetry'; import { uniqueNamesGenerator, adjectives, animals, colors, NumberDictionary } from '@joaomoreno/unique-names-generator'; import { ForcePushMode, GitErrorCodes, Ref, RefType, Status, CommitOptions, RemoteSourcePublisher, Remote } from './api/git'; import { Git, Stash } from './git'; import { Model } from './model'; import { Repository, Resource, ResourceGroupType } from './repository'; -import { applyLineChanges, getModifiedRange, intersectDiffWithRange, invertLineChange, toLineRanges } from './staging'; +import { DiffEditorSelectionHunkToolbarContext, applyLineChanges, getModifiedRange, intersectDiffWithRange, invertLineChange, toLineRanges } from './staging'; import { fromGitUri, toGitUri, isGitUri, toMergeUris, toMultiFileDiffEditorUris } from './uri'; import { dispose, grep, isDefined, isDescendant, pathEquals, relativePath } from './util'; import { GitTimelineItem } from './timelineProvider'; @@ -23,7 +23,7 @@ import { RemoteSourceAction } from './api/git-base'; abstract class CheckoutCommandItem implements QuickPickItem { abstract get label(): string; get description(): string { return ''; } - get alwaysShow(): boolean { return false; } + get alwaysShow(): boolean { return true; } } class CreateBranchItem extends CheckoutCommandItem { @@ -1194,7 +1194,7 @@ export class CommandCenter { const activeTextEditor = window.activeTextEditor; // Must extract these now because opening a new document will change the activeTextEditor reference - const previousVisibleRange = activeTextEditor?.visibleRanges[0]; + const previousVisibleRanges = activeTextEditor?.visibleRanges; const previousURI = activeTextEditor?.document.uri; const previousSelection = activeTextEditor?.selection; @@ -1225,8 +1225,13 @@ export class CommandCenter { opts.selection = previousSelection; const editor = await window.showTextDocument(document, opts); // This should always be defined but just in case - if (previousVisibleRange) { - editor.revealRange(previousVisibleRange); + if (previousVisibleRanges && previousVisibleRanges.length > 0) { + let rangeToReveal = previousVisibleRanges[0]; + if (previousSelection && previousVisibleRanges.length > 1) { + // In case of multiple visible ranges, find the one that intersects with the selection + rangeToReveal = previousVisibleRanges.find(r => r.intersection(previousSelection)) ?? rangeToReveal; + } + editor.revealRange(rangeToReveal); } } } @@ -1506,6 +1511,33 @@ export class CommandCenter { textEditor.selections = [new Selection(firstStagedLine, 0, firstStagedLine, 0)]; } + @command('git.diff.stageHunk') + async diffStageHunk(changes: DiffEditorSelectionHunkToolbarContext): Promise { + this.diffStageHunkOrSelection(changes); + } + + @command('git.diff.stageSelection') + async diffStageSelection(changes: DiffEditorSelectionHunkToolbarContext): Promise { + this.diffStageHunkOrSelection(changes); + } + + async diffStageHunkOrSelection(changes: DiffEditorSelectionHunkToolbarContext): Promise { + let modifiedUri = changes.modifiedUri; + if (!modifiedUri) { + const textEditor = window.activeTextEditor; + if (!textEditor) { + return; + } + const modifiedDocument = textEditor.document; + modifiedUri = modifiedDocument.uri; + } + if (modifiedUri.scheme !== 'file') { + return; + } + const result = changes.originalWithModifiedChanges; + await this.runByRepository(modifiedUri, async (repository, resource) => await repository.stage(resource, result)); + } + @command('git.stageSelectedRanges', { diff: true }) async stageSelectedChanges(changes: LineChange[]): Promise { const textEditor = window.activeTextEditor; @@ -2461,9 +2493,10 @@ export class CommandCenter { const createBranchFrom = new CreateBranchFromItem(); const checkoutDetached = new CheckoutDetachedItem(); const picks: QuickPickItem[] = []; + const commands: QuickPickItem[] = []; if (!opts?.detached) { - picks.push(createBranch, createBranchFrom, checkoutDetached); + commands.push(createBranch, createBranchFrom, checkoutDetached); } const disposables: Disposable[] = []; @@ -2477,7 +2510,7 @@ export class CommandCenter { quickPick.show(); picks.push(... await createCheckoutItems(repository, opts?.detached)); - quickPick.items = picks; + quickPick.items = [...commands, ...picks]; quickPick.busy = false; const choice = await new Promise(c => { @@ -2492,6 +2525,22 @@ export class CommandCenter { c(undefined); }))); + disposables.push(quickPick.onDidChangeValue(value => { + switch (true) { + case value === '': + quickPick.items = [...commands, ...picks]; + break; + case commands.length === 0: + quickPick.items = picks; + break; + case picks.length === 0: + quickPick.items = commands; + break; + default: + quickPick.items = [...picks, { label: '', kind: QuickPickItemKind.Separator }, ...commands]; + break; + } + })); }); dispose(disposables); @@ -3977,6 +4026,37 @@ export class CommandCenter { repository.closeDiffEditors(undefined, undefined, true); } + @command('git.closeAllUnmodifiedEditors') + closeUnmodifiedEditors(): void { + const editorTabsToClose: Tab[] = []; + + // Collect all modified files + const modifiedFiles: string[] = []; + for (const repository of this.model.repositories) { + modifiedFiles.push(...repository.indexGroup.resourceStates.map(r => r.resourceUri.fsPath)); + modifiedFiles.push(...repository.workingTreeGroup.resourceStates.map(r => r.resourceUri.fsPath)); + modifiedFiles.push(...repository.untrackedGroup.resourceStates.map(r => r.resourceUri.fsPath)); + modifiedFiles.push(...repository.mergeGroup.resourceStates.map(r => r.resourceUri.fsPath)); + } + + // Collect all editor tabs that are not dirty and not modified + for (const tab of window.tabGroups.all.map(g => g.tabs).flat()) { + if (tab.isDirty) { + continue; + } + + if (tab.input instanceof TabInputText || tab.input instanceof TabInputNotebook) { + const { uri } = tab.input; + if (!modifiedFiles.find(p => pathEquals(p, uri.fsPath))) { + editorTabsToClose.push(tab); + } + } + } + + // Close editors + window.tabGroups.close(editorTabsToClose, true); + } + @command('git.openRepositoriesInParentFolders') async openRepositoriesInParentFolders(): Promise { const parentRepositories: string[] = []; diff --git a/extensions/git/src/decorationProvider.ts b/extensions/git/src/decorationProvider.ts index c630f00c71234..3f8553260e9c6 100644 --- a/extensions/git/src/decorationProvider.ts +++ b/extensions/git/src/decorationProvider.ts @@ -3,13 +3,13 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { window, workspace, Uri, Disposable, Event, EventEmitter, FileDecoration, FileDecorationProvider, ThemeColor } from 'vscode'; +import { window, workspace, Uri, Disposable, Event, EventEmitter, FileDecoration, FileDecorationProvider, ThemeColor, l10n } from 'vscode'; import * as path from 'path'; import { Repository, GitResourceGroup } from './repository'; import { Model } from './model'; import { debounce } from './decorators'; -import { filterEvent, dispose, anyEvent, fireEvent, PromiseSource } from './util'; -import { GitErrorCodes, Status } from './api/git'; +import { filterEvent, dispose, anyEvent, fireEvent, PromiseSource, combinedDisposable, runAndSubscribeEvent } from './util'; +import { Change, GitErrorCodes, Status } from './api/git'; class GitIgnoreDecorationProvider implements FileDecorationProvider { @@ -101,7 +101,7 @@ class GitDecorationProvider implements FileDecorationProvider { constructor(private repository: Repository) { this.disposables.push( window.registerFileDecorationProvider(this), - repository.onDidRunGitStatus(this.onDidRunGitStatus, this) + runAndSubscribeEvent(repository.onDidRunGitStatus, () => this.onDidRunGitStatus()) ); } @@ -153,6 +153,97 @@ class GitDecorationProvider implements FileDecorationProvider { } } +class GitIncomingChangesFileDecorationProvider implements FileDecorationProvider { + + private readonly _onDidChangeDecorations = new EventEmitter(); + readonly onDidChangeFileDecorations: Event = this._onDidChangeDecorations.event; + + private decorations = new Map(); + private readonly disposables: Disposable[] = []; + + constructor(private readonly repository: Repository) { + this.disposables.push( + window.registerFileDecorationProvider(this), + runAndSubscribeEvent(repository.historyProvider.onDidChangeCurrentHistoryItemGroup, () => this.onDidChangeCurrentHistoryItemGroup()) + ); + } + + private async onDidChangeCurrentHistoryItemGroup(): Promise { + const newDecorations = new Map(); + await this.collectIncomingChangesFileDecorations(newDecorations); + const uris = new Set([...this.decorations.keys()].concat([...newDecorations.keys()])); + + this.decorations = newDecorations; + this._onDidChangeDecorations.fire([...uris.values()].map(value => Uri.parse(value, true))); + } + + private async collectIncomingChangesFileDecorations(bucket: Map): Promise { + for (const change of await this.getIncomingChanges()) { + switch (change.status) { + case Status.INDEX_ADDED: + bucket.set(change.uri.toString(), { + badge: '↓A', + tooltip: l10n.t('Incoming Changes (added)'), + }); + break; + case Status.DELETED: + bucket.set(change.uri.toString(), { + badge: '↓D', + tooltip: l10n.t('Incoming Changes (deleted)'), + }); + break; + case Status.INDEX_RENAMED: + bucket.set(change.originalUri.toString(), { + badge: '↓R', + tooltip: l10n.t('Incoming Changes (renamed)'), + }); + break; + case Status.MODIFIED: + bucket.set(change.uri.toString(), { + badge: '↓M', + tooltip: l10n.t('Incoming Changes (modified)'), + }); + break; + default: { + bucket.set(change.uri.toString(), { + badge: '↓~', + tooltip: l10n.t('Incoming Changes'), + }); + break; + } + } + } + } + + private async getIncomingChanges(): Promise { + try { + const historyProvider = this.repository.historyProvider; + const currentHistoryItemGroup = historyProvider.currentHistoryItemGroup; + + if (!currentHistoryItemGroup?.base) { + return []; + } + + const ancestor = await historyProvider.resolveHistoryItemGroupCommonAncestor(currentHistoryItemGroup.id, currentHistoryItemGroup.base.id); + if (!ancestor) { + return []; + } + + const changes = await this.repository.diffBetween(ancestor.id, currentHistoryItemGroup.base.id); + return changes; + } catch (err) { + return []; + } + } + + provideFileDecoration(uri: Uri): FileDecoration | undefined { + return this.decorations.get(uri.toString()); + } + + dispose(): void { + dispose(this.disposables); + } +} export class GitDecorations { @@ -191,8 +282,12 @@ export class GitDecorations { } private onDidOpenRepository(repository: Repository): void { - const provider = new GitDecorationProvider(repository); - this.providers.set(repository, provider); + const providers = combinedDisposable([ + new GitDecorationProvider(repository), + new GitIncomingChangesFileDecorationProvider(repository) + ]); + + this.providers.set(repository, providers); } private onDidCloseRepository(repository: Repository): void { diff --git a/extensions/git/src/diagnostics.ts b/extensions/git/src/diagnostics.ts new file mode 100644 index 0000000000000..a8c1a3deea3c5 --- /dev/null +++ b/extensions/git/src/diagnostics.ts @@ -0,0 +1,224 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { CodeAction, CodeActionKind, CodeActionProvider, Diagnostic, DiagnosticCollection, DiagnosticSeverity, Disposable, Range, Selection, TextDocument, Uri, WorkspaceEdit, l10n, languages, workspace } from 'vscode'; +import { mapEvent, filterEvent, dispose } from './util'; +import { Model } from './model'; + +export enum DiagnosticCodes { + empty_message = 'empty_message', + line_length = 'line_length' +} + +export class GitCommitInputBoxDiagnosticsManager { + + private readonly diagnostics: DiagnosticCollection; + private readonly severity = DiagnosticSeverity.Warning; + private readonly disposables: Disposable[] = []; + + constructor(private readonly model: Model) { + this.diagnostics = languages.createDiagnosticCollection(); + + this.migrateInputValidationSettings() + .then(() => { + mapEvent(filterEvent(workspace.onDidChangeTextDocument, e => e.document.uri.scheme === 'vscode-scm'), e => e.document)(this.onDidChangeTextDocument, this, this.disposables); + filterEvent(workspace.onDidChangeConfiguration, e => e.affectsConfiguration('git.inputValidation') || e.affectsConfiguration('git.inputValidationLength') || e.affectsConfiguration('git.inputValidationSubjectLength'))(this.onDidChangeConfiguration, this, this.disposables); + }); + } + + public getDiagnostics(uri: Uri): ReadonlyArray { + return this.diagnostics.get(uri) ?? []; + } + + private async migrateInputValidationSettings(): Promise { + try { + const config = workspace.getConfiguration('git'); + const inputValidation = config.inspect<'always' | 'warn' | 'off' | boolean>('inputValidation'); + + if (inputValidation === undefined) { + return; + } + + // Workspace setting + if (typeof inputValidation.workspaceValue === 'string') { + await config.update('inputValidation', inputValidation.workspaceValue !== 'off', false); + } + + // User setting + if (typeof inputValidation.globalValue === 'string') { + await config.update('inputValidation', inputValidation.workspaceValue !== 'off', true); + } + } catch { } + } + + private onDidChangeConfiguration(): void { + for (const repository of this.model.repositories) { + this.onDidChangeTextDocument(repository.inputBox.document); + } + } + + private onDidChangeTextDocument(document: TextDocument): void { + const config = workspace.getConfiguration('git'); + const inputValidation = config.get('inputValidation', false); + if (!inputValidation) { + this.diagnostics.set(document.uri, undefined); + return; + } + + if (/^\s+$/.test(document.getText())) { + const documentRange = new Range(document.lineAt(0).range.start, document.lineAt(document.lineCount - 1).range.end); + const diagnostic = new Diagnostic(documentRange, l10n.t('Current commit message only contains whitespace characters'), this.severity); + diagnostic.code = DiagnosticCodes.empty_message; + + this.diagnostics.set(document.uri, [diagnostic]); + return; + } + + const diagnostics: Diagnostic[] = []; + const inputValidationLength = config.get('inputValidationLength', 50); + const inputValidationSubjectLength = config.get('inputValidationSubjectLength', undefined); + + for (let index = 0; index < document.lineCount; index++) { + const line = document.lineAt(index); + const threshold = index === 0 ? inputValidationSubjectLength ?? inputValidationLength : inputValidationLength; + + if (line.text.length > threshold) { + const diagnostic = new Diagnostic(line.range, l10n.t('{0} characters over {1} in current line', line.text.length - threshold, threshold), this.severity); + diagnostic.code = DiagnosticCodes.line_length; + + diagnostics.push(diagnostic); + } + } + + this.diagnostics.set(document.uri, diagnostics); + } + + dispose() { + dispose(this.disposables); + } +} + +export class GitCommitInputBoxCodeActionsProvider implements CodeActionProvider { + + private readonly disposables: Disposable[] = []; + + constructor(private readonly diagnosticsManager: GitCommitInputBoxDiagnosticsManager) { + this.disposables.push(languages.registerCodeActionsProvider({ scheme: 'vscode-scm' }, this)); + } + + provideCodeActions(document: TextDocument, range: Range | Selection): CodeAction[] { + const codeActions: CodeAction[] = []; + const diagnostics = this.diagnosticsManager.getDiagnostics(document.uri); + const wrapAllLinesCodeAction = this.getWrapAllLinesCodeAction(document, diagnostics); + + for (const diagnostic of diagnostics) { + if (!diagnostic.range.contains(range)) { + continue; + } + + switch (diagnostic.code) { + case DiagnosticCodes.empty_message: { + const workspaceEdit = new WorkspaceEdit(); + workspaceEdit.delete(document.uri, diagnostic.range); + + const codeAction = new CodeAction(l10n.t('Clear whitespace characters'), CodeActionKind.QuickFix); + codeAction.diagnostics = [diagnostic]; + codeAction.edit = workspaceEdit; + codeActions.push(codeAction); + + break; + } + case DiagnosticCodes.line_length: { + const workspaceEdit = this.getWrapLineWorkspaceEdit(document, diagnostic.range); + + const codeAction = new CodeAction(l10n.t('Hard wrap line'), CodeActionKind.QuickFix); + codeAction.diagnostics = [diagnostic]; + codeAction.edit = workspaceEdit; + codeActions.push(codeAction); + + if (wrapAllLinesCodeAction) { + wrapAllLinesCodeAction.diagnostics = [diagnostic]; + codeActions.push(wrapAllLinesCodeAction); + } + + break; + } + } + } + + return codeActions; + } + + private getWrapLineWorkspaceEdit(document: TextDocument, range: Range): WorkspaceEdit { + const lineSegments = this.wrapTextDocumentLine(document, range.start.line); + + const workspaceEdit = new WorkspaceEdit(); + workspaceEdit.replace(document.uri, range, lineSegments.join('\n')); + + return workspaceEdit; + } + + private getWrapAllLinesCodeAction(document: TextDocument, diagnostics: readonly Diagnostic[]): CodeAction | undefined { + const lineLengthDiagnostics = diagnostics.filter(d => d.code === DiagnosticCodes.line_length); + if (lineLengthDiagnostics.length < 2) { + return undefined; + } + + const wrapAllLinesCodeAction = new CodeAction(l10n.t('Hard wrap all lines'), CodeActionKind.QuickFix); + wrapAllLinesCodeAction.edit = this.getWrapAllLinesWorkspaceEdit(document, lineLengthDiagnostics); + + return wrapAllLinesCodeAction; + } + + private getWrapAllLinesWorkspaceEdit(document: TextDocument, diagnostics: Diagnostic[]): WorkspaceEdit { + const workspaceEdit = new WorkspaceEdit(); + + for (const diagnostic of diagnostics) { + const lineSegments = this.wrapTextDocumentLine(document, diagnostic.range.start.line); + workspaceEdit.replace(document.uri, diagnostic.range, lineSegments.join('\n')); + } + + return workspaceEdit; + } + + private wrapTextDocumentLine(document: TextDocument, line: number): string[] { + const config = workspace.getConfiguration('git'); + const inputValidationLength = config.get('inputValidationLength', 50); + const inputValidationSubjectLength = config.get('inputValidationSubjectLength', undefined); + const lineLengthThreshold = line === 0 ? inputValidationSubjectLength ?? inputValidationLength : inputValidationLength; + + const lineSegments: string[] = []; + const lineText = document.lineAt(line).text.trim(); + + let position = 0; + while (lineText.length - position > lineLengthThreshold) { + const lastSpaceBeforeThreshold = lineText.lastIndexOf(' ', position + lineLengthThreshold); + + if (lastSpaceBeforeThreshold !== -1 && lastSpaceBeforeThreshold > position) { + lineSegments.push(lineText.substring(position, lastSpaceBeforeThreshold)); + position = lastSpaceBeforeThreshold + 1; + } else { + // Find first space after threshold + const firstSpaceAfterThreshold = lineText.indexOf(' ', position + lineLengthThreshold); + if (firstSpaceAfterThreshold !== -1) { + lineSegments.push(lineText.substring(position, firstSpaceAfterThreshold)); + position = firstSpaceAfterThreshold + 1; + } else { + lineSegments.push(lineText.substring(position)); + position = lineText.length; + } + } + } + if (position < lineText.length) { + lineSegments.push(lineText.substring(position)); + } + + return lineSegments; + } + + dispose() { + dispose(this.disposables); + } +} diff --git a/extensions/git/src/fileSystemProvider.ts b/extensions/git/src/fileSystemProvider.ts index 7829483dc9850..af80924ae1386 100644 --- a/extensions/git/src/fileSystemProvider.ts +++ b/extensions/git/src/fileSystemProvider.ts @@ -63,9 +63,14 @@ export class GitFileSystemProvider implements FileSystemProvider { return; } - const gitUri = toGitUri(uri, '', { replaceFileExtension: true }); + const diffOriginalResourceUri = toGitUri(uri, '~',); + const quickDiffOriginalResourceUri = toGitUri(uri, '', { replaceFileExtension: true }); + this.mtime = new Date().getTime(); - this._onDidChangeFile.fire([{ type: FileChangeType.Changed, uri: gitUri }]); + this._onDidChangeFile.fire([ + { type: FileChangeType.Changed, uri: diffOriginalResourceUri }, + { type: FileChangeType.Changed, uri: quickDiffOriginalResourceUri } + ]); } @debounce(1100) diff --git a/extensions/git/src/git.ts b/extensions/git/src/git.ts index 738295bdec1d7..710d7a4d11076 100644 --- a/extensions/git/src/git.ts +++ b/extensions/git/src/git.ts @@ -1161,6 +1161,10 @@ export class Repository { args.push(`-n${options?.maxEntries ?? 32}`); } + if (options?.author) { + args.push(`--author="${options.author}"`); + } + if (options?.path) { args.push('--', options.path); } diff --git a/extensions/git/src/historyProvider.ts b/extensions/git/src/historyProvider.ts index 0063f45cad31d..f238010e14c22 100644 --- a/extensions/git/src/historyProvider.ts +++ b/extensions/git/src/historyProvider.ts @@ -6,7 +6,7 @@ import { Disposable, Event, EventEmitter, FileDecoration, FileDecorationProvider, SourceControlHistoryItem, SourceControlHistoryItemChange, SourceControlHistoryItemGroup, SourceControlHistoryOptions, SourceControlHistoryProvider, ThemeIcon, Uri, window, LogOutputChannel } from 'vscode'; import { Repository, Resource } from './repository'; -import { IDisposable, filterEvent } from './util'; +import { IDisposable, dispose, filterEvent } from './util'; import { toGitUri } from './uri'; import { Branch, RefType, UpstreamRef } from './api/git'; import { emojify, ensureEmojis } from './emoji'; @@ -22,7 +22,6 @@ export class GitHistoryProvider implements SourceControlHistoryProvider, FileDec private _HEAD: Branch | undefined; private _currentHistoryItemGroup: SourceControlHistoryItemGroup | undefined; - get currentHistoryItemGroup(): SourceControlHistoryItemGroup | undefined { return this._currentHistoryItemGroup; } set currentHistoryItemGroup(value: SourceControlHistoryItemGroup | undefined) { this._currentHistoryItemGroup = value; @@ -34,39 +33,48 @@ export class GitHistoryProvider implements SourceControlHistoryProvider, FileDec private disposables: Disposable[] = []; constructor(protected readonly repository: Repository, private readonly logger: LogOutputChannel) { - this.disposables.push(repository.onDidRunGitStatus(this.onDidRunGitStatus, this)); - this.disposables.push(filterEvent(repository.onDidRunOperation, e => e.operation === Operation.Refresh)(() => this._onDidChangeCurrentHistoryItemGroup.fire())); + this.disposables.push(repository.onDidRunGitStatus(() => this.onDidRunGitStatus(), this)); + this.disposables.push(filterEvent(repository.onDidRunOperation, e => e.operation === Operation.Refresh)(() => this.onDidRunGitStatus(true), this)); this.disposables.push(window.registerFileDecorationProvider(this)); } - private async onDidRunGitStatus(): Promise { + private async onDidRunGitStatus(force = false): Promise { + this.logger.trace('GitHistoryProvider:onDidRunGitStatus - HEAD:', JSON.stringify(this._HEAD)); + this.logger.trace('GitHistoryProvider:onDidRunGitStatus - repository.HEAD:', JSON.stringify(this.repository.HEAD)); + // Check if HEAD has changed - if (this._HEAD?.name === this.repository.HEAD?.name && + if (!force && + this._HEAD?.name === this.repository.HEAD?.name && this._HEAD?.commit === this.repository.HEAD?.commit && this._HEAD?.upstream?.name === this.repository.HEAD?.upstream?.name && this._HEAD?.upstream?.remote === this.repository.HEAD?.upstream?.remote && this._HEAD?.upstream?.commit === this.repository.HEAD?.upstream?.commit) { + this.logger.trace('GitHistoryProvider:onDidRunGitStatus - HEAD has not changed'); return; } this._HEAD = this.repository.HEAD; // Check if HEAD does not support incoming/outgoing (detached commit, tag) - if (!this._HEAD?.name || !this._HEAD?.commit || this._HEAD.type === RefType.Tag) { + if (!this.repository.HEAD?.name || !this.repository.HEAD?.commit || this.repository.HEAD.type === RefType.Tag) { + this.logger.trace('GitHistoryProvider:onDidRunGitStatus - HEAD does not support incoming/outgoing'); + this.currentHistoryItemGroup = undefined; return; } this.currentHistoryItemGroup = { - id: `refs/heads/${this._HEAD.name ?? ''}`, - label: this._HEAD.name ?? '', - base: this._HEAD.upstream ? + id: `refs/heads/${this.repository.HEAD.name ?? ''}`, + name: this.repository.HEAD.name ?? '', + base: this.repository.HEAD.upstream ? { - id: `refs/remotes/${this._HEAD.upstream.remote}/${this._HEAD.upstream.name}`, - label: `${this._HEAD.upstream.remote}/${this._HEAD.upstream.name}`, + id: `refs/remotes/${this.repository.HEAD.upstream.remote}/${this.repository.HEAD.upstream.name}`, + name: `${this.repository.HEAD.upstream.remote}/${this.repository.HEAD.upstream.name}`, } : undefined }; + + this.logger.trace(`GitHistoryProvider:onDidRunGitStatus - currentHistoryItemGroup (${force}): ${JSON.stringify(this.currentHistoryItemGroup)}`); } async provideHistoryItems(historyItemGroupId: string, options: SourceControlHistoryOptions): Promise { @@ -93,8 +101,8 @@ export class GitHistoryProvider implements SourceControlHistoryProvider, FileDec return { id: commit.hash, parentIds: commit.parents, - label: emojify(subject), - description: commit.authorName, + message: emojify(subject), + author: commit.authorName, icon: new ThemeIcon('git-commit'), timestamp: commit.authorDate?.getTime(), statistics: commit.shortStat ?? { files: 0, insertions: 0, deletions: 0 }, @@ -111,7 +119,7 @@ export class GitHistoryProvider implements SourceControlHistoryProvider, FileDec } const allChanges = await this.repository.diffBetweenShortStat(historyItemParentId, historyItemId); - return { id: historyItemId, parentIds: [historyItemParentId], label: '', statistics: allChanges }; + return { id: historyItemId, parentIds: [historyItemParentId], message: '', statistics: allChanges }; } async provideHistoryItemChanges(historyItemId: string, historyItemParentId: string | undefined): Promise { @@ -155,6 +163,7 @@ export class GitHistoryProvider implements SourceControlHistoryProvider, FileDec if (!historyItemId2) { const upstreamRef = await this.resolveHistoryItemGroupBase(historyItemId1); if (!upstreamRef) { + this.logger.info(`GitHistoryProvider:resolveHistoryItemGroupCommonAncestor - Failed to resolve history item group base for '${historyItemId1}'`); return undefined; } @@ -163,14 +172,16 @@ export class GitHistoryProvider implements SourceControlHistoryProvider, FileDec const ancestor = await this.repository.getMergeBase(historyItemId1, historyItemId2); if (!ancestor) { + this.logger.info(`GitHistoryProvider:resolveHistoryItemGroupCommonAncestor - Failed to resolve common ancestor for '${historyItemId1}' and '${historyItemId2}'`); return undefined; } try { const commitCount = await this.repository.getCommitCount(`${historyItemId1}...${historyItemId2}`); + this.logger.trace(`GitHistoryProvider:resolveHistoryItemGroupCommonAncestor - Resolved common ancestor for '${historyItemId1}' and '${historyItemId2}': ${JSON.stringify({ id: ancestor, ahead: commitCount.ahead, behind: commitCount.behind })}`); return { id: ancestor, ahead: commitCount.ahead, behind: commitCount.behind }; } catch (err) { - this.logger.error(`Failed to get ahead/behind for '${historyItemId1}...${historyItemId2}': ${err.message}`); + this.logger.error(`GitHistoryProvider:resolveHistoryItemGroupCommonAncestor - Failed to get ahead/behind for '${historyItemId1}...${historyItemId2}': ${err.message}`); } return undefined; @@ -191,6 +202,7 @@ export class GitHistoryProvider implements SourceControlHistoryProvider, FileDec // Base (config -> reflog -> default) const remoteBranch = await this.repository.getBranchBase(historyItemId); if (!remoteBranch?.remote || !remoteBranch?.name || !remoteBranch?.commit || remoteBranch?.type !== RefType.RemoteHead) { + this.logger.info(`GitHistoryProvider:resolveHistoryItemGroupBase - Failed to resolve history item group base for '${historyItemId}'`); return undefined; } @@ -201,13 +213,13 @@ export class GitHistoryProvider implements SourceControlHistoryProvider, FileDec }; } catch (err) { - this.logger.error(`Failed to get branch base for '${historyItemId}': ${err.message}`); + this.logger.error(`GitHistoryProvider:resolveHistoryItemGroupBase - Failed to get branch base for '${historyItemId}': ${err.message}`); } return undefined; } dispose(): void { - this.disposables.forEach(d => d.dispose()); + dispose(this.disposables); } } diff --git a/extensions/git/src/main.ts b/extensions/git/src/main.ts index 5440795cce9cf..c2d9b974be726 100644 --- a/extensions/git/src/main.ts +++ b/extensions/git/src/main.ts @@ -25,6 +25,7 @@ import { createIPCServer, IPCServer } from './ipc/ipcServer'; import { GitEditor } from './gitEditor'; import { GitPostCommitCommandsProvider } from './postCommitCommands'; import { GitEditSessionIdentityProvider } from './editSessionIdentityProvider'; +import { GitCommitInputBoxCodeActionsProvider, GitCommitInputBoxDiagnosticsManager } from './diagnostics'; const deactivateTasks: { (): Promise }[] = []; @@ -118,6 +119,12 @@ async function createModel(context: ExtensionContext, logger: LogOutputChannel, const postCommitCommandsProvider = new GitPostCommitCommandsProvider(); model.registerPostCommitCommandsProvider(postCommitCommandsProvider); + const diagnosticsManager = new GitCommitInputBoxDiagnosticsManager(model); + disposables.push(diagnosticsManager); + + const codeActionsProvider = new GitCommitInputBoxCodeActionsProvider(diagnosticsManager); + disposables.push(codeActionsProvider); + checkGitVersion(info); commands.executeCommand('setContext', 'gitVersion2.35', git.compareGitVersionTo('2.35') >= 0); diff --git a/extensions/git/src/operation.ts b/extensions/git/src/operation.ts index ead345c0b0621..223f1945b0214 100644 --- a/extensions/git/src/operation.ts +++ b/extensions/git/src/operation.ts @@ -146,7 +146,7 @@ export const Operation = { DeleteRef: { kind: OperationKind.DeleteRef, blocking: false, readOnly: false, remote: false, retry: false, showProgress: true } as DeleteRefOperation, DeleteRemoteTag: { kind: OperationKind.DeleteRemoteTag, blocking: false, readOnly: false, remote: true, retry: false, showProgress: true } as DeleteRemoteTagOperation, DeleteTag: { kind: OperationKind.DeleteTag, blocking: false, readOnly: false, remote: false, retry: false, showProgress: true } as DeleteTagOperation, - Diff: { kind: OperationKind.Diff, blocking: false, readOnly: true, remote: false, retry: false, showProgress: true } as DiffOperation, + Diff: { kind: OperationKind.Diff, blocking: false, readOnly: true, remote: false, retry: false, showProgress: false } as DiffOperation, Fetch: (showProgress: boolean) => ({ kind: OperationKind.Fetch, blocking: false, readOnly: false, remote: true, retry: true, showProgress } as FetchOperation), FindTrackingBranches: { kind: OperationKind.FindTrackingBranches, blocking: false, readOnly: true, remote: false, retry: false, showProgress: true } as FindTrackingBranchesOperation, GetBranch: { kind: OperationKind.GetBranch, blocking: false, readOnly: true, remote: false, retry: false, showProgress: true } as GetBranchOperation, diff --git a/extensions/git/src/protocolHandler.ts b/extensions/git/src/protocolHandler.ts index 00f5817301497..dc73fe39965c2 100644 --- a/extensions/git/src/protocolHandler.ts +++ b/extensions/git/src/protocolHandler.ts @@ -4,10 +4,13 @@ *--------------------------------------------------------------------------------------------*/ import { UriHandler, Uri, window, Disposable, commands, LogOutputChannel, l10n } from 'vscode'; -import { dispose } from './util'; +import { dispose, isWindows } from './util'; import * as querystring from 'querystring'; -const schemes = new Set(['file', 'git', 'http', 'https', 'ssh']); +const schemes = isWindows ? + new Set(['git', 'http', 'https', 'ssh']) : + new Set(['file', 'git', 'http', 'https', 'ssh']); + const refRegEx = /^$|[~\^:\\\*\s\[\]]|^-|^\.|\/\.|\.\.|\.lock\/|\.lock$|\/$|\.$/; export class GitProtocolHandler implements UriHandler { diff --git a/extensions/git/src/repository.ts b/extensions/git/src/repository.ts index c71069fd3c823..30369a1efa101 100644 --- a/extensions/git/src/repository.ts +++ b/extensions/git/src/repository.ts @@ -975,10 +975,9 @@ export class Repository implements Disposable { this.setCountBadge(); } - validateInput(text: string, position: number): SourceControlInputBoxValidation | undefined { - let tooManyChangesWarning: SourceControlInputBoxValidation | undefined; + validateInput(text: string, _: number): SourceControlInputBoxValidation | undefined { if (this.isRepositoryHuge) { - tooManyChangesWarning = { + return { message: l10n.t('Too many changes were detected. Only the first {0} changes will be shown below.', this.isRepositoryHuge.limit), type: SourceControlInputBoxValidationType.Warning }; @@ -993,59 +992,7 @@ export class Repository implements Disposable { } } - const config = workspace.getConfiguration('git'); - const setting = config.get<'always' | 'warn' | 'off'>('inputValidation'); - - if (setting === 'off') { - return tooManyChangesWarning; - } - - if (/^\s+$/.test(text)) { - return { - message: l10n.t('Current commit message only contains whitespace characters'), - type: SourceControlInputBoxValidationType.Warning - }; - } - - let lineNumber = 0; - let start = 0; - let match: RegExpExecArray | null; - const regex = /\r?\n/g; - - while ((match = regex.exec(text)) && position > match.index) { - start = match.index + match[0].length; - lineNumber++; - } - - const end = match ? match.index : text.length; - - const line = text.substring(start, end); - - let threshold = config.get('inputValidationLength', 50); - - if (lineNumber === 0) { - const inputValidationSubjectLength = config.get('inputValidationSubjectLength', null); - - if (inputValidationSubjectLength !== null) { - threshold = inputValidationSubjectLength; - } - } - - if (line.length <= threshold) { - if (setting !== 'always') { - return tooManyChangesWarning; - } - - return { - message: l10n.t('{0} characters left in current line', threshold - line.length), - type: SourceControlInputBoxValidationType.Information - }; - } else { - return { - message: l10n.t('{0} characters over {1} in current line', line.length - threshold, threshold), - type: SourceControlInputBoxValidationType.Warning - }; - } + return undefined; } /** @@ -1219,9 +1166,10 @@ export class Repository implements Disposable { const path = relativePath(this.repository.root, resource.fsPath).replace(/\\/g, '/'); await this.run(Operation.Stage, async () => { await this.repository.stage(path, contents); + + this._onDidChangeOriginalResource.fire(resource); this.closeDiffEditors([], [...resource.fsPath]); }); - this._onDidChangeOriginalResource.fire(resource); } async revert(resources: Uri[]): Promise { @@ -1651,21 +1599,6 @@ export class Repository implements Disposable { return await this.run(Operation.RevList, () => this.repository.getCommitCount(range)); } - async getDiff(): Promise { - const diff: string[] = []; - if (this.indexGroup.resourceStates.length !== 0) { - for (const file of this.indexGroup.resourceStates.map(r => r.resourceUri.fsPath)) { - diff.push(await this.diffIndexWithHEAD(file)); - } - } else { - for (const file of this.workingTreeGroup.resourceStates.map(r => r.resourceUri.fsPath)) { - diff.push(await this.diffWithHEAD(file)); - } - } - - return diff; - } - async revParse(ref: string): Promise { return await this.run(Operation.RevParse, () => this.repository.revParse(ref)); } @@ -2630,13 +2563,23 @@ export class Repository implements Disposable { throw new Error(`Unable to extract tag names from error message: ${raw}`); } - // Notification - const replaceLocalTags = l10n.t('Replace Local Tag(s)'); - const message = l10n.t('Unable to pull from remote repository due to conflicting tag(s): {0}. Would you like to resolve the conflict by replacing the local tag(s)?', tags.join(', ')); - const choice = await window.showErrorMessage(message, { modal: true }, replaceLocalTags); + const config = workspace.getConfiguration('git', Uri.file(this.repository.root)); + const replaceTagsWhenPull = config.get('replaceTagsWhenPull', false) === true; - if (choice !== replaceLocalTags) { - return false; + if (!replaceTagsWhenPull) { + // Notification + const replaceLocalTags = l10n.t('Replace Local Tag(s)'); + const replaceLocalTagsAlways = l10n.t('Always Replace Local Tag(s)'); + const message = l10n.t('Unable to pull from remote repository due to conflicting tag(s): {0}. Would you like to resolve the conflict by replacing the local tag(s)?', tags.join(', ')); + const choice = await window.showErrorMessage(message, { modal: true }, replaceLocalTags, replaceLocalTagsAlways); + + if (choice !== replaceLocalTags && choice !== replaceLocalTagsAlways) { + return false; + } + + if (choice === replaceLocalTagsAlways) { + await config.update('replaceTagsWhenPull', true, true); + } } // Force fetch tags diff --git a/extensions/git/src/staging.ts b/extensions/git/src/staging.ts index 2813bfb1ee989..2dcc6d54487a7 100644 --- a/extensions/git/src/staging.ts +++ b/extensions/git/src/staging.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { TextDocument, Range, LineChange, Selection } from 'vscode'; +import { TextDocument, Range, LineChange, Selection, Uri } from 'vscode'; export function applyLineChanges(original: TextDocument, modified: TextDocument, diffs: LineChange[]): string { const result: string[] = []; @@ -142,3 +142,14 @@ export function invertLineChange(diff: LineChange): LineChange { originalEndLineNumber: diff.modifiedEndLineNumber }; } + +export interface DiffEditorSelectionHunkToolbarContext { + mapping: unknown; + /** + * The original text with the selected modified changes applied. + */ + originalWithModifiedChanges: string; + + modifiedUri: Uri; + originalUri: Uri; +} diff --git a/extensions/git/src/util.ts b/extensions/git/src/util.ts index 0d4b9241b55be..219c87b148dc2 100644 --- a/extensions/git/src/util.ts +++ b/extensions/git/src/util.ts @@ -47,6 +47,13 @@ export function filterEvent(event: Event, filter: (e: T) => boolean): Even return (listener: (e: T) => any, thisArgs?: any, disposables?: Disposable[]) => event(e => filter(e) && listener.call(thisArgs, e), null, disposables); } +export function runAndSubscribeEvent(event: Event, handler: (e: T) => any, initial: T): IDisposable; +export function runAndSubscribeEvent(event: Event, handler: (e: T | undefined) => any): IDisposable; +export function runAndSubscribeEvent(event: Event, handler: (e: T | undefined) => any, initial?: T): IDisposable { + handler(initial); + return event(e => handler(e)); +} + export function anyEvent(...events: Event[]): Event { return (listener: (e: T) => any, thisArgs?: any, disposables?: Disposable[]) => { const result = combinedDisposable(events.map(event => event(i => listener.call(thisArgs, i)))); diff --git a/extensions/git/tsconfig.json b/extensions/git/tsconfig.json index 48b9d4227f047..d485d90421543 100644 --- a/extensions/git/tsconfig.json +++ b/extensions/git/tsconfig.json @@ -17,6 +17,7 @@ "../../src/vscode-dts/vscode.proposed.scmSelectedProvider.d.ts", "../../src/vscode-dts/vscode.proposed.scmValidation.d.ts", "../../src/vscode-dts/vscode.proposed.scmMultiDiffEditor.d.ts", + "../../src/vscode-dts/vscode.proposed.scmTextDocument.d.ts", "../../src/vscode-dts/vscode.proposed.tabInputTextMerge.d.ts", "../../src/vscode-dts/vscode.proposed.timeline.d.ts", "../types/lib.textEncoder.d.ts" diff --git a/extensions/github-authentication/package.json b/extensions/github-authentication/package.json index dc26d5c07e845..d55e8dcfd03d1 100644 --- a/extensions/github-authentication/package.json +++ b/extensions/github-authentication/package.json @@ -61,7 +61,7 @@ "dependencies": { "node-fetch": "2.6.7", "@vscode/extension-telemetry": "^0.9.0", - "vscode-tas-client": "^0.1.47" + "vscode-tas-client": "^0.1.84" }, "devDependencies": { "@types/mocha": "^9.1.1", diff --git a/extensions/github-authentication/src/common/errors.ts b/extensions/github-authentication/src/common/errors.ts index 3ba3dfc006a04..f60b723349914 100644 --- a/extensions/github-authentication/src/common/errors.ts +++ b/extensions/github-authentication/src/common/errors.ts @@ -8,3 +8,7 @@ export const TIMED_OUT_ERROR = 'Timed out'; // These error messages are internal and should not be shown to the user in any way. export const USER_CANCELLATION_ERROR = 'User Cancelled'; export const NETWORK_ERROR = 'network error'; + +// This is the error message that we throw if the login was cancelled for any reason. Extensions +// calling `getSession` can handle this error to know that the user cancelled the login. +export const CANCELLATION_ERROR = 'Cancelled'; diff --git a/extensions/github-authentication/src/flows.ts b/extensions/github-authentication/src/flows.ts index 3641ffb3a36e5..7498a2b22025a 100644 --- a/extensions/github-authentication/src/flows.ts +++ b/extensions/github-authentication/src/flows.ts @@ -68,6 +68,7 @@ interface IFlowTriggerOptions { callbackUri: Uri; uriHandler: UriEventHandler; enterpriseUri?: Uri; + existingLogin?: string; } interface IFlow { @@ -149,7 +150,8 @@ const allFlows: IFlow[] = [ nonce, callbackUri, uriHandler, - enterpriseUri + enterpriseUri, + existingLogin }: IFlowTriggerOptions): Promise { logger.info(`Trying without local server... (${scopes})`); return await window.withProgress({ @@ -169,6 +171,9 @@ const allFlows: IFlow[] = [ ['scope', scopes], ['state', encodeURIComponent(callbackUri.toString(true))] ]); + if (existingLogin) { + searchParams.append('login', existingLogin); + } // The extra toString, parse is apparently needed for env.openExternal // to open the correct URL. @@ -215,7 +220,8 @@ const allFlows: IFlow[] = [ baseUri, redirectUri, logger, - enterpriseUri + enterpriseUri, + existingLogin }: IFlowTriggerOptions): Promise { logger.info(`Trying with local server... (${scopes})`); return await window.withProgress({ @@ -232,6 +238,9 @@ const allFlows: IFlow[] = [ ['redirect_uri', redirectUri.toString(true)], ['scope', scopes], ]); + if (existingLogin) { + searchParams.append('login', existingLogin); + } const loginUrl = baseUri.with({ path: '/login/oauth/authorize', diff --git a/extensions/github-authentication/src/github.ts b/extensions/github-authentication/src/github.ts index 71aa17bd5ccdf..3d73bfb765614 100644 --- a/extensions/github-authentication/src/github.ts +++ b/extensions/github-authentication/src/github.ts @@ -11,7 +11,7 @@ import { PromiseAdapter, arrayEquals, promiseFromEvent } from './common/utils'; import { ExperimentationTelemetry } from './common/experimentationService'; import { Log } from './common/logger'; import { crypto } from './node/crypto'; -import { TIMED_OUT_ERROR, USER_CANCELLATION_ERROR } from './common/errors'; +import { CANCELLATION_ERROR, TIMED_OUT_ERROR, USER_CANCELLATION_ERROR } from './common/errors'; interface SessionData { id: string; @@ -296,13 +296,44 @@ export class GitHubAuthenticationProvider implements vscode.AuthenticationProvid scopes: JSON.stringify(scopes), }); + const sessions = await this._sessionsPromise; const scopeString = sortedScopes.join(' '); - const token = await this._githubServer.login(scopeString); + const existingLogin = sessions[0]?.account.label; + const token = await this._githubServer.login(scopeString, existingLogin); const session = await this.tokenToSession(token, scopes); this.afterSessionLoad(session); - const sessions = await this._sessionsPromise; + if (sessions.some(s => s.account.id !== session.account.id)) { + const otherAccountsIndexes = new Array(); + const otherAccountsLabels = new Set(); + for (let i = 0; i < sessions.length; i++) { + if (sessions[i].account.id !== session.account.id) { + otherAccountsIndexes.push(i); + otherAccountsLabels.add(sessions[i].account.label); + } + } + const proceed = vscode.l10n.t("Continue"); + const labelstr = [...otherAccountsLabels].join(', '); + const result = await vscode.window.showInformationMessage( + vscode.l10n.t({ + message: "You are logged into another account already ({0}).\n\nDo you want to log out of that account and log in to '{1}' instead?", + comment: ['{0} is a comma-separated list of account names. {1} is the account name to log into.'], + args: [labelstr, session.account.label] + }), + { modal: true }, + proceed + ); + if (result !== proceed) { + throw new Error(CANCELLATION_ERROR); + } + + // Remove other accounts + for (const i of otherAccountsIndexes) { + sessions.splice(i, 1); + } + } + const sessionIndex = sessions.findIndex(s => s.id === session.id || arrayEquals([...s.scopes].sort(), sortedScopes)); if (sessionIndex > -1) { sessions.splice(sessionIndex, 1, session); diff --git a/extensions/github-authentication/src/githubServer.ts b/extensions/github-authentication/src/githubServer.ts index 0729c4c50776a..af2cf22724f94 100644 --- a/extensions/github-authentication/src/githubServer.ts +++ b/extensions/github-authentication/src/githubServer.ts @@ -11,19 +11,15 @@ import { isSupportedClient, isSupportedTarget } from './common/env'; import { crypto } from './node/crypto'; import { fetching } from './node/fetch'; import { ExtensionHost, GitHubTarget, getFlows } from './flows'; -import { NETWORK_ERROR, USER_CANCELLATION_ERROR } from './common/errors'; +import { CANCELLATION_ERROR, NETWORK_ERROR, USER_CANCELLATION_ERROR } from './common/errors'; import { Config } from './config'; import { base64Encode } from './node/buffer'; -// This is the error message that we throw if the login was cancelled for any reason. Extensions -// calling `getSession` can handle this error to know that the user cancelled the login. -const CANCELLATION_ERROR = 'Cancelled'; - const REDIRECT_URL_STABLE = 'https://vscode.dev/redirect'; const REDIRECT_URL_INSIDERS = 'https://insiders.vscode.dev/redirect'; export interface IGitHubServer { - login(scopes: string): Promise; + login(scopes: string, existingLogin?: string): Promise; logout(session: vscode.AuthenticationSession): Promise; getUserInfo(token: string): Promise<{ id: string; accountName: string }>; sendAdditionalTelemetryInfo(session: vscode.AuthenticationSession): Promise; @@ -91,7 +87,7 @@ export class GitHubServer implements IGitHubServer { return this._isNoCorsEnvironment; } - public async login(scopes: string): Promise { + public async login(scopes: string, existingLogin?: string): Promise { this._logger.info(`Logging in for the following scopes: ${scopes}`); // Used for showing a friendlier message to the user when the explicitly cancel a flow. @@ -143,6 +139,7 @@ export class GitHubServer implements IGitHubServer { uriHandler: this._uriHandler, enterpriseUri: this._ghesUri, redirectUri: vscode.Uri.parse(await this.getRedirectEndpoint()), + existingLogin }); } catch (e) { userCancelled = this.processLoginError(e); diff --git a/extensions/github-authentication/yarn.lock b/extensions/github-authentication/yarn.lock index a1c68b8d5a6b7..724b304c53ed3 100644 --- a/extensions/github-authentication/yarn.lock +++ b/extensions/github-authentication/yarn.lock @@ -132,15 +132,6 @@ asynckit@^0.4.0: resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79" integrity sha1-x57Zf380y48robyXkLzDZkdLS3k= -axios@^1.6.1: - version "1.6.2" - resolved "https://registry.yarnpkg.com/axios/-/axios-1.6.2.tgz#de67d42c755b571d3e698df1b6504cde9b0ee9f2" - integrity sha512-7i24Ri4pmDRfJTR7LDBhsOTtcm+9kjX5WiY1X3wIisx6G9So3pfMkEiU7emUBe46oceVImccTEM3k6C5dbVW8A== - dependencies: - follow-redirects "^1.15.0" - form-data "^4.0.0" - proxy-from-env "^1.1.0" - combined-stream@^1.0.8: version "1.0.8" resolved "https://registry.yarnpkg.com/combined-stream/-/combined-stream-1.0.8.tgz#c3d45a8b34fd730631a110a8a2520682b31d5a7f" @@ -153,11 +144,6 @@ delayed-stream@~1.0.0: resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619" integrity sha1-3zrhmayt+31ECqrgsp4icrJOxhk= -follow-redirects@^1.15.0: - version "1.15.4" - resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.4.tgz#cdc7d308bf6493126b17ea2191ea0ccf3e535adf" - integrity sha512-Cr4D/5wlrb0z9dgERpUL3LrmPKVDsETIJhaCMeDfuFYcqa5bldGV6wBsAN6X/vxlXQtFBMrXdXxdL8CbDTGniw== - form-data@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/form-data/-/form-data-3.0.0.tgz#31b7e39c85f1355b7139ee0c647cf0de7f83c682" @@ -167,15 +153,6 @@ form-data@^3.0.0: combined-stream "^1.0.8" mime-types "^2.1.12" -form-data@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/form-data/-/form-data-4.0.0.tgz#93919daeaf361ee529584b9b31664dc12c9fa452" - integrity sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww== - dependencies: - asynckit "^0.4.0" - combined-stream "^1.0.8" - mime-types "^2.1.12" - mime-db@1.44.0: version "1.44.0" resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.44.0.tgz#fa11c5eb0aca1334b4233cb4d52f10c5a6272f92" @@ -195,29 +172,22 @@ node-fetch@2.6.7: dependencies: whatwg-url "^5.0.0" -proxy-from-env@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz#e102f16ca355424865755d2c9e8ea4f24d58c3e2" - integrity sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg== - -tas-client@0.1.73: - version "0.1.73" - resolved "https://registry.yarnpkg.com/tas-client/-/tas-client-0.1.73.tgz#2dacf68547a37989ef1554c6510dc108a1ea7a71" - integrity sha512-UDdUF9kV2hYdlv+7AgqP2kXarVSUhjK7tg1BUflIRGEgND0/QoNpN64rcEuhEcM8AIbW65yrCopJWqRhLZ3m8w== - dependencies: - axios "^1.6.1" +tas-client@0.2.33: + version "0.2.33" + resolved "https://registry.yarnpkg.com/tas-client/-/tas-client-0.2.33.tgz#451bf114a8a64748030ce4068ab7d079958402e6" + integrity sha512-V+uqV66BOQnWxvI6HjDnE4VkInmYZUQ4dgB7gzaDyFyFSK1i1nF/j7DpS9UbQAgV9NaF1XpcyuavnM1qOeiEIg== tr46@~0.0.3: version "0.0.3" resolved "https://registry.yarnpkg.com/tr46/-/tr46-0.0.3.tgz#8184fd347dac9cdc185992f3a6622e14b9d9ab6a" integrity sha1-gYT9NH2snNwYWZLzpmIuFLnZq2o= -vscode-tas-client@^0.1.47: - version "0.1.75" - resolved "https://registry.yarnpkg.com/vscode-tas-client/-/vscode-tas-client-0.1.75.tgz#771780a9a178163028299f52d41973300060dd38" - integrity sha512-/+ALFWPI4U3obeRvLFSt39guT7P9bZQrkmcLoiS+2HtzJ/7iPKNt5Sj+XTiitGlPYVFGFc0plxX8AAp6Uxs0xQ== +vscode-tas-client@^0.1.84: + version "0.1.84" + resolved "https://registry.yarnpkg.com/vscode-tas-client/-/vscode-tas-client-0.1.84.tgz#906bdcfd8c9e1dc04321d6bc0335184f9119968e" + integrity sha512-rUTrUopV+70hvx1hW5ebdw1nd6djxubkLvVxjGdyD/r5v/wcVF41LIfiAtbm5qLZDtQdsMH1IaCuDoluoIa88w== dependencies: - tas-client "0.1.73" + tas-client "0.2.33" webidl-conversions@^3.0.0: version "3.0.1" diff --git a/extensions/github/markdown.css b/extensions/github/markdown.css index 84441f42b16df..7cfc8ba75a597 100644 --- a/extensions/github/markdown.css +++ b/extensions/github/markdown.css @@ -5,7 +5,7 @@ .vscode-dark img[src$=\#gh-light-mode-only], .vscode-light img[src$=\#gh-dark-mode-only], -.vscode-high-contrast img[src$=\#gh-light-mode-only], +.vscode-high-contrast:not(.vscode-high-contrast-light) img[src$=\#gh-light-mode-only], .vscode-high-contrast-light img[src$=\#gh-dark-mode-only] { display: none; } diff --git a/extensions/go/cgmanifest.json b/extensions/go/cgmanifest.json index 2b837e80a2fc7..7b7bc3d51f975 100644 --- a/extensions/go/cgmanifest.json +++ b/extensions/go/cgmanifest.json @@ -6,12 +6,12 @@ "git": { "name": "go-syntax", "repositoryUrl": "https://github.com/worlpaker/go-syntax", - "commitHash": "de0edabe11035e7035155c68eddc5817d5ec4af9" + "commitHash": "f53c71e58787fb719399b7c38a08bceaa0c0e2d9" } }, "license": "MIT", "description": "The file syntaxes/go.tmLanguage.json is from https://github.com/worlpaker/go-syntax, which in turn was derived from https://github.com/jeff-hykin/better-go-syntax.", - "version": "0.5.6" + "version": "0.6.1" } ], "version": 1 diff --git a/extensions/go/syntaxes/go.tmLanguage.json b/extensions/go/syntaxes/go.tmLanguage.json index 3641d3edf99a1..efd69afbcd271 100644 --- a/extensions/go/syntaxes/go.tmLanguage.json +++ b/extensions/go/syntaxes/go.tmLanguage.json @@ -4,7 +4,7 @@ "If you want to provide a fix or improvement, please create a pull request against the original repository.", "Once accepted there, we are happy to receive an update request." ], - "version": "https://github.com/worlpaker/go-syntax/commit/de0edabe11035e7035155c68eddc5817d5ec4af9", + "version": "https://github.com/worlpaker/go-syntax/commit/f53c71e58787fb719399b7c38a08bceaa0c0e2d9", "name": "Go", "scopeName": "source.go", "patterns": [ @@ -499,7 +499,7 @@ "comment": "Note that the order here is very important!", "patterns": [ { - "match": "((?:\\*|&)+)(?:(?!\\d)(?=(?:[\\w\\[\\]])|(?:\\<\\-)))", + "match": "((?:\\*|\\&)+)(?:(?!\\d)(?=(?:[\\w\\[\\]])|(?:\\<\\-)))", "name": "keyword.operator.address.go" }, { @@ -1185,12 +1185,7 @@ "name": "entity.name.function.go" } ] - }, - "patterns": [ - { - "include": "#type-declarations" - } - ] + } }, "end": "(?:(?<=\\))\\s*((?:(?:(?:[\\*\\[\\]]+)?(?:\\<\\-\\s*)?\\bchan\\b(?:\\s*\\<\\-)?\\s*)+)?(?!(?:[\\[\\]\\*]+)?(?:\\bstruct\\b|\\binterface\\b))[\\w\\.\\-\\*\\[\\]]+)?\\s*(?=\\{))", "endCaptures": { @@ -1261,7 +1256,7 @@ }, { "comment": "single function as a type returned type(s) declaration", - "match": "(?:(?<=\\))\\s+((?:(?:\\s*(?:[\\*\\[\\]]+)?(?:\\<\\-\\s*)?\\bchan\\b(?:\\s*\\<\\-)?\\s*)+)?[\\w\\*\\.\\[\\]\\<\\>\\-]+(?:\\s*)(?:\\/(?:\\/|\\*).*)?)$)", + "match": "(?:(?<=\\))(?:\\s*)((?:(?:\\s*(?:[\\*\\[\\]]+)?(?:\\<\\-\\s*)?\\bchan\\b(?:\\s*\\<\\-)?\\s*)+)?[\\w\\*\\.\\[\\]\\<\\>\\-]+(?:\\s*)(?:\\/(?:\\/|\\*).*)?)$)", "captures": { "1": { "patterns": [ @@ -1272,7 +1267,7 @@ "include": "#parameter-variable-types" }, { - "match": "(?:\\w+)", + "match": "\\w+", "name": "entity.name.type.go" } ] @@ -1513,7 +1508,7 @@ }, "functions_inline": { "comment": "functions in-line with multi return types", - "match": "(?:(\\bfunc\\b)((?:\\((?:[^/]*)\\))(?:\\s+)(?:\\((?:[^/]*)\\)))(?:\\s+)(?=\\{))", + "match": "(?:(\\bfunc\\b)((?:\\((?:[^/]*?)\\))(?:\\s+)(?:\\((?:[^/]*?)\\)))(?:\\s+)(?=\\{))", "captures": { "1": { "name": "keyword.function.go" @@ -1571,7 +1566,7 @@ }, "support_functions": { "comment": "Support Functions", - "match": "(?:(?:((?<=\\.)\\w+)|(\\w+))(\\[(?:(?:[\\w\\.\\*\\[\\]\\{\\}\"\\']+)(?:(?:\\,\\s*(?:[\\w\\.\\*\\[\\]\\{\\}]+))*))?\\])?(?=\\())", + "match": "(?:(?:((?<=\\.)\\b\\w+)|(\\b\\w+))(\\[(?:(?:[\\w\\.\\*\\[\\]\\{\\}\"\\']+)(?:(?:\\,\\s*(?:[\\w\\.\\*\\[\\]\\{\\}]+))*))?\\])?(?=\\())", "captures": { "1": { "name": "entity.name.function.support.go" @@ -1867,11 +1862,18 @@ "patterns": [ { "comment": "Struct variable for struct in struct types", - "begin": "(?:\\s*)?([\\s\\,\\w]+)(?:\\s+)(?:(?:[\\[\\]\\*])+)?(\\bstruct\\b)\\s*(\\{)", + "begin": "(?:(\\w+(?:\\,\\s*\\w+)*)(?:\\s+)(?:(?:[\\[\\]\\*])+)?(\\bstruct\\b)(?:\\s*)(\\{))", "beginCaptures": { "1": { - "match": "(?:\\w+)", - "name": "variable.other.property.go" + "patterns": [ + { + "include": "#type-declarations" + }, + { + "match": "\\w+", + "name": "variable.other.property.go" + } + ] }, "2": { "name": "keyword.struct.go" @@ -1911,6 +1913,42 @@ } }, "patterns": [ + { + "include": "#support_functions" + }, + { + "include": "#type-declarations-without-brackets" + }, + { + "begin": "(?:([\\w\\.\\*]+)?(\\[))", + "beginCaptures": { + "1": { + "patterns": [ + { + "include": "#type-declarations" + }, + { + "match": "(?:\\w+)", + "name": "entity.name.type.go" + } + ] + }, + "2": { + "name": "punctuation.definition.begin.bracket.square.go" + } + }, + "end": "\\]", + "endCaptures": { + "0": { + "name": "punctuation.definition.end.bracket.square.go" + } + }, + "patterns": [ + { + "include": "#generic_param_types" + } + ] + }, { "begin": "\\(", "beginCaptures": { @@ -1927,18 +1965,12 @@ "patterns": [ { "include": "#function_param_types" - }, - { - "include": "$self" } ] }, { - "include": "#support_functions" - }, - { - "comment": "single declaration | with or declarations", - "match": "((?:\\s+\\|)?(?:[\\w\\.\\[\\]\\*]+)(?:\\s+\\|)?)", + "comment": "other types", + "match": "([\\w\\.]+)", "captures": { "1": { "patterns": [ @@ -1946,10 +1978,7 @@ "include": "#type-declarations" }, { - "include": "#generic_types" - }, - { - "match": "(?:\\w+)", + "match": "\\w+", "name": "entity.name.type.go" } ] @@ -2145,7 +2174,7 @@ }, "after_control_variables": { "comment": "After control variables, to not highlight as a struct/interface (before formatting with gofmt)", - "match": "(?:(?<=\\brange\\b|\\bswitch\\b|\\;|\\bif\\b|\\bfor\\b|\\<|\\>|\\<\\=|\\>\\=|\\=\\=|\\!\\=|\\w(?:\\+|/|\\-|\\*|\\%)|\\w(?:\\+|/|\\-|\\*|\\%)\\=|\\|\\||\\&\\&)(?:\\s*)([[:alnum:]\\-\\_\\!\\.\\[\\]\\<\\>\\=\\*/\\+\\%\\:]+)(?:\\s*)(?=\\{))", + "match": "(?:(?<=\\brange\\b|\\bswitch\\b|\\;|\\bif\\b|\\bfor\\b|\\<|\\>|\\<\\=|\\>\\=|\\=\\=|\\!\\=|\\w(?:\\+|/|\\-|\\*|\\%)|\\w(?:\\+|/|\\-|\\*|\\%)\\=|\\|\\||\\&\\&)(?:\\s*)((?![\\[\\]]+)[[:alnum:]\\-\\_\\!\\.\\[\\]\\<\\>\\=\\*/\\+\\%\\:]+)(?:\\s*)(?=\\{))", "captures": { "1": { "patterns": [ @@ -2234,7 +2263,7 @@ }, { "comment": "make keyword", - "match": "(?:(\\bmake\\b)(?:(\\()((?:(?:(?:[\\*\\[\\]]+)?(?:\\<\\-\\s*)?\\bchan\\b(?:\\s*\\<\\-)?\\s*)+(?:\\([^\\)]+\\))?)?(?:[\\w\\.\\*\\[\\]\\{\\}]+)?(?:\\[(?:[^\\]]+)?\\])?(?:[\\w\\.\\*\\[\\]\\{\\}]+)?)?((?:\\,\\s*(?:[\\w\\.\\(\\)]+)?)+)?(\\))))", + "match": "(?:(\\bmake\\b)(?:(\\()((?:(?:(?:[\\*\\[\\]]+)?(?:\\<\\-\\s*)?\\bchan\\b(?:\\s*\\<\\-)?\\s*)+(?:\\([^\\)]+\\))?)?(?:[\\w\\.\\*\\[\\]\\{\\}]+)?(?:\\[(?:[^\\]]+)?\\])?(?:[\\w\\.\\*\\[\\]\\{\\}]+)?)?((?:\\,\\s*(?:[\\w\\.\\(\\)/\\+\\-\\<\\>\\&\\|\\%\\*]+)?)+)?(\\))))", "captures": { "1": { "name": "entity.name.function.support.builtin.go" @@ -2291,6 +2320,7 @@ } }, "switch_types": { + "comment": "switch type assertions, only highlights types after case keyword", "begin": "(?<=\\bswitch\\b)(?:\\s*)(?:(\\w+\\s*\\:\\=)?\\s*([\\w\\.\\*\\(\\)\\[\\]]+))(\\.\\(\\btype\\b\\)\\s*)(\\{)", "beginCaptures": { "1": { @@ -2299,7 +2329,7 @@ "include": "#operators" }, { - "match": "(?:\\w+)", + "match": "\\w+", "name": "variable.other.assignment.go" } ] @@ -2313,7 +2343,7 @@ "include": "#type-declarations" }, { - "match": "(?:\\w+)", + "match": "\\w+", "name": "variable.other.go" } ] @@ -2344,9 +2374,7 @@ }, "patterns": [ { - "include": "#type-declarations" - }, - { + "comment": "types after case keyword with single line", "match": "(?:^\\s*(\\bcase\\b))(?:\\s+)([\\w\\.\\,\\*\\=\\<\\>\\!\\s]+)(:)(\\s*/(?:/|\\*)\\s*.*)?$", "captures": { "1": { @@ -2375,6 +2403,30 @@ } } }, + { + "comment": "types after case keyword with multi lines", + "begin": "\\bcase\\b", + "beginCaptures": { + "0": { + "name": "keyword.control.go" + } + }, + "end": "\\:", + "endCaptures": { + "0": { + "name": "punctuation.other.colon.go" + } + }, + "patterns": [ + { + "include": "#type-declarations" + }, + { + "match": "\\w+", + "name": "entity.name.type.go" + } + ] + }, { "include": "$self" } @@ -2573,7 +2625,7 @@ } }, "switch_select_case_variables": { - "comment": "variables after case control keyword in switch/select expression", + "comment": "variables after case control keyword in switch/select expression, to not scope them as property variables", "match": "(?:(?:^\\s*(\\bcase\\b))(?:\\s+)([\\s\\S]+(?:\\:)\\s*(?:/(?:/|\\*).*)?)$)", "captures": { "1": { @@ -2587,6 +2639,9 @@ { "include": "#support_functions" }, + { + "include": "#variable_assignment" + }, { "match": "\\w+", "name": "variable.other.go" @@ -2710,7 +2765,7 @@ }, "double_parentheses_types": { "comment": "double parentheses types", - "match": "(?:(\\((?:[\\w\\.\\[\\]\\*\\&]+)\\))(?=\\())", + "match": "(?:(?= 4.5 have an id. - * Details of the spec can be found here https://jupyter.org/enhancement-proposals/62-cell-id/cell-id.html# - */ -export function ensureAllNewCellsHaveCellIds(context: ExtensionContext) { - workspace.onDidChangeNotebookDocument(onDidChangeNotebookCells, undefined, context.subscriptions); -} - -function onDidChangeNotebookCells(e: NotebookDocumentChangeEvent) { - const nbMetadata = getNotebookMetadata(e.notebook); - if (!isCellIdRequired(nbMetadata)) { - return; - } - e.contentChanges.forEach(change => { - change.addedCells.forEach(cell => { - const cellMetadata = getCellMetadata(cell); - if (cellMetadata?.id) { - return; - } - const id = generateCellId(e.notebook); - const edit = new WorkspaceEdit(); - // Don't edit the metadata directly, always get a clone (prevents accidental singletons and directly editing the objects). - const updatedMetadata: CellMetadata = { ...JSON.parse(JSON.stringify(cellMetadata || {})) }; - updatedMetadata.id = id; - edit.set(cell.notebook.uri, [NotebookEdit.updateCellMetadata(cell.index, { ...(cell.metadata), custom: updatedMetadata })]); - workspace.applyEdit(edit); - }); - }); -} - -/** - * Cell ids are required in notebooks only in notebooks with nbformat >= 4.5 - */ -function isCellIdRequired(metadata: Pick, 'nbformat' | 'nbformat_minor'>) { - if ((metadata.nbformat || 0) >= 5) { - return true; - } - if ((metadata.nbformat || 0) === 4 && (metadata.nbformat_minor || 0) >= 5) { - return true; - } - return false; -} - -function generateCellId(notebook: NotebookDocument) { - while (true) { - // Details of the id can be found here https://jupyter.org/enhancement-proposals/62-cell-id/cell-id.html#adding-an-id-field, - // & here https://jupyter.org/enhancement-proposals/62-cell-id/cell-id.html#updating-older-formats - const id = generateUuid().replace(/-/g, '').substring(0, 8); - let duplicate = false; - for (let index = 0; index < notebook.cellCount; index++) { - const cell = notebook.cellAt(index); - const existingId = getCellMetadata(cell)?.id; - if (!existingId) { - continue; - } - if (existingId === id) { - duplicate = true; - break; - } - } - if (!duplicate) { - return id; - } - } -} - - -/** - * Copied from src/vs/base/common/uuid.ts - */ -function generateUuid() { - // use `randomValues` if possible - function getRandomValues(bucket: Uint8Array): Uint8Array { - for (let i = 0; i < bucket.length; i++) { - bucket[i] = Math.floor(Math.random() * 256); - } - return bucket; - } - - // prep-work - const _data = new Uint8Array(16); - const _hex: string[] = []; - for (let i = 0; i < 256; i++) { - _hex.push(i.toString(16).padStart(2, '0')); - } - - // get data - getRandomValues(_data); - - // set version bits - _data[6] = (_data[6] & 0x0f) | 0x40; - _data[8] = (_data[8] & 0x3f) | 0x80; - - // print as string - let i = 0; - let result = ''; - result += _hex[_data[i++]]; - result += _hex[_data[i++]]; - result += _hex[_data[i++]]; - result += _hex[_data[i++]]; - result += '-'; - result += _hex[_data[i++]]; - result += _hex[_data[i++]]; - result += '-'; - result += _hex[_data[i++]]; - result += _hex[_data[i++]]; - result += '-'; - result += _hex[_data[i++]]; - result += _hex[_data[i++]]; - result += '-'; - result += _hex[_data[i++]]; - result += _hex[_data[i++]]; - result += _hex[_data[i++]]; - result += _hex[_data[i++]]; - result += _hex[_data[i++]]; - result += _hex[_data[i++]]; - return result; -} diff --git a/extensions/ipynb/src/common.ts b/extensions/ipynb/src/common.ts index d5ff5f86069ea..a25973e95a607 100644 --- a/extensions/ipynb/src/common.ts +++ b/extensions/ipynb/src/common.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import type * as nbformat from '@jupyterlab/nbformat'; +import { workspace } from 'vscode'; /** * Metadata we store in VS Code cell output items. @@ -58,5 +59,13 @@ export interface CellMetadata { /** * Stores cell metadata. */ - metadata?: Partial; + metadata?: Partial & { vscode?: { languageId?: string } }; + /** + * The code cell's prompt number. Will be null if the cell has not been run. + */ + execution_count?: number; +} + +export function useCustomPropertyInMetadata() { + return !workspace.getConfiguration('jupyter', undefined).get('experimental.dropCustomMetadata', false); } diff --git a/extensions/ipynb/src/deserializers.ts b/extensions/ipynb/src/deserializers.ts index 21dd078b89b2b..920689c748ceb 100644 --- a/extensions/ipynb/src/deserializers.ts +++ b/extensions/ipynb/src/deserializers.ts @@ -5,7 +5,7 @@ import type * as nbformat from '@jupyterlab/nbformat'; import { extensions, NotebookCellData, NotebookCellExecutionSummary, NotebookCellKind, NotebookCellOutput, NotebookCellOutputItem, NotebookData } from 'vscode'; -import { CellMetadata, CellOutputMetadata } from './common'; +import { CellMetadata, CellOutputMetadata, useCustomPropertyInMetadata } from './common'; const jupyterLanguageToMonacoLanguageMapping = new Map([ ['c#', 'csharp'], @@ -154,24 +154,51 @@ function convertJupyterOutputToBuffer(mime: string, value: unknown): NotebookCel function getNotebookCellMetadata(cell: nbformat.IBaseCell): { [key: string]: any; } { - const cellMetadata: { [key: string]: any } = {}; - // We put this only for VSC to display in diff view. - // Else we don't use this. - const custom: CellMetadata = {}; - if (cell['metadata']) { - custom['metadata'] = JSON.parse(JSON.stringify(cell['metadata'])); - } + if (useCustomPropertyInMetadata()) { + const cellMetadata: { [key: string]: any } = {}; + // We put this only for VSC to display in diff view. + // Else we don't use this. + const custom: CellMetadata = {}; + + if (cell.cell_type === 'code' && typeof cell['execution_count'] === 'number') { + custom.execution_count = cell['execution_count']; + } - if ('id' in cell && typeof cell.id === 'string') { - custom.id = cell.id; - } + if (cell['metadata']) { + custom['metadata'] = JSON.parse(JSON.stringify(cell['metadata'])); + } - cellMetadata.custom = custom; + if ('id' in cell && typeof cell.id === 'string') { + custom.id = cell.id; + } + + cellMetadata.custom = custom; - if (cell['attachments']) { - cellMetadata.attachments = JSON.parse(JSON.stringify(cell['attachments'])); + if (cell['attachments']) { + cellMetadata.attachments = JSON.parse(JSON.stringify(cell['attachments'])); + } + return cellMetadata; + } else { + // We put this only for VSC to display in diff view. + // Else we don't use this. + const cellMetadata: CellMetadata = {}; + if (cell.cell_type === 'code' && typeof cell['execution_count'] === 'number') { + cellMetadata.execution_count = cell['execution_count']; + } + + if (cell['metadata']) { + cellMetadata['metadata'] = JSON.parse(JSON.stringify(cell['metadata'])); + } + + if ('id' in cell && typeof cell.id === 'string') { + cellMetadata.id = cell.id; + } + + if (cell['attachments']) { + cellMetadata.attachments = JSON.parse(JSON.stringify(cell['attachments'])); + } + return cellMetadata; } - return cellMetadata; } function getOutputMetadata(output: nbformat.IOutput): CellOutputMetadata { @@ -364,6 +391,6 @@ export function jupyterNotebookModelToNotebookData( .filter((item): item is NotebookCellData => !!item); const notebookData = new NotebookData(cells); - notebookData.metadata = { custom: notebookContentWithoutCells }; + notebookData.metadata = useCustomPropertyInMetadata() ? { custom: notebookContentWithoutCells } : notebookContentWithoutCells; return notebookData; } diff --git a/extensions/ipynb/src/ipynbMain.ts b/extensions/ipynb/src/ipynbMain.ts index c256e3b4f6522..889f4c074453f 100644 --- a/extensions/ipynb/src/ipynbMain.ts +++ b/extensions/ipynb/src/ipynbMain.ts @@ -5,9 +5,10 @@ import * as vscode from 'vscode'; import { NotebookSerializer } from './notebookSerializer'; -import { ensureAllNewCellsHaveCellIds } from './cellIdService'; +import { activate as keepNotebookModelStoreInSync } from './notebookModelStoreSync'; import { notebookImagePasteSetup } from './notebookImagePaste'; import { AttachmentCleaner } from './notebookAttachmentCleaner'; +import { useCustomPropertyInMetadata } from './common'; // From {nbformat.INotebookMetadata} in @jupyterlab/coreutils type NotebookMetadata = { @@ -30,13 +31,18 @@ type NotebookMetadata = { export function activate(context: vscode.ExtensionContext) { const serializer = new NotebookSerializer(context); - ensureAllNewCellsHaveCellIds(context); + keepNotebookModelStoreInSync(context); context.subscriptions.push(vscode.workspace.registerNotebookSerializer('jupyter-notebook', serializer, { transientOutputs: false, - transientCellMetadata: { + transientCellMetadata: useCustomPropertyInMetadata() ? { breakpointMargin: true, custom: false, attachments: false + } : { + breakpointMargin: true, + id: false, + metadata: false, + attachments: false }, cellContentMetadata: { attachments: true @@ -45,10 +51,15 @@ export function activate(context: vscode.ExtensionContext) { context.subscriptions.push(vscode.workspace.registerNotebookSerializer('interactive', serializer, { transientOutputs: false, - transientCellMetadata: { + transientCellMetadata: useCustomPropertyInMetadata() ? { breakpointMargin: true, custom: false, attachments: false + } : { + breakpointMargin: true, + id: false, + metadata: false, + attachments: false }, cellContentMetadata: { attachments: true @@ -73,13 +84,18 @@ export function activate(context: vscode.ExtensionContext) { const language = 'python'; const cell = new vscode.NotebookCellData(vscode.NotebookCellKind.Code, '', language); const data = new vscode.NotebookData([cell]); - data.metadata = { + data.metadata = useCustomPropertyInMetadata() ? { custom: { cells: [], metadata: {}, nbformat: 4, nbformat_minor: 2 } + } : { + cells: [], + metadata: {}, + nbformat: 4, + nbformat_minor: 2 }; const doc = await vscode.workspace.openNotebookDocument('jupyter-notebook', data); await vscode.window.showNotebookDocument(doc); @@ -109,6 +125,9 @@ export function activate(context: vscode.ExtensionContext) { return { + get dropCustomMetadata() { + return !useCustomPropertyInMetadata(); + }, exportNotebook: (notebook: vscode.NotebookData): string => { return exportNotebook(notebook, serializer); }, @@ -119,16 +138,26 @@ export function activate(context: vscode.ExtensionContext) { } const edit = new vscode.WorkspaceEdit(); - edit.set(resource, [vscode.NotebookEdit.updateNotebookMetadata({ - ...document.metadata, - custom: { - ...(document.metadata.custom ?? {}), + if (useCustomPropertyInMetadata()) { + edit.set(resource, [vscode.NotebookEdit.updateNotebookMetadata({ + ...document.metadata, + custom: { + ...(document.metadata.custom ?? {}), + metadata: { + ...(document.metadata.custom?.metadata ?? {}), + ...metadata + }, + } + })]); + } else { + edit.set(resource, [vscode.NotebookEdit.updateNotebookMetadata({ + ...document.metadata, metadata: { - ...(document.metadata.custom?.metadata ?? {}), + ...(document.metadata.metadata ?? {}), ...metadata }, - } - })]); + })]); + } return vscode.workspace.applyEdit(edit); }, }; diff --git a/extensions/ipynb/src/notebookAttachmentCleaner.ts b/extensions/ipynb/src/notebookAttachmentCleaner.ts index cad19f07b29e0..32aae0c5d1e6e 100644 --- a/extensions/ipynb/src/notebookAttachmentCleaner.ts +++ b/extensions/ipynb/src/notebookAttachmentCleaner.ts @@ -81,34 +81,31 @@ export class AttachmentCleaner implements vscode.CodeActionProvider { this._disposables.push(vscode.workspace.onWillSaveNotebookDocument(e => { if (e.reason === vscode.TextDocumentSaveReason.Manual) { this._delayer.dispose(); - - e.waitUntil(new Promise((resolve) => { - if (e.notebook.getCells().length === 0) { - return; - } - - const notebookEdits: vscode.NotebookEdit[] = []; - for (const cell of e.notebook.getCells()) { - if (cell.kind !== vscode.NotebookCellKind.Markup) { - continue; - } - - const metadataEdit = this.cleanNotebookAttachments({ - notebook: e.notebook, - cell: cell, - document: cell.document - }); - - if (metadataEdit) { - notebookEdits.push(metadataEdit); - } + if (e.notebook.getCells().length === 0) { + return; + } + const notebookEdits: vscode.NotebookEdit[] = []; + for (const cell of e.notebook.getCells()) { + if (cell.kind !== vscode.NotebookCellKind.Markup) { + continue; } - const workspaceEdit = new vscode.WorkspaceEdit(); - workspaceEdit.set(e.notebook.uri, notebookEdits); + const metadataEdit = this.cleanNotebookAttachments({ + notebook: e.notebook, + cell: cell, + document: cell.document + }); - resolve(workspaceEdit); - })); + if (metadataEdit) { + notebookEdits.push(metadataEdit); + } + } + if (!notebookEdits.length) { + return; + } + const workspaceEdit = new vscode.WorkspaceEdit(); + workspaceEdit.set(e.notebook.uri, notebookEdits); + e.waitUntil(Promise.resolve(workspaceEdit)); } })); @@ -229,7 +226,7 @@ export class AttachmentCleaner implements vscode.CodeActionProvider { this.updateDiagnostics(cell.document.uri, diagnostics); - if (cell.index > -1 && !objectEquals(markdownAttachmentsInUse, cell.metadata.attachments)) { + if (cell.index > -1 && !objectEquals(markdownAttachmentsInUse || {}, cell.metadata.attachments || {})) { const updateMetadata: { [key: string]: any } = deepClone(cell.metadata); if (Object.keys(markdownAttachmentsInUse).length === 0) { updateMetadata.attachments = undefined; diff --git a/extensions/ipynb/src/notebookImagePaste.ts b/extensions/ipynb/src/notebookImagePaste.ts index 94292c26a7498..7ea63e7026a2d 100644 --- a/extensions/ipynb/src/notebookImagePaste.ts +++ b/extensions/ipynb/src/notebookImagePaste.ts @@ -48,14 +48,15 @@ function getImageMimeType(uri: vscode.Uri): string | undefined { class DropOrPasteEditProvider implements vscode.DocumentPasteEditProvider, vscode.DocumentDropEditProvider { - public readonly id = 'insertAttachment'; + public static readonly kind = vscode.DocumentPasteEditKind.Empty.append('markdown', 'image', 'attachment'); async provideDocumentPasteEdits( document: vscode.TextDocument, _ranges: readonly vscode.Range[], dataTransfer: vscode.DataTransfer, + _context: vscode.DocumentPasteEditContext, token: vscode.CancellationToken, - ): Promise { + ): Promise { const enabled = vscode.workspace.getConfiguration('ipynb', document).get('pasteImagesAsAttachments.enabled', true); if (!enabled) { return; @@ -66,10 +67,10 @@ class DropOrPasteEditProvider implements vscode.DocumentPasteEditProvider, vscod return; } - const pasteEdit = new vscode.DocumentPasteEdit(insert.insertText, vscode.l10n.t('Insert Image as Attachment')); - pasteEdit.yieldTo = [{ mimeType: MimeType.plain }]; + const pasteEdit = new vscode.DocumentPasteEdit(insert.insertText, vscode.l10n.t('Insert Image as Attachment'), DropOrPasteEditProvider.kind); + pasteEdit.yieldTo = [vscode.DocumentPasteEditKind.Empty.append('text')]; pasteEdit.additionalEdit = insert.additionalEdit; - return pasteEdit; + return [pasteEdit]; } async provideDocumentDropEdits( @@ -84,9 +85,9 @@ class DropOrPasteEditProvider implements vscode.DocumentPasteEditProvider, vscod } const dropEdit = new vscode.DocumentDropEdit(insert.insertText); - dropEdit.yieldTo = [{ mimeType: MimeType.plain }]; + dropEdit.yieldTo = [vscode.DocumentPasteEditKind.Empty.append('text')]; dropEdit.additionalEdit = insert.additionalEdit; - dropEdit.label = vscode.l10n.t('Insert Image as Attachment'); + dropEdit.title = vscode.l10n.t('Insert Image as Attachment'); return dropEdit; } @@ -299,14 +300,14 @@ export function notebookImagePasteSetup(): vscode.Disposable { const provider = new DropOrPasteEditProvider(); return vscode.Disposable.from( vscode.languages.registerDocumentPasteEditProvider(JUPYTER_NOTEBOOK_MARKDOWN_SELECTOR, provider, { - id: provider.id, + providedPasteEditKinds: [DropOrPasteEditProvider.kind], pasteMimeTypes: [ MimeType.png, MimeType.uriList, ], }), vscode.languages.registerDocumentDropEditProvider(JUPYTER_NOTEBOOK_MARKDOWN_SELECTOR, provider, { - id: provider.id, + providedDropEditKinds: [DropOrPasteEditProvider.kind], dropMimeTypes: [ ...Object.values(imageExtToMime), MimeType.uriList, diff --git a/extensions/ipynb/src/notebookModelStoreSync.ts b/extensions/ipynb/src/notebookModelStoreSync.ts new file mode 100644 index 0000000000000..737034266f032 --- /dev/null +++ b/extensions/ipynb/src/notebookModelStoreSync.ts @@ -0,0 +1,251 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { ExtensionContext, NotebookCellKind, NotebookDocument, NotebookDocumentChangeEvent, NotebookEdit, workspace, WorkspaceEdit, type NotebookCell, type NotebookDocumentWillSaveEvent } from 'vscode'; +import { getCellMetadata, getVSCodeCellLanguageId, removeVSCodeCellLanguageId, setVSCodeCellLanguageId, sortObjectPropertiesRecursively } from './serializers'; +import { CellMetadata, useCustomPropertyInMetadata } from './common'; +import { getNotebookMetadata } from './notebookSerializer'; +import type * as nbformat from '@jupyterlab/nbformat'; + +const noop = () => { + // +}; + +/** + * Code here is used to ensure the Notebook Model is in sync the the ipynb JSON file. + * E.g. assume you add a new cell, this new cell will not have any metadata at all. + * However when we save the ipynb, the metadata will be an empty object `{}`. + * Now thats completely different from the metadata os being `empty/undefined` in the model. + * As a result, when looking at things like diff view or accessing metadata, we'll see differences. +* +* This code ensures that the model is in sync with the ipynb file. +*/ +export const pendingNotebookCellModelUpdates = new WeakMap>>(); +export function activate(context: ExtensionContext) { + workspace.onDidChangeNotebookDocument(onDidChangeNotebookCells, undefined, context.subscriptions); + workspace.onWillSaveNotebookDocument(waitForPendingModelUpdates, undefined, context.subscriptions); +} + +function isSupportedNotebook(notebook: NotebookDocument) { + return notebook.notebookType === 'jupyter-notebook' || notebook.notebookType === 'interactive'; +} + +function waitForPendingModelUpdates(e: NotebookDocumentWillSaveEvent) { + if (!isSupportedNotebook(e.notebook)) { + return; + } + + const promises = pendingNotebookCellModelUpdates.get(e.notebook); + if (!promises) { + return; + } + e.waitUntil(Promise.all(promises)); +} + +function cleanup(notebook: NotebookDocument, promise: PromiseLike) { + const pendingUpdates = pendingNotebookCellModelUpdates.get(notebook); + if (pendingUpdates) { + pendingUpdates.delete(promise); + if (!pendingUpdates.size) { + pendingNotebookCellModelUpdates.delete(notebook); + } + } +} +function trackAndUpdateCellMetadata(notebook: NotebookDocument, updates: { cell: NotebookCell; metadata: CellMetadata & { vscode?: { languageId: string } } }[]) { + const pendingUpdates = pendingNotebookCellModelUpdates.get(notebook) ?? new Set>(); + pendingNotebookCellModelUpdates.set(notebook, pendingUpdates); + const edit = new WorkspaceEdit(); + updates.forEach(({ cell, metadata }) => { + let newMetadata: any = {}; + if (useCustomPropertyInMetadata()) { + newMetadata = { ...(cell.metadata), custom: metadata }; + } else { + newMetadata = { ...cell.metadata, ...metadata }; + if (!metadata.execution_count && newMetadata.execution_count) { + delete newMetadata.execution_count; + } + if (!metadata.attachments && newMetadata.attachments) { + delete newMetadata.attachments; + } + } + edit.set(cell.notebook.uri, [NotebookEdit.updateCellMetadata(cell.index, sortObjectPropertiesRecursively(newMetadata))]); + }); + const promise = workspace.applyEdit(edit).then(noop, noop); + pendingUpdates.add(promise); + const clean = () => cleanup(notebook, promise); + promise.then(clean, clean); +} + +function onDidChangeNotebookCells(e: NotebookDocumentChangeEvent) { + if (!isSupportedNotebook(e.notebook)) { + return; + } + + const notebook = e.notebook; + const notebookMetadata = getNotebookMetadata(e.notebook); + + // use the preferred language from document metadata or the first cell language as the notebook preferred cell language + const preferredCellLanguage = notebookMetadata.metadata?.language_info?.name; + const updates: { cell: NotebookCell; metadata: CellMetadata & { vscode?: { languageId: string } } }[] = []; + // When we change the language of a cell, + // Ensure the metadata in the notebook cell has been updated as well, + // Else model will be out of sync with ipynb https://github.com/microsoft/vscode/issues/207968#issuecomment-2002858596 + e.cellChanges.forEach(e => { + if (!preferredCellLanguage || e.cell.kind !== NotebookCellKind.Code) { + return; + } + const currentMetadata = e.metadata ? getCellMetadata({ metadata: e.metadata }) : getCellMetadata({ cell: e.cell }); + const languageIdInMetadata = getVSCodeCellLanguageId(currentMetadata); + const metadata: CellMetadata = JSON.parse(JSON.stringify(currentMetadata)); + metadata.metadata = metadata.metadata || {}; + let metadataUpdated = false; + if (e.executionSummary?.executionOrder && typeof e.executionSummary.success === 'boolean' && currentMetadata.execution_count !== e.executionSummary?.executionOrder) { + metadata.execution_count = e.executionSummary.executionOrder; + metadataUpdated = true; + } else if (!e.executionSummary && !e.metadata && e.outputs?.length === 0 && currentMetadata.execution_count) { + // Clear all. + delete metadata.execution_count; + metadataUpdated = true; + } + + if (e.document?.languageId && e.document?.languageId !== preferredCellLanguage && e.document?.languageId !== languageIdInMetadata) { + setVSCodeCellLanguageId(metadata, e.document.languageId); + metadataUpdated = true; + } else if (e.document?.languageId && e.document.languageId === preferredCellLanguage && languageIdInMetadata) { + removeVSCodeCellLanguageId(metadata); + metadataUpdated = true; + } else if (e.document?.languageId && e.document.languageId === preferredCellLanguage && e.document.languageId === languageIdInMetadata) { + removeVSCodeCellLanguageId(metadata); + metadataUpdated = true; + } + + if (metadataUpdated) { + updates.push({ cell: e.cell, metadata }); + } + }); + + // Ensure all new cells in notebooks with nbformat >= 4.5 have an id. + // Details of the spec can be found here https://jupyter.org/enhancement-proposals/62-cell-id/cell-id.html# + e.contentChanges.forEach(change => { + change.addedCells.forEach(cell => { + // When ever a cell is added, always update the metadata + // as metadata is always an empty `{}` in ipynb JSON file + const cellMetadata = getCellMetadata({ cell }); + + // Avoid updating the metadata if it's not required. + if (cellMetadata.metadata) { + if (!isCellIdRequired(notebookMetadata)) { + return; + } + if (isCellIdRequired(notebookMetadata) && cellMetadata?.id) { + return; + } + } + + // Don't edit the metadata directly, always get a clone (prevents accidental singletons and directly editing the objects). + const metadata: CellMetadata = { ...JSON.parse(JSON.stringify(cellMetadata || {})) }; + metadata.metadata = metadata.metadata || {}; + + if (isCellIdRequired(notebookMetadata) && !cellMetadata?.id) { + metadata.id = generateCellId(e.notebook); + } + updates.push({ cell, metadata }); + }); + }); + + if (updates.length) { + trackAndUpdateCellMetadata(notebook, updates); + } +} + + +/** + * Cell ids are required in notebooks only in notebooks with nbformat >= 4.5 + */ +function isCellIdRequired(metadata: Pick, 'nbformat' | 'nbformat_minor'>) { + if ((metadata.nbformat || 0) >= 5) { + return true; + } + if ((metadata.nbformat || 0) === 4 && (metadata.nbformat_minor || 0) >= 5) { + return true; + } + return false; +} + +function generateCellId(notebook: NotebookDocument) { + while (true) { + // Details of the id can be found here https://jupyter.org/enhancement-proposals/62-cell-id/cell-id.html#adding-an-id-field, + // & here https://jupyter.org/enhancement-proposals/62-cell-id/cell-id.html#updating-older-formats + const id = generateUuid().replace(/-/g, '').substring(0, 8); + let duplicate = false; + for (let index = 0; index < notebook.cellCount; index++) { + const cell = notebook.cellAt(index); + const existingId = getCellMetadata({ cell })?.id; + if (!existingId) { + continue; + } + if (existingId === id) { + duplicate = true; + break; + } + } + if (!duplicate) { + return id; + } + } +} + + +/** + * Copied from src/vs/base/common/uuid.ts + */ +function generateUuid() { + // use `randomValues` if possible + function getRandomValues(bucket: Uint8Array): Uint8Array { + for (let i = 0; i < bucket.length; i++) { + bucket[i] = Math.floor(Math.random() * 256); + } + return bucket; + } + + // prep-work + const _data = new Uint8Array(16); + const _hex: string[] = []; + for (let i = 0; i < 256; i++) { + _hex.push(i.toString(16).padStart(2, '0')); + } + + // get data + getRandomValues(_data); + + // set version bits + _data[6] = (_data[6] & 0x0f) | 0x40; + _data[8] = (_data[8] & 0x3f) | 0x80; + + // print as string + let i = 0; + let result = ''; + result += _hex[_data[i++]]; + result += _hex[_data[i++]]; + result += _hex[_data[i++]]; + result += _hex[_data[i++]]; + result += '-'; + result += _hex[_data[i++]]; + result += _hex[_data[i++]]; + result += '-'; + result += _hex[_data[i++]]; + result += _hex[_data[i++]]; + result += '-'; + result += _hex[_data[i++]]; + result += _hex[_data[i++]]; + result += '-'; + result += _hex[_data[i++]]; + result += _hex[_data[i++]]; + result += _hex[_data[i++]]; + result += _hex[_data[i++]]; + result += _hex[_data[i++]]; + result += _hex[_data[i++]]; + return result; +} diff --git a/extensions/ipynb/src/notebookSerializer.ts b/extensions/ipynb/src/notebookSerializer.ts index 26cc2b442323f..1d44a45881293 100644 --- a/extensions/ipynb/src/notebookSerializer.ts +++ b/extensions/ipynb/src/notebookSerializer.ts @@ -10,6 +10,7 @@ import { defaultNotebookFormat } from './constants'; import { getPreferredLanguage, jupyterNotebookModelToNotebookData } from './deserializers'; import { createJupyterCellFromNotebookCell, pruneCell, sortObjectPropertiesRecursively } from './serializers'; import * as fnv from '@enonic/fnv-plus'; +import { useCustomPropertyInMetadata } from './common'; export class NotebookSerializer implements vscode.NotebookSerializer { constructor(readonly context: vscode.ExtensionContext) { @@ -99,10 +100,11 @@ export class NotebookSerializer implements vscode.NotebookSerializer { } export function getNotebookMetadata(document: vscode.NotebookDocument | vscode.NotebookData) { - const notebookContent: Partial = document.metadata?.custom || {}; - notebookContent.cells = notebookContent.cells || []; - notebookContent.nbformat = notebookContent.nbformat || defaultNotebookFormat.major; - notebookContent.nbformat_minor = notebookContent.nbformat_minor ?? defaultNotebookFormat.minor; - notebookContent.metadata = notebookContent.metadata || {}; + const existingContent: Partial = (useCustomPropertyInMetadata() ? document.metadata?.custom : document.metadata) || {}; + const notebookContent: Partial = {}; + notebookContent.cells = existingContent.cells || []; + notebookContent.nbformat = existingContent.nbformat || defaultNotebookFormat.major; + notebookContent.nbformat_minor = existingContent.nbformat_minor ?? defaultNotebookFormat.minor; + notebookContent.metadata = existingContent.metadata || {}; return notebookContent; } diff --git a/extensions/ipynb/src/serializers.ts b/extensions/ipynb/src/serializers.ts index 27c45bce918b8..3eb6e90eabd46 100644 --- a/extensions/ipynb/src/serializers.ts +++ b/extensions/ipynb/src/serializers.ts @@ -5,7 +5,7 @@ import type * as nbformat from '@jupyterlab/nbformat'; import { NotebookCell, NotebookCellData, NotebookCellKind, NotebookCellOutput } from 'vscode'; -import { CellOutputMetadata } from './common'; +import { CellOutputMetadata, useCustomPropertyInMetadata, type CellMetadata } from './common'; import { textMimeTypes } from './deserializers'; const textDecoder = new TextDecoder(); @@ -54,28 +54,71 @@ export function sortObjectPropertiesRecursively(obj: any): any { return obj; } -export function getCellMetadata(cell: NotebookCell | NotebookCellData) { - return { - // it contains the cell id, and the cell metadata, along with other nb cell metadata - ...(cell.metadata?.custom ?? {}), - // promote the cell attachments to the top level - attachments: cell.metadata?.custom?.attachments ?? cell.metadata?.attachments - }; +export function getCellMetadata(options: { cell: NotebookCell | NotebookCellData } | { metadata?: { [key: string]: any } }): CellMetadata { + if ('cell' in options) { + const cell = options.cell; + if (useCustomPropertyInMetadata()) { + const metadata: CellMetadata = { + // it contains the cell id, and the cell metadata, along with other nb cell metadata + ...(cell.metadata?.custom ?? {}) + }; + // promote the cell attachments to the top level + const attachments = cell.metadata?.custom?.attachments ?? cell.metadata?.attachments; + if (attachments) { + metadata.attachments = attachments; + } + return metadata; + } + const metadata = { + // it contains the cell id, and the cell metadata, along with other nb cell metadata + ...(cell.metadata ?? {}) + }; + + return metadata; + } else { + const cell = options; + if (useCustomPropertyInMetadata()) { + const metadata: CellMetadata = { + // it contains the cell id, and the cell metadata, along with other nb cell metadata + ...(cell.metadata?.custom ?? {}) + }; + // promote the cell attachments to the top level + const attachments = cell.metadata?.custom?.attachments ?? cell.metadata?.attachments; + if (attachments) { + metadata.attachments = attachments; + } + return metadata; + } + const metadata = { + // it contains the cell id, and the cell metadata, along with other nb cell metadata + ...(cell.metadata ?? {}) + }; + + return metadata; + } +} + +export function getVSCodeCellLanguageId(metadata: CellMetadata): string | undefined { + return metadata.metadata?.vscode?.languageId; +} +export function setVSCodeCellLanguageId(metadata: CellMetadata, languageId: string) { + metadata.metadata = metadata.metadata || {}; + metadata.metadata.vscode = { languageId }; +} +export function removeVSCodeCellLanguageId(metadata: CellMetadata) { + if (metadata.metadata?.vscode) { + delete metadata.metadata.vscode; + } } function createCodeCellFromNotebookCell(cell: NotebookCellData, preferredLanguage: string | undefined): nbformat.ICodeCell { - const cellMetadata = getCellMetadata(cell); - let metadata = cellMetadata?.metadata || {}; // This cannot be empty. + const cellMetadata: CellMetadata = JSON.parse(JSON.stringify(getCellMetadata({ cell }))); + cellMetadata.metadata = cellMetadata.metadata || {}; // This cannot be empty. if (cell.languageId !== preferredLanguage) { - metadata = { - ...metadata, - vscode: { - languageId: cell.languageId - } - }; + setVSCodeCellLanguageId(cellMetadata, cell.languageId); } else { // cell current language is the same as the preferred cell language in the document, flush the vscode custom language id metadata - metadata.vscode = undefined; + removeVSCodeCellLanguageId(cellMetadata); } const codeCell: any = { @@ -83,7 +126,7 @@ function createCodeCellFromNotebookCell(cell: NotebookCellData, preferredLanguag execution_count: cell.executionSummary?.executionOrder ?? null, source: splitMultilineString(cell.value.replace(/\r\n/g, '\n')), outputs: (cell.outputs || []).map(translateCellDisplayOutput), - metadata: metadata + metadata: cellMetadata.metadata }; if (cellMetadata?.id) { codeCell.id = cellMetadata.id; @@ -92,7 +135,7 @@ function createCodeCellFromNotebookCell(cell: NotebookCellData, preferredLanguag } function createRawCellFromNotebookCell(cell: NotebookCellData): nbformat.IRawCell { - const cellMetadata = getCellMetadata(cell); + const cellMetadata = getCellMetadata({ cell }); const rawCell: any = { cell_type: 'raw', source: splitMultilineString(cell.value.replace(/\r\n/g, '\n')), @@ -343,7 +386,7 @@ function convertOutputMimeToJupyterOutput(mime: string, value: Uint8Array) { } export function createMarkdownCellFromNotebookCell(cell: NotebookCellData): nbformat.IMarkdownCell { - const cellMetadata = getCellMetadata(cell); + const cellMetadata = getCellMetadata({ cell }); const markdownCell: any = { cell_type: 'markdown', source: splitMultilineString(cell.value.replace(/\r\n/g, '\n')), diff --git a/extensions/ipynb/src/test/notebookModelStoreSync.test.ts b/extensions/ipynb/src/test/notebookModelStoreSync.test.ts new file mode 100644 index 0000000000000..1afca8c123997 --- /dev/null +++ b/extensions/ipynb/src/test/notebookModelStoreSync.test.ts @@ -0,0 +1,680 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as assert from 'assert'; +import * as sinon from 'sinon'; +import { CancellationTokenSource, Disposable, EventEmitter, ExtensionContext, NotebookCellKind, NotebookDocumentChangeEvent, NotebookDocumentWillSaveEvent, NotebookEdit, NotebookRange, TextDocumentSaveReason, workspace, type CancellationToken, type NotebookCell, type NotebookDocument, type WorkspaceEdit, type WorkspaceEditMetadata } from 'vscode'; +import { activate } from '../notebookModelStoreSync'; + +[true, false].forEach(useCustomPropertyInMetadata => { + suite(`Notebook Model Store Sync (${useCustomPropertyInMetadata ? 'with custom metadata (standard behaviour)' : 'without custom metadata'})`, () => { + let disposables: Disposable[] = []; + let onDidChangeNotebookDocument: EventEmitter; + let onWillSaveNotebookDocument: AsyncEmitter; + let notebook: NotebookDocument; + let token: CancellationTokenSource; + let editsApplied: WorkspaceEdit[] = []; + let pendingPromises: Promise[] = []; + let cellMetadataUpdates: NotebookEdit[] = []; + let applyEditStub: sinon.SinonStub<[edit: WorkspaceEdit, metadata?: WorkspaceEditMetadata | undefined], Thenable>; + setup(() => { + disposables = []; + notebook = { + notebookType: '', + metadata: {} + } as NotebookDocument; + sinon.stub(workspace, 'getConfiguration').callsFake((section, scope) => { + if (section === 'jupyter') { + return { + get: () => { + return !useCustomPropertyInMetadata; + } + }; + } else { + return (workspace.getConfiguration as any).wrappedMethod.call(workspace, section, scope); + } + }); + token = new CancellationTokenSource(); + disposables.push(token); + sinon.stub(notebook, 'notebookType').get(() => 'jupyter-notebook'); + applyEditStub = sinon.stub(workspace, 'applyEdit').callsFake((edit: WorkspaceEdit) => { + editsApplied.push(edit); + return Promise.resolve(true); + }); + const context = { subscriptions: [] as Disposable[] } as ExtensionContext; + onDidChangeNotebookDocument = new EventEmitter(); + disposables.push(onDidChangeNotebookDocument); + onWillSaveNotebookDocument = new AsyncEmitter(); + + sinon.stub(NotebookEdit, 'updateCellMetadata').callsFake((index, metadata) => { + const edit = (NotebookEdit.updateCellMetadata as any).wrappedMethod.call(NotebookEdit, index, metadata); + cellMetadataUpdates.push(edit); + return edit; + } + ); + sinon.stub(workspace, 'onDidChangeNotebookDocument').callsFake(cb => + onDidChangeNotebookDocument.event(cb) + ); + sinon.stub(workspace, 'onWillSaveNotebookDocument').callsFake(cb => + onWillSaveNotebookDocument.event(cb) + ); + activate(context); + }); + teardown(async () => { + await Promise.allSettled(pendingPromises); + editsApplied = []; + pendingPromises = []; + cellMetadataUpdates = []; + disposables.forEach(d => d.dispose()); + disposables = []; + sinon.restore(); + }); + + test('Empty cell will not result in any updates', async () => { + const e: NotebookDocumentChangeEvent = { + notebook, + metadata: undefined, + contentChanges: [], + cellChanges: [] + }; + + onDidChangeNotebookDocument.fire(e); + + assert.strictEqual(editsApplied.length, 0); + }); + test('Adding cell for non Jupyter Notebook will not result in any updates', async () => { + sinon.stub(notebook, 'notebookType').get(() => 'some-other-type'); + const cell: NotebookCell = { + document: {} as any, + executionSummary: {}, + index: 0, + kind: NotebookCellKind.Code, + metadata: {}, + notebook, + outputs: [] + }; + const e: NotebookDocumentChangeEvent = { + notebook, + metadata: undefined, + contentChanges: [ + { + range: new NotebookRange(0, 0), + removedCells: [], + addedCells: [cell] + } + ], + cellChanges: [] + }; + + onDidChangeNotebookDocument.fire(e); + + assert.strictEqual(editsApplied.length, 0); + assert.strictEqual(cellMetadataUpdates.length, 0); + }); + test('Adding cell will result in an update to the metadata', async () => { + const cell: NotebookCell = { + document: {} as any, + executionSummary: {}, + index: 0, + kind: NotebookCellKind.Code, + metadata: {}, + notebook, + outputs: [] + }; + const e: NotebookDocumentChangeEvent = { + notebook, + metadata: undefined, + contentChanges: [ + { + range: new NotebookRange(0, 0), + removedCells: [], + addedCells: [cell] + } + ], + cellChanges: [] + }; + + onDidChangeNotebookDocument.fire(e); + + assert.strictEqual(editsApplied.length, 1); + assert.strictEqual(cellMetadataUpdates.length, 1); + const newMetadata = cellMetadataUpdates[0].newCellMetadata; + if (useCustomPropertyInMetadata) { + assert.deepStrictEqual(newMetadata, { custom: { metadata: {} } }); + } else { + assert.deepStrictEqual(newMetadata, { metadata: {} }); + } + }); + test('Add cell id if nbformat is 4.5', async () => { + if (useCustomPropertyInMetadata) { + sinon.stub(notebook, 'metadata').get(() => ({ custom: { nbformat: 4, nbformat_minor: 5 } })); + } else { + sinon.stub(notebook, 'metadata').get(() => ({ nbformat: 4, nbformat_minor: 5 })); + } + const cell: NotebookCell = { + document: {} as any, + executionSummary: {}, + index: 0, + kind: NotebookCellKind.Code, + metadata: {}, + notebook, + outputs: [] + }; + const e: NotebookDocumentChangeEvent = { + notebook, + metadata: undefined, + contentChanges: [ + { + range: new NotebookRange(0, 0), + removedCells: [], + addedCells: [cell] + } + ], + cellChanges: [] + }; + + onDidChangeNotebookDocument.fire(e); + + assert.strictEqual(editsApplied.length, 1); + assert.strictEqual(cellMetadataUpdates.length, 1); + const newMetadata = cellMetadataUpdates[0].newCellMetadata || {}; + if (useCustomPropertyInMetadata) { + assert.strictEqual(Object.keys(newMetadata).length, 1); + assert.strictEqual(Object.keys(newMetadata.custom).length, 2); + assert.deepStrictEqual(newMetadata.custom.metadata, {}); + assert.ok(newMetadata.custom.id); + } else { + assert.strictEqual(Object.keys(newMetadata).length, 2); + assert.deepStrictEqual(newMetadata.metadata, {}); + assert.ok(newMetadata.id); + } + }); + test('Do not add cell id if one already exists', async () => { + if (useCustomPropertyInMetadata) { + sinon.stub(notebook, 'metadata').get(() => ({ custom: { nbformat: 4, nbformat_minor: 5 } })); + } else { + sinon.stub(notebook, 'metadata').get(() => ({ nbformat: 4, nbformat_minor: 5 })); + } + const cell: NotebookCell = { + document: {} as any, + executionSummary: {}, + index: 0, + kind: NotebookCellKind.Code, + metadata: useCustomPropertyInMetadata ? { + custom: { + id: '1234' + } + } : { + id: '1234' + }, + notebook, + outputs: [] + }; + const e: NotebookDocumentChangeEvent = { + notebook, + metadata: undefined, + contentChanges: [ + { + range: new NotebookRange(0, 0), + removedCells: [], + addedCells: [cell] + } + ], + cellChanges: [] + }; + + onDidChangeNotebookDocument.fire(e); + + assert.strictEqual(editsApplied.length, 1); + assert.strictEqual(cellMetadataUpdates.length, 1); + const newMetadata = cellMetadataUpdates[0].newCellMetadata || {}; + if (useCustomPropertyInMetadata) { + assert.strictEqual(Object.keys(newMetadata).length, 1); + assert.strictEqual(Object.keys(newMetadata.custom).length, 2); + assert.deepStrictEqual(newMetadata.custom.metadata, {}); + assert.strictEqual(newMetadata.custom.id, '1234'); + } else { + assert.strictEqual(Object.keys(newMetadata).length, 2); + assert.deepStrictEqual(newMetadata.metadata, {}); + assert.strictEqual(newMetadata.id, '1234'); + } + }); + test('Do not perform any updates if cell id and metadata exists', async () => { + if (useCustomPropertyInMetadata) { + sinon.stub(notebook, 'metadata').get(() => ({ custom: { nbformat: 4, nbformat_minor: 5 } })); + } else { + sinon.stub(notebook, 'metadata').get(() => ({ nbformat: 4, nbformat_minor: 5 })); + } + const cell: NotebookCell = { + document: {} as any, + executionSummary: {}, + index: 0, + kind: NotebookCellKind.Code, + metadata: useCustomPropertyInMetadata ? { + custom: { + id: '1234', + metadata: {} + } + } : { + id: '1234', + metadata: {} + }, + notebook, + outputs: [] + }; + const e: NotebookDocumentChangeEvent = { + notebook, + metadata: undefined, + contentChanges: [ + { + range: new NotebookRange(0, 0), + removedCells: [], + addedCells: [cell] + } + ], + cellChanges: [] + }; + + onDidChangeNotebookDocument.fire(e); + + assert.strictEqual(editsApplied.length, 0); + assert.strictEqual(cellMetadataUpdates.length, 0); + }); + test('Store language id in custom metadata, whilst preserving existing metadata', async () => { + if (useCustomPropertyInMetadata) { + sinon.stub(notebook, 'metadata').get(() => ({ + custom: { + nbformat: 4, nbformat_minor: 5, + metadata: { + language_info: { name: 'python' } + } + } + })); + } else { + sinon.stub(notebook, 'metadata').get(() => ({ + nbformat: 4, nbformat_minor: 5, + metadata: { + language_info: { name: 'python' } + } + })); + } + const cell: NotebookCell = { + document: { + languageId: 'javascript' + } as any, + executionSummary: {}, + index: 0, + kind: NotebookCellKind.Code, + metadata: useCustomPropertyInMetadata ? { + custom: { + id: '1234', + metadata: { + collapsed: true, scrolled: true + } + } + } : { + id: '1234', + metadata: { + collapsed: true, scrolled: true + } + }, + notebook, + outputs: [] + }; + const e: NotebookDocumentChangeEvent = { + notebook, + metadata: undefined, + contentChanges: [], + cellChanges: [ + { + cell, + document: { + languageId: 'javascript' + } as any, + metadata: undefined, + outputs: undefined, + executionSummary: undefined + } + ] + }; + + onDidChangeNotebookDocument.fire(e); + + assert.strictEqual(editsApplied.length, 1); + assert.strictEqual(cellMetadataUpdates.length, 1); + const newMetadata = cellMetadataUpdates[0].newCellMetadata || {}; + if (useCustomPropertyInMetadata) { + assert.strictEqual(Object.keys(newMetadata).length, 1); + assert.strictEqual(Object.keys(newMetadata.custom).length, 2); + assert.deepStrictEqual(newMetadata.custom.metadata, { collapsed: true, scrolled: true, vscode: { languageId: 'javascript' } }); + assert.strictEqual(newMetadata.custom.id, '1234'); + } else { + assert.strictEqual(Object.keys(newMetadata).length, 2); + assert.deepStrictEqual(newMetadata.metadata, { collapsed: true, scrolled: true, vscode: { languageId: 'javascript' } }); + assert.strictEqual(newMetadata.id, '1234'); + } + }); + test('No changes when language is javascript', async () => { + if (useCustomPropertyInMetadata) { + sinon.stub(notebook, 'metadata').get(() => ({ + custom: { + nbformat: 4, nbformat_minor: 5, + metadata: { + language_info: { name: 'javascript' } + } + } + })); + } else { + sinon.stub(notebook, 'metadata').get(() => ({ + nbformat: 4, nbformat_minor: 5, + metadata: { + language_info: { name: 'javascript' } + } + })); + } + const cell: NotebookCell = { + document: { + languageId: 'javascript' + } as any, + executionSummary: {}, + index: 0, + kind: NotebookCellKind.Code, + metadata: useCustomPropertyInMetadata ? { + custom: { + id: '1234', + metadata: { + collapsed: true, scrolled: true + } + } + } : { + id: '1234', + metadata: { + collapsed: true, scrolled: true + } + }, + notebook, + outputs: [] + }; + const e: NotebookDocumentChangeEvent = { + notebook, + metadata: undefined, + contentChanges: [], + cellChanges: [ + { + cell, + document: undefined, + metadata: undefined, + outputs: undefined, + executionSummary: undefined + } + ] + }; + + onDidChangeNotebookDocument.fire(e); + + assert.strictEqual(editsApplied.length, 0); + assert.strictEqual(cellMetadataUpdates.length, 0); + }); + test('Remove language from metadata when cell language matches kernel language', async () => { + if (useCustomPropertyInMetadata) { + sinon.stub(notebook, 'metadata').get(() => ({ + custom: { + nbformat: 4, nbformat_minor: 5, + metadata: { + language_info: { name: 'javascript' } + } + } + })); + } else { + sinon.stub(notebook, 'metadata').get(() => ({ + nbformat: 4, nbformat_minor: 5, + metadata: { + language_info: { name: 'javascript' } + } + })); + } + const cell: NotebookCell = { + document: { + languageId: 'javascript' + } as any, + executionSummary: {}, + index: 0, + kind: NotebookCellKind.Code, + metadata: useCustomPropertyInMetadata ? { + custom: { + id: '1234', + metadata: { + vscode: { languageId: 'python' }, + collapsed: true, scrolled: true + } + } + } : { + id: '1234', + metadata: { + vscode: { languageId: 'python' }, + collapsed: true, scrolled: true + } + }, + notebook, + outputs: [] + }; + const e: NotebookDocumentChangeEvent = { + notebook, + metadata: undefined, + contentChanges: [], + cellChanges: [ + { + cell, + document: { + languageId: 'javascript' + } as any, + metadata: undefined, + outputs: undefined, + executionSummary: undefined + } + ] + }; + + onDidChangeNotebookDocument.fire(e); + + assert.strictEqual(editsApplied.length, 1); + assert.strictEqual(cellMetadataUpdates.length, 1); + const newMetadata = cellMetadataUpdates[0].newCellMetadata || {}; + if (useCustomPropertyInMetadata) { + assert.strictEqual(Object.keys(newMetadata).length, 1); + assert.strictEqual(Object.keys(newMetadata.custom).length, 2); + assert.deepStrictEqual(newMetadata.custom.metadata, { collapsed: true, scrolled: true }); + assert.strictEqual(newMetadata.custom.id, '1234'); + } else { + assert.strictEqual(Object.keys(newMetadata).length, 2); + assert.deepStrictEqual(newMetadata.metadata, { collapsed: true, scrolled: true }); + assert.strictEqual(newMetadata.id, '1234'); + } + }); + test('Update language in metadata', async () => { + if (useCustomPropertyInMetadata) { + sinon.stub(notebook, 'metadata').get(() => ({ + custom: { + nbformat: 4, nbformat_minor: 5, + metadata: { + language_info: { name: 'javascript' } + } + } + })); + } else { + + sinon.stub(notebook, 'metadata').get(() => ({ + nbformat: 4, nbformat_minor: 5, + metadata: { + language_info: { name: 'javascript' } + } + })); + } + const cell: NotebookCell = { + document: { + languageId: 'powershell' + } as any, + executionSummary: {}, + index: 0, + kind: NotebookCellKind.Code, + metadata: useCustomPropertyInMetadata ? { + custom: { + id: '1234', + metadata: { + vscode: { languageId: 'python' }, + collapsed: true, scrolled: true + } + } + } : { + id: '1234', + metadata: { + vscode: { languageId: 'python' }, + collapsed: true, scrolled: true + } + }, + notebook, + outputs: [] + }; + const e: NotebookDocumentChangeEvent = { + notebook, + metadata: undefined, + contentChanges: [], + cellChanges: [ + { + cell, + document: { + languageId: 'powershell' + } as any, + metadata: undefined, + outputs: undefined, + executionSummary: undefined + } + ] + }; + + onDidChangeNotebookDocument.fire(e); + + assert.strictEqual(editsApplied.length, 1); + assert.strictEqual(cellMetadataUpdates.length, 1); + const newMetadata = cellMetadataUpdates[0].newCellMetadata || {}; + if (useCustomPropertyInMetadata) { + assert.strictEqual(Object.keys(newMetadata).length, 1); + assert.strictEqual(Object.keys(newMetadata.custom).length, 2); + assert.deepStrictEqual(newMetadata.custom.metadata, { collapsed: true, scrolled: true, vscode: { languageId: 'powershell' } }); + assert.strictEqual(newMetadata.custom.id, '1234'); + } else { + assert.strictEqual(Object.keys(newMetadata).length, 2); + assert.deepStrictEqual(newMetadata.metadata, { collapsed: true, scrolled: true, vscode: { languageId: 'powershell' } }); + assert.strictEqual(newMetadata.id, '1234'); + } + }); + + test('Will save event without any changes', async () => { + await onWillSaveNotebookDocument.fireAsync({ notebook, reason: TextDocumentSaveReason.Manual }, token.token); + }); + test('Wait for pending updates to complete when saving', async () => { + let resolveApplyEditPromise: (value: boolean) => void; + const promise = new Promise((resolve) => resolveApplyEditPromise = resolve); + applyEditStub.restore(); + sinon.stub(workspace, 'applyEdit').callsFake((edit: WorkspaceEdit) => { + editsApplied.push(edit); + return promise; + }); + + const cell: NotebookCell = { + document: {} as any, + executionSummary: {}, + index: 0, + kind: NotebookCellKind.Code, + metadata: {}, + notebook, + outputs: [] + }; + const e: NotebookDocumentChangeEvent = { + notebook, + metadata: undefined, + contentChanges: [ + { + range: new NotebookRange(0, 0), + removedCells: [], + addedCells: [cell] + } + ], + cellChanges: [] + }; + + onDidChangeNotebookDocument.fire(e); + + assert.strictEqual(editsApplied.length, 1); + assert.strictEqual(cellMetadataUpdates.length, 1); + + // Try to save. + let saveCompleted = false; + const saved = onWillSaveNotebookDocument.fireAsync({ + notebook, + reason: TextDocumentSaveReason.Manual + }, token.token); + saved.finally(() => saveCompleted = true); + await new Promise((resolve) => setTimeout(resolve, 10)); + + // Verify we have not yet completed saving. + assert.strictEqual(saveCompleted, false); + + resolveApplyEditPromise!(true); + await new Promise((resolve) => setTimeout(resolve, 1)); + + // Should have completed saving. + saved.finally(() => saveCompleted = true); + }); + + interface IWaitUntil { + token: CancellationToken; + waitUntil(thenable: Promise): void; + } + + interface IWaitUntil { + token: CancellationToken; + waitUntil(thenable: Promise): void; + } + type IWaitUntilData = Omit, 'token'>; + + class AsyncEmitter { + private listeners: ((d: T) => void)[] = []; + get event(): (listener: (e: T) => any, thisArgs?: any, disposables?: Disposable[]) => Disposable { + + return (listener, thisArgs, _disposables) => { + this.listeners.push(listener.bind(thisArgs)); + return { + dispose: () => { + // + } + }; + }; + } + dispose() { + this.listeners = []; + } + async fireAsync(data: IWaitUntilData, token: CancellationToken): Promise { + if (!this.listeners.length) { + return; + } + + const promises: Promise[] = []; + this.listeners.forEach(cb => { + const event = { + ...data, + token, + waitUntil: (thenable: Promise) => { + promises.push(thenable); + } + } as T; + cb(event); + }); + + await Promise.all(promises); + } + } + }); +}); diff --git a/extensions/ipynb/src/test/serializers.test.ts b/extensions/ipynb/src/test/serializers.test.ts index b8f56fc5dcb9b..cc7f53fe44217 100644 --- a/extensions/ipynb/src/test/serializers.test.ts +++ b/extensions/ipynb/src/test/serializers.test.ts @@ -3,6 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import * as sinon from 'sinon'; import type * as nbformat from '@jupyterlab/nbformat'; import * as assert from 'assert'; import * as vscode from 'vscode'; @@ -18,75 +19,119 @@ function deepStripProperties(obj: any, props: string[]) { } } } +[true, false].forEach(useCustomPropertyInMetadata => { + suite(`ipynb serializer (${useCustomPropertyInMetadata ? 'with custom metadata (standard behaviour)' : 'without custom metadata'})`, () => { + let disposables: vscode.Disposable[] = []; + setup(() => { + disposables = []; + sinon.stub(vscode.workspace, 'getConfiguration').callsFake((section, scope) => { + if (section === 'jupyter') { + return { + get: () => { + return !useCustomPropertyInMetadata; + } + }; + } else { + return (vscode.workspace.getConfiguration as any).wrappedMethod.call(vscode.workspace, section, scope); + } + }); + }); + teardown(async () => { + disposables.forEach(d => d.dispose()); + disposables = []; + sinon.restore(); + }); -suite('ipynb serializer', () => { - const base64EncodedImage = - 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mOUlZL6DwAB/wFSU1jVmgAAAABJRU5ErkJggg=='; - test('Deserialize', async () => { - const cells: nbformat.ICell[] = [ - { - cell_type: 'code', - execution_count: 10, - outputs: [], - source: 'print(1)', - metadata: {} - }, - { - cell_type: 'markdown', - source: '# HEAD', - metadata: {} - } - ]; - const notebook = jupyterNotebookModelToNotebookData({ cells }, 'python'); - assert.ok(notebook); + const base64EncodedImage = + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mOUlZL6DwAB/wFSU1jVmgAAAABJRU5ErkJggg=='; + test('Deserialize', async () => { + const cells: nbformat.ICell[] = [ + { + cell_type: 'code', + execution_count: 10, + outputs: [], + source: 'print(1)', + metadata: {} + }, + { + cell_type: 'markdown', + source: '# HEAD', + metadata: {} + } + ]; + const notebook = jupyterNotebookModelToNotebookData({ cells }, 'python'); + assert.ok(notebook); - const expectedCodeCell = new vscode.NotebookCellData(vscode.NotebookCellKind.Code, 'print(1)', 'python'); - expectedCodeCell.outputs = []; - expectedCodeCell.metadata = { custom: { metadata: {} } }; - expectedCodeCell.executionSummary = { executionOrder: 10 }; + const expectedCodeCell = new vscode.NotebookCellData(vscode.NotebookCellKind.Code, 'print(1)', 'python'); + expectedCodeCell.outputs = []; + expectedCodeCell.metadata = useCustomPropertyInMetadata ? { custom: { execution_count: 10, metadata: {} } } : { execution_count: 10, metadata: {} }; + expectedCodeCell.executionSummary = { executionOrder: 10 }; - const expectedMarkdownCell = new vscode.NotebookCellData(vscode.NotebookCellKind.Markup, '# HEAD', 'markdown'); - expectedMarkdownCell.outputs = []; - expectedMarkdownCell.metadata = { - custom: { metadata: {} } - }; + const expectedMarkdownCell = new vscode.NotebookCellData(vscode.NotebookCellKind.Markup, '# HEAD', 'markdown'); + expectedMarkdownCell.outputs = []; + expectedMarkdownCell.metadata = useCustomPropertyInMetadata ? { + custom: { metadata: {} } + } : { + metadata: {} + }; - assert.deepStrictEqual(notebook.cells, [expectedCodeCell, expectedMarkdownCell]); - }); + assert.deepStrictEqual(notebook.cells, [expectedCodeCell, expectedMarkdownCell]); + }); - test('Serialize', async () => { - const markdownCell = new vscode.NotebookCellData(vscode.NotebookCellKind.Markup, '# header1', 'markdown'); - markdownCell.metadata = { - attachments: { - 'image.png': { - 'image/png': 'abc' + test('Serialize', async () => { + const markdownCell = new vscode.NotebookCellData(vscode.NotebookCellKind.Markup, '# header1', 'markdown'); + markdownCell.metadata = useCustomPropertyInMetadata ? { + attachments: { + 'image.png': { + 'image/png': 'abc' + } + }, + custom: { + id: '123', + metadata: { + foo: 'bar' + } } - }, - custom: { + } : { + attachments: { + 'image.png': { + 'image/png': 'abc' + } + }, id: '123', metadata: { foo: 'bar' } - } - }; + }; - const cellMetadata = getCellMetadata(markdownCell); - assert.deepStrictEqual(cellMetadata, { - id: '123', - metadata: { - foo: 'bar', - }, - attachments: { - 'image.png': { - 'image/png': 'abc' + const cellMetadata = getCellMetadata({ cell: markdownCell }); + assert.deepStrictEqual(cellMetadata, { + id: '123', + metadata: { + foo: 'bar', + }, + attachments: { + 'image.png': { + 'image/png': 'abc' + } } - } - }); + }); - const markdownCell2 = new vscode.NotebookCellData(vscode.NotebookCellKind.Markup, '# header1', 'markdown'); - markdownCell2.metadata = { - custom: { + const markdownCell2 = new vscode.NotebookCellData(vscode.NotebookCellKind.Markup, '# header1', 'markdown'); + markdownCell2.metadata = useCustomPropertyInMetadata ? { + custom: { + id: '123', + metadata: { + foo: 'bar' + }, + attachments: { + 'image.png': { + 'image/png': 'abc' + } + } + } + } : { id: '123', metadata: { foo: 'bar' @@ -96,132 +141,145 @@ suite('ipynb serializer', () => { 'image/png': 'abc' } } - } - }; + }; - const nbMarkdownCell = createMarkdownCellFromNotebookCell(markdownCell); - const nbMarkdownCell2 = createMarkdownCellFromNotebookCell(markdownCell2); - assert.deepStrictEqual(nbMarkdownCell, nbMarkdownCell2); + const nbMarkdownCell = createMarkdownCellFromNotebookCell(markdownCell); + const nbMarkdownCell2 = createMarkdownCellFromNotebookCell(markdownCell2); + assert.deepStrictEqual(nbMarkdownCell, nbMarkdownCell2); - assert.deepStrictEqual(nbMarkdownCell, { - cell_type: 'markdown', - source: ['# header1'], - metadata: { - foo: 'bar', - }, - attachments: { - 'image.png': { - 'image/png': 'abc' - } - }, - id: '123' + assert.deepStrictEqual(nbMarkdownCell, { + cell_type: 'markdown', + source: ['# header1'], + metadata: { + foo: 'bar', + }, + attachments: { + 'image.png': { + 'image/png': 'abc' + } + }, + id: '123' + }); }); - }); - suite('Outputs', () => { - function validateCellOutputTranslation( - outputs: nbformat.IOutput[], - expectedOutputs: vscode.NotebookCellOutput[], - propertiesToExcludeFromComparison: string[] = [] - ) { - const cells: nbformat.ICell[] = [ - { - cell_type: 'code', - execution_count: 10, - outputs, - source: 'print(1)', - metadata: {} - } - ]; - const notebook = jupyterNotebookModelToNotebookData({ cells }, 'python'); + suite('Outputs', () => { + function validateCellOutputTranslation( + outputs: nbformat.IOutput[], + expectedOutputs: vscode.NotebookCellOutput[], + propertiesToExcludeFromComparison: string[] = [] + ) { + const cells: nbformat.ICell[] = [ + { + cell_type: 'code', + execution_count: 10, + outputs, + source: 'print(1)', + metadata: {} + } + ]; + const notebook = jupyterNotebookModelToNotebookData({ cells }, 'python'); - // OutputItems contain an `id` property generated by VSC. - // Exclude that property when comparing. - const propertiesToExclude = propertiesToExcludeFromComparison.concat(['id']); - const actualOuts = notebook.cells[0].outputs; - deepStripProperties(actualOuts, propertiesToExclude); - deepStripProperties(expectedOutputs, propertiesToExclude); - assert.deepStrictEqual(actualOuts, expectedOutputs); - } + // OutputItems contain an `id` property generated by VSC. + // Exclude that property when comparing. + const propertiesToExclude = propertiesToExcludeFromComparison.concat(['id']); + const actualOuts = notebook.cells[0].outputs; + deepStripProperties(actualOuts, propertiesToExclude); + deepStripProperties(expectedOutputs, propertiesToExclude); + assert.deepStrictEqual(actualOuts, expectedOutputs); + } - test('Empty output', () => { - validateCellOutputTranslation([], []); - }); + test('Empty output', () => { + validateCellOutputTranslation([], []); + }); - test('Stream output', () => { - validateCellOutputTranslation( - [ - { - output_type: 'stream', - name: 'stderr', - text: 'Error' - }, - { - output_type: 'stream', - name: 'stdout', - text: 'NoError' - } - ], - [ - new vscode.NotebookCellOutput([vscode.NotebookCellOutputItem.stderr('Error')], { - outputType: 'stream' - }), - new vscode.NotebookCellOutput([vscode.NotebookCellOutputItem.stdout('NoError')], { - outputType: 'stream' - }) - ] - ); - }); - test('Stream output and line endings', () => { - validateCellOutputTranslation( - [ - { - output_type: 'stream', - name: 'stdout', - text: [ - 'Line1\n', - '\n', - 'Line3\n', - 'Line4' - ] - } - ], - [ - new vscode.NotebookCellOutput([vscode.NotebookCellOutputItem.stdout('Line1\n\nLine3\nLine4')], { - outputType: 'stream' - }) - ] - ); - validateCellOutputTranslation( - [ - { - output_type: 'stream', - name: 'stdout', - text: [ - 'Hello\n', - 'Hello\n', - 'Hello\n', - 'Hello\n', - 'Hello\n', - 'Hello\n' - ] - } - ], - [ - new vscode.NotebookCellOutput([vscode.NotebookCellOutputItem.stdout('Hello\nHello\nHello\nHello\nHello\nHello\n')], { - outputType: 'stream' - }) - ] - ); - }); - test('Multi-line Stream output', () => { - validateCellOutputTranslation( - [ - { - name: 'stdout', - output_type: 'stream', - text: [ - 'Epoch 1/5\n', + test('Stream output', () => { + validateCellOutputTranslation( + [ + { + output_type: 'stream', + name: 'stderr', + text: 'Error' + }, + { + output_type: 'stream', + name: 'stdout', + text: 'NoError' + } + ], + [ + new vscode.NotebookCellOutput([vscode.NotebookCellOutputItem.stderr('Error')], { + outputType: 'stream' + }), + new vscode.NotebookCellOutput([vscode.NotebookCellOutputItem.stdout('NoError')], { + outputType: 'stream' + }) + ] + ); + }); + test('Stream output and line endings', () => { + validateCellOutputTranslation( + [ + { + output_type: 'stream', + name: 'stdout', + text: [ + 'Line1\n', + '\n', + 'Line3\n', + 'Line4' + ] + } + ], + [ + new vscode.NotebookCellOutput([vscode.NotebookCellOutputItem.stdout('Line1\n\nLine3\nLine4')], { + outputType: 'stream' + }) + ] + ); + validateCellOutputTranslation( + [ + { + output_type: 'stream', + name: 'stdout', + text: [ + 'Hello\n', + 'Hello\n', + 'Hello\n', + 'Hello\n', + 'Hello\n', + 'Hello\n' + ] + } + ], + [ + new vscode.NotebookCellOutput([vscode.NotebookCellOutputItem.stdout('Hello\nHello\nHello\nHello\nHello\nHello\n')], { + outputType: 'stream' + }) + ] + ); + }); + test('Multi-line Stream output', () => { + validateCellOutputTranslation( + [ + { + name: 'stdout', + output_type: 'stream', + text: [ + 'Epoch 1/5\n', + '...\n', + 'Epoch 2/5\n', + '...\n', + 'Epoch 3/5\n', + '...\n', + 'Epoch 4/5\n', + '...\n', + 'Epoch 5/5\n', + '...\n' + ] + } + ], + [ + new vscode.NotebookCellOutput([vscode.NotebookCellOutputItem.stdout(['Epoch 1/5\n', '...\n', 'Epoch 2/5\n', '...\n', @@ -230,35 +288,35 @@ suite('ipynb serializer', () => { 'Epoch 4/5\n', '...\n', 'Epoch 5/5\n', - '...\n' - ] - } - ], - [ - new vscode.NotebookCellOutput([vscode.NotebookCellOutputItem.stdout(['Epoch 1/5\n', - '...\n', - 'Epoch 2/5\n', - '...\n', - 'Epoch 3/5\n', - '...\n', - 'Epoch 4/5\n', - '...\n', - 'Epoch 5/5\n', - '...\n'].join(''))], { - outputType: 'stream' - }) - ] - ); - }); + '...\n'].join(''))], { + outputType: 'stream' + }) + ] + ); + }); - test('Multi-line Stream output (last empty line should not be saved in ipynb)', () => { - validateCellOutputTranslation( - [ - { - name: 'stderr', - output_type: 'stream', - text: [ - 'Epoch 1/5\n', + test('Multi-line Stream output (last empty line should not be saved in ipynb)', () => { + validateCellOutputTranslation( + [ + { + name: 'stderr', + output_type: 'stream', + text: [ + 'Epoch 1/5\n', + '...\n', + 'Epoch 2/5\n', + '...\n', + 'Epoch 3/5\n', + '...\n', + 'Epoch 4/5\n', + '...\n', + 'Epoch 5/5\n', + '...\n' + ] + } + ], + [ + new vscode.NotebookCellOutput([vscode.NotebookCellOutputItem.stderr(['Epoch 1/5\n', '...\n', 'Epoch 2/5\n', '...\n', @@ -267,436 +325,423 @@ suite('ipynb serializer', () => { 'Epoch 4/5\n', '...\n', 'Epoch 5/5\n', - '...\n' - ] - } - ], - [ - new vscode.NotebookCellOutput([vscode.NotebookCellOutputItem.stderr(['Epoch 1/5\n', - '...\n', - 'Epoch 2/5\n', - '...\n', - 'Epoch 3/5\n', - '...\n', - 'Epoch 4/5\n', - '...\n', - 'Epoch 5/5\n', - '...\n', - // This last empty line should not be saved in ipynb. - '\n'].join(''))], { - outputType: 'stream' - }) - ] - ); - }); + '...\n', + // This last empty line should not be saved in ipynb. + '\n'].join(''))], { + outputType: 'stream' + }) + ] + ); + }); - test('Streamed text with Ansi characters', async () => { - validateCellOutputTranslation( - [ - { - name: 'stderr', - text: '\u001b[K\u001b[33m✅ \u001b[0m Loading\n', - output_type: 'stream' - } - ], - [ - new vscode.NotebookCellOutput( - [vscode.NotebookCellOutputItem.stderr('\u001b[K\u001b[33m✅ \u001b[0m Loading\n')], + test('Streamed text with Ansi characters', async () => { + validateCellOutputTranslation( + [ { - outputType: 'stream' + name: 'stderr', + text: '\u001b[K\u001b[33m✅ \u001b[0m Loading\n', + output_type: 'stream' } - ) - ] - ); - }); - - test('Streamed text with angle bracket characters', async () => { - validateCellOutputTranslation( - [ - { - name: 'stderr', - text: '1 is < 2', - output_type: 'stream' - } - ], - [ - new vscode.NotebookCellOutput([vscode.NotebookCellOutputItem.stderr('1 is < 2')], { - outputType: 'stream' - }) - ] - ); - }); + ], + [ + new vscode.NotebookCellOutput( + [vscode.NotebookCellOutputItem.stderr('\u001b[K\u001b[33m✅ \u001b[0m Loading\n')], + { + outputType: 'stream' + } + ) + ] + ); + }); - test('Streamed text with angle bracket characters and ansi chars', async () => { - validateCellOutputTranslation( - [ - { - name: 'stderr', - text: '1 is < 2\u001b[K\u001b[33m✅ \u001b[0m Loading\n', - output_type: 'stream' - } - ], - [ - new vscode.NotebookCellOutput( - [vscode.NotebookCellOutputItem.stderr('1 is < 2\u001b[K\u001b[33m✅ \u001b[0m Loading\n')], + test('Streamed text with angle bracket characters', async () => { + validateCellOutputTranslation( + [ { - outputType: 'stream' + name: 'stderr', + text: '1 is < 2', + output_type: 'stream' } - ) - ] - ); - }); + ], + [ + new vscode.NotebookCellOutput([vscode.NotebookCellOutputItem.stderr('1 is < 2')], { + outputType: 'stream' + }) + ] + ); + }); - test('Error', async () => { - validateCellOutputTranslation( - [ - { - ename: 'Error Name', - evalue: 'Error Value', - traceback: ['stack1', 'stack2', 'stack3'], - output_type: 'error' - } - ], - [ - new vscode.NotebookCellOutput( - [ - vscode.NotebookCellOutputItem.error({ - name: 'Error Name', - message: 'Error Value', - stack: ['stack1', 'stack2', 'stack3'].join('\n') - }) - ], + test('Streamed text with angle bracket characters and ansi chars', async () => { + validateCellOutputTranslation( + [ { - outputType: 'error', - originalError: { - ename: 'Error Name', - evalue: 'Error Value', - traceback: ['stack1', 'stack2', 'stack3'], - output_type: 'error' - } + name: 'stderr', + text: '1 is < 2\u001b[K\u001b[33m✅ \u001b[0m Loading\n', + output_type: 'stream' } - ) - ] - ); - }); - - ['display_data', 'execute_result'].forEach(output_type => { - suite(`Rich output for output_type = ${output_type}`, () => { - // Properties to exclude when comparing. - let propertiesToExcludeFromComparison: string[] = []; - setup(() => { - if (output_type === 'display_data') { - // With display_data the execution_count property will never exist in the output. - // We can ignore that (as it will never exist). - // But we leave it in the case of `output_type === 'execute_result'` - propertiesToExcludeFromComparison = ['execution_count', 'executionCount']; - } - }); + ], + [ + new vscode.NotebookCellOutput( + [vscode.NotebookCellOutputItem.stderr('1 is < 2\u001b[K\u001b[33m✅ \u001b[0m Loading\n')], + { + outputType: 'stream' + } + ) + ] + ); + }); - test('Text mimeType output', async () => { - validateCellOutputTranslation( - [ + test('Error', async () => { + validateCellOutputTranslation( + [ + { + ename: 'Error Name', + evalue: 'Error Value', + traceback: ['stack1', 'stack2', 'stack3'], + output_type: 'error' + } + ], + [ + new vscode.NotebookCellOutput( + [ + vscode.NotebookCellOutputItem.error({ + name: 'Error Name', + message: 'Error Value', + stack: ['stack1', 'stack2', 'stack3'].join('\n') + }) + ], { - data: { - 'text/plain': 'Hello World!' - }, - output_type, - metadata: {}, - execution_count: 1 + outputType: 'error', + originalError: { + ename: 'Error Name', + evalue: 'Error Value', + traceback: ['stack1', 'stack2', 'stack3'], + output_type: 'error' + } } - ], - [ - new vscode.NotebookCellOutput( - [new vscode.NotebookCellOutputItem(Buffer.from('Hello World!', 'utf8'), 'text/plain')], + ) + ] + ); + }); + + ['display_data', 'execute_result'].forEach(output_type => { + suite(`Rich output for output_type = ${output_type}`, () => { + // Properties to exclude when comparing. + let propertiesToExcludeFromComparison: string[] = []; + setup(() => { + if (output_type === 'display_data') { + // With display_data the execution_count property will never exist in the output. + // We can ignore that (as it will never exist). + // But we leave it in the case of `output_type === 'execute_result'` + propertiesToExcludeFromComparison = ['execution_count', 'executionCount']; + } + }); + + test('Text mimeType output', async () => { + validateCellOutputTranslation( + [ { - outputType: output_type, - metadata: {}, // display_data & execute_result always have metadata. - executionCount: 1 + data: { + 'text/plain': 'Hello World!' + }, + output_type, + metadata: {}, + execution_count: 1 } - ) - ], - propertiesToExcludeFromComparison - ); - }); + ], + [ + new vscode.NotebookCellOutput( + [new vscode.NotebookCellOutputItem(Buffer.from('Hello World!', 'utf8'), 'text/plain')], + { + outputType: output_type, + metadata: {}, // display_data & execute_result always have metadata. + executionCount: 1 + } + ) + ], + propertiesToExcludeFromComparison + ); + }); - test('png,jpeg images', async () => { - validateCellOutputTranslation( - [ - { - execution_count: 1, - data: { - 'image/png': base64EncodedImage, - 'image/jpeg': base64EncodedImage - }, - metadata: {}, - output_type - } - ], - [ - new vscode.NotebookCellOutput( - [ - new vscode.NotebookCellOutputItem(Buffer.from(base64EncodedImage, 'base64'), 'image/png'), - new vscode.NotebookCellOutputItem(Buffer.from(base64EncodedImage, 'base64'), 'image/jpeg') - ], + test('png,jpeg images', async () => { + validateCellOutputTranslation( + [ { - executionCount: 1, - outputType: output_type, - metadata: {} // display_data & execute_result always have metadata. + execution_count: 1, + data: { + 'image/png': base64EncodedImage, + 'image/jpeg': base64EncodedImage + }, + metadata: {}, + output_type } - ) - ], - propertiesToExcludeFromComparison - ); - }); + ], + [ + new vscode.NotebookCellOutput( + [ + new vscode.NotebookCellOutputItem(Buffer.from(base64EncodedImage, 'base64'), 'image/png'), + new vscode.NotebookCellOutputItem(Buffer.from(base64EncodedImage, 'base64'), 'image/jpeg') + ], + { + executionCount: 1, + outputType: output_type, + metadata: {} // display_data & execute_result always have metadata. + } + ) + ], + propertiesToExcludeFromComparison + ); + }); - test('png image with a light background', async () => { - validateCellOutputTranslation( - [ - { - execution_count: 1, - data: { - 'image/png': base64EncodedImage - }, - metadata: { - needs_background: 'light' - }, - output_type - } - ], - [ - new vscode.NotebookCellOutput( - [new vscode.NotebookCellOutputItem(Buffer.from(base64EncodedImage, 'base64'), 'image/png')], + test('png image with a light background', async () => { + validateCellOutputTranslation( + [ { - executionCount: 1, + execution_count: 1, + data: { + 'image/png': base64EncodedImage + }, metadata: { needs_background: 'light' }, - outputType: output_type + output_type } - ) - ], - propertiesToExcludeFromComparison - ); - }); + ], + [ + new vscode.NotebookCellOutput( + [new vscode.NotebookCellOutputItem(Buffer.from(base64EncodedImage, 'base64'), 'image/png')], + { + executionCount: 1, + metadata: { + needs_background: 'light' + }, + outputType: output_type + } + ) + ], + propertiesToExcludeFromComparison + ); + }); - test('png image with a dark background', async () => { - validateCellOutputTranslation( - [ - { - execution_count: 1, - data: { - 'image/png': base64EncodedImage - }, - metadata: { - needs_background: 'dark' - }, - output_type - } - ], - [ - new vscode.NotebookCellOutput( - [new vscode.NotebookCellOutputItem(Buffer.from(base64EncodedImage, 'base64'), 'image/png')], + test('png image with a dark background', async () => { + validateCellOutputTranslation( + [ { - executionCount: 1, + execution_count: 1, + data: { + 'image/png': base64EncodedImage + }, metadata: { needs_background: 'dark' }, - outputType: output_type + output_type } - ) - ], - propertiesToExcludeFromComparison - ); - }); + ], + [ + new vscode.NotebookCellOutput( + [new vscode.NotebookCellOutputItem(Buffer.from(base64EncodedImage, 'base64'), 'image/png')], + { + executionCount: 1, + metadata: { + needs_background: 'dark' + }, + outputType: output_type + } + ) + ], + propertiesToExcludeFromComparison + ); + }); - test('png image with custom dimensions', async () => { - validateCellOutputTranslation( - [ - { - execution_count: 1, - data: { - 'image/png': base64EncodedImage - }, - metadata: { - 'image/png': { height: '111px', width: '999px' } - }, - output_type - } - ], - [ - new vscode.NotebookCellOutput( - [new vscode.NotebookCellOutputItem(Buffer.from(base64EncodedImage, 'base64'), 'image/png')], + test('png image with custom dimensions', async () => { + validateCellOutputTranslation( + [ { - executionCount: 1, + execution_count: 1, + data: { + 'image/png': base64EncodedImage + }, metadata: { 'image/png': { height: '111px', width: '999px' } }, - outputType: output_type + output_type } - ) - ], - propertiesToExcludeFromComparison - ); - }); + ], + [ + new vscode.NotebookCellOutput( + [new vscode.NotebookCellOutputItem(Buffer.from(base64EncodedImage, 'base64'), 'image/png')], + { + executionCount: 1, + metadata: { + 'image/png': { height: '111px', width: '999px' } + }, + outputType: output_type + } + ) + ], + propertiesToExcludeFromComparison + ); + }); - test('png allowed to scroll', async () => { - validateCellOutputTranslation( - [ - { - execution_count: 1, - data: { - 'image/png': base64EncodedImage - }, - metadata: { - unconfined: true, - 'image/png': { width: '999px' } - }, - output_type - } - ], - [ - new vscode.NotebookCellOutput( - [new vscode.NotebookCellOutputItem(Buffer.from(base64EncodedImage, 'base64'), 'image/png')], + test('png allowed to scroll', async () => { + validateCellOutputTranslation( + [ { - executionCount: 1, + execution_count: 1, + data: { + 'image/png': base64EncodedImage + }, metadata: { unconfined: true, 'image/png': { width: '999px' } }, - outputType: output_type + output_type } - ) - ], - propertiesToExcludeFromComparison - ); + ], + [ + new vscode.NotebookCellOutput( + [new vscode.NotebookCellOutputItem(Buffer.from(base64EncodedImage, 'base64'), 'image/png')], + { + executionCount: 1, + metadata: { + unconfined: true, + 'image/png': { width: '999px' } + }, + outputType: output_type + } + ) + ], + propertiesToExcludeFromComparison + ); + }); }); }); }); - }); - suite('Output Order', () => { - test('Verify order of outputs', async () => { - const dataAndExpectedOrder: { output: nbformat.IDisplayData; expectedMimeTypesOrder: string[] }[] = [ - { - output: { - data: { - 'application/vnd.vegalite.v4+json': 'some json', - 'text/html': 'Hello' + suite('Output Order', () => { + test('Verify order of outputs', async () => { + const dataAndExpectedOrder: { output: nbformat.IDisplayData; expectedMimeTypesOrder: string[] }[] = [ + { + output: { + data: { + 'application/vnd.vegalite.v4+json': 'some json', + 'text/html': 'Hello' + }, + metadata: {}, + output_type: 'display_data' }, - metadata: {}, - output_type: 'display_data' + expectedMimeTypesOrder: ['application/vnd.vegalite.v4+json', 'text/html'] }, - expectedMimeTypesOrder: ['application/vnd.vegalite.v4+json', 'text/html'] - }, - { - output: { - data: { - 'application/vnd.vegalite.v4+json': 'some json', - 'application/javascript': 'some js', - 'text/plain': 'some text', - 'text/html': 'Hello' + { + output: { + data: { + 'application/vnd.vegalite.v4+json': 'some json', + 'application/javascript': 'some js', + 'text/plain': 'some text', + 'text/html': 'Hello' + }, + metadata: {}, + output_type: 'display_data' }, - metadata: {}, - output_type: 'display_data' + expectedMimeTypesOrder: [ + 'application/vnd.vegalite.v4+json', + 'text/html', + 'application/javascript', + 'text/plain' + ] }, - expectedMimeTypesOrder: [ - 'application/vnd.vegalite.v4+json', - 'text/html', - 'application/javascript', - 'text/plain' - ] - }, - { - output: { - data: { - 'application/vnd.vegalite.v4+json': '', // Empty, should give preference to other mimetypes. - 'application/javascript': 'some js', - 'text/plain': 'some text', - 'text/html': 'Hello' + { + output: { + data: { + 'application/vnd.vegalite.v4+json': '', // Empty, should give preference to other mimetypes. + 'application/javascript': 'some js', + 'text/plain': 'some text', + 'text/html': 'Hello' + }, + metadata: {}, + output_type: 'display_data' }, - metadata: {}, - output_type: 'display_data' + expectedMimeTypesOrder: [ + 'text/html', + 'application/javascript', + 'text/plain', + 'application/vnd.vegalite.v4+json' + ] }, - expectedMimeTypesOrder: [ - 'text/html', - 'application/javascript', - 'text/plain', - 'application/vnd.vegalite.v4+json' - ] - }, - { - output: { - data: { - 'text/plain': 'some text', - 'text/html': 'Hello' + { + output: { + data: { + 'text/plain': 'some text', + 'text/html': 'Hello' + }, + metadata: {}, + output_type: 'display_data' }, - metadata: {}, - output_type: 'display_data' + expectedMimeTypesOrder: ['text/html', 'text/plain'] }, - expectedMimeTypesOrder: ['text/html', 'text/plain'] - }, - { - output: { - data: { - 'application/javascript': 'some js', - 'text/plain': 'some text' + { + output: { + data: { + 'application/javascript': 'some js', + 'text/plain': 'some text' + }, + metadata: {}, + output_type: 'display_data' }, - metadata: {}, - output_type: 'display_data' + expectedMimeTypesOrder: ['application/javascript', 'text/plain'] }, - expectedMimeTypesOrder: ['application/javascript', 'text/plain'] - }, - { - output: { - data: { - 'image/svg+xml': 'some svg', - 'text/plain': 'some text' + { + output: { + data: { + 'image/svg+xml': 'some svg', + 'text/plain': 'some text' + }, + metadata: {}, + output_type: 'display_data' }, - metadata: {}, - output_type: 'display_data' + expectedMimeTypesOrder: ['image/svg+xml', 'text/plain'] }, - expectedMimeTypesOrder: ['image/svg+xml', 'text/plain'] - }, - { - output: { - data: { - 'text/latex': 'some latex', - 'text/plain': 'some text' + { + output: { + data: { + 'text/latex': 'some latex', + 'text/plain': 'some text' + }, + metadata: {}, + output_type: 'display_data' }, - metadata: {}, - output_type: 'display_data' + expectedMimeTypesOrder: ['text/latex', 'text/plain'] }, - expectedMimeTypesOrder: ['text/latex', 'text/plain'] - }, - { - output: { - data: { - 'application/vnd.jupyter.widget-view+json': 'some widget', - 'text/plain': 'some text' + { + output: { + data: { + 'application/vnd.jupyter.widget-view+json': 'some widget', + 'text/plain': 'some text' + }, + metadata: {}, + output_type: 'display_data' }, - metadata: {}, - output_type: 'display_data' + expectedMimeTypesOrder: ['application/vnd.jupyter.widget-view+json', 'text/plain'] }, - expectedMimeTypesOrder: ['application/vnd.jupyter.widget-view+json', 'text/plain'] - }, - { - output: { - data: { - 'text/plain': 'some text', - 'image/svg+xml': 'some svg', - 'image/png': 'some png' + { + output: { + data: { + 'text/plain': 'some text', + 'image/svg+xml': 'some svg', + 'image/png': 'some png' + }, + metadata: {}, + output_type: 'display_data' }, - metadata: {}, - output_type: 'display_data' - }, - expectedMimeTypesOrder: ['image/png', 'image/svg+xml', 'text/plain'] - } - ]; + expectedMimeTypesOrder: ['image/png', 'image/svg+xml', 'text/plain'] + } + ]; - dataAndExpectedOrder.forEach(({ output, expectedMimeTypesOrder }) => { - const sortedOutputs = jupyterCellOutputToCellOutput(output); - const mimeTypes = sortedOutputs.items.map((item) => item.mime).join(','); - assert.equal(mimeTypes, expectedMimeTypesOrder.join(',')); + dataAndExpectedOrder.forEach(({ output, expectedMimeTypesOrder }) => { + const sortedOutputs = jupyterCellOutputToCellOutput(output); + const mimeTypes = sortedOutputs.items.map((item) => item.mime).join(','); + assert.equal(mimeTypes, expectedMimeTypesOrder.join(',')); + }); }); }); }); diff --git a/extensions/javascript/javascript-language-configuration.json b/extensions/javascript/javascript-language-configuration.json index 4029985233ad2..fb2fb0397d790 100644 --- a/extensions/javascript/javascript-language-configuration.json +++ b/extensions/javascript/javascript-language-configuration.json @@ -111,7 +111,7 @@ }, "indentationRules": { "decreaseIndentPattern": { - "pattern": "^((?!.*?/\\*).*\\*\/)?\\s*[\\}\\]].*$" + "pattern": "^((?!.*?/\\*).*\\*\/)?\\s*[\\}\\]\\)].*$" }, "increaseIndentPattern": { "pattern": "^((?!//).)*(\\{([^}\"'`/]*|(\\t|[ ])*//.*)|\\([^)\"'`/]*|\\[[^\\]\"'`/]*)$" @@ -119,6 +119,9 @@ // e.g. * ...| or */| or *-----*/| "unIndentedLinePattern": { "pattern": "^(\\t|[ ])*[ ]\\*[^/]*\\*/\\s*$|^(\\t|[ ])*[ ]\\*/\\s*$|^(\\t|[ ])*[ ]\\*([ ]([^\\*]|\\*(?!/))*)?$" + }, + "indentNextLinePattern": { + "pattern": "^((.*=>\\s*)|((.*[^\\w]+|\\s*)(if|while|for)\\s*\\(.*\\)\\s*))$" } }, "onEnterRules": [ @@ -197,6 +200,33 @@ "action": { "indent": "outdent" } - } + }, + // Indent when pressing enter from inside () + { + "beforeText": "^.*\\([^\\)]*$", + "afterText": "^\\s*\\).*$", + "action": { + "indent": "indentOutdent", + "appendText": "\t", + } + }, + // Indent when pressing enter from inside {} + { + "beforeText": "^.*\\{[^\\}]*$", + "afterText": "^\\s*\\}.*$", + "action": { + "indent": "indentOutdent", + "appendText": "\t", + } + }, + // Indent when pressing enter from inside [] + { + "beforeText": "^.*\\[[^\\]]*$", + "afterText": "^\\s*\\].*$", + "action": { + "indent": "indentOutdent", + "appendText": "\t", + } + }, ] } diff --git a/extensions/json-language-features/client/src/browser/jsonClientMain.ts b/extensions/json-language-features/client/src/browser/jsonClientMain.ts index f7c87fbf9fa5b..f78f494d72713 100644 --- a/extensions/json-language-features/client/src/browser/jsonClientMain.ts +++ b/extensions/json-language-features/client/src/browser/jsonClientMain.ts @@ -3,9 +3,9 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { Disposable, ExtensionContext, Uri, l10n } from 'vscode'; +import { Disposable, ExtensionContext, Uri, l10n, window } from 'vscode'; import { LanguageClientOptions } from 'vscode-languageclient'; -import { startClient, LanguageClientConstructor, SchemaRequestService, AsyncDisposable } from '../jsonClient'; +import { startClient, LanguageClientConstructor, SchemaRequestService, AsyncDisposable, languageServerDescription } from '../jsonClient'; import { LanguageClient } from 'vscode-languageclient/browser'; declare const Worker: { @@ -43,7 +43,10 @@ export async function activate(context: ExtensionContext) { } }; - client = await startClient(context, newLanguageClient, { schemaRequests, timer }); + const logOutputChannel = window.createOutputChannel(languageServerDescription, { log: true }); + context.subscriptions.push(logOutputChannel); + + client = await startClient(context, newLanguageClient, { schemaRequests, timer, logOutputChannel }); } catch (e) { console.log(e); diff --git a/extensions/json-language-features/client/src/jsonClient.ts b/extensions/json-language-features/client/src/jsonClient.ts index ce81dcb4c9ee2..f892664d9170f 100644 --- a/extensions/json-language-features/client/src/jsonClient.ts +++ b/extensions/json-language-features/client/src/jsonClient.ts @@ -6,12 +6,12 @@ export type JSONLanguageStatus = { schemas: string[] }; import { - workspace, window, languages, commands, OutputChannel, ExtensionContext, extensions, Uri, ColorInformation, + workspace, window, languages, commands, LogOutputChannel, ExtensionContext, extensions, Uri, ColorInformation, Diagnostic, StatusBarAlignment, TextEditor, TextDocument, FormattingOptions, CancellationToken, FoldingRange, ProviderResult, TextEdit, Range, Position, Disposable, CompletionItem, CompletionList, CompletionContext, Hover, MarkdownString, FoldingContext, DocumentSymbol, SymbolInformation, l10n } from 'vscode'; import { - LanguageClientOptions, RequestType, NotificationType, FormattingOptions as LSPFormattingOptions, + LanguageClientOptions, RequestType, NotificationType, FormattingOptions as LSPFormattingOptions, DocumentDiagnosticReportKind, DidChangeConfigurationNotification, HandleDiagnosticsSignature, ResponseError, DocumentRangeFormattingParams, DocumentRangeFormattingRequest, ProvideCompletionItemsSignature, ProvideHoverSignature, BaseLanguageClient, ProvideFoldingRangeSignature, ProvideDocumentSymbolsSignature, ProvideDocumentColorsSignature } from 'vscode-languageclient'; @@ -130,6 +130,7 @@ export interface Runtime { readonly timer: { setTimeout(callback: (...args: any[]) => void, ms: number, ...args: any[]): Disposable; }; + logOutputChannel: LogOutputChannel; } export interface SchemaRequestService { @@ -150,12 +151,10 @@ export interface AsyncDisposable { } export async function startClient(context: ExtensionContext, newLanguageClient: LanguageClientConstructor, runtime: Runtime): Promise { - const outputChannel = window.createOutputChannel(languageServerDescription); - const languageParticipants = getLanguageParticipants(); context.subscriptions.push(languageParticipants); - let client: Disposable | undefined = await startClientWithParticipants(context, languageParticipants, newLanguageClient, outputChannel, runtime); + let client: Disposable | undefined = await startClientWithParticipants(context, languageParticipants, newLanguageClient, runtime); let restartTrigger: Disposable | undefined; languageParticipants.onDidChange(() => { @@ -164,12 +163,12 @@ export async function startClient(context: ExtensionContext, newLanguageClient: } restartTrigger = runtime.timer.setTimeout(async () => { if (client) { - outputChannel.appendLine('Extensions have changed, restarting JSON server...'); - outputChannel.appendLine(''); + runtime.logOutputChannel.info('Extensions have changed, restarting JSON server...'); + runtime.logOutputChannel.info(''); const oldClient = client; client = undefined; await oldClient.dispose(); - client = await startClientWithParticipants(context, languageParticipants, newLanguageClient, outputChannel, runtime); + client = await startClientWithParticipants(context, languageParticipants, newLanguageClient, runtime); } }, 2000); }); @@ -178,12 +177,11 @@ export async function startClient(context: ExtensionContext, newLanguageClient: dispose: async () => { restartTrigger?.dispose(); await client?.dispose(); - outputChannel.dispose(); } }; } -async function startClientWithParticipants(context: ExtensionContext, languageParticipants: LanguageParticipants, newLanguageClient: LanguageClientConstructor, outputChannel: OutputChannel, runtime: Runtime): Promise { +async function startClientWithParticipants(context: ExtensionContext, languageParticipants: LanguageParticipants, newLanguageClient: LanguageClientConstructor, runtime: Runtime): Promise { const toDispose: Disposable[] = []; @@ -232,6 +230,21 @@ async function startClientWithParticipants(context: ExtensionContext, languagePa } })); + function filterSchemaErrorDiagnostics(uri: Uri, diagnostics: Diagnostic[]): Diagnostic[] { + const schemaErrorIndex = diagnostics.findIndex(isSchemaResolveError); + if (schemaErrorIndex !== -1) { + const schemaResolveDiagnostic = diagnostics[schemaErrorIndex]; + fileSchemaErrors.set(uri.toString(), schemaResolveDiagnostic.message); + if (!schemaDownloadEnabled) { + diagnostics = diagnostics.filter(d => !isSchemaResolveError(d)); + } + if (window.activeTextEditor && window.activeTextEditor.document.uri.toString() === uri.toString()) { + schemaResolutionErrorStatusBarItem.show(); + } + } + return diagnostics; + } + // Options to control the language client const clientOptions: LanguageClientOptions = { // Register the server for json documents @@ -250,25 +263,16 @@ async function startClientWithParticipants(context: ExtensionContext, languagePa workspace: { didChangeConfiguration: () => client.sendNotification(DidChangeConfigurationNotification.type, { settings: getSettings() }) }, - handleDiagnostics: (uri: Uri, diagnostics: Diagnostic[], next: HandleDiagnosticsSignature) => { - const schemaErrorIndex = diagnostics.findIndex(isSchemaResolveError); - - if (schemaErrorIndex === -1) { - fileSchemaErrors.delete(uri.toString()); - return next(uri, diagnostics); + provideDiagnostics: async (uriOrDoc, previousResolutId, token, next) => { + const diagnostics = await next(uriOrDoc, previousResolutId, token); + if (diagnostics && diagnostics.kind === DocumentDiagnosticReportKind.Full) { + const uri = uriOrDoc instanceof Uri ? uriOrDoc : uriOrDoc.uri; + diagnostics.items = filterSchemaErrorDiagnostics(uri, diagnostics.items); } - - const schemaResolveDiagnostic = diagnostics[schemaErrorIndex]; - fileSchemaErrors.set(uri.toString(), schemaResolveDiagnostic.message); - - if (!schemaDownloadEnabled) { - diagnostics = diagnostics.filter(d => !isSchemaResolveError(d)); - } - - if (window.activeTextEditor && window.activeTextEditor.document.uri.toString() === uri.toString()) { - schemaResolutionErrorStatusBarItem.show(); - } - + return diagnostics; + }, + handleDiagnostics: (uri: Uri, diagnostics: Diagnostic[], next: HandleDiagnosticsSignature) => { + diagnostics = filterSchemaErrorDiagnostics(uri, diagnostics); next(uri, diagnostics); }, // testing the replace / insert mode @@ -348,7 +352,7 @@ async function startClientWithParticipants(context: ExtensionContext, languagePa } }; - clientOptions.outputChannel = outputChannel; + clientOptions.outputChannel = runtime.logOutputChannel; // Create the language client and start the client. const client = newLanguageClient('json', languageServerDescription, clientOptions); client.registerProposedFeatures(); diff --git a/extensions/json-language-features/client/src/node/jsonClientMain.ts b/extensions/json-language-features/client/src/node/jsonClientMain.ts index 79d66e32ddafb..d57ebf8083400 100644 --- a/extensions/json-language-features/client/src/node/jsonClientMain.ts +++ b/extensions/json-language-features/client/src/node/jsonClientMain.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { Disposable, ExtensionContext, OutputChannel, window, workspace, l10n, env } from 'vscode'; +import { Disposable, ExtensionContext, LogOutputChannel, window, l10n, env, LogLevel } from 'vscode'; import { startClient, LanguageClientConstructor, SchemaRequestService, languageServerDescription, AsyncDisposable } from '../jsonClient'; import { ServerOptions, TransportKind, LanguageClientOptions, LanguageClient } from 'vscode-languageclient/node'; @@ -14,15 +14,16 @@ import { xhr, XHRResponse, getErrorStatusDescription, Headers } from 'request-li import TelemetryReporter from '@vscode/extension-telemetry'; import { JSONSchemaCache } from './schemaCache'; -let telemetry: TelemetryReporter | undefined; let client: AsyncDisposable | undefined; // this method is called when vs code is activated export async function activate(context: ExtensionContext) { const clientPackageJSON = await getPackageInfo(context); - telemetry = new TelemetryReporter(clientPackageJSON.aiKey); + const telemetry = new TelemetryReporter(clientPackageJSON.aiKey); + context.subscriptions.push(telemetry); - const outputChannel = window.createOutputChannel(languageServerDescription); + const logOutputChannel = window.createOutputChannel(languageServerDescription, { log: true }); + context.subscriptions.push(logOutputChannel); const serverMain = `./server/${clientPackageJSON.main.indexOf('/dist/') !== -1 ? 'dist' : 'out'}/node/jsonServerMain`; const serverModule = context.asAbsolutePath(serverMain); @@ -38,11 +39,8 @@ export async function activate(context: ExtensionContext) { }; const newLanguageClient: LanguageClientConstructor = (id: string, name: string, clientOptions: LanguageClientOptions) => { - clientOptions.outputChannel = outputChannel; return new LanguageClient(id, name, serverOptions, clientOptions); }; - const log = getLog(outputChannel); - context.subscriptions.push(log); const timer = { setTimeout(callback: (...args: any[]) => void, ms: number, ...args: any[]): Disposable { @@ -54,9 +52,9 @@ export async function activate(context: ExtensionContext) { // pass the location of the localization bundle to the server process.env['VSCODE_L10N_BUNDLE_LOCATION'] = l10n.uri?.toString() ?? ''; - const schemaRequests = await getSchemaRequestService(context, log); + const schemaRequests = await getSchemaRequestService(context, logOutputChannel); - client = await startClient(context, newLanguageClient, { schemaRequests, telemetry, timer }); + client = await startClient(context, newLanguageClient, { schemaRequests, telemetry, timer, logOutputChannel }); } export async function deactivate(): Promise { @@ -64,7 +62,6 @@ export async function deactivate(): Promise { await client.dispose(); client = undefined; } - telemetry?.dispose(); } interface IPackageInfo { @@ -84,36 +81,9 @@ async function getPackageInfo(context: ExtensionContext): Promise } } -interface Log { - trace(message: string): void; - isTrace(): boolean; - dispose(): void; -} - -const traceSetting = 'json.trace.server'; -function getLog(outputChannel: OutputChannel): Log { - let trace = workspace.getConfiguration().get(traceSetting) === 'verbose'; - const configListener = workspace.onDidChangeConfiguration(e => { - if (e.affectsConfiguration(traceSetting)) { - trace = workspace.getConfiguration().get(traceSetting) === 'verbose'; - } - }); - return { - trace(message: string) { - if (trace) { - outputChannel.appendLine(message); - } - }, - isTrace() { - return trace; - }, - dispose: () => configListener.dispose() - }; -} - const retryTimeoutInHours = 2 * 24; // 2 days -async function getSchemaRequestService(context: ExtensionContext, log: Log): Promise { +async function getSchemaRequestService(context: ExtensionContext, log: LogOutputChannel): Promise { let cache: JSONSchemaCache | undefined = undefined; const globalStorage = context.globalStorageUri; @@ -191,7 +161,7 @@ async function getSchemaRequestService(context: ExtensionContext, log: Log): Pro if (cache && /^https?:\/\/json\.schemastore\.org\//.test(uri)) { const content = await cache.getSchemaIfUpdatedSince(uri, retryTimeoutInHours); if (content) { - if (log.isTrace()) { + if (log.logLevel === LogLevel.Trace) { log.trace(`[json schema cache] Schema ${uri} from cache without request (last accessed ${cache.getLastUpdatedInHours(uri)} hours ago)`); } diff --git a/extensions/json-language-features/server/package.json b/extensions/json-language-features/server/package.json index bddc40acd347f..7d50c250b0d0a 100644 --- a/extensions/json-language-features/server/package.json +++ b/extensions/json-language-features/server/package.json @@ -15,8 +15,8 @@ "@vscode/l10n": "^0.0.18", "jsonc-parser": "^3.2.1", "request-light": "^0.7.0", - "vscode-json-languageservice": "^5.3.9", - "vscode-languageserver": "^9.0.2-next.1", + "vscode-json-languageservice": "^5.3.10", + "vscode-languageserver": "^10.0.0-next.2", "vscode-uri": "^3.0.8" }, "devDependencies": { diff --git a/extensions/json-language-features/server/yarn.lock b/extensions/json-language-features/server/yarn.lock index fc1159b9160e0..598fe8229940b 100644 --- a/extensions/json-language-features/server/yarn.lock +++ b/extensions/json-language-features/server/yarn.lock @@ -27,10 +27,10 @@ request-light@^0.7.0: resolved "https://registry.yarnpkg.com/request-light/-/request-light-0.7.0.tgz#885628bb2f8040c26401ebf258ec51c4ae98ac2a" integrity sha512-lMbBMrDoxgsyO+yB3sDcrDuX85yYt7sS8BfQd11jtbW/z5ZWgLZRcEGLsLoYw7I0WSUGQBs8CC8ScIxkTX1+6Q== -vscode-json-languageservice@^5.3.9: - version "5.3.9" - resolved "https://registry.yarnpkg.com/vscode-json-languageservice/-/vscode-json-languageservice-5.3.9.tgz#512463ed580237d958df9280b43da9e3b5b621ce" - integrity sha512-0IcymTw0ZYX5Zcx+7KLLwTRvg0FzXUVnM1hrUH+sPhqEX0fHGg2h5UUOSp1f8ydGS7/xxzlFI3TR01yaHs6Y0Q== +vscode-json-languageservice@^5.3.10: + version "5.3.10" + resolved "https://registry.yarnpkg.com/vscode-json-languageservice/-/vscode-json-languageservice-5.3.10.tgz#7d56872cbb7460baf0491cea31807e537244dbae" + integrity sha512-KlbUYaer3DAnsVyRtgg/MhXOu4TTwY8TjaZYRY7Mt80zSpmvbmd58YT4Wq2ZiqHzdioD6lAvRSxhSCL0DvVY8Q== dependencies: "@vscode/l10n" "^0.0.18" jsonc-parser "^3.2.1" @@ -38,40 +38,40 @@ vscode-json-languageservice@^5.3.9: vscode-languageserver-types "^3.17.5" vscode-uri "^3.0.8" -vscode-jsonrpc@8.2.1-next.1: - version "8.2.1-next.1" - resolved "https://registry.yarnpkg.com/vscode-jsonrpc/-/vscode-jsonrpc-8.2.1-next.1.tgz#52e1091907b56759114fabac803b18c44a48f2a9" - integrity sha512-L+DYtdUtqUXGpyMgHqer6IBKvFFhl/1ToiMmCmG85LYHuuX0jllHMz77MYt0RicakoYY+Lq1yLK6Qj3YBqgzDQ== +vscode-jsonrpc@9.0.0-next.2: + version "9.0.0-next.2" + resolved "https://registry.yarnpkg.com/vscode-jsonrpc/-/vscode-jsonrpc-9.0.0-next.2.tgz#29e9741c742c80329bba1c60ce38fd014651ba80" + integrity sha512-meIaXAgChCHzWy45QGU8YpCNyqnZQ/sYeCj32OLDDbUYsCF7AvgpdXx3nnZn9yzr8ed0Od9bW+NGphEmXsqvIQ== -vscode-languageserver-protocol@3.17.6-next.1: - version "3.17.6-next.1" - resolved "https://registry.yarnpkg.com/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.17.6-next.1.tgz#5d87f7f708667cf04dbefb5c860901df7d01ebc1" - integrity sha512-2npXUc8oe/fb9Bjcwm2HTWYZXyCbW4NTo7jkOrEciGO+/LfWbSMgqZ6PwKWgqUkgCbkPxQHNjoMqr9ol/Ehjgg== +vscode-languageserver-protocol@3.17.6-next.3: + version "3.17.6-next.3" + resolved "https://registry.yarnpkg.com/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.17.6-next.3.tgz#09d3e28e9ad12270233d07fa0b69cf1d51d7dfe4" + integrity sha512-H8ATH5SAvc3JzttS+AL6g681PiBOZM/l34WP2JZk4akY3y7NqTP+f9cJ+MhrVBbD3aDS8bdAKewZgbFLW6M8Pg== dependencies: - vscode-jsonrpc "8.2.1-next.1" - vscode-languageserver-types "3.17.6-next.1" + vscode-jsonrpc "9.0.0-next.2" + vscode-languageserver-types "3.17.6-next.3" vscode-languageserver-textdocument@^1.0.11: version "1.0.11" resolved "https://registry.yarnpkg.com/vscode-languageserver-textdocument/-/vscode-languageserver-textdocument-1.0.11.tgz#0822a000e7d4dc083312580d7575fe9e3ba2e2bf" integrity sha512-X+8T3GoiwTVlJbicx/sIAF+yuJAqz8VvwJyoMVhwEMoEKE/fkDmrqUgDMyBECcM2A2frVZIUj5HI/ErRXCfOeA== -vscode-languageserver-types@3.17.6-next.1: - version "3.17.6-next.1" - resolved "https://registry.yarnpkg.com/vscode-languageserver-types/-/vscode-languageserver-types-3.17.6-next.1.tgz#a3d2006d52f7d4026ea67668113ec16c73cd8f1d" - integrity sha512-7xVc/xLtNhKuCKX0mINT6mFUrUuRz0EinhwPGT8Gtsv2hlo+xJb5NKbiGailcWa1/T5e4dr5Pb2MfGchHreHAA== +vscode-languageserver-types@3.17.6-next.3: + version "3.17.6-next.3" + resolved "https://registry.yarnpkg.com/vscode-languageserver-types/-/vscode-languageserver-types-3.17.6-next.3.tgz#f71d6c57f18d921346cfe0c227aabd72eb8cd2f0" + integrity sha512-l5kNFXFRQGuzriXpuBqFpRmkf6f6A4VoU3h95OsVkqIOoi1k7KbwSo600cIdsKSJWrPg/+vX+QMPcMw1oI7ItA== vscode-languageserver-types@^3.17.5: version "3.17.5" resolved "https://registry.yarnpkg.com/vscode-languageserver-types/-/vscode-languageserver-types-3.17.5.tgz#3273676f0cf2eab40b3f44d085acbb7f08a39d8a" integrity sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg== -vscode-languageserver@^9.0.2-next.1: - version "9.0.2-next.1" - resolved "https://registry.yarnpkg.com/vscode-languageserver/-/vscode-languageserver-9.0.2-next.1.tgz#cc9bbd66716346aa761e5bafa19d64559ab4e030" - integrity sha512-xySldxoHIcKXtxoI0LqRX3QcTdOVFt1SeHV0hyPq28p7xGPqWxUPcmTcfIqYdHefXG22nd8DQbGWOEe52yu08A== +vscode-languageserver@^10.0.0-next.2: + version "10.0.0-next.2" + resolved "https://registry.yarnpkg.com/vscode-languageserver/-/vscode-languageserver-10.0.0-next.2.tgz#9a8ac58f72979961497c4fd7f6097561d4134d5f" + integrity sha512-WZdK/XO6EkNU6foYck49NpS35sahWhYFs4hwCGalH/6lhPmdUKABTnWioK/RLZKWqH8E5HdlAHQMfSBIxKBV9Q== dependencies: - vscode-languageserver-protocol "3.17.6-next.1" + vscode-languageserver-protocol "3.17.6-next.3" vscode-uri@^3.0.8: version "3.0.8" diff --git a/extensions/json-language-features/yarn.lock b/extensions/json-language-features/yarn.lock index 9f29ee2197acc..df4818e025b7c 100644 --- a/extensions/json-language-features/yarn.lock +++ b/extensions/json-language-features/yarn.lock @@ -141,9 +141,9 @@ request-light@^0.7.0: integrity sha512-lMbBMrDoxgsyO+yB3sDcrDuX85yYt7sS8BfQd11jtbW/z5ZWgLZRcEGLsLoYw7I0WSUGQBs8CC8ScIxkTX1+6Q== semver@^7.3.7: - version "7.5.4" - resolved "https://registry.yarnpkg.com/semver/-/semver-7.5.4.tgz#483986ec4ed38e1c6c48c34894a9182dbff68a6e" - integrity sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA== + version "7.6.0" + resolved "https://registry.yarnpkg.com/semver/-/semver-7.6.0.tgz#1a46a4db4bffcccd97b743b5005c8325f23d4e2d" + integrity sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg== dependencies: lru-cache "^6.0.0" diff --git a/extensions/latex/cgmanifest.json b/extensions/latex/cgmanifest.json index cd025113ad7aa..965df91bed4f3 100644 --- a/extensions/latex/cgmanifest.json +++ b/extensions/latex/cgmanifest.json @@ -6,11 +6,11 @@ "git": { "name": "jlelong/vscode-latex-basics", "repositoryUrl": "https://github.com/jlelong/vscode-latex-basics", - "commitHash": "69915f318570484ef40ed8798c73c63c58704183" + "commitHash": "45c7b12ee68563afd50407e5eac02d30d33dbe7a" } }, "license": "MIT", - "version": "1.5.4", + "version": "1.6.0", "description": "The files in syntaxes/ were originally part of https://github.com/James-Yu/LaTeX-Workshop. They have been extracted in the hope that they can useful outside of the LaTeX-Workshop extension.", "licenseDetail": [ "Copyright (c) vscode-latex-basics authors", diff --git a/extensions/mangle-loader.js b/extensions/mangle-loader.js index b6b22ce3f1aae..016d0f6903310 100644 --- a/extensions/mangle-loader.js +++ b/extensions/mangle-loader.js @@ -41,6 +41,10 @@ module.exports = async function (source, sourceMap, meta) { // Only enable mangling in production builds return source; } + if (true) { + // disable mangling for now, SEE https://github.com/microsoft/vscode/issues/204692 + return source; + } const options = this.getOptions(); if (options.disabled) { // Dynamically disabled diff --git a/extensions/markdown-basics/cgmanifest.json b/extensions/markdown-basics/cgmanifest.json index bf4ee5e89bedf..60c6b192bed6a 100644 --- a/extensions/markdown-basics/cgmanifest.json +++ b/extensions/markdown-basics/cgmanifest.json @@ -33,7 +33,7 @@ "git": { "name": "microsoft/vscode-markdown-tm-grammar", "repositoryUrl": "https://github.com/microsoft/vscode-markdown-tm-grammar", - "commitHash": "0b36cbbf917fb0188e1a1bafc8287c7abf8b0b37" + "commitHash": "f75d5f55730e72ee7ff386841949048b2395e440" } }, "license": "MIT", diff --git a/extensions/markdown-basics/syntaxes/markdown.tmLanguage.json b/extensions/markdown-basics/syntaxes/markdown.tmLanguage.json index b5472c56cfdac..c84c468b80c8a 100644 --- a/extensions/markdown-basics/syntaxes/markdown.tmLanguage.json +++ b/extensions/markdown-basics/syntaxes/markdown.tmLanguage.json @@ -4,7 +4,7 @@ "If you want to provide a fix or improvement, please create a pull request against the original repository.", "Once accepted there, we are happy to receive an update request." ], - "version": "https://github.com/microsoft/vscode-markdown-tm-grammar/commit/0b36cbbf917fb0188e1a1bafc8287c7abf8b0b37", + "version": "https://github.com/microsoft/vscode-markdown-tm-grammar/commit/f75d5f55730e72ee7ff386841949048b2395e440", "name": "Markdown", "scopeName": "text.html.markdown", "patterns": [ @@ -1257,7 +1257,7 @@ ] }, "fenced_code_block_powershell": { - "begin": "(^|\\G)(\\s*)(`{3,}|~{3,})\\s*(?i:(powershell|ps1|psm1|psd1)((\\s+|:|,|\\{|\\?)[^`]*)?$)", + "begin": "(^|\\G)(\\s*)(`{3,}|~{3,})\\s*(?i:(powershell|ps1|psm1|psd1|pwsh)((\\s+|:|,|\\{|\\?)[^`]*)?$)", "name": "markup.fenced_code.block.markdown", "end": "(^|\\G)(\\2|\\s{0,3})(\\3)\\s*$", "beginCaptures": { diff --git a/extensions/markdown-language-features/media/highlight.css b/extensions/markdown-language-features/media/highlight.css index 47444a103481f..6342ac12cd52a 100644 --- a/extensions/markdown-language-features/media/highlight.css +++ b/extensions/markdown-language-features/media/highlight.css @@ -159,13 +159,13 @@ Visual Studio-like style based on original C# coloring by Jason Diamond (' export const getReferencesToFileInWorkspace = new RequestType<{ uri: string }, lsp.Location[], any>('markdown/getReferencesToFileInWorkspace'); export const getEditForFileRenames = new RequestType('markdown/getEditForFileRenames'); +export const prepareUpdatePastedLinks = new RequestType<{ uri: string; ranges: lsp.Range[] }, string, any>('markdown/prepareUpdatePastedLinks'); +export const getUpdatePastedLinksEdit = new RequestType<{ pasteIntoDoc: string; metadata: string; edits: lsp.TextEdit[] }, lsp.TextEdit[] | undefined, any>('markdown/getUpdatePastedLinksEdit'); + export const fs_watcher_onChange = new RequestType<{ id: number; uri: string; kind: 'create' | 'change' | 'delete' }, void, any>('markdown/fs/watcher/onChange'); export const resolveLinkTarget = new RequestType<{ linkText: string; uri: string }, md.ResolvedDocumentLinkTarget, any>('markdown/resolveLinkTarget'); diff --git a/extensions/markdown-language-features/server/src/server.ts b/extensions/markdown-language-features/server/src/server.ts index 50c343784714e..bcfbb1be899d1 100644 --- a/extensions/markdown-language-features/server/src/server.ts +++ b/extensions/markdown-language-features/server/src/server.ts @@ -262,6 +262,26 @@ export async function startServer(connection: Connection, serverConfig: { }; })); + connection.onRequest(protocol.prepareUpdatePastedLinks, (async (params, token: CancellationToken) => { + const document = documents.get(params.uri); + if (!document) { + return undefined; + } + + return mdLs!.prepareUpdatePastedLinks(document, params.ranges, token); + })); + + connection.onRequest(protocol.getUpdatePastedLinksEdit, (async (params, token: CancellationToken) => { + const document = documents.get(params.pasteIntoDoc); + if (!document) { + return undefined; + } + + // TODO: Figure out why range types are lying + const edits = params.edits.map((edit: any) => lsp.TextEdit.replace(lsp.Range.create(edit.range[0].line, edit.range[0].character, edit.range[1].line, edit.range[1].character), edit.newText)); + return mdLs!.getUpdatePastedLinksEdit(document, edits, params.metadata, token); + })); + connection.onRequest(protocol.resolveLinkTarget, (async (params, token: CancellationToken) => { return mdLs!.resolveLinkTarget(params.linkText, URI.parse(params.uri), token); })); diff --git a/extensions/markdown-language-features/server/src/workspace.ts b/extensions/markdown-language-features/server/src/workspace.ts index b1bf87c302083..13e5c6b447287 100644 --- a/extensions/markdown-language-features/server/src/workspace.ts +++ b/extensions/markdown-language-features/server/src/workspace.ts @@ -63,6 +63,18 @@ class VsCodeDocument implements md.ITextDocument { throw new Error('Document has been closed'); } + offsetAt(position: Position): number { + if (this.inMemoryDoc) { + return this.inMemoryDoc.offsetAt(position); + } + + if (this.onDiskDoc) { + return this.onDiskDoc.offsetAt(position); + } + + throw new Error('Document has been closed'); + } + hasInMemoryDoc(): boolean { return !!this.inMemoryDoc; } diff --git a/extensions/markdown-language-features/server/yarn.lock b/extensions/markdown-language-features/server/yarn.lock index f3a8efb60237e..e0da1e19db1d9 100644 --- a/extensions/markdown-language-features/server/yarn.lock +++ b/extensions/markdown-language-features/server/yarn.lock @@ -103,6 +103,11 @@ vscode-jsonrpc@8.1.0: resolved "https://registry.yarnpkg.com/vscode-jsonrpc/-/vscode-jsonrpc-8.1.0.tgz#cb9989c65e219e18533cc38e767611272d274c94" integrity sha512-6TDy/abTQk+zDGYazgbIPc+4JoXdwC8NHU9Pbn4UJP1fehUyZmM4RHp5IthX7A6L5KS30PRui+j+tbbMMMafdw== +vscode-jsonrpc@8.2.0: + version "8.2.0" + resolved "https://registry.yarnpkg.com/vscode-jsonrpc/-/vscode-jsonrpc-8.2.0.tgz#f43dfa35fb51e763d17cd94dcca0c9458f35abf9" + integrity sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA== + vscode-languageserver-protocol@3.17.3: version "3.17.3" resolved "https://registry.yarnpkg.com/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.17.3.tgz#6d0d54da093f0c0ee3060b81612cce0f11060d57" @@ -111,6 +116,19 @@ vscode-languageserver-protocol@3.17.3: vscode-jsonrpc "8.1.0" vscode-languageserver-types "3.17.3" +vscode-languageserver-protocol@^3.17.1: + version "3.17.5" + resolved "https://registry.yarnpkg.com/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.17.5.tgz#864a8b8f390835572f4e13bd9f8313d0e3ac4bea" + integrity sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg== + dependencies: + vscode-jsonrpc "8.2.0" + vscode-languageserver-types "3.17.5" + +vscode-languageserver-textdocument@^1.0.11: + version "1.0.11" + resolved "https://registry.yarnpkg.com/vscode-languageserver-textdocument/-/vscode-languageserver-textdocument-1.0.11.tgz#0822a000e7d4dc083312580d7575fe9e3ba2e2bf" + integrity sha512-X+8T3GoiwTVlJbicx/sIAF+yuJAqz8VvwJyoMVhwEMoEKE/fkDmrqUgDMyBECcM2A2frVZIUj5HI/ErRXCfOeA== + vscode-languageserver-textdocument@^1.0.8: version "1.0.8" resolved "https://registry.yarnpkg.com/vscode-languageserver-textdocument/-/vscode-languageserver-textdocument-1.0.8.tgz#9eae94509cbd945ea44bca8dcfe4bb0c15bb3ac0" @@ -121,6 +139,11 @@ vscode-languageserver-types@3.17.3, vscode-languageserver-types@^3.17.3: resolved "https://registry.yarnpkg.com/vscode-languageserver-types/-/vscode-languageserver-types-3.17.3.tgz#72d05e47b73be93acb84d6e311b5786390f13f64" integrity sha512-SYU4z1dL0PyIMd4Vj8YOqFvHu7Hz/enbWtpfnVbJHU4Nd1YNYx8u0ennumc6h48GQNeOLxmwySmnADouT/AuZA== +vscode-languageserver-types@3.17.5: + version "3.17.5" + resolved "https://registry.yarnpkg.com/vscode-languageserver-types/-/vscode-languageserver-types-3.17.5.tgz#3273676f0cf2eab40b3f44d085acbb7f08a39d8a" + integrity sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg== + vscode-languageserver@^8.1.0: version "8.1.0" resolved "https://registry.yarnpkg.com/vscode-languageserver/-/vscode-languageserver-8.1.0.tgz#5024253718915d84576ce6662dd46a791498d827" @@ -128,16 +151,16 @@ vscode-languageserver@^8.1.0: dependencies: vscode-languageserver-protocol "3.17.3" -vscode-markdown-languageservice@^0.4.0: - version "0.4.0" - resolved "https://registry.yarnpkg.com/vscode-markdown-languageservice/-/vscode-markdown-languageservice-0.4.0.tgz#1ccca383703d38043b58e096fd3224796a2b2ab5" - integrity sha512-3C8pZlC0ofHEYmWwHgenxL6//XrpkrgyytrqNpMlft46q9uBxSUfcXtEGt7wIDNLWsvmgqPqHBwEnBFtLwrWFA== +vscode-markdown-languageservice@^0.5.0-alpha.4: + version "0.5.0-alpha.4" + resolved "https://registry.yarnpkg.com/vscode-markdown-languageservice/-/vscode-markdown-languageservice-0.5.0-alpha.4.tgz#900faff6cb0784495117399bc6215691f7800d9b" + integrity sha512-Df6GzywR5cJ3LfBio3Fx45MfJJwDBVg5tcfpzH9XTvExYeHd1pHOUKssJLW4LBSX4bXwiFsNqrOB8va03d8LQQ== dependencies: "@vscode/l10n" "^0.0.10" node-html-parser "^6.1.5" picomatch "^2.3.1" - vscode-languageserver-textdocument "^1.0.8" - vscode-languageserver-types "^3.17.3" + vscode-languageserver-protocol "^3.17.1" + vscode-languageserver-textdocument "^1.0.11" vscode-uri "^3.0.7" vscode-uri@^3.0.7: diff --git a/extensions/markdown-language-features/src/client/client.ts b/extensions/markdown-language-features/src/client/client.ts index f0e925f615746..cf7dee25246cf 100644 --- a/extensions/markdown-language-features/src/client/client.ts +++ b/extensions/markdown-language-features/src/client/client.ts @@ -4,14 +4,13 @@ *--------------------------------------------------------------------------------------------*/ import * as vscode from 'vscode'; -import { BaseLanguageClient, LanguageClientOptions, NotebookDocumentSyncRegistrationType } from 'vscode-languageclient'; +import { BaseLanguageClient, LanguageClientOptions, NotebookDocumentSyncRegistrationType, Range, TextEdit } from 'vscode-languageclient'; import { IMdParser } from '../markdownEngine'; -import * as proto from './protocol'; +import { IDisposable } from '../util/dispose'; import { looksLikeMarkdownPath, markdownFileExtensions } from '../util/file'; -import { VsCodeMdWorkspace } from './workspace'; import { FileWatcherManager } from './fileWatchingManager'; -import { IDisposable } from '../util/dispose'; - +import * as proto from './protocol'; +import { VsCodeMdWorkspace } from './workspace'; export type LanguageClientConstructor = (name: string, description: string, clientOptions: LanguageClientOptions) => BaseLanguageClient; @@ -38,6 +37,21 @@ export class MdLanguageClient implements IDisposable { getReferencesToFileInWorkspace(resource: vscode.Uri, token: vscode.CancellationToken) { return this._client.sendRequest(proto.getReferencesToFileInWorkspace, { uri: resource.toString() }, token); } + + prepareUpdatePastedLinks(doc: vscode.Uri, ranges: readonly vscode.Range[], token: vscode.CancellationToken) { + return this._client.sendRequest(proto.prepareUpdatePastedLinks, { + uri: doc.toString(), + ranges: ranges.map(range => Range.create(range.start.line, range.start.character, range.end.line, range.end.character)), + }, token); + } + + getUpdatePastedLinksEdit(pastingIntoDoc: vscode.Uri, edits: readonly vscode.TextEdit[], metadata: string, token: vscode.CancellationToken) { + return this._client.sendRequest(proto.getUpdatePastedLinksEdit, { + metadata, + pasteIntoDoc: pastingIntoDoc.toString(), + edits: edits.map(edit => TextEdit.replace(edit.range, edit.newText)), + }, token); + } } export async function startClient(factory: LanguageClientConstructor, parser: IMdParser): Promise { diff --git a/extensions/markdown-language-features/src/client/protocol.ts b/extensions/markdown-language-features/src/client/protocol.ts index f906460fce9ed..2f6c48b371d7c 100644 --- a/extensions/markdown-language-features/src/client/protocol.ts +++ b/extensions/markdown-language-features/src/client/protocol.ts @@ -32,6 +32,9 @@ export const findMarkdownFilesInWorkspace = new RequestType<{}, string[], any>(' export const getReferencesToFileInWorkspace = new RequestType<{ uri: string }, lsp.Location[], any>('markdown/getReferencesToFileInWorkspace'); export const getEditForFileRenames = new RequestType, { participatingRenames: readonly FileRename[]; edit: lsp.WorkspaceEdit }, any>('markdown/getEditForFileRenames'); +export const prepareUpdatePastedLinks = new RequestType<{ uri: string; ranges: lsp.Range[] }, string, any>('markdown/prepareUpdatePastedLinks'); +export const getUpdatePastedLinksEdit = new RequestType<{ pasteIntoDoc: string; metadata: string; edits: lsp.TextEdit[] }, lsp.TextEdit[] | undefined, any>('markdown/getUpdatePastedLinksEdit'); + export const fs_watcher_onChange = new RequestType<{ id: number; uri: string; kind: 'create' | 'change' | 'delete' }, void, any>('markdown/fs/watcher/onChange'); export const resolveLinkTarget = new RequestType<{ linkText: string; uri: string }, ResolvedDocumentLinkTarget, any>('markdown/resolveLinkTarget'); diff --git a/extensions/markdown-language-features/src/extension.shared.ts b/extensions/markdown-language-features/src/extension.shared.ts index d6499591039ef..e062666c748d4 100644 --- a/extensions/markdown-language-features/src/extension.shared.ts +++ b/extensions/markdown-language-features/src/extension.shared.ts @@ -20,6 +20,7 @@ import { MarkdownPreviewManager } from './preview/previewManager'; import { ExtensionContentSecurityPolicyArbiter } from './preview/security'; import { loadDefaultTelemetryReporter } from './telemetryReporter'; import { MdLinkOpener } from './util/openDocumentLink'; +import { registerUpdatePastedLinks } from './languageFeatures/updateLinksOnPaste'; export function activateShared( context: vscode.ExtensionContext, @@ -58,8 +59,9 @@ function registerMarkdownLanguageFeatures( // Language features registerDiagnosticSupport(selector, commandManager), registerFindFileReferenceSupport(commandManager, client), - registerResourceDropOrPasteSupport(selector), + registerResourceDropOrPasteSupport(selector, parser), registerPasteUrlSupport(selector, parser), registerUpdateLinksOnRename(client), + registerUpdatePastedLinks(selector, client), ); } diff --git a/extensions/markdown-language-features/src/languageFeatures/copyFiles/dropOrPasteResource.ts b/extensions/markdown-language-features/src/languageFeatures/copyFiles/dropOrPasteResource.ts index 41c8bd131466b..ef5310013b83a 100644 --- a/extensions/markdown-language-features/src/languageFeatures/copyFiles/dropOrPasteResource.ts +++ b/extensions/markdown-language-features/src/languageFeatures/copyFiles/dropOrPasteResource.ts @@ -4,12 +4,20 @@ *--------------------------------------------------------------------------------------------*/ import * as vscode from 'vscode'; +import { IMdParser } from '../../markdownEngine'; import { coalesce } from '../../util/arrays'; import { getParentDocumentUri } from '../../util/document'; import { Mime, mediaMimes } from '../../util/mimes'; import { Schemes } from '../../util/schemes'; import { NewFilePathGenerator } from './newFilePathGenerator'; -import { createInsertUriListEdit, createUriListSnippet, getSnippetLabel } from './shared'; +import { DropOrPasteEdit, createInsertUriListEdit, createUriListSnippet, getSnippetLabel } from './shared'; +import { InsertMarkdownLink, shouldInsertMarkdownLinkByDefault } from './smartDropOrPaste'; +import { UriList } from '../../util/uriList'; + +enum CopyFilesSettings { + Never = 'never', + MediaFiles = 'mediaFiles', +} /** * Provides support for pasting or dropping resources into markdown documents. @@ -22,7 +30,7 @@ import { createInsertUriListEdit, createUriListSnippet, getSnippetLabel } from ' */ class ResourcePasteOrDropProvider implements vscode.DocumentPasteEditProvider, vscode.DocumentDropEditProvider { - public static readonly id = 'insertResource'; + public static readonly kind = vscode.DocumentPasteEditKind.Empty.append('markdown', 'link'); public static readonly mimeTypes = [ Mime.textUriList, @@ -31,129 +39,155 @@ class ResourcePasteOrDropProvider implements vscode.DocumentPasteEditProvider, v ]; private readonly _yieldTo = [ - { mimeType: 'text/plain' }, - { extensionId: 'vscode.ipynb', providerId: 'insertAttachment' }, + vscode.DocumentPasteEditKind.Empty.append('text'), + vscode.DocumentPasteEditKind.Empty.append('markdown', 'image', 'attachment'), ]; + constructor( + private readonly _parser: IMdParser, + ) { } + public async provideDocumentDropEdits( document: vscode.TextDocument, position: vscode.Position, dataTransfer: vscode.DataTransfer, token: vscode.CancellationToken, ): Promise { - const enabled = vscode.workspace.getConfiguration('markdown', document).get('editor.drop.enabled', true); - if (!enabled) { - return; - } - - const filesEdit = await this._getMediaFilesDropEdit(document, dataTransfer, token); - if (filesEdit) { - return filesEdit; - } + const edit = await this._createEdit(document, [new vscode.Range(position, position)], dataTransfer, { + insert: this._getEnabled(document, 'editor.drop.enabled'), + copyIntoWorkspace: vscode.workspace.getConfiguration('markdown', document).get('editor.drop.copyIntoWorkspace', CopyFilesSettings.MediaFiles) + }, undefined, token); - if (token.isCancellationRequested) { + if (!edit || token.isCancellationRequested) { return; } - return this._createEditFromUriListData(document, [new vscode.Range(position, position)], dataTransfer, token); + const dropEdit = new vscode.DocumentDropEdit(edit.snippet); + dropEdit.title = edit.label; + dropEdit.kind = ResourcePasteOrDropProvider.kind; + dropEdit.additionalEdit = edit.additionalEdits; + dropEdit.yieldTo = [...this._yieldTo, ...edit.yieldTo]; + return dropEdit; } public async provideDocumentPasteEdits( document: vscode.TextDocument, ranges: readonly vscode.Range[], dataTransfer: vscode.DataTransfer, + context: vscode.DocumentPasteEditContext, token: vscode.CancellationToken, - ): Promise { - const enabled = vscode.workspace.getConfiguration('markdown', document).get('editor.filePaste.enabled', true); - if (!enabled) { + ): Promise { + const edit = await this._createEdit(document, ranges, dataTransfer, { + insert: this._getEnabled(document, 'editor.paste.enabled'), + copyIntoWorkspace: vscode.workspace.getConfiguration('markdown', document).get('editor.paste.copyIntoWorkspace', CopyFilesSettings.MediaFiles) + }, context, token); + + if (!edit || token.isCancellationRequested) { return; } - const createEdit = await this._getMediaFilesPasteEdit(document, dataTransfer, token); - if (createEdit) { - return createEdit; - } + const pasteEdit = new vscode.DocumentPasteEdit(edit.snippet, edit.label, ResourcePasteOrDropProvider.kind); + pasteEdit.additionalEdit = edit.additionalEdits; + pasteEdit.yieldTo = [...this._yieldTo, ...edit.yieldTo]; + return [pasteEdit]; + } - if (token.isCancellationRequested) { - return; + private _getEnabled(document: vscode.TextDocument, settingName: string): InsertMarkdownLink { + const setting = vscode.workspace.getConfiguration('markdown', document).get(settingName, true); + // Convert old boolean values to new enum setting + if (setting === false) { + return InsertMarkdownLink.Never; + } else if (setting === true) { + return InsertMarkdownLink.Smart; + } else { + return setting; } - - return this._createEditFromUriListData(document, ranges, dataTransfer, token); } - private async _createEditFromUriListData( + private async _createEdit( document: vscode.TextDocument, ranges: readonly vscode.Range[], dataTransfer: vscode.DataTransfer, + settings: { + insert: InsertMarkdownLink; + copyIntoWorkspace: CopyFilesSettings; + }, + context: vscode.DocumentPasteEditContext | undefined, token: vscode.CancellationToken, - ): Promise { - const uriList = await dataTransfer.get(Mime.textUriList)?.asString(); - if (!uriList || token.isCancellationRequested) { + ): Promise { + if (settings.insert === InsertMarkdownLink.Never) { return; } - const pasteEdit = createInsertUriListEdit(document, ranges, uriList); - if (!pasteEdit) { + let edit = await this._createEditForMediaFiles(document, dataTransfer, settings.copyIntoWorkspace, token); + if (token.isCancellationRequested) { return; } - const uriEdit = new vscode.DocumentPasteEdit('', pasteEdit.label); - const edit = new vscode.WorkspaceEdit(); - edit.set(document.uri, pasteEdit.edits); - uriEdit.additionalEdit = edit; - uriEdit.yieldTo = this._yieldTo; - return uriEdit; - } - - private async _getMediaFilesPasteEdit( - document: vscode.TextDocument, - dataTransfer: vscode.DataTransfer, - token: vscode.CancellationToken, - ): Promise { - if (getParentDocumentUri(document.uri).scheme === Schemes.untitled) { - return; + if (!edit) { + edit = await this._createEditFromUriListData(document, ranges, dataTransfer, context, token); } - const copyFilesIntoWorkspace = vscode.workspace.getConfiguration('markdown', document).get<'mediaFiles' | 'never'>('editor.filePaste.copyIntoWorkspace', 'mediaFiles'); - if (copyFilesIntoWorkspace !== 'mediaFiles') { + if (!edit || token.isCancellationRequested) { return; } - const edit = await this._createEditForMediaFiles(document, dataTransfer, token); - if (!edit) { - return; + if (!(await shouldInsertMarkdownLinkByDefault(this._parser, document, settings.insert, ranges, token))) { + edit.yieldTo.push(vscode.DocumentPasteEditKind.Empty.append('uri')); } - const pasteEdit = new vscode.DocumentPasteEdit(edit.snippet, edit.label); - pasteEdit.additionalEdit = edit.additionalEdits; - pasteEdit.yieldTo = this._yieldTo; - return pasteEdit; + return edit; } - private async _getMediaFilesDropEdit( + private async _createEditFromUriListData( document: vscode.TextDocument, + ranges: readonly vscode.Range[], dataTransfer: vscode.DataTransfer, + context: vscode.DocumentPasteEditContext | undefined, token: vscode.CancellationToken, - ): Promise { - if (getParentDocumentUri(document.uri).scheme === Schemes.untitled) { + ): Promise { + const uriListData = await dataTransfer.get(Mime.textUriList)?.asString(); + if (!uriListData || token.isCancellationRequested) { return; } - const copyIntoWorkspace = vscode.workspace.getConfiguration('markdown', document).get<'mediaFiles' | 'never'>('editor.drop.copyIntoWorkspace', 'mediaFiles'); - if (copyIntoWorkspace !== 'mediaFiles') { + const uriList = UriList.from(uriListData); + if (!uriList.entries.length) { return; } - const edit = await this._createEditForMediaFiles(document, dataTransfer, token); + // In some browsers, copying from the address bar sets both text/uri-list and text/plain. + // Disable ourselves if there's also a text entry with the same http(s) uri as our list, + // unless we are explicitly requested. + if ( + uriList.entries.length === 1 + && (uriList.entries[0].uri.scheme === Schemes.http || uriList.entries[0].uri.scheme === Schemes.https) + && !context?.only?.contains(ResourcePasteOrDropProvider.kind) + ) { + const text = await dataTransfer.get(Mime.textPlain)?.asString(); + if (token.isCancellationRequested) { + return; + } + + if (text && textMatchesUriList(text, uriList)) { + return; + } + } + + const edit = createInsertUriListEdit(document, ranges, uriList); if (!edit) { return; } - const dropEdit = new vscode.DocumentDropEdit(edit.snippet); - dropEdit.label = edit.label; - dropEdit.additionalEdit = edit.additionalEdits; - dropEdit.yieldTo = this._yieldTo; - return dropEdit; + const additionalEdits = new vscode.WorkspaceEdit(); + additionalEdits.set(document.uri, edit.edits); + + return { + label: edit.label, + snippet: new vscode.SnippetString(''), + additionalEdits, + yieldTo: [] + }; } /** @@ -164,8 +198,13 @@ class ResourcePasteOrDropProvider implements vscode.DocumentPasteEditProvider, v private async _createEditForMediaFiles( document: vscode.TextDocument, dataTransfer: vscode.DataTransfer, + copyIntoWorkspace: CopyFilesSettings, token: vscode.CancellationToken, - ): Promise<{ snippet: vscode.SnippetString; label: string; additionalEdits: vscode.WorkspaceEdit } | undefined> { + ): Promise { + if (copyIntoWorkspace !== CopyFilesSettings.MediaFiles || getParentDocumentUri(document.uri).scheme === Schemes.untitled) { + return; + } + interface FileEntry { readonly uri: vscode.Uri; readonly newFile?: { readonly contents: vscode.DataTransferFile; readonly overwrite: boolean }; @@ -200,37 +239,51 @@ class ResourcePasteOrDropProvider implements vscode.DocumentPasteEditProvider, v return; } - const workspaceEdit = new vscode.WorkspaceEdit(); + const snippet = createUriListSnippet(document.uri, fileEntries); + if (!snippet) { + return; + } + + const additionalEdits = new vscode.WorkspaceEdit(); for (const entry of fileEntries) { if (entry.newFile) { - workspaceEdit.createFile(entry.uri, { + additionalEdits.createFile(entry.uri, { contents: entry.newFile.contents, overwrite: entry.newFile.overwrite, }); } } - const snippet = createUriListSnippet(document.uri, fileEntries); - if (!snippet) { - return; - } - return { snippet: snippet.snippet, label: getSnippetLabel(snippet), - additionalEdits: workspaceEdit, + additionalEdits, + yieldTo: [], }; } } -export function registerResourceDropOrPasteSupport(selector: vscode.DocumentSelector): vscode.Disposable { +function textMatchesUriList(text: string, uriList: UriList): boolean { + if (text === uriList.entries[0].str) { + return true; + } + + try { + const uri = vscode.Uri.parse(text); + return uriList.entries.some(entry => entry.uri.toString() === uri.toString()); + } catch { + return false; + } +} + +export function registerResourceDropOrPasteSupport(selector: vscode.DocumentSelector, parser: IMdParser): vscode.Disposable { return vscode.Disposable.from( - vscode.languages.registerDocumentPasteEditProvider(selector, new ResourcePasteOrDropProvider(), { - id: ResourcePasteOrDropProvider.id, + vscode.languages.registerDocumentPasteEditProvider(selector, new ResourcePasteOrDropProvider(parser), { + providedPasteEditKinds: [ResourcePasteOrDropProvider.kind], pasteMimeTypes: ResourcePasteOrDropProvider.mimeTypes, }), - vscode.languages.registerDocumentDropEditProvider(selector, new ResourcePasteOrDropProvider(), { - id: ResourcePasteOrDropProvider.id, + vscode.languages.registerDocumentDropEditProvider(selector, new ResourcePasteOrDropProvider(parser), { + providedDropEditKinds: [ResourcePasteOrDropProvider.kind], dropMimeTypes: ResourcePasteOrDropProvider.mimeTypes, }), ); diff --git a/extensions/markdown-language-features/src/languageFeatures/copyFiles/pasteUrlProvider.ts b/extensions/markdown-language-features/src/languageFeatures/copyFiles/pasteUrlProvider.ts index 4968f3cb22733..7fcff576a3dce 100644 --- a/extensions/markdown-language-features/src/languageFeatures/copyFiles/pasteUrlProvider.ts +++ b/extensions/markdown-language-features/src/languageFeatures/copyFiles/pasteUrlProvider.ts @@ -5,22 +5,10 @@ import * as vscode from 'vscode'; import { IMdParser } from '../../markdownEngine'; -import { ITextDocument } from '../../types/textDocument'; import { Mime } from '../../util/mimes'; -import { Schemes } from '../../util/schemes'; import { createInsertUriListEdit } from './shared'; - -export enum PasteUrlAsMarkdownLink { - Always = 'always', - SmartWithSelection = 'smartWithSelection', - Smart = 'smart', - Never = 'never' -} - -function getPasteUrlAsFormattedLinkSetting(document: vscode.TextDocument): PasteUrlAsMarkdownLink { - return vscode.workspace.getConfiguration('markdown', document) - .get('editor.pasteUrlAsFormattedLink.enabled', PasteUrlAsMarkdownLink.SmartWithSelection); -} +import { InsertMarkdownLink, findValidUriInText, shouldInsertMarkdownLinkByDefault } from './smartDropOrPaste'; +import { UriList } from '../../util/uriList'; /** * Adds support for pasting text uris to create markdown links. @@ -29,7 +17,7 @@ function getPasteUrlAsFormattedLinkSetting(document: vscode.TextDocument): Paste */ class PasteUrlEditProvider implements vscode.DocumentPasteEditProvider { - public static readonly id = 'insertMarkdownLink'; + public static readonly kind = vscode.DocumentPasteEditKind.Empty.append('markdown', 'link'); public static readonly pasteMimeTypes = [Mime.textPlain]; @@ -41,10 +29,12 @@ class PasteUrlEditProvider implements vscode.DocumentPasteEditProvider { document: vscode.TextDocument, ranges: readonly vscode.Range[], dataTransfer: vscode.DataTransfer, + _context: vscode.DocumentPasteEditContext, token: vscode.CancellationToken, - ): Promise { - const pasteUrlSetting = getPasteUrlAsFormattedLinkSetting(document); - if (pasteUrlSetting === PasteUrlAsMarkdownLink.Never) { + ): Promise { + const pasteUrlSetting = vscode.workspace.getConfiguration('markdown', document) + .get('editor.pasteUrlAsFormattedLink.enabled', InsertMarkdownLink.SmartWithSelection); + if (pasteUrlSetting === InsertMarkdownLink.Never) { return; } @@ -59,191 +49,30 @@ class PasteUrlEditProvider implements vscode.DocumentPasteEditProvider { return; } - const edit = createInsertUriListEdit(document, ranges, uriText, { preserveAbsoluteUris: true }); + const edit = createInsertUriListEdit(document, ranges, UriList.from(uriText), { preserveAbsoluteUris: true }); if (!edit) { return; } - const pasteEdit = new vscode.DocumentPasteEdit('', edit.label); + const pasteEdit = new vscode.DocumentPasteEdit('', edit.label, PasteUrlEditProvider.kind); const workspaceEdit = new vscode.WorkspaceEdit(); workspaceEdit.set(document.uri, edit.edits); pasteEdit.additionalEdit = workspaceEdit; if (!(await shouldInsertMarkdownLinkByDefault(this._parser, document, pasteUrlSetting, ranges, token))) { - pasteEdit.yieldTo = [{ mimeType: Mime.textPlain }]; + pasteEdit.yieldTo = [ + vscode.DocumentPasteEditKind.Empty.append('text'), + vscode.DocumentPasteEditKind.Empty.append('uri') + ]; } - return pasteEdit; + return [pasteEdit]; } } export function registerPasteUrlSupport(selector: vscode.DocumentSelector, parser: IMdParser) { return vscode.languages.registerDocumentPasteEditProvider(selector, new PasteUrlEditProvider(parser), { - id: PasteUrlEditProvider.id, + providedPasteEditKinds: [PasteUrlEditProvider.kind], pasteMimeTypes: PasteUrlEditProvider.pasteMimeTypes, }); } - -const smartPasteLineRegexes = [ - { regex: /(\[[^\[\]]*](?:\([^\(\)]*\)|\[[^\[\]]*]))/g }, // In a Markdown link - { regex: /\$\$[\s\S]*?\$\$/gm }, // In a fenced math block - { regex: /`[^`]*`/g }, // In inline code - { regex: /\$[^$]*\$/g }, // In inline math - { regex: /^[ ]{0,3}\[\w+\]:\s.*$/g, isWholeLine: true }, // Block link definition (needed as tokens are not generated for these) -]; - -export async function shouldInsertMarkdownLinkByDefault( - parser: IMdParser, - document: ITextDocument, - pasteUrlSetting: PasteUrlAsMarkdownLink, - ranges: readonly vscode.Range[], - token: vscode.CancellationToken, -): Promise { - switch (pasteUrlSetting) { - case PasteUrlAsMarkdownLink.Always: { - return true; - } - case PasteUrlAsMarkdownLink.Smart: { - return checkSmart(); - } - case PasteUrlAsMarkdownLink.SmartWithSelection: { - // At least one range must not be empty - if (!ranges.some(range => document.getText(range).trim().length > 0)) { - return false; - } - // And all ranges must be smart - return checkSmart(); - } - default: { - return false; - } - } - - async function checkSmart(): Promise { - return (await Promise.all(ranges.map(range => shouldSmartPasteForSelection(parser, document, range, token)))).every(x => x); - } -} - -const textTokenTypes = new Set(['paragraph_open', 'inline', 'heading_open', 'ordered_list_open', 'bullet_list_open', 'list_item_open', 'blockquote_open']); - -async function shouldSmartPasteForSelection( - parser: IMdParser, - document: ITextDocument, - selectedRange: vscode.Range, - token: vscode.CancellationToken, -): Promise { - // Disable for multi-line selections - if (selectedRange.start.line !== selectedRange.end.line) { - return false; - } - - const rangeText = document.getText(selectedRange); - // Disable when the selection is already a link - if (findValidUriInText(rangeText)) { - return false; - } - - if (/\[.*\]\(.*\)/.test(rangeText) || /!\[.*\]\(.*\)/.test(rangeText)) { - return false; - } - - // Check if selection is inside a special block level element using markdown engine - const tokens = await parser.tokenize(document); - if (token.isCancellationRequested) { - return false; - } - - for (let i = 0; i < tokens.length; i++) { - const token = tokens[i]; - if (!token.map) { - continue; - } - if (token.map[0] <= selectedRange.start.line && token.map[1] > selectedRange.start.line) { - if (!textTokenTypes.has(token.type)) { - return false; - } - } - - // Special case for html such as: - // - // - // | - // - // - // In this case pasting will cause the html block to be created even though the cursor is not currently inside a block - if (token.type === 'html_block' && token.map[1] === selectedRange.start.line) { - const nextToken = tokens.at(i + 1); - // The next token does not need to be a html_block, but it must be on the next line - if (nextToken?.map?.[0] === selectedRange.end.line + 1) { - return false; - } - } - } - - // Run additional regex checks on the current line to check if we are inside an inline element - const line = document.getText(new vscode.Range(selectedRange.start.line, 0, selectedRange.start.line, Number.MAX_SAFE_INTEGER)); - for (const regex of smartPasteLineRegexes) { - for (const match of line.matchAll(regex.regex)) { - if (match.index === undefined) { - continue; - } - - if (regex.isWholeLine) { - return false; - } - - if (selectedRange.start.character > match.index && selectedRange.start.character < match.index + match[0].length) { - return false; - } - } - } - - return true; -} - - -const externalUriSchemes: ReadonlySet = new Set([ - Schemes.http, - Schemes.https, - Schemes.mailto, - Schemes.file, -]); - -export function findValidUriInText(text: string): string | undefined { - const trimmedUrlList = text.trim(); - - if ( - !/^\S+$/.test(trimmedUrlList) // Uri must consist of a single sequence of characters without spaces - || !trimmedUrlList.includes(':') // And it must have colon somewhere for the scheme. We will verify the schema again later - ) { - return; - } - - let uri: vscode.Uri; - try { - uri = vscode.Uri.parse(trimmedUrlList); - } catch { - // Could not parse - return; - } - - // `Uri.parse` is lenient and will return a `file:` uri even for non-uri text such as `abc` - // Make sure that the resolved scheme starts the original text - if (!trimmedUrlList.toLowerCase().startsWith(uri.scheme.toLowerCase() + ':')) { - return; - } - - // Only enable for an allow list of schemes. Otherwise this can be accidentally activated for non-uri text - // such as `c:\abc` or `value:foo` - if (!externalUriSchemes.has(uri.scheme.toLowerCase())) { - return; - } - - // Some part of the uri must not be empty - // This disables the feature for text such as `http:` - if (!uri.authority && uri.path.length < 2 && !uri.query && !uri.fragment) { - return; - } - - return trimmedUrlList; -} diff --git a/extensions/markdown-language-features/src/languageFeatures/copyFiles/shared.ts b/extensions/markdown-language-features/src/languageFeatures/copyFiles/shared.ts index 8bfc9ae2ff52c..87e2a0eaeb506 100644 --- a/extensions/markdown-language-features/src/languageFeatures/copyFiles/shared.ts +++ b/extensions/markdown-language-features/src/languageFeatures/copyFiles/shared.ts @@ -7,11 +7,10 @@ import * as path from 'path'; import * as vscode from 'vscode'; import * as URI from 'vscode-uri'; import { ITextDocument } from '../../types/textDocument'; -import { coalesce } from '../../util/arrays'; import { getDocumentDir } from '../../util/document'; import { Schemes } from '../../util/schemes'; +import { UriList } from '../../util/uriList'; import { resolveSnippet } from './snippets'; -import { parseUriList } from '../../util/uriList'; enum MediaKind { Image, @@ -68,22 +67,10 @@ export function getSnippetLabel(counter: { insertedAudioVideoCount: number; inse export function createInsertUriListEdit( document: ITextDocument, ranges: readonly vscode.Range[], - urlList: string, + urlList: UriList, options?: UriListSnippetOptions, ): { edits: vscode.SnippetTextEdit[]; label: string } | undefined { - if (!ranges.length) { - return; - } - - const entries = coalesce(parseUriList(urlList).map(line => { - try { - return { uri: vscode.Uri.parse(line), str: line }; - } catch { - // Uri parse failure - return undefined; - } - })); - if (!entries.length) { + if (!ranges.length || !urlList.entries.length) { return; } @@ -94,14 +81,14 @@ export function createInsertUriListEdit( let insertedAudioVideoCount = 0; // Use 1 for all empty ranges but give non-empty range unique indices starting after 1 - let placeHolderStartIndex = 1 + entries.length; + let placeHolderStartIndex = 1 + urlList.entries.length; // Sort ranges by start position const orderedRanges = [...ranges].sort((a, b) => a.start.compareTo(b.start)); const allRangesAreEmpty = orderedRanges.every(range => range.isEmpty); for (const range of orderedRanges) { - const snippet = createUriListSnippet(document.uri, entries, { + const snippet = createUriListSnippet(document.uri, urlList.entries, { placeholderText: range.isEmpty ? undefined : document.getText(range), placeholderStartIndex: allRangesAreEmpty ? 1 : placeHolderStartIndex, ...options, @@ -114,7 +101,7 @@ export function createInsertUriListEdit( insertedImageCount += snippet.insertedImageCount; insertedAudioVideoCount += snippet.insertedAudioVideoCount; - placeHolderStartIndex += entries.length; + placeHolderStartIndex += urlList.entries.length; edits.push(new vscode.SnippetTextEdit(range, snippet.snippet)); } @@ -273,3 +260,10 @@ function needsBracketLink(mdPath: string): boolean { return nestingCount > 0; } + +export interface DropOrPasteEdit { + readonly snippet: vscode.SnippetString; + readonly label: string; + readonly additionalEdits: vscode.WorkspaceEdit; + readonly yieldTo: vscode.DocumentPasteEditKind[]; +} diff --git a/extensions/markdown-language-features/src/languageFeatures/copyFiles/smartDropOrPaste.ts b/extensions/markdown-language-features/src/languageFeatures/copyFiles/smartDropOrPaste.ts new file mode 100644 index 0000000000000..deaa4b58212a6 --- /dev/null +++ b/extensions/markdown-language-features/src/languageFeatures/copyFiles/smartDropOrPaste.ts @@ -0,0 +1,188 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as vscode from 'vscode'; +import { IMdParser } from '../../markdownEngine'; +import { ITextDocument } from '../../types/textDocument'; +import { Schemes } from '../../util/schemes'; + +const smartPasteLineRegexes = [ + { regex: /(\[[^\[\]]*](?:\([^\(\)]*\)|\[[^\[\]]*]))/g }, // In a Markdown link + { regex: /\$\$[\s\S]*?\$\$/gm }, // In a fenced math block + { regex: /`[^`]*`/g }, // In inline code + { regex: /\$[^$]*\$/g }, // In inline math + { regex: /<[^<>\s]*>/g }, // Autolink + { regex: /^[ ]{0,3}\[\w+\]:\s.*$/g, isWholeLine: true }, // Block link definition (needed as tokens are not generated for these) +]; + +export async function shouldInsertMarkdownLinkByDefault( + parser: IMdParser, + document: ITextDocument, + pasteUrlSetting: InsertMarkdownLink, + ranges: readonly vscode.Range[], + token: vscode.CancellationToken +): Promise { + switch (pasteUrlSetting) { + case InsertMarkdownLink.Always: { + return true; + } + case InsertMarkdownLink.Smart: { + return checkSmart(); + } + case InsertMarkdownLink.SmartWithSelection: { + // At least one range must not be empty + if (!ranges.some(range => document.getText(range).trim().length > 0)) { + return false; + } + // And all ranges must be smart + return checkSmart(); + } + default: { + return false; + } + } + + async function checkSmart(): Promise { + return (await Promise.all(ranges.map(range => shouldSmartPasteForSelection(parser, document, range, token)))).every(x => x); + } +} + +const textTokenTypes = new Set([ + 'paragraph_open', + 'inline', + 'heading_open', + 'ordered_list_open', + 'bullet_list_open', + 'list_item_open', + 'blockquote_open', +]); + +async function shouldSmartPasteForSelection( + parser: IMdParser, + document: ITextDocument, + selectedRange: vscode.Range, + token: vscode.CancellationToken +): Promise { + // Disable for multi-line selections + if (selectedRange.start.line !== selectedRange.end.line) { + return false; + } + + const rangeText = document.getText(selectedRange); + // Disable when the selection is already a link + if (findValidUriInText(rangeText)) { + return false; + } + + if (/\[.*\]\(.*\)/.test(rangeText) || /!\[.*\]\(.*\)/.test(rangeText)) { + return false; + } + + // Check if selection is inside a special block level element using markdown engine + const tokens = await parser.tokenize(document); + if (token.isCancellationRequested) { + return false; + } + + for (let i = 0; i < tokens.length; i++) { + const token = tokens[i]; + if (!token.map) { + continue; + } + if (token.map[0] <= selectedRange.start.line && token.map[1] > selectedRange.start.line) { + if (!textTokenTypes.has(token.type)) { + return false; + } + } + + // Special case for html such as: + // + // + // | + // + // + // In this case pasting will cause the html block to be created even though the cursor is not currently inside a block + if (token.type === 'html_block' && token.map[1] === selectedRange.start.line) { + const nextToken = tokens.at(i + 1); + // The next token does not need to be a html_block, but it must be on the next line + if (nextToken?.map?.[0] === selectedRange.end.line + 1) { + return false; + } + } + } + + // Run additional regex checks on the current line to check if we are inside an inline element + const line = document.getText(new vscode.Range(selectedRange.start.line, 0, selectedRange.start.line, Number.MAX_SAFE_INTEGER)); + for (const regex of smartPasteLineRegexes) { + for (const match of line.matchAll(regex.regex)) { + if (match.index === undefined) { + continue; + } + + if (regex.isWholeLine) { + return false; + } + + if (selectedRange.start.character > match.index && selectedRange.start.character < match.index + match[0].length) { + return false; + } + } + } + + return true; +} + +const externalUriSchemes: ReadonlySet = new Set([ + Schemes.http, + Schemes.https, + Schemes.mailto, + Schemes.file, +]); + +export function findValidUriInText(text: string): string | undefined { + const trimmedUrlList = text.trim(); + + if (!/^\S+$/.test(trimmedUrlList) // Uri must consist of a single sequence of characters without spaces + || !trimmedUrlList.includes(':') // And it must have colon somewhere for the scheme. We will verify the schema again later + ) { + return; + } + + let uri: vscode.Uri; + try { + uri = vscode.Uri.parse(trimmedUrlList); + } catch { + // Could not parse + return; + } + + // `Uri.parse` is lenient and will return a `file:` uri even for non-uri text such as `abc` + // Make sure that the resolved scheme starts the original text + if (!trimmedUrlList.toLowerCase().startsWith(uri.scheme.toLowerCase() + ':')) { + return; + } + + // Only enable for an allow list of schemes. Otherwise this can be accidentally activated for non-uri text + // such as `c:\abc` or `value:foo` + if (!externalUriSchemes.has(uri.scheme.toLowerCase())) { + return; + } + + // Some part of the uri must not be empty + // This disables the feature for text such as `http:` + if (!uri.authority && uri.path.length < 2 && !uri.query && !uri.fragment) { + return; + } + + return trimmedUrlList; +} + +export enum InsertMarkdownLink { + Always = 'always', + SmartWithSelection = 'smartWithSelection', + Smart = 'smart', + Never = 'never' +} + diff --git a/extensions/markdown-language-features/src/languageFeatures/fileReferences.ts b/extensions/markdown-language-features/src/languageFeatures/fileReferences.ts index 2f2af15df0871..bda8b721e8bca 100644 --- a/extensions/markdown-language-features/src/languageFeatures/fileReferences.ts +++ b/extensions/markdown-language-features/src/languageFeatures/fileReferences.ts @@ -28,7 +28,7 @@ export class FindFileReferencesCommand implements Command { location: vscode.ProgressLocation.Window, title: vscode.l10n.t("Finding file references") }, async (_progress, token) => { - const locations = (await this._client.getReferencesToFileInWorkspace(resource!, token)).map(loc => { + const locations = (await this._client.getReferencesToFileInWorkspace(resource, token)).map(loc => { return new vscode.Location(vscode.Uri.parse(loc.uri), convertRange(loc.range)); }); diff --git a/extensions/markdown-language-features/src/languageFeatures/updateLinksOnPaste.ts b/extensions/markdown-language-features/src/languageFeatures/updateLinksOnPaste.ts new file mode 100644 index 0000000000000..dd244cac76b43 --- /dev/null +++ b/extensions/markdown-language-features/src/languageFeatures/updateLinksOnPaste.ts @@ -0,0 +1,81 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as vscode from 'vscode'; +import { MdLanguageClient } from '../client/client'; +import { Mime } from '../util/mimes'; + +class UpdatePastedLinksEditProvider implements vscode.DocumentPasteEditProvider { + + public static readonly kind = vscode.DocumentPasteEditKind.Empty.append('text', 'markdown', 'updateLinks'); + + public static readonly metadataMime = 'vnd.vscode.markdown.updateLinksMetadata'; + + constructor( + private readonly _client: MdLanguageClient, + ) { } + + async prepareDocumentPaste(document: vscode.TextDocument, ranges: readonly vscode.Range[], dataTransfer: vscode.DataTransfer, token: vscode.CancellationToken): Promise { + if (!this._isEnabled(document)) { + return; + } + + const metadata = await this._client.prepareUpdatePastedLinks(document.uri, ranges, token); + if (token.isCancellationRequested) { + return; + } + dataTransfer.set(UpdatePastedLinksEditProvider.metadataMime, new vscode.DataTransferItem(metadata)); + } + + async provideDocumentPasteEdits( + document: vscode.TextDocument, + ranges: readonly vscode.Range[], + dataTransfer: vscode.DataTransfer, + _context: vscode.DocumentPasteEditContext, + token: vscode.CancellationToken, + ): Promise { + if (!this._isEnabled(document)) { + return; + } + + const metadata = dataTransfer.get(UpdatePastedLinksEditProvider.metadataMime)?.value; + if (!metadata) { + return; + } + + const textItem = dataTransfer.get(Mime.textPlain); + const text = await textItem?.asString(); + if (!text || token.isCancellationRequested) { + return; + } + + // TODO: Handle cases such as: + // - copy empty line + // - Copy with multiple cursors and paste into multiple locations + // - ... + const edits = await this._client.getUpdatePastedLinksEdit(document.uri, ranges.map(x => new vscode.TextEdit(x, text)), metadata, token); + if (!edits || !edits.length || token.isCancellationRequested) { + return; + } + + const pasteEdit = new vscode.DocumentPasteEdit('', vscode.l10n.t("Paste and update pasted links"), UpdatePastedLinksEditProvider.kind); + const workspaceEdit = new vscode.WorkspaceEdit(); + workspaceEdit.set(document.uri, edits.map(x => new vscode.TextEdit(new vscode.Range(x.range.start.line, x.range.start.character, x.range.end.line, x.range.end.character,), x.newText))); + pasteEdit.additionalEdit = workspaceEdit; + return [pasteEdit]; + } + + private _isEnabled(document: vscode.TextDocument): boolean { + return vscode.workspace.getConfiguration('markdown', document.uri).get('experimental.updateLinksOnPaste', false); + } +} + +export function registerUpdatePastedLinks(selector: vscode.DocumentSelector, client: MdLanguageClient) { + return vscode.languages.registerDocumentPasteEditProvider(selector, new UpdatePastedLinksEditProvider(client), { + copyMimeTypes: [UpdatePastedLinksEditProvider.metadataMime], + providedPasteEditKinds: [UpdatePastedLinksEditProvider.kind], + pasteMimeTypes: [UpdatePastedLinksEditProvider.metadataMime], + }); +} diff --git a/extensions/markdown-language-features/src/test/pasteUrl.test.ts b/extensions/markdown-language-features/src/test/pasteUrl.test.ts index 88b996de37ddc..ea4a3f868af0f 100644 --- a/extensions/markdown-language-features/src/test/pasteUrl.test.ts +++ b/extensions/markdown-language-features/src/test/pasteUrl.test.ts @@ -6,10 +6,12 @@ import * as assert from 'assert'; import 'mocha'; import * as vscode from 'vscode'; import { InMemoryDocument } from '../client/inMemoryDocument'; -import { PasteUrlAsMarkdownLink, findValidUriInText, shouldInsertMarkdownLinkByDefault } from '../languageFeatures/copyFiles/pasteUrlProvider'; import { createInsertUriListEdit } from '../languageFeatures/copyFiles/shared'; -import { createNewMarkdownEngine } from './engine'; +import { InsertMarkdownLink, findValidUriInText, shouldInsertMarkdownLinkByDefault } from '../languageFeatures/copyFiles/smartDropOrPaste'; import { noopToken } from '../util/cancellation'; +import { UriList } from '../util/uriList'; +import { createNewMarkdownEngine } from './engine'; +import { joinLines } from './util'; function makeTestDoc(contents: string) { return new InMemoryDocument(vscode.Uri.file('test.md'), contents); @@ -21,7 +23,7 @@ suite('createEditAddingLinksForUriList', () => { // createEditAddingLinksForUriList -> checkSmartPaste -> tryGetUriListSnippet -> createUriListSnippet -> createLinkSnippet const result = createInsertUriListEdit( - new InMemoryDocument(vscode.Uri.file('test.md'), 'hello world!'), [new vscode.Range(0, 0, 0, 12)], 'https://www.microsoft.com/'); + new InMemoryDocument(vscode.Uri.file('test.md'), 'hello world!'), [new vscode.Range(0, 0, 0, 12)], UriList.from('https://www.microsoft.com/')); // need to check the actual result -> snippet value assert.strictEqual(result?.label, 'Insert Markdown Link'); }); @@ -110,27 +112,27 @@ suite('createEditAddingLinksForUriList', () => { suite('createInsertUriListEdit', () => { test('Should create snippet with < > when pasted link has an mismatched parentheses', () => { - const edit = createInsertUriListEdit(makeTestDoc(''), [new vscode.Range(0, 0, 0, 0)], 'https://www.mic(rosoft.com'); + const edit = createInsertUriListEdit(makeTestDoc(''), [new vscode.Range(0, 0, 0, 0)], UriList.from('https://www.mic(rosoft.com')); assert.strictEqual(edit?.edits?.[0].snippet.value, '[${1:text}]()'); }); test('Should create Markdown link snippet when pasteAsMarkdownLink is true', () => { - const edit = createInsertUriListEdit(makeTestDoc(''), [new vscode.Range(0, 0, 0, 0)], 'https://www.microsoft.com'); + const edit = createInsertUriListEdit(makeTestDoc(''), [new vscode.Range(0, 0, 0, 0)], UriList.from('https://www.microsoft.com')); assert.strictEqual(edit?.edits?.[0].snippet.value, '[${1:text}](https://www.microsoft.com)'); }); test('Should use an unencoded URI string in Markdown link when passing in an external browser link', () => { - const edit = createInsertUriListEdit(makeTestDoc(''), [new vscode.Range(0, 0, 0, 0)], 'https://www.microsoft.com'); + const edit = createInsertUriListEdit(makeTestDoc(''), [new vscode.Range(0, 0, 0, 0)], UriList.from('https://www.microsoft.com')); assert.strictEqual(edit?.edits?.[0].snippet.value, '[${1:text}](https://www.microsoft.com)'); }); test('Should not decode an encoded URI string when passing in an external browser link', () => { - const edit = createInsertUriListEdit(makeTestDoc(''), [new vscode.Range(0, 0, 0, 0)], 'https://www.microsoft.com/%20'); + const edit = createInsertUriListEdit(makeTestDoc(''), [new vscode.Range(0, 0, 0, 0)], UriList.from('https://www.microsoft.com/%20')); assert.strictEqual(edit?.edits?.[0].snippet.value, '[${1:text}](https://www.microsoft.com/%20)'); }); test('Should not encode an unencoded URI string when passing in an external browser link', () => { - const edit = createInsertUriListEdit(makeTestDoc(''), [new vscode.Range(0, 0, 0, 0)], 'https://www.example.com/path?query=value&another=value#fragment'); + const edit = createInsertUriListEdit(makeTestDoc(''), [new vscode.Range(0, 0, 0, 0)], UriList.from('https://www.example.com/path?query=value&another=value#fragment')); assert.strictEqual(edit?.edits?.[0].snippet.value, '[${1:text}](https://www.example.com/path?query=value&another=value#fragment)'); }); }); @@ -140,41 +142,41 @@ suite('createEditAddingLinksForUriList', () => { test('Smart should be enabled for selected plain text', async () => { assert.strictEqual( - await shouldInsertMarkdownLinkByDefault(createNewMarkdownEngine(), makeTestDoc('hello world'), PasteUrlAsMarkdownLink.SmartWithSelection, [new vscode.Range(0, 0, 0, 12)], noopToken), + await shouldInsertMarkdownLinkByDefault(createNewMarkdownEngine(), makeTestDoc('hello world'), InsertMarkdownLink.SmartWithSelection, [new vscode.Range(0, 0, 0, 12)], noopToken), true); }); test('Smart should be enabled in headers', async () => { assert.strictEqual( - await shouldInsertMarkdownLinkByDefault(createNewMarkdownEngine(), makeTestDoc('# title'), PasteUrlAsMarkdownLink.Smart, [new vscode.Range(0, 2, 0, 2)], noopToken), + await shouldInsertMarkdownLinkByDefault(createNewMarkdownEngine(), makeTestDoc('# title'), InsertMarkdownLink.Smart, [new vscode.Range(0, 2, 0, 2)], noopToken), true); }); test('Smart should be enabled in lists', async () => { assert.strictEqual( - await shouldInsertMarkdownLinkByDefault(createNewMarkdownEngine(), makeTestDoc('1. text'), PasteUrlAsMarkdownLink.Smart, [new vscode.Range(0, 3, 0, 3)], noopToken), + await shouldInsertMarkdownLinkByDefault(createNewMarkdownEngine(), makeTestDoc('1. text'), InsertMarkdownLink.Smart, [new vscode.Range(0, 3, 0, 3)], noopToken), true); }); test('Smart should be enabled in blockquotes', async () => { assert.strictEqual( - await shouldInsertMarkdownLinkByDefault(createNewMarkdownEngine(), makeTestDoc('> text'), PasteUrlAsMarkdownLink.Smart, [new vscode.Range(0, 3, 0, 3)], noopToken), + await shouldInsertMarkdownLinkByDefault(createNewMarkdownEngine(), makeTestDoc('> text'), InsertMarkdownLink.Smart, [new vscode.Range(0, 3, 0, 3)], noopToken), true); }); test('Smart should be disabled in indented code blocks', async () => { assert.strictEqual( - await shouldInsertMarkdownLinkByDefault(createNewMarkdownEngine(), makeTestDoc(' code'), PasteUrlAsMarkdownLink.Smart, [new vscode.Range(0, 4, 0, 4)], noopToken), + await shouldInsertMarkdownLinkByDefault(createNewMarkdownEngine(), makeTestDoc(' code'), InsertMarkdownLink.Smart, [new vscode.Range(0, 4, 0, 4)], noopToken), false); }); test('Smart should be disabled in fenced code blocks', async () => { assert.strictEqual( - await shouldInsertMarkdownLinkByDefault(createNewMarkdownEngine(), makeTestDoc('```\r\n\r\n```'), PasteUrlAsMarkdownLink.Smart, [new vscode.Range(0, 5, 0, 5)], noopToken), + await shouldInsertMarkdownLinkByDefault(createNewMarkdownEngine(), makeTestDoc('```\r\n\r\n```'), InsertMarkdownLink.Smart, [new vscode.Range(0, 5, 0, 5)], noopToken), false); assert.strictEqual( - await shouldInsertMarkdownLinkByDefault(createNewMarkdownEngine(), makeTestDoc('~~~\r\n\r\n~~~'), PasteUrlAsMarkdownLink.Smart, [new vscode.Range(0, 5, 0, 5)], noopToken), + await shouldInsertMarkdownLinkByDefault(createNewMarkdownEngine(), makeTestDoc('~~~\r\n\r\n~~~'), InsertMarkdownLink.Smart, [new vscode.Range(0, 5, 0, 5)], noopToken), false); }); @@ -183,122 +185,159 @@ suite('createEditAddingLinksForUriList', () => { const engine = createNewMarkdownEngine(); (await engine.getEngine(undefined)).use(katex); assert.strictEqual( - await shouldInsertMarkdownLinkByDefault(engine, makeTestDoc('$$\r\n\r\n$$'), PasteUrlAsMarkdownLink.Smart, [new vscode.Range(0, 5, 0, 5)], noopToken), + await shouldInsertMarkdownLinkByDefault(engine, makeTestDoc('$$\r\n\r\n$$'), InsertMarkdownLink.Smart, [new vscode.Range(0, 5, 0, 5)], noopToken), false); }); test('Smart should be disabled in link definitions', async () => { assert.strictEqual( - await shouldInsertMarkdownLinkByDefault(createNewMarkdownEngine(), makeTestDoc('[ref]: http://example.com'), PasteUrlAsMarkdownLink.Smart, [new vscode.Range(0, 4, 0, 6)], noopToken), + await shouldInsertMarkdownLinkByDefault(createNewMarkdownEngine(), makeTestDoc('[ref]: http://example.com'), InsertMarkdownLink.Smart, [new vscode.Range(0, 4, 0, 6)], noopToken), false); assert.strictEqual( - await shouldInsertMarkdownLinkByDefault(createNewMarkdownEngine(), makeTestDoc('[ref]: '), PasteUrlAsMarkdownLink.Smart, [new vscode.Range(0, 7, 0, 7)], noopToken), + await shouldInsertMarkdownLinkByDefault(createNewMarkdownEngine(), makeTestDoc('[ref]: '), InsertMarkdownLink.Smart, [new vscode.Range(0, 7, 0, 7)], noopToken), false); assert.strictEqual( - await shouldInsertMarkdownLinkByDefault(createNewMarkdownEngine(), makeTestDoc('[ref]: '), PasteUrlAsMarkdownLink.Smart, [new vscode.Range(0, 0, 0, 0)], noopToken), + await shouldInsertMarkdownLinkByDefault(createNewMarkdownEngine(), makeTestDoc('[ref]: '), InsertMarkdownLink.Smart, [new vscode.Range(0, 0, 0, 0)], noopToken), false); }); test('Smart should be disabled in html blocks', async () => { assert.strictEqual( - await shouldInsertMarkdownLinkByDefault(createNewMarkdownEngine(), makeTestDoc('

\na\n

'), PasteUrlAsMarkdownLink.Smart, [new vscode.Range(1, 0, 1, 0)], noopToken), + await shouldInsertMarkdownLinkByDefault(createNewMarkdownEngine(), makeTestDoc('

\na\n

'), InsertMarkdownLink.Smart, [new vscode.Range(1, 0, 1, 0)], noopToken), false); }); test('Smart should be disabled in html blocks where paste creates the block', async () => { assert.strictEqual( - await shouldInsertMarkdownLinkByDefault(createNewMarkdownEngine(), makeTestDoc('

\n\n

'), PasteUrlAsMarkdownLink.Smart, [new vscode.Range(1, 0, 1, 0)], noopToken), + await shouldInsertMarkdownLinkByDefault(createNewMarkdownEngine(), makeTestDoc('

\n\n

'), InsertMarkdownLink.Smart, [new vscode.Range(1, 0, 1, 0)], noopToken), false, 'Between two html tags should be treated as html block'); assert.strictEqual( - await shouldInsertMarkdownLinkByDefault(createNewMarkdownEngine(), makeTestDoc('

\n\ntext'), PasteUrlAsMarkdownLink.Smart, [new vscode.Range(1, 0, 1, 0)], noopToken), + await shouldInsertMarkdownLinkByDefault(createNewMarkdownEngine(), makeTestDoc('

\n\ntext'), InsertMarkdownLink.Smart, [new vscode.Range(1, 0, 1, 0)], noopToken), false, 'Between opening html tag and text should be treated as html block'); assert.strictEqual( - await shouldInsertMarkdownLinkByDefault(createNewMarkdownEngine(), makeTestDoc('

\n\n\n

'), PasteUrlAsMarkdownLink.Smart, [new vscode.Range(1, 0, 1, 0)], noopToken), + await shouldInsertMarkdownLinkByDefault(createNewMarkdownEngine(), makeTestDoc('

\n\n\n

'), InsertMarkdownLink.Smart, [new vscode.Range(1, 0, 1, 0)], noopToken), true, 'Extra new line after paste should not be treated as html block'); }); test('Smart should be disabled in Markdown links', async () => { assert.strictEqual( - await shouldInsertMarkdownLinkByDefault(createNewMarkdownEngine(), makeTestDoc('[a](bcdef)'), PasteUrlAsMarkdownLink.Smart, [new vscode.Range(0, 4, 0, 6)], noopToken), + await shouldInsertMarkdownLinkByDefault(createNewMarkdownEngine(), makeTestDoc('[a](bcdef)'), InsertMarkdownLink.Smart, [new vscode.Range(0, 4, 0, 6)], noopToken), false); }); test('Smart should be disabled in Markdown images', async () => { assert.strictEqual( - await shouldInsertMarkdownLinkByDefault(createNewMarkdownEngine(), makeTestDoc('![a](bcdef)'), PasteUrlAsMarkdownLink.Smart, [new vscode.Range(0, 5, 0, 10)], noopToken), + await shouldInsertMarkdownLinkByDefault(createNewMarkdownEngine(), makeTestDoc('![a](bcdef)'), InsertMarkdownLink.Smart, [new vscode.Range(0, 5, 0, 10)], noopToken), false); }); test('Smart should be disabled in inline code', async () => { assert.strictEqual( - await shouldInsertMarkdownLinkByDefault(createNewMarkdownEngine(), makeTestDoc('``'), PasteUrlAsMarkdownLink.Smart, [new vscode.Range(0, 1, 0, 1)], noopToken), + await shouldInsertMarkdownLinkByDefault(createNewMarkdownEngine(), makeTestDoc('``'), InsertMarkdownLink.Smart, [new vscode.Range(0, 1, 0, 1)], noopToken), false, 'Should be disabled inside of inline code'); assert.strictEqual( - await shouldInsertMarkdownLinkByDefault(createNewMarkdownEngine(), makeTestDoc('``'), PasteUrlAsMarkdownLink.Smart, [new vscode.Range(0, 0, 0, 0)], noopToken), + await shouldInsertMarkdownLinkByDefault(createNewMarkdownEngine(), makeTestDoc('``'), InsertMarkdownLink.Smart, [new vscode.Range(0, 0, 0, 0)], noopToken), true, 'Should be enabled when cursor is outside but next to inline code'); assert.strictEqual( - await shouldInsertMarkdownLinkByDefault(createNewMarkdownEngine(), makeTestDoc('`a`'), PasteUrlAsMarkdownLink.Smart, [new vscode.Range(0, 3, 0, 3)], noopToken), + await shouldInsertMarkdownLinkByDefault(createNewMarkdownEngine(), makeTestDoc('`a`'), InsertMarkdownLink.Smart, [new vscode.Range(0, 3, 0, 3)], noopToken), true, 'Should be enabled when cursor is outside but next to inline code'); }); test('Smart should be enabled when pasting over inline code ', async () => { assert.strictEqual( - await shouldInsertMarkdownLinkByDefault(createNewMarkdownEngine(), makeTestDoc('`xyz`'), PasteUrlAsMarkdownLink.Smart, [new vscode.Range(0, 0, 0, 5)], noopToken), + await shouldInsertMarkdownLinkByDefault(createNewMarkdownEngine(), makeTestDoc('`xyz`'), InsertMarkdownLink.Smart, [new vscode.Range(0, 0, 0, 5)], noopToken), true); }); test('Smart should be disabled in inline math', async () => { assert.strictEqual( - await shouldInsertMarkdownLinkByDefault(createNewMarkdownEngine(), makeTestDoc('$$'), PasteUrlAsMarkdownLink.SmartWithSelection, [new vscode.Range(0, 1, 0, 1)], noopToken), + await shouldInsertMarkdownLinkByDefault(createNewMarkdownEngine(), makeTestDoc('$$'), InsertMarkdownLink.SmartWithSelection, [new vscode.Range(0, 1, 0, 1)], noopToken), false); }); test('Smart should be enabled for empty selection', async () => { assert.strictEqual( - await shouldInsertMarkdownLinkByDefault(createNewMarkdownEngine(), makeTestDoc('xyz'), PasteUrlAsMarkdownLink.Smart, [new vscode.Range(0, 0, 0, 0)], noopToken), + await shouldInsertMarkdownLinkByDefault(createNewMarkdownEngine(), makeTestDoc('xyz'), InsertMarkdownLink.Smart, [new vscode.Range(0, 0, 0, 0)], noopToken), true); }); test('SmartWithSelection should disable for empty selection', async () => { assert.strictEqual( - await shouldInsertMarkdownLinkByDefault(createNewMarkdownEngine(), makeTestDoc('xyz'), PasteUrlAsMarkdownLink.SmartWithSelection, [new vscode.Range(0, 0, 0, 0)], noopToken), + await shouldInsertMarkdownLinkByDefault(createNewMarkdownEngine(), makeTestDoc('xyz'), InsertMarkdownLink.SmartWithSelection, [new vscode.Range(0, 0, 0, 0)], noopToken), false); }); test('Smart should disable for selected link', async () => { assert.strictEqual( - await shouldInsertMarkdownLinkByDefault(createNewMarkdownEngine(), makeTestDoc('https://www.microsoft.com'), PasteUrlAsMarkdownLink.SmartWithSelection, [new vscode.Range(0, 0, 0, 25)], noopToken), + await shouldInsertMarkdownLinkByDefault(createNewMarkdownEngine(), makeTestDoc('https://www.microsoft.com'), InsertMarkdownLink.SmartWithSelection, [new vscode.Range(0, 0, 0, 25)], noopToken), false); }); test('Smart should disable for selected link with trailing whitespace', async () => { assert.strictEqual( - await shouldInsertMarkdownLinkByDefault(createNewMarkdownEngine(), makeTestDoc(' https://www.microsoft.com '), PasteUrlAsMarkdownLink.SmartWithSelection, [new vscode.Range(0, 0, 0, 30)], noopToken), + await shouldInsertMarkdownLinkByDefault(createNewMarkdownEngine(), makeTestDoc(' https://www.microsoft.com '), InsertMarkdownLink.SmartWithSelection, [new vscode.Range(0, 0, 0, 30)], noopToken), false); }); test('Should evaluate pasteAsMarkdownLink as true for a link pasted in square brackets', async () => { assert.strictEqual( - await shouldInsertMarkdownLinkByDefault(createNewMarkdownEngine(), makeTestDoc('[abc]'), PasteUrlAsMarkdownLink.SmartWithSelection, [new vscode.Range(0, 1, 0, 4)], noopToken), + await shouldInsertMarkdownLinkByDefault(createNewMarkdownEngine(), makeTestDoc('[abc]'), InsertMarkdownLink.SmartWithSelection, [new vscode.Range(0, 1, 0, 4)], noopToken), true); }); test('Should evaluate pasteAsMarkdownLink as false for selected whitespace and new lines', async () => { assert.strictEqual( - await shouldInsertMarkdownLinkByDefault(createNewMarkdownEngine(), makeTestDoc(' \r\n\r\n'), PasteUrlAsMarkdownLink.SmartWithSelection, [new vscode.Range(0, 0, 0, 7)], noopToken), + await shouldInsertMarkdownLinkByDefault(createNewMarkdownEngine(), makeTestDoc(' \r\n\r\n'), InsertMarkdownLink.SmartWithSelection, [new vscode.Range(0, 0, 0, 7)], noopToken), false); }); + + test('Smart should be disabled inside of autolinks', async () => { + assert.strictEqual( + await shouldInsertMarkdownLinkByDefault(createNewMarkdownEngine(), makeTestDoc('<>'), InsertMarkdownLink.Smart, [new vscode.Range(0, 1, 0, 1)], noopToken), + false); + }); + + test('Smart should be disabled in frontmatter', async () => { + const textDoc = makeTestDoc(joinLines( + `---`, + `layout: post`, + `title: Blogging Like a Hacker`, + `---`, + ``, + `Link Text` + )); + assert.strictEqual( + await shouldInsertMarkdownLinkByDefault(createNewMarkdownEngine(), textDoc, InsertMarkdownLink.Smart, [new vscode.Range(0, 0, 0, 0)], noopToken), + false); + + assert.strictEqual( + await shouldInsertMarkdownLinkByDefault(createNewMarkdownEngine(), textDoc, InsertMarkdownLink.Smart, [new vscode.Range(1, 0, 1, 0)], noopToken), + false); + }); + + test('Smart should enabled after frontmatter', async () => { + assert.strictEqual( + await shouldInsertMarkdownLinkByDefault(createNewMarkdownEngine(), makeTestDoc(joinLines( + `---`, + `layout: post`, + `title: Blogging Like a Hacker`, + `---`, + ``, + `Link Text` + )), InsertMarkdownLink.Smart, [new vscode.Range(5, 0, 5, 0)], noopToken), + true); + }); }); }); diff --git a/extensions/markdown-language-features/src/util/uriList.ts b/extensions/markdown-language-features/src/util/uriList.ts index 04897af453e41..8b7f52e568f07 100644 --- a/extensions/markdown-language-features/src/util/uriList.ts +++ b/extensions/markdown-language-features/src/util/uriList.ts @@ -3,12 +3,33 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { coalesce } from './arrays'; +import * as vscode from 'vscode'; + function splitUriList(str: string): string[] { return str.split('\r\n'); } -export function parseUriList(str: string): string[] { +function parseUriList(str: string): string[] { return splitUriList(str) .filter(value => !value.startsWith('#')) // Remove comments .map(value => value.trim()); } + +export class UriList { + + static from(str: string): UriList { + return new UriList(coalesce(parseUriList(str).map(line => { + try { + return { uri: vscode.Uri.parse(line), str: line }; + } catch { + // Uri parse failure + return undefined; + } + }))); + } + + private constructor( + public readonly entries: ReadonlyArray<{ readonly uri: vscode.Uri; readonly str: string }> + ) { } +} diff --git a/extensions/markdown-language-features/yarn.lock b/extensions/markdown-language-features/yarn.lock index 7ff0968e5af20..2b688e4059269 100644 --- a/extensions/markdown-language-features/yarn.lock +++ b/extensions/markdown-language-features/yarn.lock @@ -217,9 +217,9 @@ highlight.js@^11.8.0: integrity sha512-MedQhoqVdr0U6SSnWPzfiadUcDHfN/Wzq25AkXiQv9oiOO/sG0S7XkvpFIqWBl9Yq1UYyYOOVORs5UW2XlPyzg== katex@^0.16.4: - version "0.16.9" - resolved "https://registry.yarnpkg.com/katex/-/katex-0.16.9.tgz#bc62d8f7abfea6e181250f85a56e4ef292dcb1fa" - integrity sha512-fsSYjWS0EEOwvy81j3vRA8TEAhQhKiqO+FQaKWp0m39qwOzHVBgAUBIXWj1pB+O2W3fIpNa6Y9KSKCVbfPhyAQ== + version "0.16.10" + resolved "https://registry.yarnpkg.com/katex/-/katex-0.16.10.tgz#6f81b71ac37ff4ec7556861160f53bc5f058b185" + integrity sha512-ZiqaC04tp2O5utMsl2TEZTXxa6WSC4yo0fv5ML++D3QZv/vx2Mct0mTlRx3O+uUkjfuAgOkzsCmq5MiUEsDDdA== dependencies: commander "^8.3.0" @@ -242,10 +242,10 @@ lru-cache@^6.0.0: dependencies: yallist "^4.0.0" -markdown-it-front-matter@^0.2.1: - version "0.2.1" - resolved "https://registry.yarnpkg.com/markdown-it-front-matter/-/markdown-it-front-matter-0.2.1.tgz#dca49a827bb3cebb0528452c1d87dff276eb28dc" - integrity sha512-ydUIqlKfDscRpRUTRcA3maeeUKn3Cl5EaKZSA+I/f0KOGCBurW7e+bbz59sxqkC3FA9Q2S2+t4mpkH9T0BCM6A== +markdown-it-front-matter@^0.2.4: + version "0.2.4" + resolved "https://registry.yarnpkg.com/markdown-it-front-matter/-/markdown-it-front-matter-0.2.4.tgz#cf29bc8222149b53575699357b1ece697bf39507" + integrity sha512-25GUs0yjS2hLl8zAemVndeEzThB1p42yxuDEKbd4JlL3jiz+jsm6e56Ya8B0VREOkNxLYB4TTwaoPJ3ElMmW+w== markdown-it@^12.3.2: version "12.3.2" diff --git a/extensions/markdown-math/yarn.lock b/extensions/markdown-math/yarn.lock index 38e50260d03e3..f6b6729fce6c3 100644 --- a/extensions/markdown-math/yarn.lock +++ b/extensions/markdown-math/yarn.lock @@ -25,8 +25,8 @@ commander@^8.3.0: integrity sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww== katex@^0.16.4: - version "0.16.9" - resolved "https://registry.yarnpkg.com/katex/-/katex-0.16.9.tgz#bc62d8f7abfea6e181250f85a56e4ef292dcb1fa" - integrity sha512-fsSYjWS0EEOwvy81j3vRA8TEAhQhKiqO+FQaKWp0m39qwOzHVBgAUBIXWj1pB+O2W3fIpNa6Y9KSKCVbfPhyAQ== + version "0.16.10" + resolved "https://registry.yarnpkg.com/katex/-/katex-0.16.10.tgz#6f81b71ac37ff4ec7556861160f53bc5f058b185" + integrity sha512-ZiqaC04tp2O5utMsl2TEZTXxa6WSC4yo0fv5ML++D3QZv/vx2Mct0mTlRx3O+uUkjfuAgOkzsCmq5MiUEsDDdA== dependencies: commander "^8.3.0" diff --git a/extensions/package.json b/extensions/package.json index 4365c20acc16a..35b736f04437d 100644 --- a/extensions/package.json +++ b/extensions/package.json @@ -4,7 +4,7 @@ "license": "MIT", "description": "Dependencies shared by all extensions", "dependencies": { - "typescript": "5.3.2" + "typescript": "5.4.3" }, "scripts": { "postinstall": "node ./postinstall.mjs" diff --git a/extensions/powershell/package.json b/extensions/powershell/package.json index 0fd7abd41494f..d73e5e72faddd 100644 --- a/extensions/powershell/package.json +++ b/extensions/powershell/package.json @@ -24,7 +24,8 @@ "PowerShell", "powershell", "ps", - "ps1" + "ps1", + "pwsh" ], "firstLine": "^#!\\s*/.*\\bpwsh\\b", "configuration": "./language-configuration.json" diff --git a/extensions/razor/cgmanifest.json b/extensions/razor/cgmanifest.json index e90c7d75d8c3a..d3685974bdb62 100644 --- a/extensions/razor/cgmanifest.json +++ b/extensions/razor/cgmanifest.json @@ -6,7 +6,7 @@ "git": { "name": "dotnet/razor", "repositoryUrl": "https://github.com/dotnet/razor", - "commitHash": "b44d0a906d054d2d343adc3f58cbea11d97d7488" + "commitHash": "f01e110af179981942987384d2b5d4e489eab014" } }, "license": "MIT", diff --git a/extensions/razor/syntaxes/cshtml.tmLanguage.json b/extensions/razor/syntaxes/cshtml.tmLanguage.json index 4594037960ac6..389a6daf249e7 100644 --- a/extensions/razor/syntaxes/cshtml.tmLanguage.json +++ b/extensions/razor/syntaxes/cshtml.tmLanguage.json @@ -4,7 +4,7 @@ "If you want to provide a fix or improvement, please create a pull request against the original repository.", "Once accepted there, we are happy to receive an update request." ], - "version": "https://github.com/dotnet/razor/commit/b44d0a906d054d2d343adc3f58cbea11d97d7488", + "version": "https://github.com/dotnet/razor/commit/f01e110af179981942987384d2b5d4e489eab014", "name": "ASP.NET Razor", "scopeName": "text.html.cshtml", "patterns": [ @@ -527,6 +527,15 @@ }, { "include": "#using-directive" + }, + { + "include": "#rendermode-directive" + }, + { + "include": "#preservewhitespace-directive" + }, + { + "include": "#typeparam-directive" } ] }, @@ -851,6 +860,75 @@ } } }, + "rendermode-directive": { + "name": "meta.directive", + "match": "(@)(rendermode)\\s+([^$]+)?", + "captures": { + "1": { + "patterns": [ + { + "include": "#transition" + } + ] + }, + "2": { + "name": "keyword.control.razor.directive.rendermode" + }, + "3": { + "patterns": [ + { + "include": "source.cs#type" + } + ] + } + } + }, + "preservewhitespace-directive": { + "name": "meta.directive", + "match": "(@)(preservewhitespace)\\s+([^$]+)?", + "captures": { + "1": { + "patterns": [ + { + "include": "#transition" + } + ] + }, + "2": { + "name": "keyword.control.razor.directive.preservewhitespace" + }, + "3": { + "patterns": [ + { + "include": "source.cs#boolean-literal" + } + ] + } + } + }, + "typeparam-directive": { + "name": "meta.directive", + "match": "(@)(typeparam)\\s+([^$]+)?", + "captures": { + "1": { + "patterns": [ + { + "include": "#transition" + } + ] + }, + "2": { + "name": "keyword.control.razor.directive.typeparam" + }, + "3": { + "patterns": [ + { + "include": "source.cs#type" + } + ] + } + } + }, "attribute-directive": { "name": "meta.directive", "begin": "(@)(attribute)\\b\\s+", diff --git a/extensions/ruby/language-configuration.json b/extensions/ruby/language-configuration.json index a86f592e3bdbd..e1125e0bf2b8e 100644 --- a/extensions/ruby/language-configuration.json +++ b/extensions/ruby/language-configuration.json @@ -26,6 +26,6 @@ ], "indentationRules": { "increaseIndentPattern": "^\\s*((begin|class|(private|protected)\\s+def|def|else|elsif|ensure|for|if|module|rescue|unless|until|when|in|while|case)|([^#]*\\sdo\\b)|([^#]*=\\s*(case|if|unless)))\\b([^#\\{;]|(\"|'|\/).*\\4)*(#.*)?$", - "decreaseIndentPattern": "^\\s*([}\\]]([,)]?\\s*(#|$)|\\.[a-zA-Z_]\\w*\\b)|(end|rescue|ensure|else|elsif|when|in)\\b)" + "decreaseIndentPattern": "^\\s*([}\\]]([,)]?\\s*(#|$)|\\.[a-zA-Z_]\\w*\\b)|(end|rescue|ensure|else|elsif)\\b|(in|when)\\s)" } } diff --git a/extensions/scss/cgmanifest.json b/extensions/scss/cgmanifest.json index 12247769ce224..a67a4f546096c 100644 --- a/extensions/scss/cgmanifest.json +++ b/extensions/scss/cgmanifest.json @@ -6,12 +6,12 @@ "git": { "name": "atom/language-sass", "repositoryUrl": "https://github.com/atom/language-sass", - "commitHash": "f52ab12f7f9346cc2568129d8c4419bd3d506b47" + "commitHash": "303bbf0c250fe380b9e57375598cfd916110758b" } }, "license": "MIT", "description": "The file syntaxes/scss.json was derived from the Atom package https://github.com/atom/language-sass which was originally converted from the TextMate bundle https://github.com/alexsancho/SASS.tmbundle.", - "version": "0.62.1" + "version": "0.61.4" } ], "version": 1 diff --git a/extensions/shellscript/cgmanifest.json b/extensions/shellscript/cgmanifest.json index dbb4301b62ce9..d12320c1b9501 100644 --- a/extensions/shellscript/cgmanifest.json +++ b/extensions/shellscript/cgmanifest.json @@ -6,11 +6,11 @@ "git": { "name": "jeff-hykin/better-shell-syntax", "repositoryUrl": "https://github.com/jeff-hykin/better-shell-syntax", - "commitHash": "a3de7b32f1537194a83ee848838402fbf4b67424" + "commitHash": "4ba5d703087cac3c60cd57b206fd1cea0ff959cc" } }, "license": "MIT", - "version": "1.6.2" + "version": "1.7.1" } ], "version": 1 diff --git a/extensions/shellscript/syntaxes/shell-unix-bash.tmLanguage.json b/extensions/shellscript/syntaxes/shell-unix-bash.tmLanguage.json index 9950c577c4869..21766dc8477e3 100644 --- a/extensions/shellscript/syntaxes/shell-unix-bash.tmLanguage.json +++ b/extensions/shellscript/syntaxes/shell-unix-bash.tmLanguage.json @@ -4,7 +4,7 @@ "If you want to provide a fix or improvement, please create a pull request against the original repository.", "Once accepted there, we are happy to receive an update request." ], - "version": "https://github.com/jeff-hykin/better-shell-syntax/commit/a3de7b32f1537194a83ee848838402fbf4b67424", + "version": "https://github.com/jeff-hykin/better-shell-syntax/commit/4ba5d703087cac3c60cd57b206fd1cea0ff959cc", "name": "Shell Script", "scopeName": "source.shell", "patterns": [ @@ -14,34 +14,59 @@ ], "repository": { "alias_statement": { - "begin": "(alias)[ \\t]*+[ \\t]*+(?:((?<=^|;|&|[ \\t])(?:readonly|declare|typeset|export|local)(?=[ \\t]|;|&|$))[ \\t]*+)?((?\\(\\)\\$`\\\\\"\\|]+(?!>))", + "match": "(?:[ \\t]*+)((?:[^ \t\n'&;<>\\(\\)\\$`\\\\\"\\|]+)(?!>))", "captures": { "1": { "name": "string.unquoted.argument.shell", @@ -116,67 +141,108 @@ } ] }, - "assignment": { + "array_value": { + "begin": "(?:[ \\t]*+)(?:(?:((?<=^|;|&|[ \\t])(?:readonly|declare|typeset|export|local)(?=[ \\t]|;|&|$))(?:[ \\t]*+)((?:(?:((?|#|\\n|$|;|[ \\t]))(?!foreach\\b(?!\\/)|select\\b(?!\\/)|repeat\\b(?!\\/)|until\\b(?!\\/)|while\\b(?!\\/)|case\\b(?!\\/)|done\\b(?!\\/)|elif\\b(?!\\/)|else\\b(?!\\/)|esac\\b(?!\\/)|then\\b(?!\\/)|for\\b(?!\\/)|end\\b(?!\\/)|in\\b(?!\\/)|fi\\b(?!\\/)|do\\b(?!\\/)|if\\b(?!\\/))(?:((?<=^|;|&|[ \\t])(?:readonly|declare|typeset|export|local)(?=[ \\t]|;|&|$))|((?!\"|'|\\\\\\n?$)[^!'\" \\t\\n\\r]+?))(?:(?= |\\t)|(?=;|\\||&|\\n|\\)|\\`|\\{|\\}|[ \\t]*#|\\])(?|#|\\n|$|;|[ \\t]))(?!foreach\\b(?!\\/)|select\\b(?!\\/)|repeat\\b(?!\\/)|until\\b(?!\\/)|while\\b(?!\\/)|case\\b(?!\\/)|done\\b(?!\\/)|elif\\b(?!\\/)|else\\b(?!\\/)|esac\\b(?!\\/)|then\\b(?!\\/)|for\\b(?!\\/)|end\\b(?!\\/)|in\\b(?!\\/)|fi\\b(?!\\/)|do\\b(?!\\/)|if\\b(?!\\/)))(?:((?<=^|;|&|[ \\t])(?:readonly|declare|typeset|export|local)(?=[ \\t]|;|&|$))|((?!\"|'|\\\\\\n?$)(?:[^!'\" \\t\\n\\r]+?)))(?:(?= |\\t)|(?:(?=;|\\||&|\\n|\\)|\\`|\\{|\\}|[ \\t]*#|\\])(?|#|\\n|$|;|[ \\t]))(?!foreach\\b(?!\\/)|select\\b(?!\\/)|repeat\\b(?!\\/)|until\\b(?!\\/)|while\\b(?!\\/)|case\\b(?!\\/)|done\\b(?!\\/)|elif\\b(?!\\/)|else\\b(?!\\/)|esac\\b(?!\\/)|then\\b(?!\\/)|for\\b(?!\\/)|end\\b(?!\\/)|in\\b(?!\\/)|fi\\b(?!\\/)|do\\b(?!\\/)|if\\b(?!\\/))(?!\\\\\\n?$)", + "begin": "(?:(?:[ \\t]*+)(?:(?!(?:!|&|\\||\\(|\\)|\\{|\\[|<|>|#|\\n|$|;|[ \\t]))(?!foreach\\b(?!\\/)|select\\b(?!\\/)|repeat\\b(?!\\/)|until\\b(?!\\/)|while\\b(?!\\/)|case\\b(?!\\/)|done\\b(?!\\/)|elif\\b(?!\\/)|else\\b(?!\\/)|esac\\b(?!\\/)|then\\b(?!\\/)|for\\b(?!\\/)|end\\b(?!\\/)|in\\b(?!\\/)|fi\\b(?!\\/)|do\\b(?!\\/)|if\\b(?!\\/))(?!\\\\\\n?$)))", "end": "(?=;|\\||&|\\n|\\)|\\`|\\{|\\}|[ \\t]*#|\\])(?)", + "match": "(?<==| |\\t|^|\\{|\\(|\\[)(?:(?:(?:(?:(?:(0[xX][0-9A-Fa-f]+)|(0\\d+))|(\\d{1,2}#[0-9a-zA-Z@_]+))|(-?\\d+(?:\\.\\d+)))|(-?\\d+(?:\\.\\d+)+))|(-?\\d+))(?= |\\t|$|\\}|\\)|;)", "captures": { "1": { "name": "constant.numeric.shell constant.numeric.hex.shell" @@ -1531,16 +1757,19 @@ "name": "constant.numeric.shell constant.numeric.other.shell" }, "4": { - "name": "constant.numeric.shell constant.numeric.integer.shell" + "name": "constant.numeric.shell constant.numeric.decimal.shell" }, "5": { + "name": "constant.numeric.shell constant.numeric.version.shell" + }, + "6": { "name": "constant.numeric.shell constant.numeric.integer.shell" } } }, "option": { - "begin": "[ \\t]++(-)((?!(?:!|&|\\||\\(|\\)|\\{|\\[|<|>|#|\\n|$|;|[ \\t])))", - "end": "(?:(?=[ \\t])|(?=;|\\||&|\\n|\\)|\\`|\\{|\\}|[ \\t]*#|\\])(?|#|\\n|$|;|[ \\t]))))", + "end": "(?:(?=[ \\t])|(?:(?=;|\\||&|\\n|\\)|\\`|\\{|\\}|[ \\t]*#|\\])(?>?)(?:[ \\t]*+)([^ \t\n'&;<>\\(\\)\\$`\\\\\"\\|]+))", + "captures": { + "1": { + "name": "keyword.operator.redirect.shell" + }, + "2": { + "name": "string.unquoted.argument.shell" + } + } + }, "redirect_number": { - "match": "(?<=[ \\t])(?:(1)|(2)|(\\d+))(?=>)", + "match": "(?<=[ \\t])(?:(?:(1)|(2)|(\\d+))(?=>))", "captures": { "1": { "name": "keyword.operator.redirect.stdout.shell" @@ -1687,17 +1927,17 @@ "regexp": { "patterns": [ { - "match": ".+" + "match": "(?:.+)" } ] }, "simple_options": { - "match": "(?:[ \\t]++\\-\\w+)*", + "match": "(?:(?:[ \\t]++)\\-(?:\\w+))*", "captures": { "0": { "patterns": [ { - "match": "[ \\t]++(\\-)(\\w+)", + "match": "(?:[ \\t]++)(\\-)(\\w+)", "captures": { "1": { "name": "string.unquoted.argument.shell constant.other.option.dash.shell" @@ -1711,11 +1951,15 @@ } } }, + "simple_unquoted": { + "match": "[^ \\t\\n'&;<>\\(\\)\\$`\\\\\"\\|]", + "name": "string.unquoted.shell" + }, "start_of_command": { - "match": "[ \\t]*+(?!(?:!|&|\\||\\(|\\)|\\{|\\[|<|>|#|\\n|$|;|[ \\t]))(?!foreach\\b(?!\\/)|select\\b(?!\\/)|repeat\\b(?!\\/)|until\\b(?!\\/)|while\\b(?!\\/)|case\\b(?!\\/)|done\\b(?!\\/)|elif\\b(?!\\/)|else\\b(?!\\/)|esac\\b(?!\\/)|then\\b(?!\\/)|for\\b(?!\\/)|end\\b(?!\\/)|in\\b(?!\\/)|fi\\b(?!\\/)|do\\b(?!\\/)|if\\b(?!\\/))(?!\\\\\\n?$)" + "match": "(?:(?:[ \\t]*+)(?:(?!(?:!|&|\\||\\(|\\)|\\{|\\[|<|>|#|\\n|$|;|[ \\t]))(?!foreach\\b(?!\\/)|select\\b(?!\\/)|repeat\\b(?!\\/)|until\\b(?!\\/)|while\\b(?!\\/)|case\\b(?!\\/)|done\\b(?!\\/)|elif\\b(?!\\/)|else\\b(?!\\/)|esac\\b(?!\\/)|then\\b(?!\\/)|for\\b(?!\\/)|end\\b(?!\\/)|in\\b(?!\\/)|fi\\b(?!\\/)|do\\b(?!\\/)|if\\b(?!\\/))(?!\\\\\\n?$)))" }, "start_of_double_quoted_command_name": { - "match": "(?!(?:!|&|\\||\\(|\\)|\\{|\\[|<|>|#|\\n|$|;|[ \\t]))(?:[ \\t]*+([^ \t\n'&;<>\\(\\)\\$`\\\\\"\\|]+(?!>)))?(?:(?:\\$\")|\")", + "match": "(?:(?!(?:!|&|\\||\\(|\\)|\\{|\\[|<|>|#|\\n|$|;|[ \\t]))(?:(?:(?:[ \\t]*+)((?:[^ \t\n'&;<>\\(\\)\\$`\\\\\"\\|]+)(?!>)))?)(?:(?:\\$\")|\"))", "captures": { "1": { "name": "entity.name.function.call.shell entity.name.command.shell", @@ -1744,7 +1988,7 @@ "name": "meta.statement.command.name.quoted.shell string.quoted.double.shell punctuation.definition.string.begin.shell entity.name.function.call.shell entity.name.command.shell" }, "start_of_single_quoted_command_name": { - "match": "(?!(?:!|&|\\||\\(|\\)|\\{|\\[|<|>|#|\\n|$|;|[ \\t]))(?:[ \\t]*+([^ \t\n'&;<>\\(\\)\\$`\\\\\"\\|]+(?!>)))?(?:(?:\\$')|')", + "match": "(?:(?!(?:!|&|\\||\\(|\\)|\\{|\\[|<|>|#|\\n|$|;|[ \\t]))(?:(?:(?:[ \\t]*+)((?:[^ \t\n'&;<>\\(\\)\\$`\\\\\"\\|]+)(?!>)))?)(?:(?:\\$')|'))", "captures": { "1": { "name": "entity.name.function.call.shell entity.name.command.shell", @@ -1866,7 +2110,7 @@ "variable": { "patterns": [ { - "match": "(\\$)(\\@(?!\\w))", + "match": "(?:(\\$)(\\@(?!\\w)))", "captures": { "1": { "name": "punctuation.definition.variable.shell variable.parameter.positional.all.shell" @@ -1877,7 +2121,7 @@ } }, { - "match": "(\\$)([0-9](?!\\w))", + "match": "(?:(\\$)([0-9](?!\\w)))", "captures": { "1": { "name": "punctuation.definition.variable.shell variable.parameter.positional.shell" @@ -1888,7 +2132,7 @@ } }, { - "match": "(\\$)([-*#?$!0_](?!\\w))", + "match": "(?:(\\$)([-*#?$!0_](?!\\w)))", "captures": { "1": { "name": "punctuation.definition.variable.shell variable.language.special.shell" @@ -1899,7 +2143,7 @@ } }, { - "begin": "(\\$)(\\{)[ \\t]*+(?=\\d)", + "begin": "(?:(\\$)(\\{)(?:[ \\t]*+)(?=\\d))", "end": "\\}", "beginCaptures": { "1": { @@ -1921,7 +2165,7 @@ "name": "keyword.operator.expansion.shell" }, { - "match": "(\\[)[^\\]]+(\\])", + "match": "(?:(\\[)(?:[^\\]]+)(\\]))", "captures": { "1": { "name": "punctuation.section.array.shell" @@ -1936,7 +2180,7 @@ "name": "variable.parameter.positional.shell" }, { - "match": "(?\\s*)|((.*[^\\w]+|\\s*)(if|while|for)\\s*\\(.*\\)\\s*))$" } }, "onEnterRules": [ @@ -215,6 +218,33 @@ "action": { "indent": "outdent" } - } + }, + // Indent when pressing enter from inside () + { + "beforeText": "^.*\\([^\\)]*$", + "afterText": "^\\s*\\).*$", + "action": { + "indent": "indentOutdent", + "appendText": "\t", + } + }, + // Indent when pressing enter from inside {} + { + "beforeText": "^.*\\{[^\\}]*$", + "afterText": "^\\s*\\}.*$", + "action": { + "indent": "indentOutdent", + "appendText": "\t", + } + }, + // Indent when pressing enter from inside [] + { + "beforeText": "^.*\\[[^\\]]*$", + "afterText": "^\\s*\\].*$", + "action": { + "indent": "indentOutdent", + "appendText": "\t", + } + }, ] } diff --git a/extensions/typescript-language-features/package.json b/extensions/typescript-language-features/package.json index 9a5f8ff1f86dc..34dad264553fd 100644 --- a/extensions/typescript-language-features/package.json +++ b/extensions/typescript-language-features/package.json @@ -45,7 +45,7 @@ "@vscode/ts-package-manager": "^0.0.2", "jsonc-parser": "^3.2.0", "semver": "7.5.2", - "vscode-tas-client": "^0.1.63", + "vscode-tas-client": "^0.1.84", "vscode-uri": "^3.0.3" }, "devDependencies": { diff --git a/extensions/typescript-language-features/src/configuration/fileSchemes.ts b/extensions/typescript-language-features/src/configuration/fileSchemes.ts index 9fb572c717522..cfc3f66db1716 100644 --- a/extensions/typescript-language-features/src/configuration/fileSchemes.ts +++ b/extensions/typescript-language-features/src/configuration/fileSchemes.ts @@ -16,9 +16,8 @@ export const azurerepos = 'azurerepos'; export const vsls = 'vsls'; export const walkThroughSnippet = 'walkThroughSnippet'; export const vscodeNotebookCell = 'vscode-notebook-cell'; -export const memFs = 'memfs'; -export const vscodeVfs = 'vscode-vfs'; export const officeScript = 'office-script'; +export const chatCodeBlock = 'vscode-chat-code-block'; export function getSemanticSupportedSchemes() { if (isWeb() && vscode.workspace.workspaceFolders) { @@ -30,6 +29,7 @@ export function getSemanticSupportedSchemes() { untitled, walkThroughSnippet, vscodeNotebookCell, + chatCodeBlock, ]; } diff --git a/extensions/typescript-language-features/src/configuration/languageDescription.ts b/extensions/typescript-language-features/src/configuration/languageDescription.ts index 95c165bf14d5b..a97530e5ac478 100644 --- a/extensions/typescript-language-features/src/configuration/languageDescription.ts +++ b/extensions/typescript-language-features/src/configuration/languageDescription.ts @@ -32,7 +32,7 @@ export const standardLanguageDescriptions: LanguageDescription[] = [ diagnosticSource: 'ts', diagnosticLanguage: DiagnosticLanguage.TypeScript, languageIds: [languageIds.typescript, languageIds.typescriptreact], - configFilePattern: /^tsconfig(\..*)?\.json$/gi, + configFilePattern: /^tsconfig(\..*)?\.json$/i, standardFileExtensions: [ 'ts', 'tsx', @@ -45,7 +45,7 @@ export const standardLanguageDescriptions: LanguageDescription[] = [ diagnosticSource: 'ts', diagnosticLanguage: DiagnosticLanguage.JavaScript, languageIds: [languageIds.javascript, languageIds.javascriptreact], - configFilePattern: /^jsconfig(\..*)?\.json$/gi, + configFilePattern: /^jsconfig(\..*)?\.json$/i, standardFileExtensions: [ 'js', 'jsx', diff --git a/extensions/typescript-language-features/src/extension.browser.ts b/extensions/typescript-language-features/src/extension.browser.ts index 66532bc81fc5d..91a652ed6fa51 100644 --- a/extensions/typescript-language-features/src/extension.browser.ts +++ b/extensions/typescript-language-features/src/extension.browser.ts @@ -118,7 +118,7 @@ async function startPreloadWorkspaceContentsIfNeeded(context: vscode.ExtensionCo return; } - const workspaceUri = vscode.workspace.workspaceFolders?.[0].uri; + const workspaceUri = vscode.workspace.workspaceFolders?.at(0)?.uri; if (!workspaceUri || workspaceUri.scheme !== 'vscode-vfs' || !workspaceUri.authority.startsWith('github')) { logger.info(`Skipped loading workspace contents for repository ${workspaceUri?.toString()}`); return; diff --git a/extensions/typescript-language-features/src/languageFeatures/completions.ts b/extensions/typescript-language-features/src/languageFeatures/completions.ts index 817f614a6c5aa..708d7e028dd1b 100644 --- a/extensions/typescript-language-features/src/languageFeatures/completions.ts +++ b/extensions/typescript-language-features/src/languageFeatures/completions.ts @@ -39,6 +39,7 @@ interface CompletionContext { readonly wordRange: vscode.Range | undefined; readonly line: string; + readonly optionalReplacementRange: vscode.Range | undefined; } type ResolvedCompletionItem = { @@ -187,7 +188,7 @@ class MyCompletionItem extends vscode.CompletionItem { ] }; const response = await client.interruptGetErr(() => client.execute('completionEntryDetails', args, requestToken.token)); - if (response.type !== 'response' || !response.body || !response.body.length) { + if (response.type !== 'response' || !response.body?.length) { return undefined; } @@ -363,18 +364,25 @@ class MyCompletionItem extends vscode.CompletionItem { private getRangeFromReplacementSpan(tsEntry: Proto.CompletionEntry, completionContext: CompletionContext) { if (!tsEntry.replacementSpan) { - return; + if (completionContext.optionalReplacementRange) { + return { + inserting: new vscode.Range(completionContext.optionalReplacementRange.start, this.position), + replacing: completionContext.optionalReplacementRange, + }; + } + + return undefined; } - let replaceRange = typeConverters.Range.fromTextSpan(tsEntry.replacementSpan); + // If TS returns an explicit replacement range on this item, we should use it for both types of completion + // Make sure we only replace a single line at most + let replaceRange = typeConverters.Range.fromTextSpan(tsEntry.replacementSpan); if (!replaceRange.isSingleLine) { replaceRange = new vscode.Range(replaceRange.start.line, replaceRange.start.character, replaceRange.start.line, completionContext.line.length); } - - // If TS returns an explicit replacement range, we should use it for both types of completion return { - inserting: new vscode.Range(replaceRange.start, this.position), + inserting: replaceRange, replacing: replaceRange, }; } @@ -735,6 +743,7 @@ class TypeScriptCompletionItemProvider implements vscode.CompletionItemProvider< let metadata: any | undefined; let response: ServerResponse.Response | undefined; let duration: number | undefined; + let optionalReplacementRange: vscode.Range | undefined; if (this.client.apiVersion.gte(API.v300)) { const startTime = Date.now(); try { @@ -757,14 +766,12 @@ class TypeScriptCompletionItemProvider implements vscode.CompletionItemProvider< dotAccessorContext = { range, text }; } } - isIncomplete = !!response.body.isIncomplete || (response as any).metadata && (response as any).metadata.isIncomplete; + isIncomplete = !!response.body.isIncomplete || (response.metadata as any)?.isIncomplete; entries = response.body.entries; metadata = response.metadata; if (response.body.optionalReplacementSpan) { - for (const entry of entries) { - entry.replacementSpan ??= response.body.optionalReplacementSpan; - } + optionalReplacementRange = typeConverters.Range.fromTextSpan(response.body.optionalReplacementSpan); } } else { const response = await this.client.interruptGetErr(() => this.client.execute('completions', args, token)); @@ -784,6 +791,7 @@ class TypeScriptCompletionItemProvider implements vscode.CompletionItemProvider< wordRange, line: line.text, completeFunctionCalls: completionConfiguration.completeFunctionCalls, + optionalReplacementRange, }; let includesPackageJsonImport = false; diff --git a/extensions/typescript-language-features/src/languageFeatures/fileConfigurationManager.ts b/extensions/typescript-language-features/src/languageFeatures/fileConfigurationManager.ts index 42e7f9f746118..7fa9805a5437b 100644 --- a/extensions/typescript-language-features/src/languageFeatures/fileConfigurationManager.ts +++ b/extensions/typescript-language-features/src/languageFeatures/fileConfigurationManager.ts @@ -215,12 +215,16 @@ export default class FileConfigurationManager extends Disposable { private getAutoImportFileExcludePatternsPreference(config: vscode.WorkspaceConfiguration, workspaceFolder: vscode.Uri | undefined): string[] | undefined { return workspaceFolder && config.get('autoImportFileExcludePatterns')?.map(p => { // Normalization rules: https://github.com/microsoft/TypeScript/pull/49578 - const slashNormalized = p.replace(/\\/g, '/'); - const isRelative = /^\.\.?($|\/)/.test(slashNormalized); + const isRelative = /^\.\.?($|[\/\\])/.test(p); + // In TypeScript < 5.3, the first path component cannot be a wildcard, so we need to prefix + // it with a path root (e.g. `/` or `c:\`) + const wildcardPrefix = this.client.apiVersion.gte(API.v540) + ? '' + : path.parse(this.client.toTsFilePath(workspaceFolder)!).root; return path.isAbsolute(p) ? p : - p.startsWith('*') ? '/' + slashNormalized : - isRelative ? vscode.Uri.joinPath(workspaceFolder, p).fsPath : - '/**/' + slashNormalized; + p.startsWith('*') ? wildcardPrefix + p : + isRelative ? this.client.toTsFilePath(vscode.Uri.joinPath(workspaceFolder, p))! : + wildcardPrefix + '**' + path.sep + p; }); } } diff --git a/extensions/typescript-language-features/src/languageFeatures/quickFix.ts b/extensions/typescript-language-features/src/languageFeatures/quickFix.ts index afc489c0521b3..f724cfd8c4456 100644 --- a/extensions/typescript-language-features/src/languageFeatures/quickFix.ts +++ b/extensions/typescript-language-features/src/languageFeatures/quickFix.ts @@ -198,10 +198,10 @@ class SupportedCodeActionProvider { private readonly client: ITypeScriptServiceClient ) { } - public async getFixableDiagnosticsForContext(context: vscode.CodeActionContext): Promise { + public async getFixableDiagnosticsForContext(diagnostics: readonly vscode.Diagnostic[]): Promise { const fixableCodes = await this.fixableDiagnosticCodes; return DiagnosticsSet.from( - context.diagnostics.filter(diagnostic => typeof diagnostic.code !== 'undefined' && fixableCodes.has(diagnostic.code + ''))); + diagnostics.filter(diagnostic => typeof diagnostic.code !== 'undefined' && fixableCodes.has(diagnostic.code + ''))); } @memoize @@ -214,6 +214,8 @@ class SupportedCodeActionProvider { class TypeScriptQuickFixProvider implements vscode.CodeActionProvider { + private static readonly _maxCodeActionsPerFile: number = 1000; + public static readonly metadata: vscode.CodeActionProviderMetadata = { providedCodeActionKinds: [vscode.CodeActionKind.QuickFix] }; @@ -237,7 +239,7 @@ class TypeScriptQuickFixProvider implements vscode.CodeActionProvider { @@ -246,12 +248,32 @@ class TypeScriptQuickFixProvider implements vscode.CodeActionProvider { + setTimeout(resolve, 500); + }); + + if (token.isCancellationRequested) { + return; + } + const allDiagnostics: vscode.Diagnostic[] = []; + + // Match ranges again after getting new diagnostics + for (const diagnostic of this.diagnosticsManager.getDiagnostics(document.uri)) { + if (range.intersection(diagnostic.range)) { + const newLen = allDiagnostics.push(diagnostic); + if (newLen > TypeScriptQuickFixProvider._maxCodeActionsPerFile) { + break; + } + } + } + diagnostics = allDiagnostics; } - if (this.client.bufferSyncSupport.hasPendingDiagnostics(document.uri)) { + const fixableDiagnostics = await this.supportedCodeActionProvider.getFixableDiagnosticsForContext(diagnostics); + if (!fixableDiagnostics.size || token.isCancellationRequested) { return; } @@ -363,7 +385,6 @@ class TypeScriptQuickFixProvider implements vscode.CodeActionProvider vscode.Command, ) { - const title = copilotRename ? action.description + ' and suggest a name with Copilot.' : action.description; + const title = action.description; super(title, InlinedCodeAction.getKind(action)); - if (copilotRename) { - this.isAI = true; - } if (action.notApplicableReason) { this.disabled = { reason: action.notApplicableReason }; } @@ -392,15 +388,12 @@ class InlinedCodeAction extends vscode.CodeAction { if (response.body.renameLocation) { // Disable renames in interactive playground https://github.com/microsoft/vscode/issues/75137 if (this.document.uri.scheme !== fileSchemes.walkThroughSnippet) { - if (this.copilotRename && this.command) { - this.command.title = 'Copilot: ' + this.command.title; - } this.command = { command: CompositeCommand.ID, title: '', arguments: coalesce([ this.command, - this.copilotRename ? this.copilotRename(response.body) : { + { command: 'editor.action.rename', arguments: [[ this.document.uri, @@ -635,38 +628,7 @@ class TypeScriptRefactorProvider implements vscode.CodeActionProvider vscode.Command) = info => ({ - title: '', - command: EditorChatFollowUp.ID, - arguments: [{ - message: `Rename ${newName} to a better name based on usage.`, - expand: Extract_Constant.matches(action) ? { - kind: 'navtree-function', - pos: typeConverters.Position.fromLocation(info.renameLocation!), - } : { - kind: 'refactor-info', - refactor: info, - }, - action: { type: 'refactor', refactor: action }, - document, - } satisfies EditorChatFollowUp_Args] - }); - codeActions.push(new InlinedCodeAction(this.client, document, refactor, action, rangeOrSelection, copilotRename)); - } - } + codeActions.push(new InlinedCodeAction(this.client, document, refactor, action, rangeOrSelection)); } for (const codeAction of codeActions) { codeAction.isPreferred = TypeScriptRefactorProvider.isPreferred(action, allActions); diff --git a/extensions/typescript-language-features/src/task/taskProvider.ts b/extensions/typescript-language-features/src/task/taskProvider.ts index c1b1438560348..3cf0e3328fa09 100644 --- a/extensions/typescript-language-features/src/task/taskProvider.ts +++ b/extensions/typescript-language-features/src/task/taskProvider.ts @@ -53,7 +53,7 @@ class TscTaskProvider extends Disposable implements vscode.TaskProvider { public async provideTasks(token: vscode.CancellationToken): Promise { const folders = vscode.workspace.workspaceFolders; - if ((this.autoDetect === AutoDetect.off) || !folders || !folders.length) { + if ((this.autoDetect === AutoDetect.off) || !folders?.length) { return []; } diff --git a/extensions/typescript-language-features/src/tsServer/bufferSyncSupport.ts b/extensions/typescript-language-features/src/tsServer/bufferSyncSupport.ts index 90151ea6a084c..9f5d76f5ac3bf 100644 --- a/extensions/typescript-language-features/src/tsServer/bufferSyncSupport.ts +++ b/extensions/typescript-language-features/src/tsServer/bufferSyncSupport.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import * as vscode from 'vscode'; -import { officeScript, vscodeNotebookCell } from '../configuration/fileSchemes'; +import * as fileSchemes from '../configuration/fileSchemes'; import * as languageModeIds from '../configuration/languageIds'; import * as typeConverters from '../typeConverters'; import { ClientCapability, ITypeScriptServiceClient } from '../typescriptService'; @@ -227,7 +227,7 @@ class SyncedBuffer { return tsRoot?.startsWith(inMemoryResourcePrefix) ? undefined : tsRoot; } - return resource.scheme === officeScript ? '/' : undefined; + return resource.scheme === fileSchemes.officeScript || resource.scheme === fileSchemes.chatCodeBlock ? '/' : undefined; } public get resource(): vscode.Uri { @@ -395,7 +395,7 @@ class TabResourceTracker extends Disposable { } public has(resource: vscode.Uri): boolean { - if (resource.scheme === vscodeNotebookCell) { + if (resource.scheme === fileSchemes.vscodeNotebookCell) { const notebook = vscode.workspace.notebookDocuments.find(doc => doc.getCells().some(cell => cell.document.uri.toString() === resource.toString())); @@ -725,6 +725,13 @@ export default class BufferSyncSupport extends Disposable { orderedFileSet.set(buffer.resource, undefined); } + for (const { resource } of orderedFileSet.entries()) { + const buffer = this.syncedBuffers.get(resource); + if (buffer && !this.shouldValidate(buffer)) { + orderedFileSet.delete(resource); + } + } + if (orderedFileSet.size) { const getErr = this.pendingGetErr = GetErrRequest.executeGetErrRequest(this.client, orderedFileSet, () => { if (this.pendingGetErr === getErr) { @@ -745,6 +752,10 @@ export default class BufferSyncSupport extends Disposable { } private shouldValidate(buffer: SyncedBuffer): boolean { + if (buffer.resource.scheme === fileSchemes.chatCodeBlock) { + return false; + } + if (!this.client.configuration.enableProjectDiagnostics && !this._tabResources.has(buffer.resource)) { // Only validate resources that are showing to the user return false; } diff --git a/extensions/typescript-language-features/src/tsServer/protocol/protocol.d.ts b/extensions/typescript-language-features/src/tsServer/protocol/protocol.d.ts index b8d863ebb1aba..45e09d6348187 100644 --- a/extensions/typescript-language-features/src/tsServer/protocol/protocol.d.ts +++ b/extensions/typescript-language-features/src/tsServer/protocol/protocol.d.ts @@ -2,7 +2,7 @@ * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import * as ts from 'typescript/lib/tsserverlibrary'; +import type ts from '../../../../node_modules/typescript/lib/typescript'; export = ts.server.protocol; @@ -11,7 +11,7 @@ declare enum ServerType { Semantic = 'semantic', } -declare module 'typescript/lib/tsserverlibrary' { +declare module '../../../../node_modules/typescript/lib/typescript' { namespace server.protocol { type TextInsertion = ts.TextInsertion; type ScriptElementKind = ts.ScriptElementKind; diff --git a/extensions/typescript-language-features/src/tsconfig.ts b/extensions/typescript-language-features/src/tsconfig.ts index 196cf185170b9..04f08a128bca9 100644 --- a/extensions/typescript-language-features/src/tsconfig.ts +++ b/extensions/typescript-language-features/src/tsconfig.ts @@ -26,8 +26,8 @@ export function inferredProjectCompilerOptions( serviceConfig: TypeScriptServiceConfiguration, ): Proto.ExternalProjectCompilerOptions { const projectConfig: Proto.ExternalProjectCompilerOptions = { - module: 'ESNext' as Proto.ModuleKind, - moduleResolution: 'Node' as Proto.ModuleResolutionKind, + module: (version.gte(API.v540) ? 'Preserve' : 'ESNext') as Proto.ModuleKind, + moduleResolution: (version.gte(API.v540) ? 'Bundler' : 'Node') as Proto.ModuleResolutionKind, target: 'ES2022' as Proto.ScriptTarget, jsx: 'react' as Proto.JsxEmit, }; diff --git a/extensions/typescript-language-features/src/typescriptServiceClient.ts b/extensions/typescript-language-features/src/typescriptServiceClient.ts index 0796befc6bba0..812a9c457bf97 100644 --- a/extensions/typescript-language-features/src/typescriptServiceClient.ts +++ b/extensions/typescript-language-features/src/typescriptServiceClient.ts @@ -653,7 +653,7 @@ export default class TypeScriptServiceClient extends Disposable implements IType if (!this._isPromptingAfterCrash) { if (this.pluginManager.plugins.length) { prompt = vscode.window.showWarningMessage( - vscode.l10n.t("The JS/TS language service crashed.\nThis may be caused by a plugin contributed by one of these extensions: {0}.\nPlease try disabling these extensions before filing an issue against VS Code.", pluginExtensionList)); + vscode.l10n.t("The JS/TS language service crashed.\nThis may be caused by a plugin contributed by one of these extensions: {0}.\nPlease try disabling these extensions before filing an issue against VS Code.", pluginExtensionList), reportIssueItem); } else { prompt = vscode.window.showWarningMessage( vscode.l10n.t("The JS/TS language service crashed."), @@ -1039,7 +1039,7 @@ function getReportIssueArgsForError( error: TypeScriptServerError, tsServerLog: TsServerLog | undefined, globalPlugins: readonly TypeScriptServerPlugin[], -): { extensionId: string; issueTitle: string; issueBody: string } | undefined { +): { extensionId: string; issueTitle: string; issueBody: string; issueSource: string; issueData: string } | undefined { if (!error.serverStack || !error.serverMessage) { return undefined; } @@ -1089,19 +1089,20 @@ The log file may contain personal data, including full paths and source code fro After enabling this setting, future crash reports will include the server log.`); } - sections.push(`**TS Server Error Stack** + const serverErrorStack = `**TS Server Error Stack** Server: \`${error.serverId}\` \`\`\` ${error.serverStack} -\`\`\``); +\`\`\``; return { extensionId: 'vscode.typescript-language-features', issueTitle: `TS Server fatal error: ${error.serverMessage}`, - - issueBody: sections.join('\n\n') + issueSource: 'vscode', + issueBody: sections.join('\n\n'), + issueData: serverErrorStack, }; } diff --git a/extensions/typescript-language-features/src/utils/objects.ts b/extensions/typescript-language-features/src/utils/objects.ts index a31467bd8d664..88c5a435e1c88 100644 --- a/extensions/typescript-language-features/src/utils/objects.ts +++ b/extensions/typescript-language-features/src/utils/objects.ts @@ -9,6 +9,7 @@ export function equals(one: any, other: any): boolean { if (one === other) { return true; } + // eslint-disable-next-line @typescript-eslint/prefer-optional-chain if (one === null || one === undefined || other === null || other === undefined) { return false; } diff --git a/extensions/typescript-language-features/yarn.lock b/extensions/typescript-language-features/yarn.lock index 482239a3cbb79..bc72fe4cb8b72 100644 --- a/extensions/typescript-language-features/yarn.lock +++ b/extensions/typescript-language-features/yarn.lock @@ -140,46 +140,6 @@ resolved "https://registry.yarnpkg.com/@vscode/ts-package-manager/-/ts-package-manager-0.0.2.tgz#d1cade5ff0d01da8c5b5b00bf79d80e7156771cf" integrity sha512-cXPxGbPVTkEQI8mUiWYUwB6j3ga6M9i7yubUOCrjgZ01GeZPMSnaWRprfJ09uuy81wJjY2gfHgLsOgwrGvUBTw== -asynckit@^0.4.0: - version "0.4.0" - resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79" - integrity sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q== - -axios@^1.6.1: - version "1.6.2" - resolved "https://registry.yarnpkg.com/axios/-/axios-1.6.2.tgz#de67d42c755b571d3e698df1b6504cde9b0ee9f2" - integrity sha512-7i24Ri4pmDRfJTR7LDBhsOTtcm+9kjX5WiY1X3wIisx6G9So3pfMkEiU7emUBe46oceVImccTEM3k6C5dbVW8A== - dependencies: - follow-redirects "^1.15.0" - form-data "^4.0.0" - proxy-from-env "^1.1.0" - -combined-stream@^1.0.8: - version "1.0.8" - resolved "https://registry.yarnpkg.com/combined-stream/-/combined-stream-1.0.8.tgz#c3d45a8b34fd730631a110a8a2520682b31d5a7f" - integrity sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg== - dependencies: - delayed-stream "~1.0.0" - -delayed-stream@~1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619" - integrity sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ== - -follow-redirects@^1.15.0: - version "1.15.4" - resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.4.tgz#cdc7d308bf6493126b17ea2191ea0ccf3e535adf" - integrity sha512-Cr4D/5wlrb0z9dgERpUL3LrmPKVDsETIJhaCMeDfuFYcqa5bldGV6wBsAN6X/vxlXQtFBMrXdXxdL8CbDTGniw== - -form-data@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/form-data/-/form-data-4.0.0.tgz#93919daeaf361ee529584b9b31664dc12c9fa452" - integrity sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww== - dependencies: - asynckit "^0.4.0" - combined-stream "^1.0.8" - mime-types "^2.1.12" - jsonc-parser@^3.2.0: version "3.2.0" resolved "https://registry.yarnpkg.com/jsonc-parser/-/jsonc-parser-3.2.0.tgz#31ff3f4c2b9793f89c67212627c51c6394f88e76" @@ -192,23 +152,6 @@ lru-cache@^6.0.0: dependencies: yallist "^4.0.0" -mime-db@1.52.0: - version "1.52.0" - resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.52.0.tgz#bbabcdc02859f4987301c856e3387ce5ec43bf70" - integrity sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg== - -mime-types@^2.1.12: - version "2.1.35" - resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.35.tgz#381a871b62a734450660ae3deee44813f70d959a" - integrity sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw== - dependencies: - mime-db "1.52.0" - -proxy-from-env@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz#e102f16ca355424865755d2c9e8ea4f24d58c3e2" - integrity sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg== - semver@7.5.2: version "7.5.2" resolved "https://registry.yarnpkg.com/semver/-/semver-7.5.2.tgz#5b851e66d1be07c1cdaf37dfc856f543325a2beb" @@ -216,19 +159,17 @@ semver@7.5.2: dependencies: lru-cache "^6.0.0" -tas-client@0.1.73: - version "0.1.73" - resolved "https://registry.yarnpkg.com/tas-client/-/tas-client-0.1.73.tgz#2dacf68547a37989ef1554c6510dc108a1ea7a71" - integrity sha512-UDdUF9kV2hYdlv+7AgqP2kXarVSUhjK7tg1BUflIRGEgND0/QoNpN64rcEuhEcM8AIbW65yrCopJWqRhLZ3m8w== - dependencies: - axios "^1.6.1" +tas-client@0.2.33: + version "0.2.33" + resolved "https://registry.yarnpkg.com/tas-client/-/tas-client-0.2.33.tgz#451bf114a8a64748030ce4068ab7d079958402e6" + integrity sha512-V+uqV66BOQnWxvI6HjDnE4VkInmYZUQ4dgB7gzaDyFyFSK1i1nF/j7DpS9UbQAgV9NaF1XpcyuavnM1qOeiEIg== -vscode-tas-client@^0.1.63: - version "0.1.75" - resolved "https://registry.yarnpkg.com/vscode-tas-client/-/vscode-tas-client-0.1.75.tgz#771780a9a178163028299f52d41973300060dd38" - integrity sha512-/+ALFWPI4U3obeRvLFSt39guT7P9bZQrkmcLoiS+2HtzJ/7iPKNt5Sj+XTiitGlPYVFGFc0plxX8AAp6Uxs0xQ== +vscode-tas-client@^0.1.84: + version "0.1.84" + resolved "https://registry.yarnpkg.com/vscode-tas-client/-/vscode-tas-client-0.1.84.tgz#906bdcfd8c9e1dc04321d6bc0335184f9119968e" + integrity sha512-rUTrUopV+70hvx1hW5ebdw1nd6djxubkLvVxjGdyD/r5v/wcVF41LIfiAtbm5qLZDtQdsMH1IaCuDoluoIa88w== dependencies: - tas-client "0.1.73" + tas-client "0.2.33" vscode-uri@3.0.3: version "3.0.3" diff --git a/extensions/vscode-api-tests/package.json b/extensions/vscode-api-tests/package.json index 97de1b94d0504..1c54b960a91f1 100644 --- a/extensions/vscode-api-tests/package.json +++ b/extensions/vscode-api-tests/package.json @@ -7,9 +7,10 @@ "enabledApiProposals": [ "activeComment", "authSession", - "chatAgents2", - "chatRequestAccess", - "defaultChatAgent", + "chatParticipant", + "languageModels", + "defaultChatParticipant", + "chatVariableResolver", "contribViewsRemote", "contribStatusBarItems", "createFileSystemWatcher", @@ -22,6 +23,7 @@ "extensionsAny", "externalUriOpener", "fileSearchProvider", + "findFiles2", "findTextInFiles", "fsChunks", "interactive", @@ -43,7 +45,6 @@ "terminalDataWriteEvent", "terminalDimensions", "tunnels", - "testCoverage", "testObserver", "textSearchProvider", "timeline", @@ -62,6 +63,20 @@ }, "icon": "media/icon.png", "contributes": { + "chatParticipants": [ + { + "id": "api-test.participant", + "name": "participant", + "description": "test", + "isDefault": true, + "commands": [ + { + "name": "hello", + "description": "Hello" + } + ] + } + ], "configuration": { "type": "object", "title": "Test Config", diff --git a/extensions/vscode-api-tests/src/singlefolder-tests/chat.test.ts b/extensions/vscode-api-tests/src/singlefolder-tests/chat.test.ts index fd23f14d5076b..cd8614a86e7c1 100644 --- a/extensions/vscode-api-tests/src/singlefolder-tests/chat.test.ts +++ b/extensions/vscode-api-tests/src/singlefolder-tests/chat.test.ts @@ -5,8 +5,8 @@ import * as assert from 'assert'; import 'mocha'; -import { CancellationToken, chat, ChatAgentRequest, ChatVariableLevel, Disposable, interactive, InteractiveSession, ProviderResult } from 'vscode'; -import { assertNoRpc, closeAllEditors, DeferredPromise, disposeAll } from '../utils'; +import { commands, CancellationToken, ChatContext, ChatRequest, ChatResult, ChatVariableLevel, Disposable, Event, EventEmitter, InteractiveSession, ProviderResult, chat, interactive } from 'vscode'; +import { DeferredPromise, assertNoRpc, closeAllEditors, disposeAll } from '../utils'; suite('chat', () => { @@ -21,7 +21,16 @@ suite('chat', () => { disposeAll(disposables); }); - function getDeferredForRequest(): DeferredPromise { + function getDeferredForRequest(): DeferredPromise { + const deferred = new DeferredPromise(); + disposables.push(setupParticipant()(request => deferred.complete(request.request))); + + return deferred; + } + + function setupParticipant(): Event<{ request: ChatRequest; context: ChatContext }> { + const emitter = new EventEmitter<{ request: ChatRequest; context: ChatContext }>(); + disposables.push(emitter); disposables.push(interactive.registerInteractiveSessionProvider('provider', { prepareSession: (_token: CancellationToken): ProviderResult => { return { @@ -31,40 +40,72 @@ suite('chat', () => { }, })); - const deferred = new DeferredPromise(); - const agent = chat.createChatAgent('agent', (request, _context, _progress, _token) => { - deferred.complete(request); - return null; + const participant = chat.createChatParticipant('api-test.participant', (request, context, _progress, _token) => { + emitter.fire({ request, context }); }); - agent.isDefault = true; - agent.subCommandProvider = { - provideSubCommands: (_token) => { - return [{ name: 'hello', description: 'Hello' }]; - } - }; - disposables.push(agent); - return deferred; + participant.isDefault = true; + disposables.push(participant); + return emitter.event; } - test('agent and slash command', async () => { - const deferred = getDeferredForRequest(); - interactive.sendInteractiveRequestToProvider('provider', { message: '@agent /hello friend' }); - const request = await deferred.p; - assert.deepStrictEqual(request.subCommand, 'hello'); - assert.strictEqual(request.prompt, 'friend'); + test('participant and slash command history', async () => { + const onRequest = setupParticipant(); + commands.executeCommand('workbench.action.chat.open', { query: '@participant /hello friend' }); + + let i = 0; + disposables.push(onRequest(request => { + if (i === 0) { + assert.deepStrictEqual(request.request.command, 'hello'); + assert.strictEqual(request.request.prompt, 'friend'); + i++; + commands.executeCommand('workbench.action.chat.open', { query: '@participant /hello friend' }); + } else { + assert.strictEqual(request.context.history.length, 1); + assert.strictEqual(request.context.history[0].participant, 'api-test.participant'); + assert.strictEqual(request.context.history[0].command, 'hello'); + } + })); }); - test('agent and variable', async () => { - disposables.push(chat.registerVariable('myVar', 'My variable', { + test('participant and variable', async () => { + disposables.push(chat.registerChatVariableResolver('myVar', 'My variable', { resolve(_name, _context, _token) { return [{ level: ChatVariableLevel.Full, value: 'myValue' }]; } })); const deferred = getDeferredForRequest(); - interactive.sendInteractiveRequestToProvider('provider', { message: '@agent hi #myVar' }); + commands.executeCommand('workbench.action.chat.open', { query: '@participant hi #myVar' }); const request = await deferred.p; - assert.strictEqual(request.prompt, 'hi [#myVar](values:myVar)'); - assert.strictEqual(request.variables['myVar'][0].value, 'myValue'); + assert.strictEqual(request.prompt, 'hi #myVar'); + assert.strictEqual(request.variables[0].values[0].value, 'myValue'); + }); + + test('result metadata is returned to the followup provider', async () => { + disposables.push(interactive.registerInteractiveSessionProvider('provider', { + prepareSession: (_token: CancellationToken): ProviderResult => { + return { + requester: { name: 'test' }, + responder: { name: 'test' }, + }; + }, + })); + + const deferred = new DeferredPromise(); + const participant = chat.createChatParticipant('api-test.participant', (_request, _context, _progress, _token) => { + return { metadata: { key: 'value' } }; + }); + participant.isDefault = true; + participant.followupProvider = { + provideFollowups(result, _context, _token) { + deferred.complete(result); + return []; + }, + }; + disposables.push(participant); + + commands.executeCommand('workbench.action.chat.open', { query: '@participant /hello friend' }); + const result = await deferred.p; + assert.deepStrictEqual(result.metadata, { key: 'value' }); }); }); diff --git a/extensions/vscode-api-tests/src/singlefolder-tests/documentPaste.test.ts b/extensions/vscode-api-tests/src/singlefolder-tests/documentPaste.test.ts index c2cdd073d7443..e2145d4ee28c8 100644 --- a/extensions/vscode-api-tests/src/singlefolder-tests/documentPaste.test.ts +++ b/extensions/vscode-api-tests/src/singlefolder-tests/documentPaste.test.ts @@ -37,7 +37,7 @@ suite.skip('vscode API - Copy Paste', function () { dataTransfer.set(textPlain, new vscode.DataTransferItem(reversed)); } } - }, { id: 'test', copyMimeTypes: [textPlain] })); + }, { providedPasteEditKinds: [vscode.DocumentPasteEditKind.Empty.append('test')], copyMimeTypes: [textPlain] })); await vscode.commands.executeCommand('editor.action.clipboardCopyAction'); const newDocContent = getNextDocumentText(testDisposables, doc); @@ -62,7 +62,7 @@ suite.skip('vscode API - Copy Paste', function () { dataTransfer.set(textPlain, new vscode.DataTransferItem(reversed + '\n')); } } - }, { id: 'test', copyMimeTypes: [textPlain] })); + }, { providedPasteEditKinds: [vscode.DocumentPasteEditKind.Empty.append('test')], copyMimeTypes: [textPlain] })); await vscode.commands.executeCommand('editor.action.clipboardCopyAction'); const newDocContent = getNextDocumentText(testDisposables, doc); @@ -88,7 +88,7 @@ suite.skip('vscode API - Copy Paste', function () { dataTransfer.set(textPlain, new vscode.DataTransferItem(`(${ranges.length})${selections.join(' ')}`)); } } - }, { id: 'test', copyMimeTypes: [textPlain] })); + }, { providedPasteEditKinds: [vscode.DocumentPasteEditKind.Empty.append('test')], copyMimeTypes: [textPlain] })); await vscode.commands.executeCommand('editor.action.clipboardCopyAction'); editor.selections = [new vscode.Selection(0, 0, 0, 0)]; @@ -118,7 +118,7 @@ suite.skip('vscode API - Copy Paste', function () { dataTransfer.set(textPlain, new vscode.DataTransferItem('a')); providerAResolve(); } - }, { id: 'test', copyMimeTypes: [textPlain] })); + }, { providedPasteEditKinds: [vscode.DocumentPasteEditKind.Empty.append('test')], copyMimeTypes: [textPlain] })); // Later registered providers will be called first testDisposables.push(vscode.languages.registerDocumentPasteEditProvider({ language: 'plaintext' }, new class implements vscode.DocumentPasteEditProvider { @@ -132,7 +132,7 @@ suite.skip('vscode API - Copy Paste', function () { dataTransfer.set(textPlain, new vscode.DataTransferItem('b')); } - }, { id: 'test', copyMimeTypes: [textPlain] })); + }, { providedPasteEditKinds: [vscode.DocumentPasteEditKind.Empty.append('test')], copyMimeTypes: [textPlain] })); await vscode.commands.executeCommand('editor.action.clipboardCopyAction'); const newDocContent = getNextDocumentText(testDisposables, doc); @@ -159,7 +159,7 @@ suite.skip('vscode API - Copy Paste', function () { dataTransfer.set(textPlain, new vscode.DataTransferItem('xyz')); providerAResolve(); } - }, { id: 'test', copyMimeTypes: [textPlain] })); + }, { providedPasteEditKinds: [vscode.DocumentPasteEditKind.Empty.append('test')], copyMimeTypes: [textPlain] })); testDisposables.push(vscode.languages.registerDocumentPasteEditProvider({ language: 'plaintext' }, new class implements vscode.DocumentPasteEditProvider { async prepareDocumentPaste(_document: vscode.TextDocument, _ranges: readonly vscode.Range[], dataTransfer: vscode.DataTransfer, _token: vscode.CancellationToken): Promise { @@ -172,7 +172,7 @@ suite.skip('vscode API - Copy Paste', function () { const str = await entry!.asString(); dataTransfer.set(textPlain, new vscode.DataTransferItem(reverseString(str))); } - }, { id: 'test', copyMimeTypes: [textPlain] })); + }, { providedPasteEditKinds: [vscode.DocumentPasteEditKind.Empty.append('test')], copyMimeTypes: [textPlain] })); await vscode.commands.executeCommand('editor.action.clipboardCopyAction'); const newDocContent = getNextDocumentText(testDisposables, doc); @@ -192,13 +192,13 @@ suite.skip('vscode API - Copy Paste', function () { async prepareDocumentPaste(_document: vscode.TextDocument, _ranges: readonly vscode.Range[], dataTransfer: vscode.DataTransfer, _token: vscode.CancellationToken): Promise { dataTransfer.set(textPlain, new vscode.DataTransferItem('xyz')); } - }, { id: 'test', copyMimeTypes: [textPlain] })); + }, { providedPasteEditKinds: [vscode.DocumentPasteEditKind.Empty.append('test')], copyMimeTypes: [textPlain] })); testDisposables.push(vscode.languages.registerDocumentPasteEditProvider({ language: 'plaintext' }, new class implements vscode.DocumentPasteEditProvider { async prepareDocumentPaste(_document: vscode.TextDocument, _ranges: readonly vscode.Range[], _dataTransfer: vscode.DataTransfer, _token: vscode.CancellationToken): Promise { throw new Error('Expected testing error from bad provider'); } - }, { id: 'test', copyMimeTypes: [textPlain] })); + }, { providedPasteEditKinds: [vscode.DocumentPasteEditKind.Empty.append('test')], copyMimeTypes: [textPlain] })); await vscode.commands.executeCommand('editor.action.clipboardCopyAction'); const newDocContent = getNextDocumentText(testDisposables, doc); diff --git a/extensions/vscode-api-tests/src/singlefolder-tests/interactiveWindow.test.ts b/extensions/vscode-api-tests/src/singlefolder-tests/interactiveWindow.test.ts index c85fdcb4c816c..a3d4036e5a2db 100644 --- a/extensions/vscode-api-tests/src/singlefolder-tests/interactiveWindow.test.ts +++ b/extensions/vscode-api-tests/src/singlefolder-tests/interactiveWindow.test.ts @@ -99,8 +99,13 @@ async function addCellAndRun(code: string, notebook: vscode.NotebookDocument) { } // Verify visible range has the last cell - assert.strictEqual(notebookEditor.visibleRanges[notebookEditor.visibleRanges.length - 1].end, notebookEditor.notebook.cellCount, `Last cell is not visible`); - + if (!lastCellIsVisible(notebookEditor)) { + // scroll happens async, so give it some time to scroll + await new Promise((resolve) => setTimeout(() => { + assert.ok(lastCellIsVisible(notebookEditor), `Last cell is not visible`); + resolve(); + }, 1000)); + } }); test('Interactive window has the correct kernel', async () => { @@ -120,3 +125,8 @@ async function addCellAndRun(code: string, notebook: vscode.NotebookDocument) { }); }); + +function lastCellIsVisible(notebookEditor: vscode.NotebookEditor) { + const lastVisibleCell = notebookEditor.visibleRanges[notebookEditor.visibleRanges.length - 1].end; + return notebookEditor.notebook.cellCount === lastVisibleCell; +} diff --git a/extensions/vscode-api-tests/src/singlefolder-tests/notebook.api.test.ts b/extensions/vscode-api-tests/src/singlefolder-tests/notebook.api.test.ts index 4990e30af5905..f9d8d6a82db80 100644 --- a/extensions/vscode-api-tests/src/singlefolder-tests/notebook.api.test.ts +++ b/extensions/vscode-api-tests/src/singlefolder-tests/notebook.api.test.ts @@ -83,14 +83,14 @@ const apiTestSerializer: vscode.NotebookSerializer = { }, deserializeNotebook(_content, _token) { const dto: vscode.NotebookData = { - metadata: { custom: { testMetadata: false } }, + metadata: { testMetadata: false }, cells: [ { value: 'test', languageId: 'typescript', kind: vscode.NotebookCellKind.Code, outputs: [], - metadata: { custom: { testCellMetadata: 123 } }, + metadata: { testCellMetadata: 123 }, executionSummary: { timing: { startTime: 10, endTime: 20 } } }, { @@ -107,7 +107,7 @@ const apiTestSerializer: vscode.NotebookSerializer = { }) ], executionSummary: { executionOrder: 5, success: true }, - metadata: { custom: { testCellMetadata: 456 } } + metadata: { testCellMetadata: 456 } } ] }; @@ -230,6 +230,30 @@ const apiTestSerializer: vscode.NotebookSerializer = { await closeAllEditors(); }); + test('#207742 - New Untitled notebook failed if previous untilted notebook is modified', async function () { + await vscode.commands.executeCommand('ipynb.newUntitledIpynb'); + assert.notStrictEqual(vscode.window.activeNotebookEditor, undefined, 'untitled notebook editor is not undefined'); + const document = vscode.window.activeNotebookEditor!.notebook; + + // open another text editor + const textDocument = await vscode.workspace.openTextDocument({ language: 'javascript', content: 'let abc = 0;' }); + await vscode.window.showTextDocument(textDocument); + + // insert a new cell to notebook document + const edit = new vscode.WorkspaceEdit(); + const notebookEdit = new vscode.NotebookEdit(new vscode.NotebookRange(1, 1), [new vscode.NotebookCellData(vscode.NotebookCellKind.Code, 'print(1)', 'python')]); + edit.set(document.uri, [notebookEdit]); + await vscode.workspace.applyEdit(edit); + + // switch to the notebook editor + await vscode.window.showNotebookDocument(document); + await closeAllEditors(); + await vscode.commands.executeCommand('ipynb.newUntitledIpynb'); + assert.notStrictEqual(vscode.window.activeNotebookEditor, undefined, 'untitled notebook editor is not undefined'); + + await closeAllEditors(); + }); + // TODO: Skipped due to notebook content provider removal test.skip('#115855 onDidSaveNotebookDocument', async function () { const resource = await createRandomNotebookFile(); diff --git a/extensions/vscode-api-tests/src/singlefolder-tests/notebook.document.test.ts b/extensions/vscode-api-tests/src/singlefolder-tests/notebook.document.test.ts index fe57d7a883c64..8d193edcc91a6 100644 --- a/extensions/vscode-api-tests/src/singlefolder-tests/notebook.document.test.ts +++ b/extensions/vscode-api-tests/src/singlefolder-tests/notebook.document.test.ts @@ -295,11 +295,11 @@ suite('Notebook Document', function () { const document = await vscode.workspace.openNotebookDocument(uri); const edit = new vscode.WorkspaceEdit(); - const metdataEdit = vscode.NotebookEdit.updateNotebookMetadata({ ...document.metadata, custom: { ...(document.metadata.custom || {}), extraNotebookMetadata: true } }); + const metdataEdit = vscode.NotebookEdit.updateNotebookMetadata({ ...document.metadata, extraNotebookMetadata: true }); edit.set(document.uri, [metdataEdit]); const success = await vscode.workspace.applyEdit(edit); assert.equal(success, true); - assert.ok(document.metadata.custom.extraNotebookMetadata, `Test metadata not found`); + assert.ok(document.metadata.extraNotebookMetadata, `Test metadata not found`); }); test('setTextDocumentLanguage for notebook cells', async function () { diff --git a/extensions/vscode-api-tests/src/singlefolder-tests/notebook.kernel.test.ts b/extensions/vscode-api-tests/src/singlefolder-tests/notebook.kernel.test.ts index 58297eb4e5bc9..37e16207ddb33 100644 --- a/extensions/vscode-api-tests/src/singlefolder-tests/notebook.kernel.test.ts +++ b/extensions/vscode-api-tests/src/singlefolder-tests/notebook.kernel.test.ts @@ -91,14 +91,14 @@ const apiTestSerializer: vscode.NotebookSerializer = { }, deserializeNotebook(_content, _token) { const dto: vscode.NotebookData = { - metadata: { custom: { testMetadata: false } }, + metadata: { testMetadata: false }, cells: [ { value: 'test', languageId: 'typescript', kind: vscode.NotebookCellKind.Code, outputs: [], - metadata: { custom: { testCellMetadata: 123 } }, + metadata: { testCellMetadata: 123 }, executionSummary: { timing: { startTime: 10, endTime: 20 } } }, { @@ -115,7 +115,7 @@ const apiTestSerializer: vscode.NotebookSerializer = { }) ], executionSummary: { executionOrder: 5, success: true }, - metadata: { custom: { testCellMetadata: 456 } } + metadata: { testCellMetadata: 456 } } ] }; diff --git a/extensions/vscode-api-tests/src/singlefolder-tests/quickInput.test.ts b/extensions/vscode-api-tests/src/singlefolder-tests/quickInput.test.ts index ba7ce21e32f02..4f8331c286ff4 100644 --- a/extensions/vscode-api-tests/src/singlefolder-tests/quickInput.test.ts +++ b/extensions/vscode-api-tests/src/singlefolder-tests/quickInput.test.ts @@ -139,9 +139,9 @@ suite('vscode API - quick input', function () { }; const quickPick = createQuickPick({ - events: ['active', 'selection', 'accept', 'active', 'selection', 'active', 'selection', 'accept', 'hide'], - activeItems: [['eins'], [], ['drei']], - selectionItems: [['eins'], [], ['drei']], + events: ['active', 'selection', 'accept', 'active', 'selection', 'accept', 'hide'], + activeItems: [['eins'], ['drei']], + selectionItems: [['eins'], ['drei']], acceptedItems: { active: [['eins'], ['drei']], selection: [['eins'], ['drei']], diff --git a/extensions/vscode-api-tests/src/singlefolder-tests/state.test.ts b/extensions/vscode-api-tests/src/singlefolder-tests/state.test.ts index 4b007a918a3a5..64b6354ac9de6 100644 --- a/extensions/vscode-api-tests/src/singlefolder-tests/state.test.ts +++ b/extensions/vscode-api-tests/src/singlefolder-tests/state.test.ts @@ -36,7 +36,7 @@ suite('vscode API - globalState / workspaceState', () => { await state.update('state.test.get', undefined); keys = state.keys(); - assert.strictEqual(keys.length, 0); + assert.strictEqual(keys.length, 0, `Unexpected keys: ${JSON.stringify(keys)}`); res = state.get('state.test.get', 'default'); assert.strictEqual(res, 'default'); diff --git a/extensions/vscode-api-tests/src/singlefolder-tests/workspace.test.ts b/extensions/vscode-api-tests/src/singlefolder-tests/workspace.test.ts index e69eecff5d132..2eb115761d15b 100644 --- a/extensions/vscode-api-tests/src/singlefolder-tests/workspace.test.ts +++ b/extensions/vscode-api-tests/src/singlefolder-tests/workspace.test.ts @@ -597,6 +597,43 @@ suite('vscode API - workspace', () => { }); }); + test('`findFiles2`', () => { + return vscode.workspace.findFiles2('**/image.png').then((res) => { + assert.strictEqual(res.length, 2); + }); + }); + + test('findFiles2 - null exclude', async () => { + await vscode.workspace.findFiles2('**/file.txt', { useDefaultExcludes: true, useDefaultSearchExcludes: false }).then((res) => { + // file.exclude folder is still searched, search.exclude folder is not + assert.strictEqual(res.length, 1); + assert.strictEqual(basename(vscode.workspace.asRelativePath(res[0])), 'file.txt'); + }); + + await vscode.workspace.findFiles2('**/file.txt', { useDefaultExcludes: false, useDefaultSearchExcludes: false }).then((res) => { + // search.exclude and files.exclude folders are both searched + assert.strictEqual(res.length, 2); + assert.strictEqual(basename(vscode.workspace.asRelativePath(res[0])), 'file.txt'); + }); + }); + + test('findFiles2, exclude', () => { + return vscode.workspace.findFiles2('**/image.png', { exclude: '**/sub/**' }).then((res) => { + assert.strictEqual(res.length, 1); + }); + }); + + test('findFiles2, cancellation', () => { + + const source = new vscode.CancellationTokenSource(); + const token = source.token; // just to get an instance first + source.cancel(); + + return vscode.workspace.findFiles2('*.js', {}, token).then((res) => { + assert.deepStrictEqual(res, []); + }); + }); + test('findTextInFiles', async () => { const options: vscode.FindTextInFilesOptions = { include: '*.ts', @@ -897,7 +934,7 @@ suite('vscode API - workspace', () => { async function test77735(withOpenedEditor: boolean): Promise { const docUriOriginal = await createRandomFile(); const docUriMoved = docUriOriginal.with({ path: `${docUriOriginal.path}.moved` }); - await deleteFile(docUriMoved); // ensure target does not exist + await deleteFile(docUriMoved); if (withOpenedEditor) { const document = await vscode.workspace.openTextDocument(docUriOriginal); @@ -930,8 +967,9 @@ suite('vscode API - workspace', () => { const document = await vscode.workspace.openTextDocument(newUri); assert.strictEqual(document.isDirty, true); - await document.save(); - assert.strictEqual(document.isDirty, false); + const result = await document.save(); + assert.strictEqual(result, true, `save failed in iteration: ${i} (docUriOriginal: ${docUriOriginal.fsPath})`); + assert.strictEqual(document.isDirty, false, `document still dirty in iteration: ${i} (docUriOriginal: ${docUriOriginal.fsPath})`); assert.strictEqual(document.getText(), expected); diff --git a/extensions/vscode-colorize-tests/test/colorize-results/test-173216_sh.json b/extensions/vscode-colorize-tests/test/colorize-results/test-173216_sh.json index c24aadf2ed971..5948ea77fcd34 100644 --- a/extensions/vscode-colorize-tests/test/colorize-results/test-173216_sh.json +++ b/extensions/vscode-colorize-tests/test/colorize-results/test-173216_sh.json @@ -29,7 +29,7 @@ }, { "c": "declare", - "t": "source.shell meta.statement.shell meta.statement.command.shell meta.statement.command.name.shell storage.modifier.declare.shell", + "t": "source.shell meta.statement.shell meta.expression.assignment.shell storage.modifier.declare.shell", "r": { "dark_plus": "storage.modifier: #569CD6", "light_plus": "storage.modifier: #0000FF", @@ -43,7 +43,7 @@ }, { "c": " ", - "t": "source.shell meta.statement.shell meta.statement.command.shell", + "t": "source.shell meta.statement.shell meta.expression.assignment.shell", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -56,22 +56,8 @@ } }, { - "c": "-", - "t": "source.shell meta.statement.shell meta.statement.command.shell string.unquoted.argument.shell constant.other.option.dash.shell", - "r": { - "dark_plus": "constant.other.option: #569CD6", - "light_plus": "constant.other.option: #0000FF", - "dark_vs": "string: #CE9178", - "light_vs": "string: #A31515", - "hc_black": "string: #CE9178", - "dark_modern": "constant.other.option: #569CD6", - "hc_light": "string: #0F4A85", - "light_modern": "constant.other.option: #0000FF" - } - }, - { - "c": "A", - "t": "source.shell meta.statement.shell meta.statement.command.shell string.unquoted.argument constant.other.option", + "c": "-A", + "t": "source.shell meta.statement.shell meta.expression.assignment.shell string.unquoted.argument.shell constant.other.option.shell", "r": { "dark_plus": "constant.other.option: #569CD6", "light_plus": "constant.other.option: #0000FF", @@ -85,7 +71,7 @@ }, { "c": " ", - "t": "source.shell meta.statement.shell meta.statement.command.shell meta.argument.shell", + "t": "source.shell meta.statement.shell meta.expression.assignment.shell", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -98,22 +84,36 @@ } }, { - "c": "juices=", - "t": "source.shell meta.statement.shell meta.statement.command.shell meta.argument.shell string.unquoted.argument.shell", + "c": "juices", + "t": "source.shell meta.statement.shell meta.expression.assignment.shell variable.other.assignment.shell", "r": { - "dark_plus": "string: #CE9178", - "light_plus": "string: #A31515", - "dark_vs": "string: #CE9178", - "light_vs": "string: #A31515", - "hc_black": "string: #CE9178", - "dark_modern": "string: #CE9178", - "hc_light": "string: #0F4A85", - "light_modern": "string: #A31515" + "dark_plus": "variable: #9CDCFE", + "light_plus": "variable: #001080", + "dark_vs": "default: #D4D4D4", + "light_vs": "default: #000000", + "hc_black": "variable: #9CDCFE", + "dark_modern": "variable: #9CDCFE", + "hc_light": "variable: #001080", + "light_modern": "variable: #001080" + } + }, + { + "c": "=", + "t": "source.shell meta.statement.shell meta.expression.assignment.shell keyword.operator.assignment.shell", + "r": { + "dark_plus": "keyword.operator: #D4D4D4", + "light_plus": "keyword.operator: #000000", + "dark_vs": "keyword.operator: #D4D4D4", + "light_vs": "keyword.operator: #000000", + "hc_black": "keyword.operator: #D4D4D4", + "dark_modern": "keyword.operator: #D4D4D4", + "hc_light": "keyword.operator: #000000", + "light_modern": "keyword.operator: #000000" } }, { "c": "(", - "t": "source.shell meta.statement.shell meta.statement.command.shell meta.argument.shell", + "t": "source.shell meta.statement.shell meta.expression.assignment.shell punctuation.definition.array.shell", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -127,7 +127,7 @@ }, { "c": " ", - "t": "source.shell meta.statement.shell", + "t": "source.shell meta.statement.shell meta.expression.assignment.shell", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -141,7 +141,7 @@ }, { "c": "[", - "t": "source.shell meta.statement.shell meta.scope.logical-expression.shell punctuation.definition.logical-expression.shell", + "t": "source.shell meta.statement.shell meta.expression.assignment.shell punctuation.definition.bracket.shell", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -154,50 +154,22 @@ } }, { - "c": "'", - "t": "source.shell meta.statement.shell meta.scope.logical-expression.shell string.quoted.single.shell punctuation.definition.string.begin.shell", - "r": { - "dark_plus": "string: #CE9178", - "light_plus": "string: #A31515", - "dark_vs": "string: #CE9178", - "light_vs": "string: #A31515", - "hc_black": "string: #CE9178", - "dark_modern": "string: #CE9178", - "hc_light": "string: #0F4A85", - "light_modern": "string: #A31515" - } - }, - { - "c": "apple", - "t": "source.shell meta.statement.shell meta.scope.logical-expression.shell string.quoted.single.shell", - "r": { - "dark_plus": "string: #CE9178", - "light_plus": "string: #A31515", - "dark_vs": "string: #CE9178", - "light_vs": "string: #A31515", - "hc_black": "string: #CE9178", - "dark_modern": "string: #CE9178", - "hc_light": "string: #0F4A85", - "light_modern": "string: #A31515" - } - }, - { - "c": "'", - "t": "source.shell meta.statement.shell meta.scope.logical-expression.shell string.quoted.single.shell punctuation.definition.string.end.shell", + "c": "'apple'", + "t": "source.shell meta.statement.shell meta.expression.assignment.shell string.unquoted.shell entity.other.attribute-name.shell", "r": { - "dark_plus": "string: #CE9178", - "light_plus": "string: #A31515", - "dark_vs": "string: #CE9178", - "light_vs": "string: #A31515", - "hc_black": "string: #CE9178", - "dark_modern": "string: #CE9178", - "hc_light": "string: #0F4A85", - "light_modern": "string: #A31515" + "dark_plus": "entity.other.attribute-name: #9CDCFE", + "light_plus": "entity.other.attribute-name: #E50000", + "dark_vs": "entity.other.attribute-name: #9CDCFE", + "light_vs": "entity.other.attribute-name: #E50000", + "hc_black": "entity.other.attribute-name: #9CDCFE", + "dark_modern": "entity.other.attribute-name: #9CDCFE", + "hc_light": "entity.other.attribute-name: #264F78", + "light_modern": "entity.other.attribute-name: #E50000" } }, { "c": "]", - "t": "source.shell meta.statement.shell meta.scope.logical-expression.shell punctuation.definition.logical-expression.shell", + "t": "source.shell meta.statement.shell meta.expression.assignment.shell punctuation.definition.bracket.shell", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -211,7 +183,7 @@ }, { "c": "=", - "t": "source.shell meta.statement.shell meta.statement.command.shell meta.statement.command.name.shell", + "t": "source.shell meta.statement.shell meta.expression.assignment.shell punctuation.definition.assignment.shell", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -225,49 +197,49 @@ }, { "c": "'", - "t": "source.shell meta.statement.shell meta.statement.command.shell meta.statement.command.name.shell meta.statement.command.name.quoted.shell string.quoted.single.shell punctuation.definition.string.begin.shell entity.name.function.call.shell entity.name.command.shell", + "t": "source.shell meta.statement.shell meta.expression.assignment.shell string.quoted.single.shell punctuation.definition.string.begin.shell", "r": { - "dark_plus": "entity.name.function: #DCDCAA", - "light_plus": "entity.name.function: #795E26", + "dark_plus": "string: #CE9178", + "light_plus": "string: #A31515", "dark_vs": "string: #CE9178", "light_vs": "string: #A31515", - "hc_black": "entity.name.function: #DCDCAA", - "dark_modern": "entity.name.function: #DCDCAA", - "hc_light": "entity.name.function: #5E2CBC", - "light_modern": "entity.name.function: #795E26" + "hc_black": "string: #CE9178", + "dark_modern": "string: #CE9178", + "hc_light": "string: #0F4A85", + "light_modern": "string: #A31515" } }, { "c": "Apple Juice", - "t": "source.shell meta.statement.shell meta.statement.command.shell meta.statement.command.name.shell meta.statement.command.name.continuation string.quoted.single entity.name.function.call entity.name.command", + "t": "source.shell meta.statement.shell meta.expression.assignment.shell string.quoted.single.shell", "r": { - "dark_plus": "entity.name.function: #DCDCAA", - "light_plus": "entity.name.function: #795E26", + "dark_plus": "string: #CE9178", + "light_plus": "string: #A31515", "dark_vs": "string: #CE9178", "light_vs": "string: #A31515", - "hc_black": "entity.name.function: #DCDCAA", - "dark_modern": "entity.name.function: #DCDCAA", - "hc_light": "entity.name.function: #5E2CBC", - "light_modern": "entity.name.function: #795E26" + "hc_black": "string: #CE9178", + "dark_modern": "string: #CE9178", + "hc_light": "string: #0F4A85", + "light_modern": "string: #A31515" } }, { "c": "'", - "t": "source.shell meta.statement.shell meta.statement.command.shell meta.statement.command.name.shell string.quoted.single.shell punctuation.definition.string.end.shell entity.name.function.call.shell entity.name.command.shell", + "t": "source.shell meta.statement.shell meta.expression.assignment.shell string.quoted.single.shell punctuation.definition.string.end.shell", "r": { - "dark_plus": "entity.name.function: #DCDCAA", - "light_plus": "entity.name.function: #795E26", + "dark_plus": "string: #CE9178", + "light_plus": "string: #A31515", "dark_vs": "string: #CE9178", "light_vs": "string: #A31515", - "hc_black": "entity.name.function: #DCDCAA", - "dark_modern": "entity.name.function: #DCDCAA", - "hc_light": "entity.name.function: #5E2CBC", - "light_modern": "entity.name.function: #795E26" + "hc_black": "string: #CE9178", + "dark_modern": "string: #CE9178", + "hc_light": "string: #0F4A85", + "light_modern": "string: #A31515" } }, { "c": " ", - "t": "source.shell meta.statement.shell", + "t": "source.shell meta.statement.shell meta.expression.assignment.shell", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -281,7 +253,7 @@ }, { "c": "[", - "t": "source.shell meta.statement.shell meta.scope.logical-expression.shell punctuation.definition.logical-expression.shell", + "t": "source.shell meta.statement.shell meta.expression.assignment.shell punctuation.definition.bracket.shell", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -294,50 +266,22 @@ } }, { - "c": "'", - "t": "source.shell meta.statement.shell meta.scope.logical-expression.shell string.quoted.single.shell punctuation.definition.string.begin.shell", - "r": { - "dark_plus": "string: #CE9178", - "light_plus": "string: #A31515", - "dark_vs": "string: #CE9178", - "light_vs": "string: #A31515", - "hc_black": "string: #CE9178", - "dark_modern": "string: #CE9178", - "hc_light": "string: #0F4A85", - "light_modern": "string: #A31515" - } - }, - { - "c": "orange", - "t": "source.shell meta.statement.shell meta.scope.logical-expression.shell string.quoted.single.shell", - "r": { - "dark_plus": "string: #CE9178", - "light_plus": "string: #A31515", - "dark_vs": "string: #CE9178", - "light_vs": "string: #A31515", - "hc_black": "string: #CE9178", - "dark_modern": "string: #CE9178", - "hc_light": "string: #0F4A85", - "light_modern": "string: #A31515" - } - }, - { - "c": "'", - "t": "source.shell meta.statement.shell meta.scope.logical-expression.shell string.quoted.single.shell punctuation.definition.string.end.shell", + "c": "'orange'", + "t": "source.shell meta.statement.shell meta.expression.assignment.shell string.unquoted.shell entity.other.attribute-name.shell", "r": { - "dark_plus": "string: #CE9178", - "light_plus": "string: #A31515", - "dark_vs": "string: #CE9178", - "light_vs": "string: #A31515", - "hc_black": "string: #CE9178", - "dark_modern": "string: #CE9178", - "hc_light": "string: #0F4A85", - "light_modern": "string: #A31515" + "dark_plus": "entity.other.attribute-name: #9CDCFE", + "light_plus": "entity.other.attribute-name: #E50000", + "dark_vs": "entity.other.attribute-name: #9CDCFE", + "light_vs": "entity.other.attribute-name: #E50000", + "hc_black": "entity.other.attribute-name: #9CDCFE", + "dark_modern": "entity.other.attribute-name: #9CDCFE", + "hc_light": "entity.other.attribute-name: #264F78", + "light_modern": "entity.other.attribute-name: #E50000" } }, { "c": "]", - "t": "source.shell meta.statement.shell meta.scope.logical-expression.shell punctuation.definition.logical-expression.shell", + "t": "source.shell meta.statement.shell meta.expression.assignment.shell punctuation.definition.bracket.shell", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -351,7 +295,7 @@ }, { "c": "=", - "t": "source.shell meta.statement.shell meta.statement.command.shell meta.statement.command.name.shell", + "t": "source.shell meta.statement.shell meta.expression.assignment.shell punctuation.definition.assignment.shell", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -365,49 +309,49 @@ }, { "c": "'", - "t": "source.shell meta.statement.shell meta.statement.command.shell meta.statement.command.name.shell meta.statement.command.name.quoted.shell string.quoted.single.shell punctuation.definition.string.begin.shell entity.name.function.call.shell entity.name.command.shell", + "t": "source.shell meta.statement.shell meta.expression.assignment.shell string.quoted.single.shell punctuation.definition.string.begin.shell", "r": { - "dark_plus": "entity.name.function: #DCDCAA", - "light_plus": "entity.name.function: #795E26", + "dark_plus": "string: #CE9178", + "light_plus": "string: #A31515", "dark_vs": "string: #CE9178", "light_vs": "string: #A31515", - "hc_black": "entity.name.function: #DCDCAA", - "dark_modern": "entity.name.function: #DCDCAA", - "hc_light": "entity.name.function: #5E2CBC", - "light_modern": "entity.name.function: #795E26" + "hc_black": "string: #CE9178", + "dark_modern": "string: #CE9178", + "hc_light": "string: #0F4A85", + "light_modern": "string: #A31515" } }, { "c": "Orange Juice", - "t": "source.shell meta.statement.shell meta.statement.command.shell meta.statement.command.name.shell meta.statement.command.name.continuation string.quoted.single entity.name.function.call entity.name.command", + "t": "source.shell meta.statement.shell meta.expression.assignment.shell string.quoted.single.shell", "r": { - "dark_plus": "entity.name.function: #DCDCAA", - "light_plus": "entity.name.function: #795E26", + "dark_plus": "string: #CE9178", + "light_plus": "string: #A31515", "dark_vs": "string: #CE9178", "light_vs": "string: #A31515", - "hc_black": "entity.name.function: #DCDCAA", - "dark_modern": "entity.name.function: #DCDCAA", - "hc_light": "entity.name.function: #5E2CBC", - "light_modern": "entity.name.function: #795E26" + "hc_black": "string: #CE9178", + "dark_modern": "string: #CE9178", + "hc_light": "string: #0F4A85", + "light_modern": "string: #A31515" } }, { "c": "'", - "t": "source.shell meta.statement.shell meta.statement.command.shell meta.statement.command.name.shell string.quoted.single.shell punctuation.definition.string.end.shell entity.name.function.call.shell entity.name.command.shell", + "t": "source.shell meta.statement.shell meta.expression.assignment.shell string.quoted.single.shell punctuation.definition.string.end.shell", "r": { - "dark_plus": "entity.name.function: #DCDCAA", - "light_plus": "entity.name.function: #795E26", + "dark_plus": "string: #CE9178", + "light_plus": "string: #A31515", "dark_vs": "string: #CE9178", "light_vs": "string: #A31515", - "hc_black": "entity.name.function: #DCDCAA", - "dark_modern": "entity.name.function: #DCDCAA", - "hc_light": "entity.name.function: #5E2CBC", - "light_modern": "entity.name.function: #795E26" + "hc_black": "string: #CE9178", + "dark_modern": "string: #CE9178", + "hc_light": "string: #0F4A85", + "light_modern": "string: #A31515" } }, { "c": ")", - "t": "source.shell meta.statement.shell", + "t": "source.shell meta.statement.shell meta.expression.assignment.shell punctuation.definition.array.shell", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", diff --git a/extensions/vscode-colorize-tests/test/colorize-results/test-173336_sh.json b/extensions/vscode-colorize-tests/test/colorize-results/test-173336_sh.json index 198ace2200502..ef48d804f66e2 100644 --- a/extensions/vscode-colorize-tests/test/colorize-results/test-173336_sh.json +++ b/extensions/vscode-colorize-tests/test/colorize-results/test-173336_sh.json @@ -29,7 +29,7 @@ }, { "c": "cmd", - "t": "source.shell meta.statement.shell meta.expression.assignment.shell variable.other.assignment.shell", + "t": "source.shell meta.statement.shell variable.other.assignment.shell", "r": { "dark_plus": "variable: #9CDCFE", "light_plus": "variable: #001080", @@ -43,7 +43,7 @@ }, { "c": "=", - "t": "source.shell meta.statement.shell meta.expression.assignment.shell keyword.operator.assignment.shell", + "t": "source.shell meta.statement.shell keyword.operator.assignment.shell", "r": { "dark_plus": "keyword.operator: #D4D4D4", "light_plus": "keyword.operator: #000000", @@ -57,7 +57,7 @@ }, { "c": "(", - "t": "source.shell meta.statement.shell meta.expression.assignment.shell punctuation.shell", + "t": "source.shell meta.statement.shell punctuation.definition.array.shell", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -71,7 +71,7 @@ }, { "c": " ", - "t": "source.shell meta.statement.shell meta.expression.assignment.shell", + "t": "source.shell meta.statement.shell", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -85,7 +85,7 @@ }, { "c": "'", - "t": "source.shell meta.statement.shell meta.expression.assignment.shell string.quoted.single.shell punctuation.definition.string.begin.shell", + "t": "source.shell meta.statement.shell string.quoted.single.shell punctuation.definition.string.begin.shell", "r": { "dark_plus": "string: #CE9178", "light_plus": "string: #A31515", @@ -99,7 +99,7 @@ }, { "c": "ls", - "t": "source.shell meta.statement.shell meta.expression.assignment.shell string.quoted.single.shell", + "t": "source.shell meta.statement.shell string.quoted.single.shell", "r": { "dark_plus": "string: #CE9178", "light_plus": "string: #A31515", @@ -113,7 +113,7 @@ }, { "c": "'", - "t": "source.shell meta.statement.shell meta.expression.assignment.shell string.quoted.single.shell punctuation.definition.string.end.shell", + "t": "source.shell meta.statement.shell string.quoted.single.shell punctuation.definition.string.end.shell", "r": { "dark_plus": "string: #CE9178", "light_plus": "string: #A31515", @@ -127,7 +127,7 @@ }, { "c": " ", - "t": "source.shell meta.statement.shell meta.expression.assignment.shell", + "t": "source.shell meta.statement.shell", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -141,7 +141,7 @@ }, { "c": "'", - "t": "source.shell meta.statement.shell meta.expression.assignment.shell string.quoted.single.shell punctuation.definition.string.begin.shell", + "t": "source.shell meta.statement.shell string.quoted.single.shell punctuation.definition.string.begin.shell", "r": { "dark_plus": "string: #CE9178", "light_plus": "string: #A31515", @@ -155,7 +155,7 @@ }, { "c": "-la", - "t": "source.shell meta.statement.shell meta.expression.assignment.shell string.quoted.single.shell", + "t": "source.shell meta.statement.shell string.quoted.single.shell", "r": { "dark_plus": "string: #CE9178", "light_plus": "string: #A31515", @@ -169,7 +169,7 @@ }, { "c": "'", - "t": "source.shell meta.statement.shell meta.expression.assignment.shell string.quoted.single.shell punctuation.definition.string.end.shell", + "t": "source.shell meta.statement.shell string.quoted.single.shell punctuation.definition.string.end.shell", "r": { "dark_plus": "string: #CE9178", "light_plus": "string: #A31515", @@ -183,7 +183,7 @@ }, { "c": " ", - "t": "source.shell meta.statement.shell meta.expression.assignment.shell", + "t": "source.shell meta.statement.shell", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -197,7 +197,7 @@ }, { "c": ")", - "t": "source.shell meta.statement.shell meta.expression.assignment.shell punctuation.shell", + "t": "source.shell meta.statement.shell punctuation.definition.array.shell", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", diff --git a/extensions/vscode-colorize-tests/test/colorize-results/test_sh.json b/extensions/vscode-colorize-tests/test/colorize-results/test_sh.json index b725f8255df49..9323c747ca808 100644 --- a/extensions/vscode-colorize-tests/test/colorize-results/test_sh.json +++ b/extensions/vscode-colorize-tests/test/colorize-results/test_sh.json @@ -1946,7 +1946,7 @@ } }, { - "c": " /path/file", + "c": " ", "t": "source.shell meta.statement.shell meta.statement.command.shell meta.argument.shell", "r": { "dark_plus": "default: #D4D4D4", @@ -1959,6 +1959,20 @@ "light_modern": "default: #3B3B3B" } }, + { + "c": "/path/file", + "t": "source.shell meta.statement.shell meta.statement.command.shell meta.argument.shell string.unquoted.argument.shell", + "r": { + "dark_plus": "string: #CE9178", + "light_plus": "string: #A31515", + "dark_vs": "string: #CE9178", + "light_vs": "string: #A31515", + "hc_black": "string: #CE9178", + "dark_modern": "string: #CE9178", + "hc_light": "string: #0F4A85", + "light_modern": "string: #A31515" + } + }, { "c": "\t# A heredoc with a variable ", "t": "source.shell meta.statement.shell meta.statement.command.shell meta.argument.shell string.unquoted.heredoc.indent", @@ -2115,7 +2129,7 @@ }, { "c": "\t", - "t": "source.shell meta.function.shell meta.function.body.shell meta.statement.shell", + "t": "source.shell meta.function.shell meta.function.body.shell meta.statement.shell meta.statement.command.shell", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -2465,7 +2479,7 @@ }, { "c": "export", - "t": "source.shell meta.function.shell meta.function.body.shell meta.statement.shell meta.expression.assignment.shell storage.modifier.export.shell", + "t": "source.shell meta.function.shell meta.function.body.shell meta.statement.shell meta.statement.shell meta.expression.assignment.shell storage.modifier.export.shell", "r": { "dark_plus": "storage.modifier: #569CD6", "light_plus": "storage.modifier: #0000FF", @@ -2479,7 +2493,7 @@ }, { "c": " ", - "t": "source.shell meta.function.shell meta.function.body.shell meta.statement.shell meta.expression.assignment.shell", + "t": "source.shell meta.function.shell meta.function.body.shell meta.statement.shell meta.statement.shell meta.expression.assignment.shell", "r": { "dark_plus": "default: #D4D4D4", "light_plus": "default: #000000", @@ -2493,7 +2507,7 @@ }, { "c": "NODE_ENV", - "t": "source.shell meta.function.shell meta.function.body.shell meta.statement.shell meta.expression.assignment.shell variable.other.assignment.shell", + "t": "source.shell meta.function.shell meta.function.body.shell meta.statement.shell meta.statement.shell meta.expression.assignment.shell variable.other.assignment.shell", "r": { "dark_plus": "variable: #9CDCFE", "light_plus": "variable: #001080", @@ -2507,7 +2521,7 @@ }, { "c": "=", - "t": "source.shell meta.function.shell meta.function.body.shell meta.statement.shell meta.expression.assignment.shell keyword.operator.assignment.shell", + "t": "source.shell meta.function.shell meta.function.body.shell meta.statement.shell meta.statement.shell meta.expression.assignment.shell keyword.operator.assignment.shell", "r": { "dark_plus": "keyword.operator: #D4D4D4", "light_plus": "keyword.operator: #000000", @@ -2521,16 +2535,16 @@ }, { "c": "development", - "t": "source.shell meta.function.shell meta.function.body.shell meta.statement.shell meta.expression.assignment.shell string.unquoted.argument.shell", + "t": "source.shell meta.function.shell meta.function.body.shell meta.statement.shell meta.statement.shell meta.expression.assignment.shell variable.other.assignment.shell", "r": { - "dark_plus": "string: #CE9178", - "light_plus": "string: #A31515", - "dark_vs": "string: #CE9178", - "light_vs": "string: #A31515", - "hc_black": "string: #CE9178", - "dark_modern": "string: #CE9178", - "hc_light": "string: #0F4A85", - "light_modern": "string: #A31515" + "dark_plus": "variable: #9CDCFE", + "light_plus": "variable: #001080", + "dark_vs": "default: #D4D4D4", + "light_vs": "default: #000000", + "hc_black": "variable: #9CDCFE", + "dark_modern": "variable: #9CDCFE", + "hc_light": "variable: #001080", + "light_modern": "variable: #001080" } }, { diff --git a/extensions/yaml/package.json b/extensions/yaml/package.json index 96e02e37da661..5223f71c52b7a 100644 --- a/extensions/yaml/package.json +++ b/extensions/yaml/package.json @@ -37,10 +37,10 @@ "yaml" ], "extensions": [ + ".yaml", ".yml", ".eyaml", ".eyml", - ".yaml", ".cff", ".yaml-tmlanguage", ".yaml-tmpreferences", diff --git a/extensions/yarn.lock b/extensions/yarn.lock index f4543f6a1c9cc..e40d6068fe52f 100644 --- a/extensions/yarn.lock +++ b/extensions/yarn.lock @@ -234,10 +234,10 @@ to-regex-range@^5.0.1: dependencies: is-number "^7.0.0" -typescript@5.3.2: - version "5.3.2" - resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.3.2.tgz#00d1c7c1c46928c5845c1ee8d0cc2791031d4c43" - integrity sha512-6l+RyNy7oAHDfxC4FzSJcz9vnjTKxrLpDG5M2Vu4SHRVNg6xzqZp6LYSR9zjqQTu8DU/f5xwxUdADOkbrIX2gQ== +typescript@5.4.3: + version "5.4.3" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.4.3.tgz#5c6fedd4c87bee01cd7a528a30145521f8e0feff" + integrity sha512-KrPd3PKaCLr78MalgiwJnA25Nm8HAmdwN3mYUYZgG/wizIo9EainNVQI9/yDavtVFRN2h3k8uf3GLHuhDMgEHg== vscode-grammar-updater@^1.1.0: version "1.1.0" diff --git a/package.json b/package.json index c94ab6966c44f..66c83e406be79 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "code-oss-dev", - "version": "1.87.0", - "distro": "0a14a7cde028e801c7aea8415afac7ddf3c9a0bd", + "version": "1.89.0", + "distro": "51cc447f78b8a92b468906c0718cda74314ba794", "author": { "name": "Microsoft Corporation" }, @@ -63,7 +63,8 @@ "core-ci-pr": "node --max-old-space-size=4095 ./node_modules/gulp/bin/gulp.js core-ci-pr", "extensions-ci": "node --max-old-space-size=8095 ./node_modules/gulp/bin/gulp.js extensions-ci", "extensions-ci-pr": "node --max-old-space-size=4095 ./node_modules/gulp/bin/gulp.js extensions-ci-pr", - "perf": "node scripts/code-perf.js" + "perf": "node scripts/code-perf.js", + "update-build-ts-version": "yarn add typescript@next && tsc -p ./build/tsconfig.build.json" }, "dependencies": { "@microsoft/1ds-core-js": "^3.2.13", @@ -80,14 +81,14 @@ "@vscode/windows-mutex": "^0.5.0", "@vscode/windows-process-tree": "^0.6.0", "@vscode/windows-registry": "^1.1.0", - "@xterm/addon-canvas": "0.6.0-beta.31", - "@xterm/addon-image": "0.7.0-beta.29", - "@xterm/addon-search": "0.14.0-beta.31", - "@xterm/addon-serialize": "0.12.0-beta.31", - "@xterm/addon-unicode11": "0.7.0-beta.31", - "@xterm/addon-webgl": "0.17.0-beta.31", - "@xterm/headless": "5.4.0-beta.31", - "@xterm/xterm": "5.4.0-beta.31", + "@xterm/addon-canvas": "0.7.0-beta.12", + "@xterm/addon-image": "0.8.0-beta.12", + "@xterm/addon-search": "0.15.0-beta.12", + "@xterm/addon-serialize": "0.13.0-beta.12", + "@xterm/addon-unicode11": "0.8.0-beta.12", + "@xterm/addon-webgl": "0.18.0-beta.12", + "@xterm/headless": "5.5.0-beta.12", + "@xterm/xterm": "5.5.0-beta.12", "graceful-fs": "4.2.11", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.2", @@ -97,13 +98,13 @@ "native-is-elevated": "0.7.0", "native-keymap": "^3.3.4", "native-watchdog": "^1.4.1", - "node-pty": "1.1.0-beta6", + "node-pty": "1.1.0-beta11", "tas-client-umd": "0.1.8", - "v8-inspect-profiler": "^0.1.0", + "v8-inspect-profiler": "^0.1.1", "vscode-oniguruma": "1.7.0", "vscode-regexpp": "^3.1.0", "vscode-textmate": "9.0.0", - "yauzl": "^2.9.2", + "yauzl": "^3.0.0", "yazl": "^2.4.3" }, "devDependencies": { @@ -112,7 +113,6 @@ "@types/cookie": "^0.3.3", "@types/debug": "^4.1.5", "@types/graceful-fs": "4.1.2", - "@types/gulp-postcss": "^8.0.6", "@types/gulp-svgmin": "^1.2.1", "@types/http-proxy-agent": "^2.0.1", "@types/kerberos": "^1.1.2", @@ -127,15 +127,15 @@ "@types/wicg-file-system-access": "^2020.9.6", "@types/windows-foreground-love": "^0.3.0", "@types/winreg": "^1.2.30", - "@types/yauzl": "^2.9.1", + "@types/yauzl": "^2.10.0", "@types/yazl": "^2.4.2", - "@typescript-eslint/eslint-plugin": "^5.57.0", + "@typescript-eslint/eslint-plugin": "^6.21.0", "@typescript-eslint/experimental-utils": "^5.57.0", - "@typescript-eslint/parser": "^5.57.0", + "@typescript-eslint/parser": "^6.21.0", "@vscode/gulp-electron": "^1.36.0", "@vscode/l10n-dev": "0.0.30", "@vscode/telemetry-extractor": "^1.10.2", - "@vscode/test-cli": "^0.0.3", + "@vscode/test-cli": "^0.0.6", "@vscode/test-electron": "^2.3.8", "@vscode/test-web": "^0.0.50", "@vscode/v8-heap-parser": "^0.1.0", @@ -149,7 +149,7 @@ "cssnano": "^6.0.3", "debounce": "^1.0.0", "deemon": "^1.8.0", - "electron": "27.3.1", + "electron": "28.2.8", "eslint": "8.36.0", "eslint-plugin-header": "3.1.1", "eslint-plugin-jsdoc": "^46.5.0", @@ -170,7 +170,6 @@ "gulp-gzip": "^1.4.2", "gulp-json-editor": "^2.5.0", "gulp-plumber": "^1.2.0", - "gulp-postcss": "^9.1.0", "gulp-rename": "^1.2.0", "gulp-replace": "^0.5.4", "gulp-sourcemaps": "^3.0.0", @@ -209,7 +208,7 @@ "ts-loader": "^9.4.2", "ts-node": "^10.9.1", "tsec": "0.2.7", - "typescript": "^5.4.0-dev.20240206", + "typescript": "^5.5.0-dev.20240401", "typescript-formatter": "7.1.0", "util": "^0.12.4", "vscode-nls-dev": "^3.3.1", diff --git a/product.json b/product.json index b17eeda961047..36ca262681327 100644 --- a/product.json +++ b/product.json @@ -50,8 +50,8 @@ }, { "name": "ms-vscode.js-debug", - "version": "1.86.1", - "sha256": "e382de75b63a57d3419bbb110e17551d40d88b4bb0a49452dab2c7278b815e72", + "version": "1.88.0", + "sha256": "3a048d87c0fac116fce9190ff6042ec7691e66fc17211a058faf0db91bb6605e", "repo": "https://github.com/microsoft/vscode-js-debug", "metadata": { "id": "25629058-ddac-4e17-abba-74678e126c5d", diff --git a/remote/.yarnrc b/remote/.yarnrc index cac528fd2dd2c..4e7208cdf690f 100644 --- a/remote/.yarnrc +++ b/remote/.yarnrc @@ -1,5 +1,5 @@ disturl "https://nodejs.org/dist" -target "18.17.1" -ms_build_id "255375" +target "18.18.2" +ms_build_id "256117" runtime "node" build_from_source "true" diff --git a/remote/package.json b/remote/package.json index 1d341b00a2062..974450e8feae7 100644 --- a/remote/package.json +++ b/remote/package.json @@ -13,14 +13,14 @@ "@vscode/vscode-languagedetection": "1.0.21", "@vscode/windows-process-tree": "^0.6.0", "@vscode/windows-registry": "^1.1.0", - "@xterm/addon-canvas": "0.6.0-beta.31", - "@xterm/addon-image": "0.7.0-beta.29", - "@xterm/addon-search": "0.14.0-beta.31", - "@xterm/addon-serialize": "0.12.0-beta.31", - "@xterm/addon-unicode11": "0.7.0-beta.31", - "@xterm/addon-webgl": "0.17.0-beta.31", - "@xterm/headless": "5.4.0-beta.31", - "@xterm/xterm": "5.4.0-beta.31", + "@xterm/addon-canvas": "0.7.0-beta.12", + "@xterm/addon-image": "0.8.0-beta.12", + "@xterm/addon-search": "0.15.0-beta.12", + "@xterm/addon-serialize": "0.13.0-beta.12", + "@xterm/addon-unicode11": "0.8.0-beta.12", + "@xterm/addon-webgl": "0.18.0-beta.12", + "@xterm/headless": "5.5.0-beta.12", + "@xterm/xterm": "5.5.0-beta.12", "cookie": "^0.4.0", "graceful-fs": "4.2.11", "http-proxy-agent": "^7.0.0", @@ -29,12 +29,12 @@ "kerberos": "^2.0.1", "minimist": "^1.2.6", "native-watchdog": "^1.4.1", - "node-pty": "1.1.0-beta6", + "node-pty": "1.1.0-beta11", "tas-client-umd": "0.1.8", "vscode-oniguruma": "1.7.0", "vscode-regexpp": "^3.1.0", "vscode-textmate": "9.0.0", - "yauzl": "^2.9.2", + "yauzl": "^3.0.0", "yazl": "^2.4.3" } } diff --git a/remote/web/package.json b/remote/web/package.json index e891be054dd68..8b140d2171c49 100644 --- a/remote/web/package.json +++ b/remote/web/package.json @@ -7,13 +7,13 @@ "@microsoft/1ds-post-js": "^3.2.13", "@vscode/iconv-lite-umd": "0.7.0", "@vscode/vscode-languagedetection": "1.0.21", - "@xterm/addon-canvas": "0.6.0-beta.31", - "@xterm/addon-image": "0.7.0-beta.29", - "@xterm/addon-search": "0.14.0-beta.31", - "@xterm/addon-serialize": "0.12.0-beta.31", - "@xterm/addon-unicode11": "0.7.0-beta.31", - "@xterm/addon-webgl": "0.17.0-beta.31", - "@xterm/xterm": "5.4.0-beta.31", + "@xterm/addon-canvas": "0.7.0-beta.12", + "@xterm/addon-image": "0.8.0-beta.12", + "@xterm/addon-search": "0.15.0-beta.12", + "@xterm/addon-serialize": "0.13.0-beta.12", + "@xterm/addon-unicode11": "0.8.0-beta.12", + "@xterm/addon-webgl": "0.18.0-beta.12", + "@xterm/xterm": "5.5.0-beta.12", "jschardet": "3.0.0", "tas-client-umd": "0.1.8", "vscode-oniguruma": "1.7.0", diff --git a/remote/web/yarn.lock b/remote/web/yarn.lock index 1af0f2be779e3..4c55cada14878 100644 --- a/remote/web/yarn.lock +++ b/remote/web/yarn.lock @@ -48,40 +48,40 @@ resolved "https://registry.yarnpkg.com/@vscode/vscode-languagedetection/-/vscode-languagedetection-1.0.21.tgz#89b48f293f6aa3341bb888c1118d16ff13b032d3" integrity sha512-zSUH9HYCw5qsCtd7b31yqkpaCU6jhtkKLkvOOA8yTrIRfBSOFb8PPhgmMicD7B/m+t4PwOJXzU1XDtrM9Fd3/g== -"@xterm/addon-canvas@0.6.0-beta.31": - version "0.6.0-beta.31" - resolved "https://registry.yarnpkg.com/@xterm/addon-canvas/-/addon-canvas-0.6.0-beta.31.tgz#17cc7d9968ede411fb23db11813b495435c068a0" - integrity sha512-jm/7FWZOgnAGG7MXjr0W4SnuIzsag+oVpyf6wAD9UlCgq5HBuk/3kJ5mYGiGR7CpdTxqXmzyBk3OhQe8npZ1aQ== - -"@xterm/addon-image@0.7.0-beta.29": - version "0.7.0-beta.29" - resolved "https://registry.yarnpkg.com/@xterm/addon-image/-/addon-image-0.7.0-beta.29.tgz#276b56007c9009e7a59605dc3809c280e7d637ed" - integrity sha512-Z5JCuhl0AcwQA+DE/kQMeSSHZbfwJVLUUBodDeujVItQrcpc9vA8mxf/qIwS3XTA/tPbFihfc/CE9zL7OFdbaw== - -"@xterm/addon-search@0.14.0-beta.31": - version "0.14.0-beta.31" - resolved "https://registry.yarnpkg.com/@xterm/addon-search/-/addon-search-0.14.0-beta.31.tgz#e6edcd257f5a66bca7e92e62684132b604fb817d" - integrity sha512-SS4CdgciLT98Uc4Dq0IjJegHcGIjGaASTcMtVkNBx9dOat9xt6lCXmtgUUj5w0KlB8nUfKrcy5T6fHgzrOzvrw== - -"@xterm/addon-serialize@0.12.0-beta.31": - version "0.12.0-beta.31" - resolved "https://registry.yarnpkg.com/@xterm/addon-serialize/-/addon-serialize-0.12.0-beta.31.tgz#daec32b94d45afcd662351d7689cb1b19eb24db7" - integrity sha512-MZ24pw33qOJrHdA6tlvwE4dSSpmIp/H9ZKtbiWZvuxVsY/hfYYPOluBQiCsOiYT7bZ8gQub2OOBX3jyMoZVxnQ== - -"@xterm/addon-unicode11@0.7.0-beta.31": - version "0.7.0-beta.31" - resolved "https://registry.yarnpkg.com/@xterm/addon-unicode11/-/addon-unicode11-0.7.0-beta.31.tgz#e1a6e965638ee6cb59b8b0777387037c42582d4b" - integrity sha512-wrZLt2s6Yjmpe4nh0Sp6DKji0EoHod7V6ABfWBf8krjmEGSleE+GSb+ZwDOMsNzLJLmxoq1e6glHcVixG1z7WQ== - -"@xterm/addon-webgl@0.17.0-beta.31": - version "0.17.0-beta.31" - resolved "https://registry.yarnpkg.com/@xterm/addon-webgl/-/addon-webgl-0.17.0-beta.31.tgz#15dfea4583ff9b65f1a442e5cdba1d1638adb05f" - integrity sha512-wqbBDDppwQ4R8o0YgnyFL8Pai2mVZqHb3E097vkFLB5Fw2hNx2dys3MgiXriSGXaUABKM3usVdZyouL6QgWdxQ== - -"@xterm/xterm@5.4.0-beta.31": - version "5.4.0-beta.31" - resolved "https://registry.yarnpkg.com/@xterm/xterm/-/xterm-5.4.0-beta.31.tgz#ff0bb3af9b00b0dfc73e84075f4218440c9886be" - integrity sha512-EpCtaYqMhJSyZrGY2sJVZeRCIRrANKtv1GGTj+IQPvk6hTiJHGrFHLM0tZ0dj0l3z65tLoOdj6EzJnjzX3Pqjw== +"@xterm/addon-canvas@0.7.0-beta.12": + version "0.7.0-beta.12" + resolved "https://registry.yarnpkg.com/@xterm/addon-canvas/-/addon-canvas-0.7.0-beta.12.tgz#200f0293c507b75064963b5fc72a115799533920" + integrity sha512-euzQyWdklaSxzmb87kuwwiVP06vuYe1oUK+CiQW24UggSXThOEvZhvYV3O6iEgLe3p+7QfgnRWohXhCM84VOew== + +"@xterm/addon-image@0.8.0-beta.12": + version "0.8.0-beta.12" + resolved "https://registry.yarnpkg.com/@xterm/addon-image/-/addon-image-0.8.0-beta.12.tgz#3fc5cea489d7159bf496b3a6d6515a109dab7226" + integrity sha512-YsBhmzwxRmym2dUA2CSm52Wt3OLhydVHM+SZmRAJ0/hvfB7dDjtuXBUSIdQWB16WWbGdi4Iazcs/TTxtarX/yA== + +"@xterm/addon-search@0.15.0-beta.12": + version "0.15.0-beta.12" + resolved "https://registry.yarnpkg.com/@xterm/addon-search/-/addon-search-0.15.0-beta.12.tgz#0c54677512135bbf820e3949c9dabeacc690d495" + integrity sha512-63ZhxXj6jBYumVrWJ7ZssICSMz+jHsXbi67tDQNMwTRO/MJxTittZeTHQ7IQrRYzKQgixrX0rLH7AwrLBrn2uQ== + +"@xterm/addon-serialize@0.13.0-beta.12": + version "0.13.0-beta.12" + resolved "https://registry.yarnpkg.com/@xterm/addon-serialize/-/addon-serialize-0.13.0-beta.12.tgz#438c1a6249cf4da3773d3de11a56638fa88d752a" + integrity sha512-/32Gpcj37Ftqf6b4+H62rcB70jLXi9IQspod/2mK3K+Yza9X+Yc8VkAz8VgpKa6tzbh3Xk0XEo/dB6kVFv1Jsg== + +"@xterm/addon-unicode11@0.8.0-beta.12": + version "0.8.0-beta.12" + resolved "https://registry.yarnpkg.com/@xterm/addon-unicode11/-/addon-unicode11-0.8.0-beta.12.tgz#7066297e2c662f6c235a4814e337c5a7fc8a91e2" + integrity sha512-uNsWmRpl4LaBfykpP9CKMo+49gVxRxHoC5MFuMhqPPNhXShsdBii3YxglwoKtit1fwzVT0CIWEniZQMlGiTIuw== + +"@xterm/addon-webgl@0.18.0-beta.12": + version "0.18.0-beta.12" + resolved "https://registry.yarnpkg.com/@xterm/addon-webgl/-/addon-webgl-0.18.0-beta.12.tgz#410396fb6a3edd033eb16f334b88f31870952afb" + integrity sha512-wnIf5Xv0qAWQ0I1G5drKpEThA+D0f03iOTdtPR3uSLDfR8OsmpnSRgiR0Y0nAOnDmiCnDxu/wdBCKOAcXhWl2Q== + +"@xterm/xterm@5.5.0-beta.12": + version "5.5.0-beta.12" + resolved "https://registry.yarnpkg.com/@xterm/xterm/-/xterm-5.5.0-beta.12.tgz#a0f7445a10f958a4949fbe6989693c93e9e0265e" + integrity sha512-+I/vQh16ndYt8erj7zrxywPb+niyZC1W0H0w/ueDB3IPC7zPXxcETR0OGmglL7kq8Erb76ukBYXw9byXR2vtxg== jschardet@3.0.0: version "3.0.0" diff --git a/remote/yarn.lock b/remote/yarn.lock index e660692a80659..d62eb4b548b66 100644 --- a/remote/yarn.lock +++ b/remote/yarn.lock @@ -59,9 +59,9 @@ integrity sha512-bRRFxLfg5dtAyl5XyiVWz/ZBPahpOpPrNYnnHpOpUZvam4tKH35wdhP4Kj6PbM0+KdliOsPzbGWpkxcdpNB/sg== "@vscode/proxy-agent@^0.19.0": - version "0.19.0" - resolved "https://registry.yarnpkg.com/@vscode/proxy-agent/-/proxy-agent-0.19.0.tgz#ea6571854abb8691ca24a95ba81a6850c54da5df" - integrity sha512-VKCEELuv/BFt1h9wAQE9zuKZM2UAJlJzucXFlvyUYrrPRG66Mgm2JQmClxkE5VRa0gCDRzUClZBT8Fptx8Ce7A== + version "0.19.1" + resolved "https://registry.yarnpkg.com/@vscode/proxy-agent/-/proxy-agent-0.19.1.tgz#d9640d85df1c48885580b68bb4b2b54e17f5332c" + integrity sha512-cs1VOx6d5n69HhgzK0cWeyfudJt+9LdJi/vtgRRxxwisWKg4h83B3+EUJ4udF5SEkJgMBp3oU0jheZVt43ImnQ== dependencies: "@tootallnate/once" "^3.0.0" agent-base "^7.0.1" @@ -114,45 +114,45 @@ resolved "https://registry.yarnpkg.com/@vscode/windows-registry/-/windows-registry-1.1.0.tgz#03dace7c29c46f658588b9885b9580e453ad21f9" integrity sha512-5AZzuWJpGscyiMOed0IuyEwt6iKmV5Us7zuwCDCFYMIq7tsvooO9BUiciywsvuthGz6UG4LSpeDeCxvgMVhnIw== -"@xterm/addon-canvas@0.6.0-beta.31": - version "0.6.0-beta.31" - resolved "https://registry.yarnpkg.com/@xterm/addon-canvas/-/addon-canvas-0.6.0-beta.31.tgz#17cc7d9968ede411fb23db11813b495435c068a0" - integrity sha512-jm/7FWZOgnAGG7MXjr0W4SnuIzsag+oVpyf6wAD9UlCgq5HBuk/3kJ5mYGiGR7CpdTxqXmzyBk3OhQe8npZ1aQ== - -"@xterm/addon-image@0.7.0-beta.29": - version "0.7.0-beta.29" - resolved "https://registry.yarnpkg.com/@xterm/addon-image/-/addon-image-0.7.0-beta.29.tgz#276b56007c9009e7a59605dc3809c280e7d637ed" - integrity sha512-Z5JCuhl0AcwQA+DE/kQMeSSHZbfwJVLUUBodDeujVItQrcpc9vA8mxf/qIwS3XTA/tPbFihfc/CE9zL7OFdbaw== - -"@xterm/addon-search@0.14.0-beta.31": - version "0.14.0-beta.31" - resolved "https://registry.yarnpkg.com/@xterm/addon-search/-/addon-search-0.14.0-beta.31.tgz#e6edcd257f5a66bca7e92e62684132b604fb817d" - integrity sha512-SS4CdgciLT98Uc4Dq0IjJegHcGIjGaASTcMtVkNBx9dOat9xt6lCXmtgUUj5w0KlB8nUfKrcy5T6fHgzrOzvrw== - -"@xterm/addon-serialize@0.12.0-beta.31": - version "0.12.0-beta.31" - resolved "https://registry.yarnpkg.com/@xterm/addon-serialize/-/addon-serialize-0.12.0-beta.31.tgz#daec32b94d45afcd662351d7689cb1b19eb24db7" - integrity sha512-MZ24pw33qOJrHdA6tlvwE4dSSpmIp/H9ZKtbiWZvuxVsY/hfYYPOluBQiCsOiYT7bZ8gQub2OOBX3jyMoZVxnQ== - -"@xterm/addon-unicode11@0.7.0-beta.31": - version "0.7.0-beta.31" - resolved "https://registry.yarnpkg.com/@xterm/addon-unicode11/-/addon-unicode11-0.7.0-beta.31.tgz#e1a6e965638ee6cb59b8b0777387037c42582d4b" - integrity sha512-wrZLt2s6Yjmpe4nh0Sp6DKji0EoHod7V6ABfWBf8krjmEGSleE+GSb+ZwDOMsNzLJLmxoq1e6glHcVixG1z7WQ== - -"@xterm/addon-webgl@0.17.0-beta.31": - version "0.17.0-beta.31" - resolved "https://registry.yarnpkg.com/@xterm/addon-webgl/-/addon-webgl-0.17.0-beta.31.tgz#15dfea4583ff9b65f1a442e5cdba1d1638adb05f" - integrity sha512-wqbBDDppwQ4R8o0YgnyFL8Pai2mVZqHb3E097vkFLB5Fw2hNx2dys3MgiXriSGXaUABKM3usVdZyouL6QgWdxQ== - -"@xterm/headless@5.4.0-beta.31": - version "5.4.0-beta.31" - resolved "https://registry.yarnpkg.com/@xterm/headless/-/headless-5.4.0-beta.31.tgz#7727c5c79d3b1b8e59526cf51c75148e13f61694" - integrity sha512-AIMP0ZZozxtvilVTKqquNPYDE5RuKINTsYjOcWzYvjpg7sS75/Tn/RBx20KfZN8Z2oCCwVgj+1mudrV0W4JmMw== - -"@xterm/xterm@5.4.0-beta.31": - version "5.4.0-beta.31" - resolved "https://registry.yarnpkg.com/@xterm/xterm/-/xterm-5.4.0-beta.31.tgz#ff0bb3af9b00b0dfc73e84075f4218440c9886be" - integrity sha512-EpCtaYqMhJSyZrGY2sJVZeRCIRrANKtv1GGTj+IQPvk6hTiJHGrFHLM0tZ0dj0l3z65tLoOdj6EzJnjzX3Pqjw== +"@xterm/addon-canvas@0.7.0-beta.12": + version "0.7.0-beta.12" + resolved "https://registry.yarnpkg.com/@xterm/addon-canvas/-/addon-canvas-0.7.0-beta.12.tgz#200f0293c507b75064963b5fc72a115799533920" + integrity sha512-euzQyWdklaSxzmb87kuwwiVP06vuYe1oUK+CiQW24UggSXThOEvZhvYV3O6iEgLe3p+7QfgnRWohXhCM84VOew== + +"@xterm/addon-image@0.8.0-beta.12": + version "0.8.0-beta.12" + resolved "https://registry.yarnpkg.com/@xterm/addon-image/-/addon-image-0.8.0-beta.12.tgz#3fc5cea489d7159bf496b3a6d6515a109dab7226" + integrity sha512-YsBhmzwxRmym2dUA2CSm52Wt3OLhydVHM+SZmRAJ0/hvfB7dDjtuXBUSIdQWB16WWbGdi4Iazcs/TTxtarX/yA== + +"@xterm/addon-search@0.15.0-beta.12": + version "0.15.0-beta.12" + resolved "https://registry.yarnpkg.com/@xterm/addon-search/-/addon-search-0.15.0-beta.12.tgz#0c54677512135bbf820e3949c9dabeacc690d495" + integrity sha512-63ZhxXj6jBYumVrWJ7ZssICSMz+jHsXbi67tDQNMwTRO/MJxTittZeTHQ7IQrRYzKQgixrX0rLH7AwrLBrn2uQ== + +"@xterm/addon-serialize@0.13.0-beta.12": + version "0.13.0-beta.12" + resolved "https://registry.yarnpkg.com/@xterm/addon-serialize/-/addon-serialize-0.13.0-beta.12.tgz#438c1a6249cf4da3773d3de11a56638fa88d752a" + integrity sha512-/32Gpcj37Ftqf6b4+H62rcB70jLXi9IQspod/2mK3K+Yza9X+Yc8VkAz8VgpKa6tzbh3Xk0XEo/dB6kVFv1Jsg== + +"@xterm/addon-unicode11@0.8.0-beta.12": + version "0.8.0-beta.12" + resolved "https://registry.yarnpkg.com/@xterm/addon-unicode11/-/addon-unicode11-0.8.0-beta.12.tgz#7066297e2c662f6c235a4814e337c5a7fc8a91e2" + integrity sha512-uNsWmRpl4LaBfykpP9CKMo+49gVxRxHoC5MFuMhqPPNhXShsdBii3YxglwoKtit1fwzVT0CIWEniZQMlGiTIuw== + +"@xterm/addon-webgl@0.18.0-beta.12": + version "0.18.0-beta.12" + resolved "https://registry.yarnpkg.com/@xterm/addon-webgl/-/addon-webgl-0.18.0-beta.12.tgz#410396fb6a3edd033eb16f334b88f31870952afb" + integrity sha512-wnIf5Xv0qAWQ0I1G5drKpEThA+D0f03iOTdtPR3uSLDfR8OsmpnSRgiR0Y0nAOnDmiCnDxu/wdBCKOAcXhWl2Q== + +"@xterm/headless@5.5.0-beta.12": + version "5.5.0-beta.12" + resolved "https://registry.yarnpkg.com/@xterm/headless/-/headless-5.5.0-beta.12.tgz#5dc79a98d4653c37c388144ea2ab4fab664304f3" + integrity sha512-s1AS30MYb0KJ7sEruyywAi79lAjSgjVOasb6EOgOalaQBYWf5BY2HKBU+GOyRPFkusgEIBg0f/ID8uS1fiku9A== + +"@xterm/xterm@5.5.0-beta.12": + version "5.5.0-beta.12" + resolved "https://registry.yarnpkg.com/@xterm/xterm/-/xterm-5.5.0-beta.12.tgz#a0f7445a10f958a4949fbe6989693c93e9e0265e" + integrity sha512-+I/vQh16ndYt8erj7zrxywPb+niyZC1W0H0w/ueDB3IPC7zPXxcETR0OGmglL7kq8Erb76ukBYXw9byXR2vtxg== agent-base@^7.0.1, agent-base@^7.0.2, agent-base@^7.1.0: version "7.1.0" @@ -314,9 +314,9 @@ ini@~1.3.0: integrity sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew== ip@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/ip/-/ip-2.0.0.tgz#4cf4ab182fee2314c75ede1276f8c80b479936da" - integrity sha512-WKa+XuLG1A1R0UWhl2+1XQSi+fZWMsYKffMZTTYsiZaUD8k2yDAj5atimTUD2TZkyCkNEeYE5NhFZmupOGtjYQ== + version "2.0.1" + resolved "https://registry.yarnpkg.com/ip/-/ip-2.0.1.tgz#e8f3595d33a3ea66490204234b77636965307105" + integrity sha512-lJUL9imLTNi1ZfXT+DU6rBBdbiKGBuay9B6xGSPVjUeQwaH1RIGqef8RZkUtHioLmSNpPR5M4HVKJGm1j8FWVQ== is-extglob@^2.1.1: version "2.1.1" @@ -431,10 +431,10 @@ node-gyp-build@^4.3.0: resolved "https://registry.yarnpkg.com/node-gyp-build/-/node-gyp-build-4.3.0.tgz#9f256b03e5826150be39c764bf51e993946d71a3" integrity sha512-iWjXZvmboq0ja1pUGULQBexmxq8CV4xBhX7VDOTbL7ZR4FOowwY/VOtRxBN/yKxmdGoIp4j5ysNT4u3S2pDQ3Q== -node-pty@1.1.0-beta6: - version "1.1.0-beta6" - resolved "https://registry.yarnpkg.com/node-pty/-/node-pty-1.1.0-beta6.tgz#8b27ce40268e313868925e1b46f2af98cc677881" - integrity sha512-ZcuPz5wIbfF4rebVv8sl+nf2Cn5dVMqlEl9PtabCt4uIffGDnovOpmwh16Oh/MThrwSmeJL6gBwu6lIbBtW7DQ== +node-pty@1.1.0-beta11: + version "1.1.0-beta11" + resolved "https://registry.yarnpkg.com/node-pty/-/node-pty-1.1.0-beta11.tgz#909d5dd8f9aa2a7857e7b632fd4d2d4768bdf69f" + integrity sha512-vTjF+VrvSCfPDILUkIT+YrG1Fdn06/eBRS2fc9a3JzYAvknMB1Ip8aoJhxl8hNpjWAbprmCEiV91mlfNpCD+GQ== dependencies: node-addon-api "^7.1.0" @@ -643,6 +643,14 @@ yauzl@^2.9.2: buffer-crc32 "~0.2.3" fd-slicer "~1.1.0" +yauzl@^3.0.0: + version "3.1.1" + resolved "https://registry.yarnpkg.com/yauzl/-/yauzl-3.1.1.tgz#d85503cc34933c0bcb3646ee2b97afedbebe32e7" + integrity sha512-MPxA7oN5cvGV0wzfkeHKF2/+Q4TkMpHSWGRy/96I4Cozljmx0ph91+Muxh6HegEtDC4GftJ8qYDE51vghFiEYA== + dependencies: + buffer-crc32 "~0.2.3" + pend "~1.2.0" + yazl@^2.4.3: version "2.4.3" resolved "https://registry.yarnpkg.com/yazl/-/yazl-2.4.3.tgz#ec26e5cc87d5601b9df8432dbdd3cd2e5173a071" diff --git a/resources/darwin/bin/code.sh b/resources/darwin/bin/code.sh index 1d66515c2c73b..de5c3bfcab0f4 100755 --- a/resources/darwin/bin/code.sh +++ b/resources/darwin/bin/code.sh @@ -31,5 +31,9 @@ fi CONTENTS="$APP_PATH/Contents" ELECTRON="$CONTENTS/MacOS/Electron" CLI="$CONTENTS/Resources/app/out/cli.js" +export VSCODE_NODE_OPTIONS=$NODE_OPTIONS +export VSCODE_NODE_REPL_EXTERNAL_MODULE=$NODE_REPL_EXTERNAL_MODULE +unset NODE_OPTIONS +unset NODE_REPL_EXTERNAL_MODULE ELECTRON_RUN_AS_NODE=1 "$ELECTRON" "$CLI" "$@" exit $? diff --git a/resources/linux/code.desktop b/resources/linux/code.desktop index e2e3f5347513e..3df90289b5c3d 100755 --- a/resources/linux/code.desktop +++ b/resources/linux/code.desktop @@ -2,7 +2,7 @@ Name=@@NAME_LONG@@ Comment=Code Editing. Redefined. GenericName=Text Editor -Exec=@@EXEC@@ --unity-launch %F +Exec=@@EXEC@@ %F Icon=@@ICON@@ Type=Application StartupNotify=false diff --git a/resources/server/bin/code-server-linux.sh b/resources/server/bin/code-server-linux.sh index e3d96bdadf2fa..3df32dfd43c78 100644 --- a/resources/server/bin/code-server-linux.sh +++ b/resources/server/bin/code-server-linux.sh @@ -9,24 +9,4 @@ esac ROOT="$(dirname "$(dirname "$(readlink -f "$0")")")" -# Do not remove this check. -# Provides a way to skip the server requirements check from -# outside the install flow. A system process can create this -# file before the server is downloaded and installed. -skip_check=0 -if [ -f "/tmp/vscode-skip-server-requirements-check" ]; then - echo "!!! WARNING: Skipping server pre-requisite check !!!" - echo "!!! Server stability is not guaranteed. Proceed at your own risk. !!!" - skip_check=1 -fi - -# Check platform requirements -if [ "$(echo "$@" | grep -c -- "--skip-requirements-check")" -eq 0 ] && [ $skip_check -eq 0 ]; then - $ROOT/bin/helpers/check-requirements.sh - exit_code=$? - if [ $exit_code -ne 0 ]; then - exit $exit_code - fi -fi - "$ROOT/node" ${INSPECT:-} "$ROOT/out/server-main.js" "$@" diff --git a/resources/server/bin/helpers/check-requirements-linux-legacy.sh b/resources/server/bin/helpers/check-requirements-linux-legacy.sh new file mode 100755 index 0000000000000..0db776769650e --- /dev/null +++ b/resources/server/bin/helpers/check-requirements-linux-legacy.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env sh +# +# Copyright (c) Microsoft Corporation. All rights reserved. +# + +set -e + +echo "!!! WARNING: Using legacy server, please check https://aka.ms/vscode-remote/faq/old-linux for additional information !!!" +exit 0 diff --git a/resources/server/bin/helpers/check-requirements-linux.sh b/resources/server/bin/helpers/check-requirements-linux.sh index 1b9199fd03930..079557869e33a 100644 --- a/resources/server/bin/helpers/check-requirements-linux.sh +++ b/resources/server/bin/helpers/check-requirements-linux.sh @@ -5,30 +5,36 @@ set -e +# The script checks necessary server requirements for the classic server +# scenarios. Currently, the script can exit with any of the following +# 3 exit codes and should be handled accordingly on the extension side. +# +# 0: All requirements are met, use the default server. +# 99: Unsupported OS, abort server startup with appropriate error message. +# 100: Use legacy server. +# + # Do not remove this check. # Provides a way to skip the server requirements check from # outside the install flow. A system process can create this # file before the server is downloaded and installed. -# -# This check is duplicated between code-server-linux.sh and here -# since remote container calls into this script directly quite early -# before the usual server startup flow. if [ -f "/tmp/vscode-skip-server-requirements-check" ]; then echo "!!! WARNING: Skipping server pre-requisite check !!!" echo "!!! Server stability is not guaranteed. Proceed at your own risk. !!!" exit 0 fi -BITNESS=$(getconf LONG_BIT) ARCH=$(uname -m) found_required_glibc=0 found_required_glibcxx=0 # Extract the ID value from /etc/os-release -OS_ID="$(cat /etc/os-release | grep -Eo 'ID=([^"]+)' | sed -n '1s/ID=//p')" -if [ "$OS_ID" = "nixos" ]; then - echo "Warning: NixOS detected, skipping GLIBC check" - exit 0 +if [ -f /etc/os-release ]; then + OS_ID="$(cat /etc/os-release | grep -Eo 'ID=([^"]+)' | sed -n '1s/ID=//p')" + if [ "$OS_ID" = "nixos" ]; then + echo "Warning: NixOS detected, skipping GLIBC check" + exit 0 + fi fi # Based on https://github.com/bminor/glibc/blob/520b1df08de68a3de328b65a25b86300a7ddf512/elf/cache.c#L162-L245 @@ -36,6 +42,7 @@ case $ARCH in x86_64) LDCONFIG_ARCH="x86-64";; armv7l | armv8l) LDCONFIG_ARCH="hard-float";; arm64 | aarch64) + BITNESS=$(getconf LONG_BIT) if [ "$BITNESS" = "32" ]; then # Can have 32-bit userland on 64-bit kernel LDCONFIG_ARCH="hard-float" @@ -45,86 +52,104 @@ case $ARCH in ;; esac -if [ -f /usr/lib64/libstdc++.so.6 ]; then - # Typical path - libstdcpp_path='/usr/lib64/libstdc++.so.6' -elif [ -f /usr/lib/libstdc++.so.6 ]; then - # Typical path - libstdcpp_path='/usr/lib/libstdc++.so.6' -elif [ -f /sbin/ldconfig ]; then - # Look up path - libstdcpp_paths=$(/sbin/ldconfig -p | grep 'libstdc++.so.6') +if [ "$OS_ID" != "alpine" ]; then + if [ -f /sbin/ldconfig ]; then + # Look up path + libstdcpp_paths=$(/sbin/ldconfig -p | grep 'libstdc++.so.6') - if [ "$(echo "$libstdcpp_paths" | wc -l)" -gt 1 ]; then - libstdcpp_path=$(echo "$libstdcpp_paths" | grep "$LDCONFIG_ARCH" | awk '{print $NF}' | head -n1) + if [ "$(echo "$libstdcpp_paths" | wc -l)" -gt 1 ]; then + libstdcpp_path=$(echo "$libstdcpp_paths" | grep "$LDCONFIG_ARCH" | awk '{print $NF}') + else + libstdcpp_path=$(echo "$libstdcpp_paths" | awk '{print $NF}') + fi + elif [ -f /usr/lib/libstdc++.so.6 ]; then + # Typical path + libstdcpp_path='/usr/lib/libstdc++.so.6' + elif [ -f /usr/lib64/libstdc++.so.6 ]; then + # Typical path + libstdcpp_path='/usr/lib64/libstdc++.so.6' else - libstdcpp_path=$(echo "$libstdcpp_paths" | awk '{print $NF}') + echo "Warning: Can't find libstdc++.so or ldconfig, can't verify libstdc++ version" fi + + while [ -n "$libstdcpp_path" ]; do + # Extracts the version number from the path, e.g. libstdc++.so.6.0.22 -> 6.0.22 + # which is then compared based on the fact that release versioning and symbol versioning + # are aligned for libstdc++. Refs https://gcc.gnu.org/onlinedocs/libstdc++/manual/abi.html + # (i-e) GLIBCXX_3.4. is provided by libstdc++.so.6.y. + libstdcpp_path_line=$(echo "$libstdcpp_path" | head -n1) + libstdcpp_real_path=$(readlink -f "$libstdcpp_path_line") + libstdcpp_version=$(grep -ao 'GLIBCXX_[0-9]*\.[0-9]*\.[0-9]*' "$libstdcpp_real_path" | sort -V | tail -1) + libstdcpp_version_number=$(echo "$libstdcpp_version" | sed 's/GLIBCXX_//') + if [ "$(printf '%s\n' "3.4.24" "$libstdcpp_version_number" | sort -V | head -n1)" = "3.4.24" ]; then + found_required_glibcxx=1 + break + fi + libstdcpp_path=$(echo "$libstdcpp_path" | tail -n +2) # remove first line + done else - echo "Warning: Can't find libstdc++.so or ldconfig, can't verify libstdc++ version" + echo "Warning: alpine distro detected, skipping GLIBCXX check" + found_required_glibcxx=1 fi - -if [ -n "$libstdcpp_path" ]; then - # Extracts the version number from the path, e.g. libstdc++.so.6.0.22 -> 6.0.22 - # which is then compared based on the fact that release versioning and symbol versioning - # are aligned for libstdc++. Refs https://gcc.gnu.org/onlinedocs/libstdc++/manual/abi.html - # (i-e) GLIBCXX_3.4. is provided by libstdc++.so.6.y. - libstdcpp_real_path=$(readlink -f "$libstdcpp_path") - libstdcpp_version=$(echo "$libstdcpp_real_path" | awk -F'\\.so\\.' '{print $NF}') - if [ "$(printf '%s\n' "6.0.25" "$libstdcpp_version" | sort -V | head -n1)" = "6.0.25" ]; then - found_required_glibcxx=1 - else - echo "Warning: Missing GLIBCXX >= 3.4.25! from $libstdcpp_real_path" - fi +if [ "$found_required_glibcxx" = "0" ]; then + echo "Warning: Missing GLIBCXX >= 3.4.25! from $libstdcpp_real_path" fi -if [ -z "$(ldd --version 2>&1 | grep 'musl libc')" ]; then - if [ -f /usr/lib64/libc.so.6 ]; then - # Typical path - libc_path='/usr/lib64/libc.so.6' - elif [ -f /usr/lib/libc.so.6 ]; then - # Typical path - libc_path='/usr/lib/libc.so.6' - elif [ -f /sbin/ldconfig ]; then +if [ "$OS_ID" = "alpine" ]; then + MUSL_RTLDLIST="/lib/ld-musl-aarch64.so.1 /lib/ld-musl-x86_64.so.1" + for rtld in ${MUSL_RTLDLIST}; do + if [ -x $rtld ]; then + musl_version=$("$rtld" --version 2>&1 | grep "Version" | awk '{print $NF}') + break + fi + done + if [ "$(printf '%s\n' "1.2.3" "$musl_version" | sort -V | head -n1)" != "1.2.3" ]; then + echo "Error: Unsupported alpine distribution. Please refer to our supported distro section https://aka.ms/vscode-remote/linux for additional information." + exit 99 + fi + found_required_glibc=1 +elif [ -z "$(ldd --version 2>&1 | grep 'musl libc')" ]; then + if [ -f /sbin/ldconfig ]; then # Look up path libc_paths=$(/sbin/ldconfig -p | grep 'libc.so.6') if [ "$(echo "$libc_paths" | wc -l)" -gt 1 ]; then - libc_path=$(echo "$libc_paths" | grep "$LDCONFIG_ARCH" | awk '{print $NF}' | head -n1) + libc_path=$(echo "$libc_paths" | grep "$LDCONFIG_ARCH" | awk '{print $NF}') else libc_path=$(echo "$libc_paths" | awk '{print $NF}') fi + elif [ -f /usr/lib/libc.so.6 ]; then + # Typical path + libc_path='/usr/lib/libc.so.6' + elif [ -f /usr/lib64/libc.so.6 ]; then + # Typical path + libc_path='/usr/lib64/libc.so.6' else echo "Warning: Can't find libc.so or ldconfig, can't verify libc version" fi - if [ -n "$libc_path" ]; then + while [ -n "$libc_path" ]; do # Rather than trusting the output of ldd --version (which is not always accurate) # we instead use the version of the cached libc.so.6 file itself. - libc_real_path=$(readlink -f "$libc_path") + libc_path_line=$(echo "$libc_path" | head -n1) + libc_real_path=$(readlink -f "$libc_path_line") libc_version=$(cat "$libc_real_path" | sed -n 's/.*release version \([0-9]\+\.[0-9]\+\).*/\1/p') if [ "$(printf '%s\n' "2.28" "$libc_version" | sort -V | head -n1)" = "2.28" ]; then found_required_glibc=1 - else - echo "Warning: Missing GLIBC >= 2.28! from $libc_real_path" + break fi + libc_path=$(echo "$libc_path" | tail -n +2) # remove first line + done + if [ "$found_required_glibc" = "0" ]; then + echo "Warning: Missing GLIBC >= 2.28! from $libc_real_path" fi else - if [ "$OS_ID" = "alpine" ]; then - musl_version=$(ldd --version 2>&1 | grep "Version" | awk '{print $NF}') - if [ "$(printf '%s\n' "1.2.3" "$musl_version" | sort -V | head -n1)" != "1.2.3" ]; then - echo "Error: Unsupported alpine distribution. Please refer to our supported distro section https://aka.ms/vscode-remote/linux for additional information." - exit 99 - fi - else - echo "Warning: musl detected, skipping GLIBC check" - fi - + echo "Warning: musl detected, skipping GLIBC check" found_required_glibc=1 fi if [ "$found_required_glibc" = "0" ] || [ "$found_required_glibcxx" = "0" ]; then - echo "Error: Missing required dependencies. Please refer to our FAQ https://aka.ms/vscode-remote/faq/old-linux for additional information." + echo "Warning: Missing required dependencies. Please refer to our FAQ https://aka.ms/vscode-remote/faq/old-linux for additional information." # Custom exit code based on https://tldp.org/LDP/abs/html/exitcodes.html - #exit 99 + exit 100 fi diff --git a/resources/win32/bin/code.cmd b/resources/win32/bin/code.cmd index 9da8ab4f7b819..7e7b92c9eb71e 100644 --- a/resources/win32/bin/code.cmd +++ b/resources/win32/bin/code.cmd @@ -3,4 +3,5 @@ setlocal set VSCODE_DEV= set ELECTRON_RUN_AS_NODE=1 "%~dp0..\@@NAME@@.exe" "%~dp0..\resources\app\out\cli.js" %* +IF %ERRORLEVEL% NEQ 0 EXIT /b %ERRORLEVEL% endlocal diff --git a/src/vs/base/browser/dom.ts b/src/vs/base/browser/dom.ts index 82459f9717800..ff113c9baa98a 100644 --- a/src/vs/base/browser/dom.ts +++ b/src/vs/base/browser/dom.ts @@ -921,13 +921,6 @@ export function getActiveWindow(): CodeWindow { return (document.defaultView?.window ?? mainWindow) as CodeWindow; } -export function focusWindow(element: Node): void { - const window = getWindow(element); - if (!window.document.hasFocus()) { - window.focus(); - } -} - const globalStylesheets = new Map>(); export function isGlobalStylesheet(node: Node): boolean { diff --git a/src/vs/base/browser/fonts.ts b/src/vs/base/browser/fonts.ts new file mode 100644 index 0000000000000..a5e78d00bef5b --- /dev/null +++ b/src/vs/base/browser/fonts.ts @@ -0,0 +1,16 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { isMacintosh, isWindows } from 'vs/base/common/platform'; + +/** + * The best font-family to be used in CSS based on the platform: + * - Windows: Segoe preferred, fallback to sans-serif + * - macOS: standard system font, fallback to sans-serif + * - Linux: standard system font preferred, fallback to Ubuntu fonts + * + * Note: this currently does not adjust for different locales. + */ +export const DEFAULT_FONT_FAMILY = isWindows ? '"Segoe WPC", "Segoe UI", sans-serif' : isMacintosh ? '-apple-system, BlinkMacSystemFont, sans-serif' : 'system-ui, "Ubuntu", "Droid Sans", sans-serif'; diff --git a/src/vs/base/browser/keyboardEvent.ts b/src/vs/base/browser/keyboardEvent.ts index 57ba7407845a7..6aa5bf530f3fd 100644 --- a/src/vs/base/browser/keyboardEvent.ts +++ b/src/vs/base/browser/keyboardEvent.ts @@ -142,7 +142,7 @@ export class StandardKeyboardEvent implements IKeyboardEvent { this.shiftKey = e.shiftKey; this.altKey = e.altKey; this.metaKey = e.metaKey; - this.altGraphKey = e.getModifierState('AltGraph'); + this.altGraphKey = e.getModifierState?.('AltGraph'); this.keyCode = extractKeyCode(e); this.code = e.code; diff --git a/src/vs/base/browser/touch.ts b/src/vs/base/browser/touch.ts index 79e3211d85fe0..5c7fe28c786a3 100644 --- a/src/vs/base/browser/touch.ts +++ b/src/vs/base/browser/touch.ts @@ -5,7 +5,6 @@ import * as DomUtils from 'vs/base/browser/dom'; import { mainWindow } from 'vs/base/browser/window'; -import * as arrays from 'vs/base/common/arrays'; import { memoize } from 'vs/base/common/decorators'; import { Event as EventUtils } from 'vs/base/common/event'; import { Disposable, IDisposable, markAsSingleton, toDisposable } from 'vs/base/common/lifecycle'; @@ -192,28 +191,28 @@ export class Gesture extends Disposable { holdTime = Date.now() - data.initialTimeStamp; if (holdTime < Gesture.HOLD_DELAY - && Math.abs(data.initialPageX - arrays.tail(data.rollingPageX)) < 30 - && Math.abs(data.initialPageY - arrays.tail(data.rollingPageY)) < 30) { + && Math.abs(data.initialPageX - data.rollingPageX.at(-1)!) < 30 + && Math.abs(data.initialPageY - data.rollingPageY.at(-1)!) < 30) { const evt = this.newGestureEvent(EventType.Tap, data.initialTarget); - evt.pageX = arrays.tail(data.rollingPageX); - evt.pageY = arrays.tail(data.rollingPageY); + evt.pageX = data.rollingPageX.at(-1)!; + evt.pageY = data.rollingPageY.at(-1)!; this.dispatchEvent(evt); } else if (holdTime >= Gesture.HOLD_DELAY - && Math.abs(data.initialPageX - arrays.tail(data.rollingPageX)) < 30 - && Math.abs(data.initialPageY - arrays.tail(data.rollingPageY)) < 30) { + && Math.abs(data.initialPageX - data.rollingPageX.at(-1)!) < 30 + && Math.abs(data.initialPageY - data.rollingPageY.at(-1)!) < 30) { const evt = this.newGestureEvent(EventType.Contextmenu, data.initialTarget); - evt.pageX = arrays.tail(data.rollingPageX); - evt.pageY = arrays.tail(data.rollingPageY); + evt.pageX = data.rollingPageX.at(-1)!; + evt.pageY = data.rollingPageY.at(-1)!; this.dispatchEvent(evt); } else if (activeTouchCount === 1) { - const finalX = arrays.tail(data.rollingPageX); - const finalY = arrays.tail(data.rollingPageY); + const finalX = data.rollingPageX.at(-1)!; + const finalY = data.rollingPageY.at(-1)!; - const deltaT = arrays.tail(data.rollingTimestamps) - data.rollingTimestamps[0]; + const deltaT = data.rollingTimestamps.at(-1)! - data.rollingTimestamps[0]; const deltaX = finalX - data.rollingPageX[0]; const deltaY = finalY - data.rollingPageY[0]; @@ -274,12 +273,25 @@ export class Gesture extends Disposable { } } + const targets: [number, HTMLElement][] = []; for (const target of this.targets) { if (target.contains(event.initialTarget)) { - target.dispatchEvent(event); - this.dispatched = true; + let depth = 0; + let now: Node | null = event.initialTarget; + while (now && now !== target) { + depth++; + now = now.parentElement; + } + targets.push([depth, target]); } } + + targets.sort((a, b) => a[0] - b[0]); + + for (const [_, target] of targets) { + target.dispatchEvent(event); + this.dispatched = true; + } } } @@ -332,8 +344,8 @@ export class Gesture extends Disposable { const data = this.activeTouches[touch.identifier]; const evt = this.newGestureEvent(EventType.Change, data.initialTarget); - evt.translationX = touch.pageX - arrays.tail(data.rollingPageX); - evt.translationY = touch.pageY - arrays.tail(data.rollingPageY); + evt.translationX = touch.pageX - data.rollingPageX.at(-1)!; + evt.translationY = touch.pageY - data.rollingPageY.at(-1)!; evt.pageX = touch.pageX; evt.pageY = touch.pageY; this.dispatchEvent(evt); diff --git a/src/vs/base/browser/ui/actionbar/actionViewItems.ts b/src/vs/base/browser/ui/actionbar/actionViewItems.ts index 906c437b3259c..98ea82272b82b 100644 --- a/src/vs/base/browser/ui/actionbar/actionViewItems.ts +++ b/src/vs/base/browser/ui/actionbar/actionViewItems.ts @@ -9,8 +9,8 @@ import { addDisposableListener, EventHelper, EventLike, EventType } from 'vs/bas import { EventType as TouchEventType, Gesture } from 'vs/base/browser/touch'; import { IActionViewItem } from 'vs/base/browser/ui/actionbar/actionbar'; import { IContextViewProvider } from 'vs/base/browser/ui/contextview/contextview'; -import { IHoverDelegate } from 'vs/base/browser/ui/iconLabel/iconHoverDelegate'; -import { ICustomHover, setupCustomHover } from 'vs/base/browser/ui/iconLabel/iconLabelHover'; +import { getDefaultHoverDelegate } from 'vs/base/browser/ui/hover/hoverDelegateFactory'; +import { IHoverDelegate } from 'vs/base/browser/ui/hover/hoverDelegate'; import { ISelectBoxOptions, ISelectBoxStyles, ISelectOptionItem, SelectBox } from 'vs/base/browser/ui/selectBox/selectBox'; import { IToggleStyles } from 'vs/base/browser/ui/toggle/toggle'; import { Action, ActionRunner, IAction, IActionChangeEvent, IActionRunner, Separator } from 'vs/base/common/actions'; @@ -19,6 +19,8 @@ import * as platform from 'vs/base/common/platform'; import * as types from 'vs/base/common/types'; import 'vs/css!./actionbar'; import * as nls from 'vs/nls'; +import type { IUpdatableHover } from 'vs/base/browser/ui/hover/hover'; +import { getBaseLayerHoverDelegate } from 'vs/base/browser/ui/hover/hoverDelegate2'; export interface IBaseActionViewItemOptions { draggable?: boolean; @@ -34,7 +36,7 @@ export class BaseActionViewItem extends Disposable implements IActionViewItem { _context: unknown; readonly _action: IAction; - private customHover?: ICustomHover; + private customHover?: IUpdatableHover; get action() { return this._action; @@ -224,14 +226,15 @@ export class BaseActionViewItem extends Disposable implements IActionViewItem { } const title = this.getTooltip() ?? ''; this.updateAriaLabel(); - if (!this.options.hoverDelegate) { + + if (this.options.hoverDelegate?.showNativeHover) { + /* While custom hover is not inside custom hover */ this.element.title = title; } else { - this.element.title = ''; - if (!this.customHover) { - this.customHover = setupCustomHover(this.options.hoverDelegate, this.element, title); - this._store.add(this.customHover); - } else { + if (!this.customHover && title !== '') { + const hoverDelegate = this.options.hoverDelegate ?? getDefaultHoverDelegate('element'); + this.customHover = this._store.add(getBaseLayerHoverDelegate().setupUpdatableHover(hoverDelegate, this.element, title)); + } else if (this.customHover) { this.customHover.update(title); } } diff --git a/src/vs/base/browser/ui/actionbar/actionbar.ts b/src/vs/base/browser/ui/actionbar/actionbar.ts index fafc3417f8b98..05505b768a5ea 100644 --- a/src/vs/base/browser/ui/actionbar/actionbar.ts +++ b/src/vs/base/browser/ui/actionbar/actionbar.ts @@ -6,7 +6,8 @@ import * as DOM from 'vs/base/browser/dom'; import { StandardKeyboardEvent } from 'vs/base/browser/keyboardEvent'; import { ActionViewItem, BaseActionViewItem, IActionViewItemOptions } from 'vs/base/browser/ui/actionbar/actionViewItems'; -import { IHoverDelegate } from 'vs/base/browser/ui/iconLabel/iconHoverDelegate'; +import { createInstantHoverDelegate } from 'vs/base/browser/ui/hover/hoverDelegateFactory'; +import { IHoverDelegate } from 'vs/base/browser/ui/hover/hoverDelegate'; import { ActionRunner, IAction, IActionRunner, IRunEvent, Separator } from 'vs/base/common/actions'; import { Emitter } from 'vs/base/common/event'; import { KeyCode, KeyMod } from 'vs/base/common/keyCodes'; @@ -46,7 +47,6 @@ export interface IActionBarOptions { readonly actionRunner?: IActionRunner; readonly ariaLabel?: string; readonly ariaRole?: string; - readonly animated?: boolean; readonly triggerKeys?: ActionTrigger; readonly allowContextMenu?: boolean; readonly preventLoopNavigation?: boolean; @@ -67,6 +67,7 @@ export interface IActionOptions extends IActionViewItemOptions { export class ActionBar extends Disposable implements IActionRunner { private readonly options: IActionBarOptions; + private readonly _hoverDelegate: IHoverDelegate; private _actionRunner: IActionRunner; private readonly _actionRunnerDisposables = this._register(new DisposableStore()); @@ -117,6 +118,8 @@ export class ActionBar extends Disposable implements IActionRunner { keys: this.options.triggerKeys?.keys ?? [KeyCode.Enter, KeyCode.Space] }; + this._hoverDelegate = options.hoverDelegate ?? this._register(createInstantHoverDelegate()); + if (this.options.actionRunner) { this._actionRunner = this.options.actionRunner; } else { @@ -133,10 +136,6 @@ export class ActionBar extends Disposable implements IActionRunner { this.domNode = document.createElement('div'); this.domNode.className = 'monaco-action-bar'; - if (options.animated !== false) { - this.domNode.classList.add('animated'); - } - let previousKeys: KeyCode[]; let nextKeys: KeyCode[]; @@ -358,7 +357,7 @@ export class ActionBar extends Disposable implements IActionRunner { let item: IActionViewItem | undefined; - const viewItemOptions = { hoverDelegate: this.options.hoverDelegate, ...options }; + const viewItemOptions = { hoverDelegate: this._hoverDelegate, ...options }; if (this.options.actionViewItemProvider) { item = this.options.actionViewItemProvider(action, viewItemOptions); } diff --git a/src/vs/base/browser/ui/breadcrumbs/breadcrumbsWidget.ts b/src/vs/base/browser/ui/breadcrumbs/breadcrumbsWidget.ts index 7094cc9c41edf..b84a3d8a8b316 100644 --- a/src/vs/base/browser/ui/breadcrumbs/breadcrumbsWidget.ts +++ b/src/vs/base/browser/ui/breadcrumbs/breadcrumbsWidget.ts @@ -57,6 +57,7 @@ export class BreadcrumbsWidget { private _focusedItemIdx: number = -1; private _selectedItemIdx: number = -1; + private _pendingDimLayout: IDisposable | undefined; private _pendingLayout: IDisposable | undefined; private _dimension: dom.Dimension | undefined; @@ -100,6 +101,7 @@ export class BreadcrumbsWidget { dispose(): void { this._disposables.dispose(); this._pendingLayout?.dispose(); + this._pendingDimLayout?.dispose(); this._onDidSelectItem.dispose(); this._onDidFocusItem.dispose(); this._onDidChangeFocus.dispose(); @@ -112,11 +114,12 @@ export class BreadcrumbsWidget { if (dim && dom.Dimension.equals(dim, this._dimension)) { return; } - this._pendingLayout?.dispose(); if (dim) { // only measure - this._pendingLayout = this._updateDimensions(dim); + this._pendingDimLayout?.dispose(); + this._pendingDimLayout = this._updateDimensions(dim); } else { + this._pendingLayout?.dispose(); this._pendingLayout = this._updateScrollbar(); } } diff --git a/src/vs/base/browser/ui/button/button.ts b/src/vs/base/browser/ui/button/button.ts index 2e845b684f69a..5491cd6590e33 100644 --- a/src/vs/base/browser/ui/button/button.ts +++ b/src/vs/base/browser/ui/button/button.ts @@ -9,6 +9,8 @@ import { sanitize } from 'vs/base/browser/dompurify/dompurify'; import { StandardKeyboardEvent } from 'vs/base/browser/keyboardEvent'; import { renderMarkdown, renderStringAsPlaintext } from 'vs/base/browser/markdownRenderer'; import { Gesture, EventType as TouchEventType } from 'vs/base/browser/touch'; +import { getDefaultHoverDelegate } from 'vs/base/browser/ui/hover/hoverDelegateFactory'; +import { IHoverDelegate } from 'vs/base/browser/ui/hover/hoverDelegate'; import { renderLabelWithIcons } from 'vs/base/browser/ui/iconLabel/iconLabels'; import { Action, IAction, IActionRunner } from 'vs/base/common/actions'; import { Codicon } from 'vs/base/common/codicons'; @@ -20,6 +22,8 @@ import { Disposable, DisposableStore, IDisposable } from 'vs/base/common/lifecyc import { ThemeIcon } from 'vs/base/common/themables'; import 'vs/css!./button'; import { localize } from 'vs/nls'; +import type { IUpdatableHover } from 'vs/base/browser/ui/hover/hover'; +import { getBaseLayerHoverDelegate } from 'vs/base/browser/ui/hover/hoverDelegate2'; export interface IButtonOptions extends Partial { readonly title?: boolean | string; @@ -27,6 +31,7 @@ export interface IButtonOptions extends Partial { readonly supportIcons?: boolean; readonly supportShortLabel?: boolean; readonly secondary?: boolean; + readonly hoverDelegate?: IHoverDelegate; } export interface IButtonStyles { @@ -74,10 +79,14 @@ export class Button extends Disposable implements IButton { protected _label: string | IMarkdownString = ''; protected _labelElement: HTMLElement | undefined; protected _labelShortElement: HTMLElement | undefined; + private _hover: IUpdatableHover | undefined; private _onDidClick = this._register(new Emitter()); get onDidClick(): BaseEvent { return this._onDidClick.event; } + private _onDidEscape = this._register(new Emitter()); + get onDidEscape(): BaseEvent { return this._onDidEscape.event; } + private focusTracker: IFocusTracker; constructor(container: HTMLElement, options: IButtonOptions) { @@ -109,6 +118,10 @@ export class Button extends Disposable implements IButton { this._element.classList.add('monaco-text-button-with-short-label'); } + if (typeof options.title === 'string') { + this.setTitle(options.title); + } + if (typeof options.ariaLabel === 'string') { this._element.setAttribute('aria-label', options.ariaLabel); } @@ -134,6 +147,7 @@ export class Button extends Disposable implements IButton { this._onDidClick.fire(e); eventHandled = true; } else if (event.equals(KeyCode.Escape)) { + this._onDidEscape.fire(e); this._element.blur(); eventHandled = true; } @@ -236,16 +250,19 @@ export class Button extends Disposable implements IButton { } } + let title: string = ''; if (typeof this.options.title === 'string') { - this._element.title = this.options.title; + title = this.options.title; } else if (this.options.title) { - this._element.title = renderStringAsPlaintext(value); + title = renderStringAsPlaintext(value); } + this.setTitle(title); + if (typeof this.options.ariaLabel === 'string') { this._element.setAttribute('aria-label', this.options.ariaLabel); } else if (this.options.ariaLabel) { - this._element.setAttribute('aria-label', this._element.title); + this._element.setAttribute('aria-label', title); } this._label = value; @@ -286,6 +303,14 @@ export class Button extends Disposable implements IButton { return !this._element.classList.contains('disabled'); } + private setTitle(title: string) { + if (!this._hover && title !== '') { + this._hover = this._register(getBaseLayerHoverDelegate().setupUpdatableHover(this.options.hoverDelegate ?? getDefaultHoverDelegate('mouse'), this._element, title)); + } else if (this._hover) { + this._hover.update(title); + } + } + focus(): void { this._element.focus(); } @@ -344,7 +369,7 @@ export class ButtonWithDropdown extends Disposable implements IButton { this.separator.style.backgroundColor = options.buttonSeparator ?? ''; this.dropdownButton = this._register(new Button(this.element, { ...options, title: false, supportIcons: true })); - this.dropdownButton.element.title = localize("button dropdown more actions", 'More Actions...'); + this._register(getBaseLayerHoverDelegate().setupUpdatableHover(getDefaultHoverDelegate('mouse'), this.dropdownButton.element, localize("button dropdown more actions", 'More Actions...'))); this.dropdownButton.element.setAttribute('aria-haspopup', 'true'); this.dropdownButton.element.setAttribute('aria-expanded', 'false'); this.dropdownButton.element.classList.add('monaco-dropdown-button'); diff --git a/src/vs/base/browser/ui/codicons/codicon/codicon.ttf b/src/vs/base/browser/ui/codicons/codicon/codicon.ttf index 4894dfa316d4a..57eda48f1db73 100644 Binary files a/src/vs/base/browser/ui/codicons/codicon/codicon.ttf and b/src/vs/base/browser/ui/codicons/codicon/codicon.ttf differ diff --git a/src/vs/base/browser/ui/contextview/contextview.ts b/src/vs/base/browser/ui/contextview/contextview.ts index af49847a810f2..c10dda88f896b 100644 --- a/src/vs/base/browser/ui/contextview/contextview.ts +++ b/src/vs/base/browser/ui/contextview/contextview.ts @@ -60,6 +60,9 @@ export interface IDelegate { canRelayout?: boolean; // default: true onDOMEvent?(e: Event, activeElement: HTMLElement): void; onHide?(data?: unknown): void; + + // context views with higher layers are rendered over contet views with lower layers + layer?: number; // Default: 0 } export interface IContextViewProvider { @@ -219,10 +222,10 @@ export class ContextView extends Disposable { // Show static box DOM.clearNode(this.view); - this.view.className = 'context-view'; + this.view.className = 'context-view monaco-component'; this.view.style.top = '0px'; this.view.style.left = '0px'; - this.view.style.zIndex = '2575'; + this.view.style.zIndex = `${2575 + (delegate.layer ?? 0)}`; this.view.style.position = this.useFixedPosition ? 'fixed' : 'absolute'; DOM.show(this.view); diff --git a/src/vs/base/browser/ui/dropdown/dropdown.ts b/src/vs/base/browser/ui/dropdown/dropdown.ts index 8fd8867058a95..1089d8275ba1d 100644 --- a/src/vs/base/browser/ui/dropdown/dropdown.ts +++ b/src/vs/base/browser/ui/dropdown/dropdown.ts @@ -8,6 +8,9 @@ import { $, addDisposableListener, append, EventHelper, EventType, isMouseEvent import { StandardKeyboardEvent } from 'vs/base/browser/keyboardEvent'; import { EventType as GestureEventType, Gesture } from 'vs/base/browser/touch'; import { AnchorAlignment } from 'vs/base/browser/ui/contextview/contextview'; +import type { IUpdatableHover } from 'vs/base/browser/ui/hover/hover'; +import { getBaseLayerHoverDelegate } from 'vs/base/browser/ui/hover/hoverDelegate2'; +import { getDefaultHoverDelegate } from 'vs/base/browser/ui/hover/hoverDelegateFactory'; import { IMenuOptions } from 'vs/base/browser/ui/menu/menu'; import { ActionRunner, IAction } from 'vs/base/common/actions'; import { Emitter } from 'vs/base/common/event'; @@ -34,6 +37,8 @@ class BaseDropdown extends ActionRunner { private _onDidChangeVisibility = this._register(new Emitter()); readonly onDidChangeVisibility = this._onDidChangeVisibility.event; + private hover: IUpdatableHover | undefined; + constructor(container: HTMLElement, options: IBaseDropdownOptions) { super(); @@ -101,7 +106,11 @@ class BaseDropdown extends ActionRunner { set tooltip(tooltip: string) { if (this._label) { - this._label.title = tooltip; + if (!this.hover && tooltip !== '') { + this.hover = this._register(getBaseLayerHoverDelegate().setupUpdatableHover(getDefaultHoverDelegate('mouse'), this._label, tooltip)); + } else if (this.hover) { + this.hover.update(tooltip); + } } } diff --git a/src/vs/base/browser/ui/dropdown/dropdownActionViewItem.ts b/src/vs/base/browser/ui/dropdown/dropdownActionViewItem.ts index a5fee4835b36f..18cfd87d2bcc6 100644 --- a/src/vs/base/browser/ui/dropdown/dropdownActionViewItem.ts +++ b/src/vs/base/browser/ui/dropdown/dropdownActionViewItem.ts @@ -19,6 +19,8 @@ import { KeyCode } from 'vs/base/common/keyCodes'; import { ResolvedKeybinding } from 'vs/base/common/keybindings'; import { IDisposable } from 'vs/base/common/lifecycle'; import 'vs/css!./dropdown'; +import { getDefaultHoverDelegate } from 'vs/base/browser/ui/hover/hoverDelegateFactory'; +import { getBaseLayerHoverDelegate } from 'vs/base/browser/ui/hover/hoverDelegate2'; export interface IKeybindingProvider { (action: IAction): ResolvedKeybinding | undefined; @@ -90,7 +92,9 @@ export class DropdownMenuActionViewItem extends BaseActionViewItem { this.element.setAttribute('role', 'button'); this.element.setAttribute('aria-haspopup', 'true'); this.element.setAttribute('aria-expanded', 'false'); - this.element.title = this._action.label || ''; + if (this._action.label) { + this._register(getBaseLayerHoverDelegate().setupUpdatableHover(this.options.hoverDelegate ?? getDefaultHoverDelegate('mouse'), this.element, this._action.label)); + } this.element.ariaLabel = this._action.label || ''; return null; @@ -203,7 +207,7 @@ export class ActionWithDropdownActionViewItem extends ActionViewItem { separator.classList.toggle('prominent', menuActionClassNames.includes('prominent')); append(this.element, separator); - this.dropdownMenuActionViewItem = this._register(new DropdownMenuActionViewItem(this._register(new Action('dropdownAction', nls.localize('moreActions', "More Actions..."))), menuActionsProvider, this.contextMenuProvider, { classNames: ['dropdown', ...ThemeIcon.asClassNameArray(Codicon.dropDownButton), ...menuActionClassNames] })); + this.dropdownMenuActionViewItem = this._register(new DropdownMenuActionViewItem(this._register(new Action('dropdownAction', nls.localize('moreActions', "More Actions..."))), menuActionsProvider, this.contextMenuProvider, { classNames: ['dropdown', ...ThemeIcon.asClassNameArray(Codicon.dropDownButton), ...menuActionClassNames], hoverDelegate: this.options.hoverDelegate })); this.dropdownMenuActionViewItem.render(this.element); this._register(addDisposableListener(this.element, EventType.KEY_DOWN, e => { diff --git a/src/vs/base/browser/ui/findinput/findInput.ts b/src/vs/base/browser/ui/findinput/findInput.ts index 76af849c278b9..ff042180b6e97 100644 --- a/src/vs/base/browser/ui/findinput/findInput.ts +++ b/src/vs/base/browser/ui/findinput/findInput.ts @@ -16,6 +16,7 @@ import { KeyCode } from 'vs/base/common/keyCodes'; import 'vs/css!./findInput'; import * as nls from 'vs/nls'; import { DisposableStore, MutableDisposable } from 'vs/base/common/lifecycle'; +import { createInstantHoverDelegate } from 'vs/base/browser/ui/hover/hoverDelegateFactory'; export interface IFindInputOptions { @@ -50,7 +51,7 @@ export class FindInput extends Widget { private readonly showCommonFindToggles: boolean; private fixFocusOnOptionClickEnabled = true; private imeSessionInProgress = false; - private additionalTogglesDisposables: MutableDisposable = this._register(new MutableDisposable()); + private readonly additionalTogglesDisposables: MutableDisposable = this._register(new MutableDisposable()); protected readonly controls: HTMLDivElement; protected readonly regex?: RegexToggle; @@ -113,10 +114,13 @@ export class FindInput extends Widget { inputBoxStyles: options.inputBoxStyles, })); + const hoverDelegate = this._register(createInstantHoverDelegate()); + if (this.showCommonFindToggles) { this.regex = this._register(new RegexToggle({ appendTitle: appendRegexLabel, isChecked: false, + hoverDelegate, ...options.toggleStyles })); this._register(this.regex.onChange(viaKeyboard => { @@ -133,6 +137,7 @@ export class FindInput extends Widget { this.wholeWords = this._register(new WholeWordsToggle({ appendTitle: appendWholeWordsLabel, isChecked: false, + hoverDelegate, ...options.toggleStyles })); this._register(this.wholeWords.onChange(viaKeyboard => { @@ -146,6 +151,7 @@ export class FindInput extends Widget { this.caseSensitive = this._register(new CaseSensitiveToggle({ appendTitle: appendCaseSensitiveLabel, isChecked: false, + hoverDelegate, ...options.toggleStyles })); this._register(this.caseSensitive.onChange(viaKeyboard => { diff --git a/src/vs/base/browser/ui/findinput/findInputToggles.ts b/src/vs/base/browser/ui/findinput/findInputToggles.ts index 591ab98157773..adce009430b75 100644 --- a/src/vs/base/browser/ui/findinput/findInputToggles.ts +++ b/src/vs/base/browser/ui/findinput/findInputToggles.ts @@ -3,6 +3,8 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { getDefaultHoverDelegate } from 'vs/base/browser/ui/hover/hoverDelegateFactory'; +import { IHoverDelegate } from 'vs/base/browser/ui/hover/hoverDelegate'; import { Toggle } from 'vs/base/browser/ui/toggle/toggle'; import { Codicon } from 'vs/base/common/codicons'; import * as nls from 'vs/nls'; @@ -13,6 +15,7 @@ export interface IFindInputToggleOpts { readonly inputActiveOptionBorder: string | undefined; readonly inputActiveOptionForeground: string | undefined; readonly inputActiveOptionBackground: string | undefined; + readonly hoverDelegate?: IHoverDelegate; } const NLS_CASE_SENSITIVE_TOGGLE_LABEL = nls.localize('caseDescription', "Match Case"); @@ -25,6 +28,7 @@ export class CaseSensitiveToggle extends Toggle { icon: Codicon.caseSensitive, title: NLS_CASE_SENSITIVE_TOGGLE_LABEL + opts.appendTitle, isChecked: opts.isChecked, + hoverDelegate: opts.hoverDelegate ?? getDefaultHoverDelegate('element'), inputActiveOptionBorder: opts.inputActiveOptionBorder, inputActiveOptionForeground: opts.inputActiveOptionForeground, inputActiveOptionBackground: opts.inputActiveOptionBackground @@ -38,6 +42,7 @@ export class WholeWordsToggle extends Toggle { icon: Codicon.wholeWord, title: NLS_WHOLE_WORD_TOGGLE_LABEL + opts.appendTitle, isChecked: opts.isChecked, + hoverDelegate: opts.hoverDelegate ?? getDefaultHoverDelegate('element'), inputActiveOptionBorder: opts.inputActiveOptionBorder, inputActiveOptionForeground: opts.inputActiveOptionForeground, inputActiveOptionBackground: opts.inputActiveOptionBackground @@ -51,6 +56,7 @@ export class RegexToggle extends Toggle { icon: Codicon.regex, title: NLS_REGEX_TOGGLE_LABEL + opts.appendTitle, isChecked: opts.isChecked, + hoverDelegate: opts.hoverDelegate ?? getDefaultHoverDelegate('element'), inputActiveOptionBorder: opts.inputActiveOptionBorder, inputActiveOptionForeground: opts.inputActiveOptionForeground, inputActiveOptionBackground: opts.inputActiveOptionBackground diff --git a/src/vs/base/browser/ui/findinput/replaceInput.ts b/src/vs/base/browser/ui/findinput/replaceInput.ts index 6cd7d4fb1c682..4dfdf549a3b79 100644 --- a/src/vs/base/browser/ui/findinput/replaceInput.ts +++ b/src/vs/base/browser/ui/findinput/replaceInput.ts @@ -16,6 +16,7 @@ import { Emitter, Event } from 'vs/base/common/event'; import { KeyCode } from 'vs/base/common/keyCodes'; import 'vs/css!./findInput'; import * as nls from 'vs/nls'; +import { getDefaultHoverDelegate } from 'vs/base/browser/ui/hover/hoverDelegateFactory'; export interface IReplaceInputOptions { @@ -44,9 +45,10 @@ class PreserveCaseToggle extends Toggle { icon: Codicon.preserveCase, title: NLS_PRESERVE_CASE_LABEL + opts.appendTitle, isChecked: opts.isChecked, + hoverDelegate: opts.hoverDelegate ?? getDefaultHoverDelegate('element'), inputActiveOptionBorder: opts.inputActiveOptionBorder, inputActiveOptionForeground: opts.inputActiveOptionForeground, - inputActiveOptionBackground: opts.inputActiveOptionBackground + inputActiveOptionBackground: opts.inputActiveOptionBackground, }); } } diff --git a/src/vs/base/browser/ui/highlightedlabel/highlightedLabel.ts b/src/vs/base/browser/ui/highlightedlabel/highlightedLabel.ts index c2b41545d793a..724075adb8736 100644 --- a/src/vs/base/browser/ui/highlightedlabel/highlightedLabel.ts +++ b/src/vs/base/browser/ui/highlightedlabel/highlightedLabel.ts @@ -4,7 +4,12 @@ *--------------------------------------------------------------------------------------------*/ import * as dom from 'vs/base/browser/dom'; +import type { IUpdatableHover } from 'vs/base/browser/ui/hover/hover'; +import { IHoverDelegate } from 'vs/base/browser/ui/hover/hoverDelegate'; +import { getBaseLayerHoverDelegate } from 'vs/base/browser/ui/hover/hoverDelegate2'; +import { getDefaultHoverDelegate } from 'vs/base/browser/ui/hover/hoverDelegateFactory'; import { renderLabelWithIcons } from 'vs/base/browser/ui/iconLabel/iconLabels'; +import { Disposable } from 'vs/base/common/lifecycle'; import * as objects from 'vs/base/common/objects'; /** @@ -22,13 +27,15 @@ export interface IHighlightedLabelOptions { * Whether the label supports rendering icons. */ readonly supportIcons?: boolean; + + readonly hoverDelegate?: IHoverDelegate; } /** * A widget which can render a label with substring highlights, often * originating from a filter function like the fuzzy matcher. */ -export class HighlightedLabel { +export class HighlightedLabel extends Disposable { private readonly domNode: HTMLElement; private text: string = ''; @@ -36,13 +43,16 @@ export class HighlightedLabel { private highlights: readonly IHighlight[] = []; private supportIcons: boolean; private didEverRender: boolean = false; + private customHover: IUpdatableHover | undefined; /** * Create a new {@link HighlightedLabel}. * * @param container The parent container to append to. */ - constructor(container: HTMLElement, options?: IHighlightedLabelOptions) { + constructor(container: HTMLElement, private readonly options?: IHighlightedLabelOptions) { + super(); + this.supportIcons = options?.supportIcons ?? false; this.domNode = dom.append(container, dom.$('span.monaco-highlighted-label')); } @@ -125,10 +135,16 @@ export class HighlightedLabel { dom.reset(this.domNode, ...children); - if (this.title) { + if (this.options?.hoverDelegate?.showNativeHover) { + /* While custom hover is not inside custom hover */ this.domNode.title = this.title; } else { - this.domNode.removeAttribute('title'); + if (!this.customHover && this.title !== '') { + const hoverDelegate = this.options?.hoverDelegate ?? getDefaultHoverDelegate('mouse'); + this.customHover = this._register(getBaseLayerHoverDelegate().setupUpdatableHover(hoverDelegate, this.domNode, this.title)); + } else if (this.customHover) { + this.customHover.update(this.title); + } } this.didEverRender = true; diff --git a/src/vs/base/browser/ui/hover/hover.ts b/src/vs/base/browser/ui/hover/hover.ts new file mode 100644 index 0000000000000..a0f9422ce0533 --- /dev/null +++ b/src/vs/base/browser/ui/hover/hover.ts @@ -0,0 +1,269 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import type { IHoverDelegate } from 'vs/base/browser/ui/hover/hoverDelegate'; +import type { HoverPosition } from 'vs/base/browser/ui/hover/hoverWidget'; +import type { CancellationToken } from 'vs/base/common/cancellation'; +import type { IMarkdownString } from 'vs/base/common/htmlContent'; +import type { IDisposable } from 'vs/base/common/lifecycle'; + +/** + * Enables the convenient display of rich markdown-based hovers in the workbench. + */ +export interface IHoverDelegate2 { + /** + * Shows a hover, provided a hover with the same options object is not already visible. + * @param options A set of options defining the characteristics of the hover. + * @param focus Whether to focus the hover (useful for keyboard accessibility). + * + * **Example:** A simple usage with a single element target. + * + * ```typescript + * showHover({ + * text: new MarkdownString('Hello world'), + * target: someElement + * }); + * ``` + */ + showHover(options: IHoverOptions, focus?: boolean): IHoverWidget | undefined; + + /** + * Hides the hover if it was visible. This call will be ignored if the the hover is currently + * "locked" via the alt/option key. + */ + hideHover(): void; + + /** + * This should only be used until we have the ability to show multiple context views + * simultaneously. #188822 + */ + showAndFocusLastHover(): void; + + // TODO: Change hoverDelegate arg to exclude the actual delegate and instead use the new options + setupUpdatableHover(hoverDelegate: IHoverDelegate, htmlElement: HTMLElement, content: IUpdatableHoverContentOrFactory, options?: IUpdatableHoverOptions): IUpdatableHover; +} + +export interface IHoverWidget extends IDisposable { + readonly isDisposed: boolean; +} + +export interface IHoverOptions { + /** + * The content to display in the primary section of the hover. The type of text determines the + * default `hideOnHover` behavior. + */ + content: IMarkdownString | string | HTMLElement; + + /** + * The target for the hover. This determines the position of the hover and it will only be + * hidden when the mouse leaves both the hover and the target. A HTMLElement can be used for + * simple cases and a IHoverTarget for more complex cases where multiple elements and/or a + * dispose method is required. + */ + target: IHoverTarget | HTMLElement; + + /* + * The container to pass to {@link IContextViewProvider.showContextView} which renders the hover + * in. This is particularly useful for more natural tab focusing behavior, where the hover is + * created as the next tab index after the element being hovered and/or to workaround the + * element's container hiding on `focusout`. + */ + container?: HTMLElement; + + /** + * An ID to associate with the hover to be used as an equality check. Normally when calling + * {@link IHoverService.showHover} the options object itself is used to determine if the hover + * is the same one that is already showing, when this is set, the ID will be used instead. + */ + id?: number | string; + + /** + * A set of actions for the hover's "status bar". + */ + actions?: IHoverAction[]; + + /** + * An optional array of classes to add to the hover element. + */ + additionalClasses?: string[]; + + /** + * An optional link handler for markdown links, if this is not provided the IOpenerService will + * be used to open the links using its default options. + */ + linkHandler?(url: string): void; + + /** + * Whether to trap focus in the following ways: + * - When the hover closes, focus goes to the element that had focus before the hover opened + * - If there are elements in the hover to focus, focus stays inside of the hover when tabbing + * Note that this is overridden to true when in screen reader optimized mode. + */ + trapFocus?: boolean; + + /** + * Options that defines where the hover is positioned. + */ + position?: IHoverPositionOptions; + + /** + * Options that defines how long the hover is shown and when it hides. + */ + persistence?: IHoverPersistenceOptions; + + /** + * Options that define how the hover looks. + */ + appearance?: IHoverAppearanceOptions; +} + +export interface IHoverPositionOptions { + /** + * Position of the hover. The default is to show above the target. This option will be ignored + * if there is not enough room to layout the hover in the specified position, unless the + * forcePosition option is set. + */ + hoverPosition?: HoverPosition; + + /** + * Force the hover position, reducing the size of the hover instead of adjusting the hover + * position. + */ + forcePosition?: boolean; +} + +export interface IHoverPersistenceOptions { + /** + * Whether to hide the hover when the mouse leaves the `target` and enters the actual hover. + * This is false by default when text is an `IMarkdownString` and true when `text` is a + * `string`. Note that this will be ignored if any `actions` are provided as hovering is + * required to make them accessible. + * + * In general hiding on hover is desired for: + * - Regular text where selection is not important + * - Markdown that contains no links where selection is not important + */ + hideOnHover?: boolean; + + /** + * Whether to hide the hover when a key is pressed. + */ + hideOnKeyDown?: boolean; + + /** + * Whether to make the hover sticky, this means it will not be hidden when the mouse leaves the + * hover. + */ + sticky?: boolean; +} + +export interface IHoverAppearanceOptions { + /** + * Whether to show the hover pointer, a little arrow that connects the target and the hover. + */ + showPointer?: boolean; + + /** + * Whether to show a compact hover, reducing the font size and padding of the hover. + */ + compact?: boolean; + + /** + * When {@link hideOnHover} is explicitly true or undefined and its auto value is detected to + * hide, show a hint at the bottom of the hover explaining how to mouse over the widget. This + * should be used in the cases where despite the hover having no interactive content, it's + * likely the user may want to interact with it somehow. + */ + showHoverHint?: boolean; + + /** + * Whether to skip the fade in animation, this should be used when hovering from one hover to + * another in the same group so it looks like the hover is moving from one element to the other. + */ + skipFadeInAnimation?: boolean; +} + +export interface IHoverAction { + /** + * The label to use in the hover's status bar. + */ + label: string; + + /** + * The command ID of the action, this is used to resolve the keybinding to display after the + * action label. + */ + commandId: string; + + /** + * An optional class of an icon that will be displayed before the label. + */ + iconClass?: string; + + /** + * The callback to run the action. + * @param target The action element that was activated. + */ + run(target: HTMLElement): void; +} + +/** + * A target for a hover. + */ +export interface IHoverTarget extends IDisposable { + /** + * A set of target elements used to position the hover. If multiple elements are used the hover + * will try to not overlap any target element. An example use case for this is show a hover for + * wrapped text. + */ + readonly targetElements: readonly HTMLElement[]; + + /** + * An optional absolute x coordinate to position the hover with, for example to position the + * hover using `MouseEvent.pageX`. + */ + x?: number; + + /** + * An optional absolute y coordinate to position the hover with, for example to position the + * hover using `MouseEvent.pageY`. + */ + y?: number; +} + +// #region Updatable hover + +export interface IUpdatableHoverTooltipMarkdownString { + markdown: IMarkdownString | string | undefined | ((token: CancellationToken) => Promise); + markdownNotSupportedFallback: string | undefined; +} + +export type IUpdatableHoverContent = string | IUpdatableHoverTooltipMarkdownString | HTMLElement | undefined; +export type IUpdatableHoverContentOrFactory = IUpdatableHoverContent | (() => IUpdatableHoverContent); + +export interface IUpdatableHoverOptions { + actions?: IHoverAction[]; + linkHandler?(url: string): void; +} + +export interface IUpdatableHover extends IDisposable { + + /** + * Allows to programmatically open the hover. + */ + show(focus?: boolean): void; + + /** + * Allows to programmatically hide the hover. + */ + hide(): void; + + /** + * Updates the contents of the hover. + */ + update(tooltip: IUpdatableHoverContent, options?: IUpdatableHoverOptions): void; +} + +// #endregion Updatable hover diff --git a/src/vs/base/browser/ui/iconLabel/iconHoverDelegate.ts b/src/vs/base/browser/ui/hover/hoverDelegate.ts similarity index 90% rename from src/vs/base/browser/ui/iconLabel/iconHoverDelegate.ts rename to src/vs/base/browser/ui/hover/hoverDelegate.ts index 74fcd97d4a95e..d2f1d7884ffda 100644 --- a/src/vs/base/browser/ui/iconLabel/iconHoverDelegate.ts +++ b/src/vs/base/browser/ui/hover/hoverDelegate.ts @@ -3,8 +3,8 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import type { IHoverWidget, IUpdatableHoverOptions } from 'vs/base/browser/ui/hover/hover'; import { HoverPosition } from 'vs/base/browser/ui/hover/hoverWidget'; -import { IUpdatableHoverOptions } from 'vs/base/browser/ui/iconLabel/iconLabelHover'; import { IMarkdownString } from 'vs/base/common/htmlContent'; import { IDisposable } from 'vs/base/common/lifecycle'; @@ -62,8 +62,7 @@ export interface IHoverDelegate { onDidHideHover?: () => void; delay: number; placement?: 'mouse' | 'element'; + showNativeHover?: boolean; // TODO@benibenj remove this, only temp fix for contextviews } -export interface IHoverWidget extends IDisposable { - readonly isDisposed: boolean; -} +export interface IScopedHoverDelegate extends IHoverDelegate, IDisposable { } diff --git a/src/vs/base/browser/ui/hover/hoverDelegate2.ts b/src/vs/base/browser/ui/hover/hoverDelegate2.ts new file mode 100644 index 0000000000000..90c71d65a1dd6 --- /dev/null +++ b/src/vs/base/browser/ui/hover/hoverDelegate2.ts @@ -0,0 +1,31 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import type { IHoverDelegate2 } from 'vs/base/browser/ui/hover/hover'; + +let baseHoverDelegate: IHoverDelegate2 = { + showHover: () => undefined, + hideHover: () => undefined, + showAndFocusLastHover: () => undefined, + setupUpdatableHover: () => null!, +}; + +/** + * Sets the hover delegate for use **only in the `base/` layer**. + */ +export function setBaseLayerHoverDelegate(hoverDelegate: IHoverDelegate2): void { + baseHoverDelegate = hoverDelegate; +} + +/** + * Gets the hover delegate for use **only in the `base/` layer**. + * + * Since the hover service depends on various platform services, this delegate essentially bypasses + * the standard dependency injection mechanism by injecting a global hover service at start up. The + * only reason this should be used is if `IHoverService` is not available. + */ +export function getBaseLayerHoverDelegate(): IHoverDelegate2 { + return baseHoverDelegate; +} diff --git a/src/vs/base/browser/ui/hover/hoverDelegateFactory.ts b/src/vs/base/browser/ui/hover/hoverDelegateFactory.ts new file mode 100644 index 0000000000000..b14f1159419a3 --- /dev/null +++ b/src/vs/base/browser/ui/hover/hoverDelegateFactory.ts @@ -0,0 +1,38 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IHoverDelegate, IScopedHoverDelegate } from 'vs/base/browser/ui/hover/hoverDelegate'; +import { Lazy } from 'vs/base/common/lazy'; + +const nullHoverDelegateFactory = () => ({ + get delay(): number { return -1; }, + dispose: () => { }, + showHover: () => { return undefined; }, +}); + +let hoverDelegateFactory: (placement: 'mouse' | 'element', enableInstantHover: boolean) => IScopedHoverDelegate = nullHoverDelegateFactory; +const defaultHoverDelegateMouse = new Lazy(() => hoverDelegateFactory('mouse', false)); +const defaultHoverDelegateElement = new Lazy(() => hoverDelegateFactory('element', false)); + +// TODO: Remove when getDefaultHoverDelegate is no longer used +export function setHoverDelegateFactory(hoverDelegateProvider: ((placement: 'mouse' | 'element', enableInstantHover: boolean) => IScopedHoverDelegate)): void { + hoverDelegateFactory = hoverDelegateProvider; +} + +// TODO: Refine type for use in new IHoverService interface +export function getDefaultHoverDelegate(placement: 'mouse' | 'element'): IHoverDelegate { + if (placement === 'element') { + return defaultHoverDelegateElement.value; + } + return defaultHoverDelegateMouse.value; +} + +// TODO: Create equivalent in IHoverService +export function createInstantHoverDelegate(): IScopedHoverDelegate { + // Creates a hover delegate with instant hover enabled. + // This hover belongs to the consumer and requires the them to dispose it. + // Instant hover only makes sense for 'element' placement. + return hoverDelegateFactory('element', true); +} diff --git a/src/vs/base/browser/ui/hover/hover.css b/src/vs/base/browser/ui/hover/hoverWidget.css similarity index 100% rename from src/vs/base/browser/ui/hover/hover.css rename to src/vs/base/browser/ui/hover/hoverWidget.css diff --git a/src/vs/base/browser/ui/hover/hoverWidget.ts b/src/vs/base/browser/ui/hover/hoverWidget.ts index dc0af66ff5a13..bff397303beae 100644 --- a/src/vs/base/browser/ui/hover/hoverWidget.ts +++ b/src/vs/base/browser/ui/hover/hoverWidget.ts @@ -8,7 +8,7 @@ import { StandardKeyboardEvent } from 'vs/base/browser/keyboardEvent'; import { DomScrollableElement } from 'vs/base/browser/ui/scrollbar/scrollableElement'; import { KeyCode } from 'vs/base/common/keyCodes'; import { Disposable } from 'vs/base/common/lifecycle'; -import 'vs/css!./hover'; +import 'vs/css!./hoverWidget'; import { localize } from 'vs/nls'; const $ = dom.$; diff --git a/src/vs/base/browser/ui/iconLabel/iconLabel.ts b/src/vs/base/browser/ui/iconLabel/iconLabel.ts index 0bb344bfbb6df..989db65bbeeb0 100644 --- a/src/vs/base/browser/ui/iconLabel/iconLabel.ts +++ b/src/vs/base/browser/ui/iconLabel/iconLabel.ts @@ -6,12 +6,16 @@ import 'vs/css!./iconlabel'; import * as dom from 'vs/base/browser/dom'; import { HighlightedLabel } from 'vs/base/browser/ui/highlightedlabel/highlightedLabel'; -import { IHoverDelegate } from 'vs/base/browser/ui/iconLabel/iconHoverDelegate'; -import { ITooltipMarkdownString, setupCustomHover, setupNativeHover } from 'vs/base/browser/ui/iconLabel/iconLabelHover'; +import { IHoverDelegate } from 'vs/base/browser/ui/hover/hoverDelegate'; import { IMatch } from 'vs/base/common/filters'; import { Disposable, IDisposable } from 'vs/base/common/lifecycle'; import { equals } from 'vs/base/common/objects'; import { Range } from 'vs/base/common/range'; +import { getDefaultHoverDelegate } from 'vs/base/browser/ui/hover/hoverDelegateFactory'; +import type { IUpdatableHoverTooltipMarkdownString } from 'vs/base/browser/ui/hover/hover'; +import { getBaseLayerHoverDelegate } from 'vs/base/browser/ui/hover/hoverDelegate2'; +import { isString } from 'vs/base/common/types'; +import { stripIcons } from 'vs/base/common/iconLabels'; export interface IIconLabelCreationOptions { readonly supportHighlights?: boolean; @@ -21,8 +25,8 @@ export interface IIconLabelCreationOptions { } export interface IIconLabelValueOptions { - title?: string | ITooltipMarkdownString; - descriptionTitle?: string; + title?: string | IUpdatableHoverTooltipMarkdownString; + descriptionTitle?: string | IUpdatableHoverTooltipMarkdownString; suffix?: string; hideIcon?: boolean; extraClasses?: readonly string[]; @@ -94,7 +98,7 @@ export class IconLabel extends Disposable { private readonly labelContainer: HTMLElement; - private readonly hoverDelegate: IHoverDelegate | undefined; + private readonly hoverDelegate: IHoverDelegate; private readonly customHovers: Map = new Map(); constructor(container: HTMLElement, options?: IIconLabelCreationOptions) { @@ -108,12 +112,12 @@ export class IconLabel extends Disposable { this.nameContainer = dom.append(this.labelContainer, dom.$('span.monaco-icon-name-container')); if (options?.supportHighlights || options?.supportIcons) { - this.nameNode = new LabelWithHighlights(this.nameContainer, !!options.supportIcons); + this.nameNode = this._register(new LabelWithHighlights(this.nameContainer, !!options.supportIcons)); } else { this.nameNode = new Label(this.nameContainer); } - this.hoverDelegate = options?.hoverDelegate; + this.hoverDelegate = options?.hoverDelegate ?? getDefaultHoverDelegate('mouse'); } get element(): HTMLElement { @@ -174,7 +178,7 @@ export class IconLabel extends Disposable { } } - private setupHover(htmlElement: HTMLElement, tooltip: string | ITooltipMarkdownString | undefined): void { + private setupHover(htmlElement: HTMLElement, tooltip: string | IUpdatableHoverTooltipMarkdownString | undefined): void { const previousCustomHover = this.customHovers.get(htmlElement); if (previousCustomHover) { previousCustomHover.dispose(); @@ -186,10 +190,20 @@ export class IconLabel extends Disposable { return; } - if (!this.hoverDelegate) { + if (this.hoverDelegate.showNativeHover) { + function setupNativeHover(htmlElement: HTMLElement, tooltip: string | IUpdatableHoverTooltipMarkdownString | undefined): void { + if (isString(tooltip)) { + // Icons don't render in the native hover so we strip them out + htmlElement.title = stripIcons(tooltip); + } else if (tooltip?.markdownNotSupportedFallback) { + htmlElement.title = tooltip.markdownNotSupportedFallback; + } else { + htmlElement.removeAttribute('title'); + } + } setupNativeHover(htmlElement, tooltip); } else { - const hoverDisposable = setupCustomHover(this.hoverDelegate, htmlElement, tooltip); + const hoverDisposable = getBaseLayerHoverDelegate().setupUpdatableHover(this.hoverDelegate, htmlElement, tooltip); if (hoverDisposable) { this.customHovers.set(htmlElement, hoverDisposable); } @@ -217,7 +231,7 @@ export class IconLabel extends Disposable { if (!this.descriptionNode) { const descriptionContainer = this._register(new FastLabelNode(dom.append(this.labelContainer, dom.$('span.monaco-icon-description-container')))); if (this.creationOptions?.supportDescriptionHighlights) { - this.descriptionNode = new HighlightedLabel(dom.append(descriptionContainer.element, dom.$('span.label-description')), { supportIcons: !!this.creationOptions.supportIcons }); + this.descriptionNode = this._register(new HighlightedLabel(dom.append(descriptionContainer.element, dom.$('span.label-description')), { supportIcons: !!this.creationOptions.supportIcons })); } else { this.descriptionNode = this._register(new FastLabelNode(dom.append(descriptionContainer.element, dom.$('span.label-description')))); } @@ -290,13 +304,15 @@ function splitMatches(labels: string[], separator: string, matches: readonly IMa }); } -class LabelWithHighlights { +class LabelWithHighlights extends Disposable { private label: string | string[] | undefined = undefined; private singleLabel: HighlightedLabel | undefined = undefined; private options: IIconLabelValueOptions | undefined; - constructor(private container: HTMLElement, private supportIcons: boolean) { } + constructor(private container: HTMLElement, private supportIcons: boolean) { + super(); + } setLabel(label: string | string[], options?: IIconLabelValueOptions): void { if (this.label === label && equals(this.options, options)) { @@ -310,7 +326,7 @@ class LabelWithHighlights { if (!this.singleLabel) { this.container.innerText = ''; this.container.classList.remove('multiple'); - this.singleLabel = new HighlightedLabel(dom.append(this.container, dom.$('a.label-name', { id: options?.domId })), { supportIcons: this.supportIcons }); + this.singleLabel = this._register(new HighlightedLabel(dom.append(this.container, dom.$('a.label-name', { id: options?.domId })), { supportIcons: this.supportIcons })); } this.singleLabel.set(label, options?.matches, undefined, options?.labelEscapeNewLines); @@ -328,7 +344,7 @@ class LabelWithHighlights { const id = options?.domId && `${options?.domId}_${i}`; const name = dom.$('a.label-name', { id, 'data-icon-label-count': label.length, 'data-icon-label-index': i, 'role': 'treeitem' }); - const highlightedLabel = new HighlightedLabel(dom.append(this.container, name), { supportIcons: this.supportIcons }); + const highlightedLabel = this._register(new HighlightedLabel(dom.append(this.container, name), { supportIcons: this.supportIcons })); highlightedLabel.set(l, m, undefined, options?.labelEscapeNewLines); if (i < label.length - 1) { diff --git a/src/vs/base/browser/ui/iconLabel/iconLabelHover.ts b/src/vs/base/browser/ui/iconLabel/iconLabelHover.ts deleted file mode 100644 index 23878f78ea830..0000000000000 --- a/src/vs/base/browser/ui/iconLabel/iconLabelHover.ts +++ /dev/null @@ -1,271 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * 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 'vs/base/browser/dom'; -import { HoverPosition } from 'vs/base/browser/ui/hover/hoverWidget'; -import { IHoverDelegate, IHoverDelegateOptions, IHoverDelegateTarget, IHoverWidget } from 'vs/base/browser/ui/iconLabel/iconHoverDelegate'; -import { TimeoutTimer } from 'vs/base/common/async'; -import { CancellationToken, CancellationTokenSource } from 'vs/base/common/cancellation'; -import { IMarkdownString, isMarkdownString } from 'vs/base/common/htmlContent'; -import { stripIcons } from 'vs/base/common/iconLabels'; -import { DisposableStore, IDisposable } from 'vs/base/common/lifecycle'; -import { isFunction, isString } from 'vs/base/common/types'; -import { localize } from 'vs/nls'; - -export interface ITooltipMarkdownString { - markdown: IMarkdownString | string | undefined | ((token: CancellationToken) => Promise); - markdownNotSupportedFallback: string | undefined; -} - -export function setupNativeHover(htmlElement: HTMLElement, tooltip: string | ITooltipMarkdownString | undefined): void { - if (isString(tooltip)) { - // Icons don't render in the native hover so we strip them out - htmlElement.title = stripIcons(tooltip); - } else if (tooltip?.markdownNotSupportedFallback) { - htmlElement.title = tooltip.markdownNotSupportedFallback; - } else { - htmlElement.removeAttribute('title'); - } -} - -type IHoverContent = string | ITooltipMarkdownString | HTMLElement | undefined; -type IResolvedHoverContent = IMarkdownString | string | HTMLElement | undefined; - -/** - * Copied from src\vs\workbench\services\hover\browser\hover.ts - * @deprecated Use IHoverService - */ -interface IHoverAction { - label: string; - commandId: string; - iconClass?: string; - run(target: HTMLElement): void; -} - -export interface IUpdatableHoverOptions { - actions?: IHoverAction[]; - linkHandler?(url: string): void; -} - -export interface ICustomHover extends IDisposable { - - /** - * Allows to programmatically open the hover. - */ - show(focus?: boolean): void; - - /** - * Allows to programmatically hide the hover. - */ - hide(): void; - - /** - * Updates the contents of the hover. - */ - update(tooltip: IHoverContent, options?: IUpdatableHoverOptions): void; -} - - -class UpdatableHoverWidget implements IDisposable { - - private _hoverWidget: IHoverWidget | undefined; - private _cancellationTokenSource: CancellationTokenSource | undefined; - - constructor(private hoverDelegate: IHoverDelegate, private target: IHoverDelegateTarget | HTMLElement, private fadeInAnimation: boolean) { - } - - async update(content: IHoverContent, focus?: boolean, options?: IUpdatableHoverOptions): Promise { - if (this._cancellationTokenSource) { - // there's an computation ongoing, cancel it - this._cancellationTokenSource.dispose(true); - this._cancellationTokenSource = undefined; - } - if (this.isDisposed) { - return; - } - - let resolvedContent; - if (content === undefined || isString(content) || content instanceof HTMLElement) { - resolvedContent = content; - } else if (!isFunction(content.markdown)) { - resolvedContent = content.markdown ?? content.markdownNotSupportedFallback; - } else { - // compute the content, potentially long-running - - // show 'Loading' if no hover is up yet - if (!this._hoverWidget) { - this.show(localize('iconLabel.loading', "Loading..."), focus); - } - - // compute the content - this._cancellationTokenSource = new CancellationTokenSource(); - const token = this._cancellationTokenSource.token; - resolvedContent = await content.markdown(token); - if (resolvedContent === undefined) { - resolvedContent = content.markdownNotSupportedFallback; - } - - if (this.isDisposed || token.isCancellationRequested) { - // either the widget has been closed in the meantime - // or there has been a new call to `update` - return; - } - } - - this.show(resolvedContent, focus, options); - } - - private show(content: IResolvedHoverContent, focus?: boolean, options?: IUpdatableHoverOptions): void { - const oldHoverWidget = this._hoverWidget; - - if (this.hasContent(content)) { - const hoverOptions: IHoverDelegateOptions = { - content, - target: this.target, - appearance: { - showPointer: this.hoverDelegate.placement === 'element', - skipFadeInAnimation: !this.fadeInAnimation || !!oldHoverWidget, // do not fade in if the hover is already showing - }, - position: { - hoverPosition: HoverPosition.BELOW, - }, - ...options - }; - - this._hoverWidget = this.hoverDelegate.showHover(hoverOptions, focus); - } - oldHoverWidget?.dispose(); - } - - private hasContent(content: IResolvedHoverContent): content is NonNullable { - if (!content) { - return false; - } - - if (isMarkdownString(content)) { - return !!content.value; - } - - return true; - } - - get isDisposed() { - return this._hoverWidget?.isDisposed; - } - - dispose(): void { - this._hoverWidget?.dispose(); - this._cancellationTokenSource?.dispose(true); - this._cancellationTokenSource = undefined; - } -} - -export function setupCustomHover(hoverDelegate: IHoverDelegate, htmlElement: HTMLElement, content: IHoverContent, options?: IUpdatableHoverOptions): ICustomHover { - let hoverPreparation: IDisposable | undefined; - - let hoverWidget: UpdatableHoverWidget | undefined; - - const hideHover = (disposeWidget: boolean, disposePreparation: boolean) => { - const hadHover = hoverWidget !== undefined; - if (disposeWidget) { - hoverWidget?.dispose(); - hoverWidget = undefined; - } - if (disposePreparation) { - hoverPreparation?.dispose(); - hoverPreparation = undefined; - } - if (hadHover) { - hoverDelegate.onDidHideHover?.(); - } - }; - - const triggerShowHover = (delay: number, focus?: boolean, target?: IHoverDelegateTarget) => { - return new TimeoutTimer(async () => { - if (!hoverWidget || hoverWidget.isDisposed) { - hoverWidget = new UpdatableHoverWidget(hoverDelegate, target || htmlElement, delay > 0); - await hoverWidget.update(content, focus, options); - } - }, delay); - }; - - let isMouseDown = false; - const mouseDownEmitter = dom.addDisposableListener(htmlElement, dom.EventType.MOUSE_DOWN, () => { - isMouseDown = true; - hideHover(true, true); - }, true); - const mouseUpEmitter = dom.addDisposableListener(htmlElement, dom.EventType.MOUSE_UP, () => { - isMouseDown = false; - }, true); - const mouseLeaveEmitter = dom.addDisposableListener(htmlElement, dom.EventType.MOUSE_LEAVE, (e: MouseEvent) => { - isMouseDown = false; - hideHover(false, (e).fromElement === htmlElement); - }, true); - - const onMouseOver = () => { - if (hoverPreparation) { - return; - } - - const toDispose: DisposableStore = new DisposableStore(); - - const target: IHoverDelegateTarget = { - targetElements: [htmlElement], - dispose: () => { } - }; - if (hoverDelegate.placement === undefined || hoverDelegate.placement === 'mouse') { - // track the mouse position - const onMouseMove = (e: MouseEvent) => { - target.x = e.x + 10; - if ((e.target instanceof HTMLElement) && e.target.classList.contains('action-label')) { - hideHover(true, true); - } - }; - toDispose.add(dom.addDisposableListener(htmlElement, dom.EventType.MOUSE_MOVE, onMouseMove, true)); - } - toDispose.add(triggerShowHover(hoverDelegate.delay, false, target)); - - hoverPreparation = toDispose; - }; - const mouseOverDomEmitter = dom.addDisposableListener(htmlElement, dom.EventType.MOUSE_OVER, onMouseOver, true); - - const onFocus = () => { - if (isMouseDown || hoverPreparation) { - return; - } - const target: IHoverDelegateTarget = { - targetElements: [htmlElement], - dispose: () => { } - }; - const toDispose: DisposableStore = new DisposableStore(); - const onBlur = () => hideHover(true, true); - toDispose.add(dom.addDisposableListener(htmlElement, dom.EventType.BLUR, onBlur, true)); - toDispose.add(triggerShowHover(hoverDelegate.delay, false, target)); - hoverPreparation = toDispose; - }; - const focusDomEmitter = dom.addDisposableListener(htmlElement, dom.EventType.FOCUS, onFocus, true); - const hover: ICustomHover = { - show: focus => { - hideHover(false, true); // terminate a ongoing mouse over preparation - triggerShowHover(0, focus); // show hover immediately - }, - hide: () => { - hideHover(true, true); - }, - update: async (newContent, hoverOptions) => { - content = newContent; - await hoverWidget?.update(content, undefined, hoverOptions); - }, - dispose: () => { - mouseOverDomEmitter.dispose(); - mouseLeaveEmitter.dispose(); - mouseDownEmitter.dispose(); - mouseUpEmitter.dispose(); - focusDomEmitter.dispose(); - hideHover(true, true); - } - }; - return hover; -} diff --git a/src/vs/base/browser/ui/iconLabel/simpleIconLabel.ts b/src/vs/base/browser/ui/iconLabel/simpleIconLabel.ts index 659572d4ff8ae..6f960b8add0f6 100644 --- a/src/vs/base/browser/ui/iconLabel/simpleIconLabel.ts +++ b/src/vs/base/browser/ui/iconLabel/simpleIconLabel.ts @@ -4,9 +4,15 @@ *--------------------------------------------------------------------------------------------*/ import { reset } from 'vs/base/browser/dom'; +import type { IUpdatableHover } from 'vs/base/browser/ui/hover/hover'; +import { getBaseLayerHoverDelegate } from 'vs/base/browser/ui/hover/hoverDelegate2'; +import { getDefaultHoverDelegate } from 'vs/base/browser/ui/hover/hoverDelegateFactory'; import { renderLabelWithIcons } from 'vs/base/browser/ui/iconLabel/iconLabels'; +import { IDisposable } from 'vs/base/common/lifecycle'; -export class SimpleIconLabel { +export class SimpleIconLabel implements IDisposable { + + private hover?: IUpdatableHover; constructor( private readonly _container: HTMLElement @@ -17,6 +23,14 @@ export class SimpleIconLabel { } set title(title: string) { - this._container.title = title; + if (!this.hover && title) { + this.hover = getBaseLayerHoverDelegate().setupUpdatableHover(getDefaultHoverDelegate('mouse'), this._container, title); + } else if (this.hover) { + this.hover.update(title); + } + } + + dispose(): void { + this.hover?.dispose(); } } diff --git a/src/vs/base/browser/ui/icons/iconSelectBox.ts b/src/vs/base/browser/ui/icons/iconSelectBox.ts index 465c1dc1181a6..b59529ffd8128 100644 --- a/src/vs/base/browser/ui/icons/iconSelectBox.ts +++ b/src/vs/base/browser/ui/icons/iconSelectBox.ts @@ -81,7 +81,7 @@ export class IconSelectBox extends Disposable { dom.append(iconSelectBoxContainer, this.scrollableElement.getDomNode()); if (this.options.showIconInfo) { - this.iconIdElement = new HighlightedLabel(dom.append(dom.append(iconSelectBoxContainer, dom.$('.icon-select-id-container')), dom.$('.icon-select-id-label'))); + this.iconIdElement = this._register(new HighlightedLabel(dom.append(dom.append(iconSelectBoxContainer, dom.$('.icon-select-id-container')), dom.$('.icon-select-id-label')))); } const iconsDisposables = disposables.add(new MutableDisposable()); diff --git a/src/vs/base/browser/ui/inputbox/inputBox.ts b/src/vs/base/browser/ui/inputbox/inputBox.ts index e4c89dd3affb6..e4215ad76422b 100644 --- a/src/vs/base/browser/ui/inputbox/inputBox.ts +++ b/src/vs/base/browser/ui/inputbox/inputBox.ts @@ -11,6 +11,9 @@ import { MarkdownRenderOptions } from 'vs/base/browser/markdownRenderer'; import { ActionBar } from 'vs/base/browser/ui/actionbar/actionbar'; import * as aria from 'vs/base/browser/ui/aria/aria'; import { AnchorAlignment, IContextViewProvider } from 'vs/base/browser/ui/contextview/contextview'; +import type { IUpdatableHover } from 'vs/base/browser/ui/hover/hover'; +import { getBaseLayerHoverDelegate } from 'vs/base/browser/ui/hover/hoverDelegate2'; +import { getDefaultHoverDelegate } from 'vs/base/browser/ui/hover/hoverDelegateFactory'; import { ScrollableElement } from 'vs/base/browser/ui/scrollbar/scrollableElement'; import { Widget } from 'vs/base/browser/ui/widget'; import { IAction } from 'vs/base/common/actions'; @@ -111,6 +114,7 @@ export class InputBox extends Widget { private cachedContentHeight: number | undefined; private maxHeight: number = Number.POSITIVE_INFINITY; private scrollableElement: ScrollableElement | undefined; + private hover: IUpdatableHover | undefined; private _onDidChange = this._register(new Emitter()); public readonly onDidChange: Event = this._onDidChange.event; @@ -230,7 +234,11 @@ export class InputBox extends Widget { public setTooltip(tooltip: string): void { this.tooltip = tooltip; - this.input.title = tooltip; + if (!this.hover) { + this.hover = this._register(getBaseLayerHoverDelegate().setupUpdatableHover(getDefaultHoverDelegate('mouse'), this.input, tooltip)); + } else { + this.hover.update(tooltip); + } } public setAriaLabel(label: string): void { @@ -305,6 +313,18 @@ export class InputBox extends Widget { return this.input.selectionEnd === this.input.value.length && this.input.selectionStart === this.input.selectionEnd; } + public getSelection(): IRange | null { + const selectionStart = this.input.selectionStart; + if (selectionStart === null) { + return null; + } + const selectionEnd = this.input.selectionEnd ?? selectionStart; + return { + start: selectionStart, + end: selectionEnd, + }; + } + public enable(): void { this.input.removeAttribute('disabled'); } diff --git a/src/vs/base/browser/ui/keybindingLabel/keybindingLabel.ts b/src/vs/base/browser/ui/keybindingLabel/keybindingLabel.ts index 431e33048cdf4..b6c8e1e4db1f9 100644 --- a/src/vs/base/browser/ui/keybindingLabel/keybindingLabel.ts +++ b/src/vs/base/browser/ui/keybindingLabel/keybindingLabel.ts @@ -4,8 +4,12 @@ *--------------------------------------------------------------------------------------------*/ import * as dom from 'vs/base/browser/dom'; +import type { IUpdatableHover } from 'vs/base/browser/ui/hover/hover'; +import { getBaseLayerHoverDelegate } from 'vs/base/browser/ui/hover/hoverDelegate2'; +import { getDefaultHoverDelegate } from 'vs/base/browser/ui/hover/hoverDelegateFactory'; import { UILabelProvider } from 'vs/base/common/keybindingLabels'; import { ResolvedKeybinding, ResolvedChord } from 'vs/base/common/keybindings'; +import { Disposable } from 'vs/base/common/lifecycle'; import { equals } from 'vs/base/common/objects'; import { OperatingSystem } from 'vs/base/common/platform'; import 'vs/css!./keybindingLabel'; @@ -50,18 +54,21 @@ export const unthemedKeybindingLabelOptions: KeybindingLabelOptions = { keybindingLabelShadow: undefined }; -export class KeybindingLabel { +export class KeybindingLabel extends Disposable { private domNode: HTMLElement; private options: KeybindingLabelOptions; private readonly keyElements = new Set(); + private hover: IUpdatableHover; private keybinding: ResolvedKeybinding | undefined; private matches: Matches | undefined; private didEverRender: boolean; constructor(container: HTMLElement, private os: OperatingSystem, options?: KeybindingLabelOptions) { + super(); + this.options = options || Object.create(null); const labelForeground = this.options.keybindingLabelForeground; @@ -71,6 +78,8 @@ export class KeybindingLabel { this.domNode.style.color = labelForeground; } + this.hover = this._register(getBaseLayerHoverDelegate().setupUpdatableHover(getDefaultHoverDelegate('mouse'), this.domNode, '')); + this.didEverRender = false; container.appendChild(this.domNode); } @@ -102,11 +111,8 @@ export class KeybindingLabel { this.renderChord(this.domNode, chords[i], this.matches ? this.matches.chordPart : null); } const title = (this.options.disableTitle ?? false) ? undefined : this.keybinding.getAriaLabel() || undefined; - if (title !== undefined) { - this.domNode.title = title; - } else { - this.domNode.removeAttribute('title'); - } + this.hover.update(title); + this.domNode.setAttribute('aria-label', title || ''); } else if (this.options && this.options.renderUnboundKeybindings) { this.renderUnbound(this.domNode); } diff --git a/src/vs/base/browser/ui/list/listPaging.ts b/src/vs/base/browser/ui/list/listPaging.ts index 0175a15779dce..2ff770688d0cd 100644 --- a/src/vs/base/browser/ui/list/listPaging.ts +++ b/src/vs/base/browser/ui/list/listPaging.ts @@ -81,7 +81,7 @@ class PagedAccessibilityProvider implements IListAccessibilityProvider implements IListView { protected rangeMap: IRangeMap; private cache: RowCache; private renderers = new Map>(); - private lastRenderTop: number; - private lastRenderHeight: number; + protected lastRenderTop: number; + protected lastRenderHeight: number; private renderWidth = 0; private rowsContainer: HTMLElement; private scrollable: Scrollable; @@ -607,7 +607,7 @@ export class ListView implements IListView { renderer.disposeElement(item.element, i, item.row.templateData, item.size); } - rows.push(item.row); + rows.unshift(item.row); } item.row = null; @@ -924,7 +924,9 @@ export class ListView implements IListView { if (item.stale || !item.row.domNode.parentElement) { const referenceNode = this.items.at(index + 1)?.row?.domNode ?? null; - this.rowsContainer.insertBefore(item.row.domNode, referenceNode); + if (item.row.domNode.parentElement !== this.rowsContainer || item.row.domNode.nextElementSibling !== referenceNode) { + this.rowsContainer.insertBefore(item.row.domNode, referenceNode); + } item.stale = false; } @@ -1400,7 +1402,7 @@ export class ListView implements IListView { return undefined; } - private getRenderRange(renderTop: number, renderHeight: number): IRange { + protected getRenderRange(renderTop: number, renderHeight: number): IRange { return { start: this.rangeMap.indexAt(renderTop), end: this.rangeMap.indexAfter(renderTop + renderHeight - 1) @@ -1517,6 +1519,9 @@ export class ListView implements IListView { if (item.row) { item.row.domNode.style.height = ''; item.size = item.row.domNode.offsetHeight; + if (item.size === 0 && !isAncestor(item.row.domNode, getWindow(item.row.domNode).document.body)) { + console.warn('Measuring item node that is not in DOM! Add ListView to the DOM before measuring row height!'); + } item.lastDynamicHeightWidth = this.renderWidth; return item.size - size; } diff --git a/src/vs/base/browser/ui/list/listWidget.ts b/src/vs/base/browser/ui/list/listWidget.ts index 1a72e4e64d0d0..576a0bfa64096 100644 --- a/src/vs/base/browser/ui/list/listWidget.ts +++ b/src/vs/base/browser/ui/list/listWidget.ts @@ -28,6 +28,7 @@ import 'vs/css!./list'; import { IIdentityProvider, IKeyboardNavigationDelegate, IKeyboardNavigationLabelProvider, IListContextMenuEvent, IListDragAndDrop, IListDragOverReaction, IListEvent, IListGestureEvent, IListMouseEvent, IListRenderer, IListTouchEvent, IListVirtualDelegate, ListError } from './list'; import { IListView, IListViewAccessibilityProvider, IListViewDragAndDrop, IListViewOptions, IListViewOptionsUpdate, ListViewTargetSector, ListView } from './listView'; import { StandardMouseEvent } from 'vs/base/browser/mouseEvent'; +import { autorun, constObservable, IObservable } from 'vs/base/common/observable'; interface ITraitChangeEvent { indexes: number[]; @@ -36,6 +37,11 @@ interface ITraitChangeEvent { type ITraitTemplateData = HTMLElement; +type IAccessibilityTemplateData = { + container: HTMLElement; + disposables: DisposableStore; +}; + interface IRenderedContainer { templateData: ITraitTemplateData; index: number; @@ -525,8 +531,11 @@ class TypeNavigationController implements IDisposable { // List: re-announce element on typing end since typed keys will interrupt aria label of focused element // Do not announce if there was a focus change at the end to prevent duplication https://github.com/microsoft/vscode/issues/95961 const ariaLabel = this.list.options.accessibilityProvider?.getAriaLabel(this.list.element(focus[0])); - if (ariaLabel) { + + if (typeof ariaLabel === 'string') { alert(ariaLabel); + } else if (ariaLabel) { + alert(ariaLabel.get()); } } this.previouslyFocused = -1; @@ -848,7 +857,7 @@ export interface IStyleController { } export interface IListAccessibilityProvider extends IListViewAccessibilityProvider { - getAriaLabel(element: T): string | null; + getAriaLabel(element: T): string | IObservable | null; getWidgetAriaLabel(): string; getWidgetRole?(): AriaRole; getAriaLevel?(element: T): number | undefined; @@ -1085,6 +1094,9 @@ export interface IListStyles { listHoverOutline: string | undefined; treeIndentGuidesStroke: string | undefined; treeInactiveIndentGuidesStroke: string | undefined; + treeStickyScrollBackground: string | undefined; + treeStickyScrollBorder: string | undefined; + treeStickyScrollShadow: string | undefined; tableColumnsBorder: string | undefined; tableOddRowsBackgroundColor: string | undefined; } @@ -1115,7 +1127,10 @@ export const unthemedListStyles: IListStyles = { listFocusOutline: undefined, listInactiveFocusOutline: undefined, listSelectionOutline: undefined, - listHoverOutline: undefined + listHoverOutline: undefined, + treeStickyScrollBackground: undefined, + treeStickyScrollBorder: undefined, + treeStickyScrollShadow: undefined }; const DefaultOptions: IListOptions = { @@ -1254,36 +1269,47 @@ class PipelineRenderer implements IListRenderer { } } -class AccessibiltyRenderer implements IListRenderer { +class AccessibiltyRenderer implements IListRenderer { templateId: string = 'a18n'; constructor(private accessibilityProvider: IListAccessibilityProvider) { } - renderTemplate(container: HTMLElement): HTMLElement { - return container; + renderTemplate(container: HTMLElement): IAccessibilityTemplateData { + return { container, disposables: new DisposableStore() }; } - renderElement(element: T, index: number, container: HTMLElement): void { + renderElement(element: T, index: number, data: IAccessibilityTemplateData): void { const ariaLabel = this.accessibilityProvider.getAriaLabel(element); + const observable = (ariaLabel && typeof ariaLabel !== 'string') ? ariaLabel : constObservable(ariaLabel); - if (ariaLabel) { - container.setAttribute('aria-label', ariaLabel); - } else { - container.removeAttribute('aria-label'); - } + data.disposables.add(autorun(reader => { + this.setAriaLabel(reader.readObservable(observable), data.container); + })); const ariaLevel = this.accessibilityProvider.getAriaLevel && this.accessibilityProvider.getAriaLevel(element); if (typeof ariaLevel === 'number') { - container.setAttribute('aria-level', `${ariaLevel}`); + data.container.setAttribute('aria-level', `${ariaLevel}`); } else { - container.removeAttribute('aria-level'); + data.container.removeAttribute('aria-level'); } } + private setAriaLabel(ariaLabel: string | null, element: HTMLElement): void { + if (ariaLabel) { + element.setAttribute('aria-label', ariaLabel); + } else { + element.removeAttribute('aria-label'); + } + } + + disposeElement(element: T, index: number, templateData: IAccessibilityTemplateData, height: number | undefined): void { + templateData.disposables.clear(); + } + disposeTemplate(templateData: any): void { - // noop + templateData.disposables.dispose(); } } @@ -1445,7 +1471,7 @@ export class List implements ISpliceable, IDisposable { const role = this._options.accessibilityProvider && this._options.accessibilityProvider.getWidgetRole ? this._options.accessibilityProvider?.getWidgetRole() : 'list'; this.selection = new SelectionTrait(role !== 'listbox'); - const baseRenderers: IListRenderer[] = [this.focus.renderer, this.selection.renderer]; + const baseRenderers: IListRenderer[] = [this.focus.renderer, this.selection.renderer]; this.accessibilityProvider = _options.accessibilityProvider; diff --git a/src/vs/base/browser/ui/menu/menu.ts b/src/vs/base/browser/ui/menu/menu.ts index 35343e4f878de..d858c4994c94b 100644 --- a/src/vs/base/browser/ui/menu/menu.ts +++ b/src/vs/base/browser/ui/menu/menu.ts @@ -14,7 +14,8 @@ import { AnchorAlignment, layout, LayoutAnchorPosition } from 'vs/base/browser/u import { DomScrollableElement } from 'vs/base/browser/ui/scrollbar/scrollableElement'; import { EmptySubmenuAction, IAction, IActionRunner, Separator, SubmenuAction } from 'vs/base/common/actions'; import { RunOnceScheduler } from 'vs/base/common/async'; -import { Codicon, getCodiconFontCharacters } from 'vs/base/common/codicons'; +import { Codicon } from 'vs/base/common/codicons'; +import { getCodiconFontCharacters } from 'vs/base/common/codiconsUtil'; import { ThemeIcon } from 'vs/base/common/themables'; import { Event } from 'vs/base/common/event'; import { stripIcons } from 'vs/base/common/iconLabels'; @@ -30,11 +31,21 @@ export const MENU_ESCAPED_MNEMONIC_REGEX = /(&)?(&)([^\s&])/g; -export enum Direction { +export enum HorizontalDirection { Right, Left } +export enum VerticalDirection { + Above, + Below +} + +export interface IMenuDirection { + horizontal: HorizontalDirection; + vertical: VerticalDirection; +} + export interface IMenuOptions { context?: unknown; actionViewItemProvider?: IActionViewItemProvider; @@ -43,7 +54,7 @@ export interface IMenuOptions { ariaLabel?: string; enableMnemonics?: boolean; anchorAlignment?: AnchorAlignment; - expandDirection?: Direction; + expandDirection?: IMenuDirection; useEventAsContext?: boolean; submenuIds?: Set; } @@ -724,7 +735,7 @@ class SubmenuMenuActionViewItem extends BaseMenuActionViewItem { private mouseOver: boolean = false; private showScheduler: RunOnceScheduler; private hideScheduler: RunOnceScheduler; - private expandDirection: Direction; + private expandDirection: IMenuDirection; constructor( action: IAction, @@ -735,7 +746,7 @@ class SubmenuMenuActionViewItem extends BaseMenuActionViewItem { ) { super(action, action, submenuOptions, menuStyles); - this.expandDirection = submenuOptions && submenuOptions.expandDirection !== undefined ? submenuOptions.expandDirection : Direction.Right; + this.expandDirection = submenuOptions && submenuOptions.expandDirection !== undefined ? submenuOptions.expandDirection : { horizontal: HorizontalDirection.Right, vertical: VerticalDirection.Below }; this.showScheduler = new RunOnceScheduler(() => { if (this.mouseOver) { @@ -849,11 +860,11 @@ class SubmenuMenuActionViewItem extends BaseMenuActionViewItem { } } - private calculateSubmenuMenuLayout(windowDimensions: Dimension, submenu: Dimension, entry: IDomNodePagePosition, expandDirection: Direction): { top: number; left: number } { + private calculateSubmenuMenuLayout(windowDimensions: Dimension, submenu: Dimension, entry: IDomNodePagePosition, expandDirection: IMenuDirection): { top: number; left: number } { const ret = { top: 0, left: 0 }; // Start with horizontal - ret.left = layout(windowDimensions.width, submenu.width, { position: expandDirection === Direction.Right ? LayoutAnchorPosition.Before : LayoutAnchorPosition.After, offset: entry.left, size: entry.width }); + ret.left = layout(windowDimensions.width, submenu.width, { position: expandDirection.horizontal === HorizontalDirection.Right ? LayoutAnchorPosition.Before : LayoutAnchorPosition.After, offset: entry.left, size: entry.width }); // We don't have enough room to layout the menu fully, so we are overlapping the menu if (ret.left >= entry.left && ret.left < entry.left + entry.width) { @@ -1056,10 +1067,6 @@ ${formatRule(Codicon.menuSubmenu)} cursor: default; } -.monaco-menu .monaco-action-bar.animated .action-item.active { - transform: scale(1.272019649, 1.272019649); /* 1.272019649 = √φ */ -} - .monaco-menu .monaco-action-bar .action-item .icon, .monaco-menu .monaco-action-bar .action-item .codicon { display: inline-block; @@ -1274,6 +1281,8 @@ ${formatRule(Codicon.menuSubmenu)} .monaco-menu .monaco-action-bar.vertical .keybinding { font-size: inherit; padding: 0 2em; + overflow: hidden; + max-height: 100%; } .monaco-menu .monaco-action-bar.vertical .menu-item-check { diff --git a/src/vs/base/browser/ui/menu/menubar.ts b/src/vs/base/browser/ui/menu/menubar.ts index feadeb47651d2..961fb8862a81b 100644 --- a/src/vs/base/browser/ui/menu/menubar.ts +++ b/src/vs/base/browser/ui/menu/menubar.ts @@ -8,7 +8,7 @@ import * as DOM from 'vs/base/browser/dom'; import { StandardKeyboardEvent } from 'vs/base/browser/keyboardEvent'; import { StandardMouseEvent } from 'vs/base/browser/mouseEvent'; import { EventType, Gesture, GestureEvent } from 'vs/base/browser/touch'; -import { cleanMnemonic, Direction, IMenuOptions, IMenuStyles, Menu, MENU_ESCAPED_MNEMONIC_REGEX, MENU_MNEMONIC_REGEX } from 'vs/base/browser/ui/menu/menu'; +import { cleanMnemonic, HorizontalDirection, IMenuDirection, IMenuOptions, IMenuStyles, Menu, MENU_ESCAPED_MNEMONIC_REGEX, MENU_MNEMONIC_REGEX, VerticalDirection } from 'vs/base/browser/ui/menu/menu'; import { ActionRunner, IAction, IActionRunner, Separator, SubmenuAction } from 'vs/base/common/actions'; import { asArray } from 'vs/base/common/arrays'; import { RunOnceScheduler } from 'vs/base/common/async'; @@ -32,7 +32,7 @@ export interface IMenuBarOptions { visibility?: string; getKeybinding?: (action: IAction) => ResolvedKeybinding | undefined; alwaysOnMnemonics?: boolean; - compactMode?: Direction; + compactMode?: IMenuDirection; actionRunner?: IActionRunner; getCompactMenuActions?: () => IAction[]; } @@ -333,9 +333,9 @@ export class MenuBar extends Disposable { } else { triggerKeys.push(KeyCode.Space); - if (this.options.compactMode === Direction.Right) { + if (this.options.compactMode?.horizontal === HorizontalDirection.Right) { triggerKeys.push(KeyCode.RightArrow); - } else if (this.options.compactMode === Direction.Left) { + } else if (this.options.compactMode?.horizontal === HorizontalDirection.Left) { triggerKeys.push(KeyCode.LeftArrow); } } @@ -1007,18 +1007,25 @@ export class MenuBar extends Disposable { const titleBoundingRect = customMenu.titleElement.getBoundingClientRect(); const titleBoundingRectZoom = DOM.getDomNodeZoomLevel(customMenu.titleElement); - if (this.options.compactMode === Direction.Right) { - menuHolder.style.top = `${titleBoundingRect.top}px`; + if (this.options.compactMode?.horizontal === HorizontalDirection.Right) { menuHolder.style.left = `${titleBoundingRect.left + this.container.clientWidth}px`; - } else if (this.options.compactMode === Direction.Left) { + } else if (this.options.compactMode?.horizontal === HorizontalDirection.Left) { menuHolder.style.top = `${titleBoundingRect.top}px`; menuHolder.style.right = `${this.container.clientWidth}px`; menuHolder.style.left = 'auto'; } else { - menuHolder.style.top = `${titleBoundingRect.bottom * titleBoundingRectZoom}px`; menuHolder.style.left = `${titleBoundingRect.left * titleBoundingRectZoom}px`; } + if (this.options.compactMode?.vertical === VerticalDirection.Above) { + // TODO@benibenj Do not hardcode the height of the menu holder + menuHolder.style.top = `${titleBoundingRect.top - this.menus.length * 30 + this.container.clientHeight}px`; + } else if (this.options.compactMode?.vertical === VerticalDirection.Below) { + menuHolder.style.top = `${titleBoundingRect.top}px`; + } else { + menuHolder.style.top = `${titleBoundingRect.bottom * titleBoundingRectZoom}px`; + } + customMenu.buttonElement.appendChild(menuHolder); const menuOptions: IMenuOptions = { @@ -1026,7 +1033,7 @@ export class MenuBar extends Disposable { actionRunner: this.actionRunner, enableMnemonics: this.options.alwaysOnMnemonics || (this.mnemonicsInUse && this.options.enableMnemonics), ariaLabel: customMenu.buttonElement.getAttribute('aria-label') ?? undefined, - expandDirection: this.isCompact ? this.options.compactMode : Direction.Right, + expandDirection: this.isCompact ? this.options.compactMode : { horizontal: HorizontalDirection.Right, vertical: VerticalDirection.Below }, useEventAsContext: true }; diff --git a/src/vs/base/browser/ui/scrollbar/scrollableElement.ts b/src/vs/base/browser/ui/scrollbar/scrollableElement.ts index 83e2d27eef7bd..be9064254c17f 100644 --- a/src/vs/base/browser/ui/scrollbar/scrollableElement.ts +++ b/src/vs/base/browser/ui/scrollbar/scrollableElement.ts @@ -5,7 +5,7 @@ import { getZoomFactor, isChrome } from 'vs/base/browser/browser'; import * as dom from 'vs/base/browser/dom'; -import { createFastDomNode, FastDomNode } from 'vs/base/browser/fastDomNode'; +import { FastDomNode, createFastDomNode } from 'vs/base/browser/fastDomNode'; import { IMouseEvent, IMouseWheelEvent, StandardWheelEvent } from 'vs/base/browser/mouseEvent'; import { ScrollbarHost } from 'vs/base/browser/ui/scrollbar/abstractScrollbar'; import { HorizontalScrollbar } from 'vs/base/browser/ui/scrollbar/horizontalScrollbar'; @@ -14,9 +14,9 @@ import { VerticalScrollbar } from 'vs/base/browser/ui/scrollbar/verticalScrollba import { Widget } from 'vs/base/browser/ui/widget'; import { TimeoutTimer } from 'vs/base/common/async'; import { Emitter, Event } from 'vs/base/common/event'; -import { dispose, IDisposable } from 'vs/base/common/lifecycle'; +import { IDisposable, dispose } from 'vs/base/common/lifecycle'; import * as platform from 'vs/base/common/platform'; -import { INewScrollDimensions, INewScrollPosition, IScrollDimensions, IScrollPosition, Scrollable, ScrollbarVisibility, ScrollEvent } from 'vs/base/common/scrollable'; +import { INewScrollDimensions, INewScrollPosition, IScrollDimensions, IScrollPosition, ScrollEvent, Scrollable, ScrollbarVisibility } from 'vs/base/common/scrollable'; import 'vs/css!./media/scrollbars'; const HIDE_TIMEOUT = 500; @@ -99,14 +99,16 @@ export class MouseWheelClassifier { } public accept(timestamp: number, deltaX: number, deltaY: number): void { + let previousItem = null; const item = new MouseWheelClassifierItem(timestamp, deltaX, deltaY); - item.score = this._computeScore(item); if (this._front === -1 && this._rear === -1) { this._memory[0] = item; this._front = 0; this._rear = 0; } else { + previousItem = this._memory[this._rear]; + this._rear = (this._rear + 1) % this._capacity; if (this._rear === this._front) { // Drop oldest @@ -114,6 +116,8 @@ export class MouseWheelClassifier { } this._memory[this._rear] = item; } + + item.score = this._computeScore(item, previousItem); } /** @@ -121,7 +125,7 @@ export class MouseWheelClassifier { * - a score towards 0 indicates that the source appears to be a physical mouse wheel * - a score towards 1 indicates that the source appears to be a touchpad or magic mouse, etc. */ - private _computeScore(item: MouseWheelClassifierItem): number { + private _computeScore(item: MouseWheelClassifierItem, previousItem: MouseWheelClassifierItem | null): number { if (Math.abs(item.deltaX) > 0 && Math.abs(item.deltaY) > 0) { // both axes exercised => definitely not a physical mouse wheel @@ -129,25 +133,34 @@ export class MouseWheelClassifier { } let score: number = 0.5; - const prev = (this._front === -1 && this._rear === -1 ? null : this._memory[this._rear]); - if (prev) { - // const deltaT = item.timestamp - prev.timestamp; - // if (deltaT < 1000 / 30) { - // // sooner than X times per second => indicator that this is not a physical mouse wheel - // score += 0.25; - // } - - // if (item.deltaX === prev.deltaX && item.deltaY === prev.deltaY) { - // // equal amplitude => indicator that this is a physical mouse wheel - // score -= 0.25; - // } - } if (!this._isAlmostInt(item.deltaX) || !this._isAlmostInt(item.deltaY)) { // non-integer deltas => indicator that this is not a physical mouse wheel score += 0.25; } + // Non-accelerating scroll => indicator that this is a physical mouse wheel + // These can be identified by seeing whether they are the module of one another. + if (previousItem) { + const absDeltaX = Math.abs(item.deltaX); + const absDeltaY = Math.abs(item.deltaY); + + const absPreviousDeltaX = Math.abs(previousItem.deltaX); + const absPreviousDeltaY = Math.abs(previousItem.deltaY); + + // Min 1 to avoid division by zero, module 1 will still be 0. + const minDeltaX = Math.max(Math.min(absDeltaX, absPreviousDeltaX), 1); + const minDeltaY = Math.max(Math.min(absDeltaY, absPreviousDeltaY), 1); + + const maxDeltaX = Math.max(absDeltaX, absPreviousDeltaX); + const maxDeltaY = Math.max(absDeltaY, absPreviousDeltaY); + + const isSameModulo = (maxDeltaX % minDeltaX === 0 && maxDeltaY % minDeltaY === 0); + if (isSameModulo) { + score -= 0.5; + } + } + return Math.min(Math.max(score, 0), 1); } @@ -383,6 +396,7 @@ export abstract class AbstractScrollableElement extends Widget { classifier.acceptStandardWheelEvent(e); } + // useful for creating unit tests: // console.log(`${Date.now()}, ${e.deltaY}, ${e.deltaX}`); let didScroll = false; diff --git a/src/vs/base/browser/ui/selectBox/selectBoxCustom.ts b/src/vs/base/browser/ui/selectBox/selectBoxCustom.ts index 83073ee2666f7..0506315a7ae37 100644 --- a/src/vs/base/browser/ui/selectBox/selectBoxCustom.ts +++ b/src/vs/base/browser/ui/selectBox/selectBoxCustom.ts @@ -9,6 +9,9 @@ import { IContentActionHandler } from 'vs/base/browser/formattedTextRenderer'; import { StandardKeyboardEvent } from 'vs/base/browser/keyboardEvent'; import { renderMarkdown } from 'vs/base/browser/markdownRenderer'; import { AnchorPosition, IContextViewProvider } from 'vs/base/browser/ui/contextview/contextview'; +import type { IUpdatableHover } from 'vs/base/browser/ui/hover/hover'; +import { getBaseLayerHoverDelegate } from 'vs/base/browser/ui/hover/hoverDelegate2'; +import { getDefaultHoverDelegate } from 'vs/base/browser/ui/hover/hoverDelegateFactory'; import { IListEvent, IListRenderer, IListVirtualDelegate } from 'vs/base/browser/ui/list/list'; import { List } from 'vs/base/browser/ui/list/listWidget'; import { ISelectBoxDelegate, ISelectBoxOptions, ISelectBoxStyles, ISelectData, ISelectOptionItem } from 'vs/base/browser/ui/selectBox/selectBox'; @@ -101,6 +104,7 @@ export class SelectBoxList extends Disposable implements ISelectBoxDelegate, ILi private selectionDetailsPane!: HTMLElement; private _skipLayout: boolean = false; private _cachedMaxDetailsHeight?: number; + private _hover?: IUpdatableHover; private _sticky: boolean = false; // for dev purposes only @@ -147,6 +151,14 @@ export class SelectBoxList extends Disposable implements ISelectBoxDelegate, ILi } + private setTitle(title: string): void { + if (!this._hover && title) { + this._hover = this._register(getBaseLayerHoverDelegate().setupUpdatableHover(getDefaultHoverDelegate('mouse'), this.selectElement, title)); + } else if (this._hover) { + this._hover.update(title); + } + } + // IDelegate - List renderer getHeight(): number { @@ -199,7 +211,7 @@ export class SelectBoxList extends Disposable implements ISelectBoxDelegate, ILi selected: e.target.value }); if (!!this.options[this.selected] && !!this.options[this.selected].text) { - this.selectElement.title = this.options[this.selected].text; + this.setTitle(this.options[this.selected].text); } })); @@ -309,7 +321,7 @@ export class SelectBoxList extends Disposable implements ISelectBoxDelegate, ILi this.selectElement.selectedIndex = this.selected; if (!!this.options[this.selected] && !!this.options[this.selected].text) { - this.selectElement.title = this.options[this.selected].text; + this.setTitle(this.options[this.selected].text); } } @@ -837,7 +849,7 @@ export class SelectBoxList extends Disposable implements ISelectBoxDelegate, ILi }); if (!!this.options[this.selected] && !!this.options[this.selected].text) { - this.selectElement.title = this.options[this.selected].text; + this.setTitle(this.options[this.selected].text); } } @@ -936,7 +948,7 @@ export class SelectBoxList extends Disposable implements ISelectBoxDelegate, ILi selected: this.options[this.selected].text }); if (!!this.options[this.selected] && !!this.options[this.selected].text) { - this.selectElement.title = this.options[this.selected].text; + this.setTitle(this.options[this.selected].text); } } diff --git a/src/vs/base/browser/ui/table/tableWidget.ts b/src/vs/base/browser/ui/table/tableWidget.ts index 6e20fd6e34a68..631c0015d4b8f 100644 --- a/src/vs/base/browser/ui/table/tableWidget.ts +++ b/src/vs/base/browser/ui/table/tableWidget.ts @@ -4,12 +4,14 @@ *--------------------------------------------------------------------------------------------*/ import { $, append, clearNode, createStyleSheet, getContentHeight, getContentWidth } from 'vs/base/browser/dom'; +import { getBaseLayerHoverDelegate } from 'vs/base/browser/ui/hover/hoverDelegate2'; +import { getDefaultHoverDelegate } from 'vs/base/browser/ui/hover/hoverDelegateFactory'; import { IListRenderer, IListVirtualDelegate } from 'vs/base/browser/ui/list/list'; import { IListOptions, IListOptionsUpdate, IListStyles, List, unthemedListStyles } from 'vs/base/browser/ui/list/listWidget'; import { ISplitViewDescriptor, IView, Orientation, SplitView } from 'vs/base/browser/ui/splitview/splitview'; import { ITableColumn, ITableContextMenuEvent, ITableEvent, ITableGestureEvent, ITableMouseEvent, ITableRenderer, ITableTouchEvent, ITableVirtualDelegate } from 'vs/base/browser/ui/table/table'; import { Emitter, Event } from 'vs/base/common/event'; -import { DisposableStore, IDisposable } from 'vs/base/common/lifecycle'; +import { Disposable, DisposableStore, IDisposable } from 'vs/base/common/lifecycle'; import { ScrollbarVisibility, ScrollEvent } from 'vs/base/common/scrollable'; import { ISpliceable } from 'vs/base/common/sequence'; import 'vs/css!./table'; @@ -115,7 +117,7 @@ function asListVirtualDelegate(delegate: ITableVirtualDelegate): ILi }; } -class ColumnHeader implements IView { +class ColumnHeader extends Disposable implements IView { readonly element: HTMLElement; @@ -127,7 +129,13 @@ class ColumnHeader implements IView { readonly onDidLayout = this._onDidLayout.event; constructor(readonly column: ITableColumn, private index: number) { - this.element = $('.monaco-table-th', { 'data-col-index': index, title: column.tooltip }, column.label); + super(); + + this.element = $('.monaco-table-th', { 'data-col-index': index }, column.label); + + if (column.tooltip) { + this._register(getBaseLayerHoverDelegate().setupUpdatableHover(getDefaultHoverDelegate('mouse'), this.element, column.tooltip)); + } } layout(size: number): void { @@ -191,7 +199,7 @@ export class Table implements ISpliceable, IDisposable { ) { this.domNode = append(container, $(`.monaco-table.${this.domId}`)); - const headers = columns.map((c, i) => new ColumnHeader(c, i)); + const headers = columns.map((c, i) => this.disposables.add(new ColumnHeader(c, i))); const descriptor: ISplitViewDescriptor = { size: headers.reduce((a, b) => a + b.column.weight, 0), views: headers.map(view => ({ size: view.column.weight, view })) diff --git a/src/vs/base/browser/ui/toggle/toggle.ts b/src/vs/base/browser/ui/toggle/toggle.ts index 4146f24d141a7..4b451c85068cd 100644 --- a/src/vs/base/browser/ui/toggle/toggle.ts +++ b/src/vs/base/browser/ui/toggle/toggle.ts @@ -13,6 +13,10 @@ import { Emitter, Event } from 'vs/base/common/event'; import { KeyCode } from 'vs/base/common/keyCodes'; import 'vs/css!./toggle'; import { isActiveElement, $, addDisposableListener, EventType } from 'vs/base/browser/dom'; +import { getDefaultHoverDelegate } from 'vs/base/browser/ui/hover/hoverDelegateFactory'; +import { IHoverDelegate } from 'vs/base/browser/ui/hover/hoverDelegate'; +import type { IUpdatableHover } from 'vs/base/browser/ui/hover/hover'; +import { getBaseLayerHoverDelegate } from 'vs/base/browser/ui/hover/hoverDelegate2'; export interface IToggleOpts extends IToggleStyles { readonly actionClassName?: string; @@ -20,6 +24,7 @@ export interface IToggleOpts extends IToggleStyles { readonly title: string; readonly isChecked: boolean; readonly notFocusable?: boolean; + readonly hoverDelegate?: IHoverDelegate; } export interface IToggleStyles { @@ -55,6 +60,7 @@ export class ToggleActionViewItem extends BaseActionViewItem { inputActiveOptionBackground: options.toggleStyles?.inputActiveOptionBackground, inputActiveOptionBorder: options.toggleStyles?.inputActiveOptionBorder, inputActiveOptionForeground: options.toggleStyles?.inputActiveOptionForeground, + hoverDelegate: options.hoverDelegate })); this._register(this.toggle.onChange(() => this._action.checked = !!this.toggle && this.toggle.checked)); } @@ -107,6 +113,7 @@ export class Toggle extends Widget { readonly domNode: HTMLElement; private _checked: boolean; + private _hover: IUpdatableHover; constructor(opts: IToggleOpts) { super(); @@ -127,7 +134,7 @@ export class Toggle extends Widget { } this.domNode = document.createElement('div'); - this.domNode.title = this._opts.title; + this._hover = this._register(getBaseLayerHoverDelegate().setupUpdatableHover(opts.hoverDelegate ?? getDefaultHoverDelegate('mouse'), this.domNode, this._opts.title)); this.domNode.classList.add(...classes); if (!this._opts.notFocusable) { this.domNode.tabIndex = 0; @@ -213,7 +220,7 @@ export class Toggle extends Widget { } setTitle(newTitle: string): void { - this.domNode.title = newTitle; + this._hover.update(newTitle); this.domNode.setAttribute('aria-label', newTitle); } } @@ -231,7 +238,7 @@ export class Checkbox extends Widget { constructor(private title: string, private isChecked: boolean, styles: ICheckboxStyles) { super(); - this.checkbox = new Toggle({ title: this.title, isChecked: this.isChecked, icon: Codicon.check, actionClassName: 'monaco-checkbox', ...unthemedToggleStyles }); + this.checkbox = this._register(new Toggle({ title: this.title, isChecked: this.isChecked, icon: Codicon.check, actionClassName: 'monaco-checkbox', ...unthemedToggleStyles })); this.domNode = this.checkbox.domNode; diff --git a/src/vs/base/browser/ui/toolbar/toolbar.ts b/src/vs/base/browser/ui/toolbar/toolbar.ts index 2f46a69235770..57aac5edb4197 100644 --- a/src/vs/base/browser/ui/toolbar/toolbar.ts +++ b/src/vs/base/browser/ui/toolbar/toolbar.ts @@ -15,6 +15,8 @@ import { ResolvedKeybinding } from 'vs/base/common/keybindings'; import { Disposable, DisposableStore } from 'vs/base/common/lifecycle'; import 'vs/css!./toolbar'; import * as nls from 'vs/nls'; +import { IHoverDelegate } from 'vs/base/browser/ui/hover/hoverDelegate'; +import { createInstantHoverDelegate } from 'vs/base/browser/ui/hover/hoverDelegateFactory'; @@ -30,6 +32,7 @@ export interface IToolBarOptions { moreIcon?: ThemeIcon; allowContextMenu?: boolean; skipTelemetry?: boolean; + hoverDelegate?: IHoverDelegate; /** * If true, toggled primary items are highlighted with a background color. @@ -52,11 +55,12 @@ export class ToolBar extends Disposable { private _onDidChangeDropdownVisibility = this._register(new EventMultiplexer()); readonly onDidChangeDropdownVisibility = this._onDidChangeDropdownVisibility.event; - private disposables = this._register(new DisposableStore()); + private readonly disposables = this._register(new DisposableStore()); constructor(container: HTMLElement, contextMenuProvider: IContextMenuProvider, options: IToolBarOptions = { orientation: ActionsOrientation.HORIZONTAL }) { super(); + options.hoverDelegate = options.hoverDelegate ?? this._register(createInstantHoverDelegate()); this.options = options; this.lookupKeybindings = typeof this.options.getKeyBinding === 'function'; @@ -72,6 +76,7 @@ export class ToolBar extends Disposable { actionRunner: options.actionRunner, allowContextMenu: options.allowContextMenu, highlightToggledItems: options.highlightToggledItems, + hoverDelegate: options.hoverDelegate, actionViewItemProvider: (action, viewItemOptions) => { if (action.id === ToggleMenuAction.ID) { this.toggleMenuActionViewItem = new DropdownMenuActionViewItem( @@ -86,7 +91,8 @@ export class ToolBar extends Disposable { anchorAlignmentProvider: this.options.anchorAlignmentProvider, menuAsChild: !!this.options.renderDropdownAsChildElement, skipTelemetry: this.options.skipTelemetry, - isMenu: true + isMenu: true, + hoverDelegate: this.options.hoverDelegate } ); this.toggleMenuActionViewItem.setActionContext(this.actionBar.context); @@ -115,7 +121,8 @@ export class ToolBar extends Disposable { classNames: action.class, anchorAlignmentProvider: this.options.anchorAlignmentProvider, menuAsChild: !!this.options.renderDropdownAsChildElement, - skipTelemetry: this.options.skipTelemetry + skipTelemetry: this.options.skipTelemetry, + hoverDelegate: this.options.hoverDelegate } ); result.setActionContext(this.actionBar.context); diff --git a/src/vs/base/browser/ui/tree/abstractTree.ts b/src/vs/base/browser/ui/tree/abstractTree.ts index 16f985264d4ec..7868ee55f6be8 100644 --- a/src/vs/base/browser/ui/tree/abstractTree.ts +++ b/src/vs/base/browser/ui/tree/abstractTree.ts @@ -33,6 +33,9 @@ import { ISpliceable } from 'vs/base/common/sequence'; import { isNumber } from 'vs/base/common/types'; import 'vs/css!./media/tree'; import { localize } from 'vs/nls'; +import { IHoverDelegate } from 'vs/base/browser/ui/hover/hoverDelegate'; +import { createInstantHoverDelegate, getDefaultHoverDelegate } from 'vs/base/browser/ui/hover/hoverDelegateFactory'; +import { autorun, constObservable } from 'vs/base/common/observable'; class TreeElementsDragAndDropData extends ElementsDragAndDropData { @@ -61,7 +64,7 @@ class TreeNodeListDragAndDrop implements IListDragAndDrop< private autoExpandNode: ITreeNode | undefined; private autoExpandDisposable: IDisposable = Disposable.None; - private disposables = new DisposableStore(); + private readonly disposables = new DisposableStore(); constructor(private modelProvider: () => ITreeModel, private dnd: ITreeDragAndDrop) { } @@ -679,6 +682,7 @@ export interface ITreeFindToggleOpts { readonly inputActiveOptionBorder: string | undefined; readonly inputActiveOptionForeground: string | undefined; readonly inputActiveOptionBackground: string | undefined; + readonly hoverDelegate?: IHoverDelegate; } export class ModeToggle extends Toggle { @@ -687,6 +691,7 @@ export class ModeToggle extends Toggle { icon: Codicon.listFilter, title: localize('filter', "Filter"), isChecked: opts.isChecked ?? false, + hoverDelegate: opts.hoverDelegate ?? getDefaultHoverDelegate('element'), inputActiveOptionBorder: opts.inputActiveOptionBorder, inputActiveOptionForeground: opts.inputActiveOptionForeground, inputActiveOptionBackground: opts.inputActiveOptionBackground @@ -700,6 +705,7 @@ export class FuzzyToggle extends Toggle { icon: Codicon.searchFuzzy, title: localize('fuzzySearch', "Fuzzy Match"), isChecked: opts.isChecked ?? false, + hoverDelegate: opts.hoverDelegate ?? getDefaultHoverDelegate('element'), inputActiveOptionBorder: opts.inputActiveOptionBorder, inputActiveOptionForeground: opts.inputActiveOptionForeground, inputActiveOptionBackground: opts.inputActiveOptionBackground @@ -802,8 +808,9 @@ class FindWidget extends Disposable { this.elements.root.style.boxShadow = `0 0 8px 2px ${styles.listFilterWidgetShadow}`; } - this.modeToggle = this._register(new ModeToggle({ ...styles.toggleStyles, isChecked: mode === TreeFindMode.Filter })); - this.matchTypeToggle = this._register(new FuzzyToggle({ ...styles.toggleStyles, isChecked: matchType === TreeFindMatchType.Fuzzy })); + const toggleHoverDelegate = this._register(createInstantHoverDelegate()); + this.modeToggle = this._register(new ModeToggle({ ...styles.toggleStyles, isChecked: mode === TreeFindMode.Filter, hoverDelegate: toggleHoverDelegate })); + this.matchTypeToggle = this._register(new FuzzyToggle({ ...styles.toggleStyles, isChecked: matchType === TreeFindMatchType.Fuzzy, hoverDelegate: toggleHoverDelegate })); this.onDidChangeMode = Event.map(this.modeToggle.onChange, () => this.modeToggle.checked ? TreeFindMode.Filter : TreeFindMode.Highlight, this._store); this.onDidChangeMatchType = Event.map(this.matchTypeToggle.onChange, () => this.matchTypeToggle.checked ? TreeFindMatchType.Fuzzy : TreeFindMatchType.Contiguous, this._store); @@ -1554,7 +1561,7 @@ class StickyScrollWidget implements IDisposable { private readonly _rootDomNode: HTMLElement; private _previousState: StickyScrollState | undefined; private _previousElements: HTMLElement[] = []; - private _previousStateDisposables: DisposableStore = new DisposableStore(); + private readonly _previousStateDisposables: DisposableStore = new DisposableStore(); private stickyScrollFocus: StickyScrollFocus; readonly onDidChangeHasFocus: Event; @@ -1657,8 +1664,14 @@ class StickyScrollWidget implements IDisposable { // Sticky element container const stickyElement = document.createElement('div'); stickyElement.style.top = `${stickyNode.position}px`; - stickyElement.style.height = `${stickyNode.height}px`; - stickyElement.style.lineHeight = `${stickyNode.height}px`; + + if (this.tree.options.setRowHeight !== false) { + stickyElement.style.height = `${stickyNode.height}px`; + } + + if (this.tree.options.setRowLineHeight !== false) { + stickyElement.style.lineHeight = `${stickyNode.height}px`; + } stickyElement.classList.add('monaco-tree-sticky-row'); stickyElement.classList.add('monaco-list-row'); @@ -1666,7 +1679,7 @@ class StickyScrollWidget implements IDisposable { stickyElement.setAttribute('data-index', `${nodeIndex}`); stickyElement.setAttribute('data-parity', nodeIndex % 2 === 0 ? 'even' : 'odd'); stickyElement.setAttribute('id', this.view.getElementID(nodeIndex)); - this.setAccessibilityAttributes(stickyElement, stickyNode.node.element, stickyIndex, stickyNodesTotal); + const accessibilityDisposable = this.setAccessibilityAttributes(stickyElement, stickyNode.node.element, stickyIndex, stickyNodesTotal); // Get the renderer for the node const nodeTemplateId = this.treeDelegate.getTemplateId(stickyNode.node); @@ -1688,6 +1701,7 @@ class StickyScrollWidget implements IDisposable { // Remove the element from the DOM when state is disposed const disposable = toDisposable(() => { + accessibilityDisposable.dispose(); renderer.disposeElement(nodeCopy, stickyNode.startIndex, templateData, stickyNode.height); renderer.disposeTemplate(templateData); stickyElement.remove(); @@ -1696,9 +1710,9 @@ class StickyScrollWidget implements IDisposable { return { element: stickyElement, disposable }; } - private setAccessibilityAttributes(container: HTMLElement, element: T, stickyIndex: number, stickyNodesTotal: number): void { + private setAccessibilityAttributes(container: HTMLElement, element: T, stickyIndex: number, stickyNodesTotal: number): IDisposable { if (!this.accessibilityProvider) { - return; + return Disposable.None; } if (this.accessibilityProvider.getSetSize) { @@ -1712,8 +1726,20 @@ class StickyScrollWidget implements IDisposable { } const ariaLabel = this.accessibilityProvider.getAriaLabel(element); - if (ariaLabel) { - container.setAttribute('aria-label', ariaLabel); + const observable = (ariaLabel && typeof ariaLabel !== 'string') ? ariaLabel : constObservable(ariaLabel); + const result = autorun(reader => { + const value = reader.readObservable(observable); + + if (value) { + container.setAttribute('aria-label', value); + } else { + container.removeAttribute('aria-label'); + } + }); + + if (typeof ariaLabel === 'string') { + } else if (ariaLabel) { + container.setAttribute('aria-label', ariaLabel.get()); } const ariaLevel = this.accessibilityProvider.getAriaLevel && this.accessibilityProvider.getAriaLevel(element); @@ -1723,6 +1749,8 @@ class StickyScrollWidget implements IDisposable { // Sticky Scroll elements can not be selected container.setAttribute('aria-selected', String(false)); + + return result; } private setVisible(visible: boolean): void { @@ -1968,11 +1996,31 @@ class StickyScrollFocus extends Disposable { } private toggleElementFocus(element: HTMLElement, focused: boolean): void { + this.toggleElementActiveFocus(element, focused && this.domHasFocus); + this.toggleElementPassiveFocus(element, focused); + } + + private toggleCurrentElementActiveFocus(focused: boolean): void { + if (this.focusedIndex === -1) { + return; + } + this.toggleElementActiveFocus(this.elements[this.focusedIndex], focused); + } + + private toggleElementActiveFocus(element: HTMLElement, focused: boolean) { + // active focus is set when sticky scroll has focus element.classList.toggle('focused', focused); } + private toggleElementPassiveFocus(element: HTMLElement, focused: boolean) { + // passive focus allows to show focus when sticky scroll does not have focus + // for example when the context menu has focus + element.classList.toggle('passive-focused', focused); + } + private toggleStickyScrollFocused(focused: boolean) { // Weather the last focus in the view was sticky scroll and not the list + // Is only removed when the focus is back in the tree an no longer in sticky scroll this.view.getHTMLElement().classList.toggle('sticky-scroll-focused', focused); } @@ -1982,6 +2030,7 @@ class StickyScrollFocus extends Disposable { } this.domHasFocus = true; this.toggleStickyScrollFocused(true); + this.toggleCurrentElementActiveFocus(true); if (this.focusedIndex === -1) { this.setFocus(0); } @@ -1989,6 +2038,7 @@ class StickyScrollFocus extends Disposable { private onBlur(): void { this.domHasFocus = false; + this.toggleCurrentElementActiveFocus(false); } override dispose(): void { @@ -2048,6 +2098,7 @@ export interface IAbstractTreeOptionsUpdate extends ITreeRendererOptions { export interface IAbstractTreeOptions extends IAbstractTreeOptionsUpdate, IListOptions { readonly contextViewProvider?: IContextViewProvider; readonly collapseByDefault?: boolean; // defaults to false + readonly allowNonCollapsibleParents?: boolean; // defaults to false readonly filter?: ITreeFilter; readonly dnd?: ITreeDragAndDrop; readonly paddingBottom?: number; @@ -2244,7 +2295,7 @@ class TreeNodeListMouseController extends MouseController< this.tree.setFocus([location]); this.tree.toggleCollapsed(location, recursive); - if (expandOnlyOnTwistieClick && onTwistie) { + if (onTwistie) { // Do not set this before calling a handler on the super class, because it will reject it as handled e.browserEvent.isHandledByList = true; return; @@ -2432,6 +2483,8 @@ export abstract class AbstractTree implements IDisposable get onMouseClick(): Event> { return Event.map(this.view.onMouseClick, asTreeMouseEvent); } get onMouseDblClick(): Event> { return Event.filter(Event.map(this.view.onMouseDblClick, asTreeMouseEvent), e => e.target !== TreeMouseEventTarget.Filter); } + get onMouseOver(): Event> { return Event.map(this.view.onMouseOver, asTreeMouseEvent); } + get onMouseOut(): Event> { return Event.map(this.view.onMouseOut, asTreeMouseEvent); } get onContextMenu(): Event> { return Event.any(Event.filter(Event.map(this.view.onContextMenu, asTreeContextMenuEvent), e => !e.isStickyScroll), this.stickyScrollController?.onContextMenu ?? Event.None); } get onTap(): Event> { return Event.map(this.view.onTap, asTreeMouseEvent); } get onPointer(): Event> { return Event.map(this.view.onPointer, asTreeMouseEvent); } @@ -2719,9 +2772,20 @@ export abstract class AbstractTree implements IDisposable } // Sticky Scroll Background - if (styles.listBackground) { - content.push(`.monaco-list${suffix} .monaco-scrollable-element .monaco-tree-sticky-container { background-color: ${styles.listBackground}; }`); - content.push(`.monaco-list${suffix} .monaco-scrollable-element .monaco-tree-sticky-container .monaco-tree-sticky-row { background-color: ${styles.listBackground}; }`); + const stickyScrollBackground = styles.treeStickyScrollBackground ?? styles.listBackground; + if (stickyScrollBackground) { + content.push(`.monaco-list${suffix} .monaco-scrollable-element .monaco-tree-sticky-container { background-color: ${stickyScrollBackground}; }`); + content.push(`.monaco-list${suffix} .monaco-scrollable-element .monaco-tree-sticky-container .monaco-tree-sticky-row { background-color: ${stickyScrollBackground}; }`); + } + + // Sticky Scroll Border + if (styles.treeStickyScrollBorder) { + content.push(`.monaco-list${suffix} .monaco-scrollable-element .monaco-tree-sticky-container { border-bottom: 1px solid ${styles.treeStickyScrollBorder}; }`); + } + + // Sticky Scroll Shadow + if (styles.treeStickyScrollShadow) { + content.push(`.monaco-list${suffix} .monaco-scrollable-element .monaco-tree-sticky-container .monaco-tree-sticky-container-shadow { box-shadow: ${styles.treeStickyScrollShadow} 0 6px 6px -6px inset; height: 3px; }`); } // Sticky Scroll Focus @@ -2741,6 +2805,8 @@ export abstract class AbstractTree implements IDisposable content.push(`.monaco-list${suffix}.sticky-scroll-focused .monaco-scrollable-element .monaco-tree-sticky-container:focus .monaco-list-row.focused { outline: 1px solid ${styles.listFocusOutline}; outline-offset: -1px; }`); content.push(`.monaco-list${suffix}:not(.sticky-scroll-focused) .monaco-scrollable-element .monaco-tree-sticky-container .monaco-list-row.focused { outline: inherit; }`); + content.push(`.monaco-workbench.context-menu-visible .monaco-list${suffix}.last-focused.sticky-scroll-focused .monaco-scrollable-element .monaco-tree-sticky-container .monaco-list-row.passive-focused { outline: 1px solid ${styles.listFocusOutline}; outline-offset: -1px; }`); + content.push(`.monaco-workbench.context-menu-visible .monaco-list${suffix}.last-focused.sticky-scroll-focused .monaco-list-rows .monaco-list-row.focused { outline: inherit; }`); content.push(`.monaco-workbench.context-menu-visible .monaco-list${suffix}.last-focused:not(.sticky-scroll-focused) .monaco-tree-sticky-container .monaco-list-rows .monaco-list-row.focused { outline: inherit; }`); } @@ -2870,27 +2936,27 @@ export abstract class AbstractTree implements IDisposable }); } - focusNext(n = 1, loop = false, browserEvent?: UIEvent, filter = (isKeyboardEvent(browserEvent) && browserEvent.altKey) ? undefined : this.focusNavigationFilter): void { + focusNext(n = 1, loop = false, browserEvent?: UIEvent, filter: ((node: ITreeNode) => boolean) | undefined = (isKeyboardEvent(browserEvent) && browserEvent.altKey) ? undefined : this.focusNavigationFilter): void { this.view.focusNext(n, loop, browserEvent, filter); } - focusPrevious(n = 1, loop = false, browserEvent?: UIEvent, filter = (isKeyboardEvent(browserEvent) && browserEvent.altKey) ? undefined : this.focusNavigationFilter): void { + focusPrevious(n = 1, loop = false, browserEvent?: UIEvent, filter: ((node: ITreeNode) => boolean) | undefined = (isKeyboardEvent(browserEvent) && browserEvent.altKey) ? undefined : this.focusNavigationFilter): void { this.view.focusPrevious(n, loop, browserEvent, filter); } - focusNextPage(browserEvent?: UIEvent, filter = (isKeyboardEvent(browserEvent) && browserEvent.altKey) ? undefined : this.focusNavigationFilter): Promise { + focusNextPage(browserEvent?: UIEvent, filter: ((node: ITreeNode) => boolean) | undefined = (isKeyboardEvent(browserEvent) && browserEvent.altKey) ? undefined : this.focusNavigationFilter): Promise { return this.view.focusNextPage(browserEvent, filter); } - focusPreviousPage(browserEvent?: UIEvent, filter = (isKeyboardEvent(browserEvent) && browserEvent.altKey) ? undefined : this.focusNavigationFilter): Promise { + focusPreviousPage(browserEvent?: UIEvent, filter: ((node: ITreeNode) => boolean) | undefined = (isKeyboardEvent(browserEvent) && browserEvent.altKey) ? undefined : this.focusNavigationFilter): Promise { return this.view.focusPreviousPage(browserEvent, filter, () => this.stickyScrollController?.height ?? 0); } - focusLast(browserEvent?: UIEvent, filter = (isKeyboardEvent(browserEvent) && browserEvent.altKey) ? undefined : this.focusNavigationFilter): void { + focusLast(browserEvent?: UIEvent, filter: ((node: ITreeNode) => boolean) | undefined = (isKeyboardEvent(browserEvent) && browserEvent.altKey) ? undefined : this.focusNavigationFilter): void { this.view.focusLast(browserEvent, filter); } - focusFirst(browserEvent?: UIEvent, filter = (isKeyboardEvent(browserEvent) && browserEvent.altKey) ? undefined : this.focusNavigationFilter): void { + focusFirst(browserEvent?: UIEvent, filter: ((node: ITreeNode) => boolean) | undefined = (isKeyboardEvent(browserEvent) && browserEvent.altKey) ? undefined : this.focusNavigationFilter): void { this.view.focusFirst(browserEvent, filter); } diff --git a/src/vs/base/browser/ui/tree/indexTreeModel.ts b/src/vs/base/browser/ui/tree/indexTreeModel.ts index 4e83338804b88..219b7c143f11d 100644 --- a/src/vs/base/browser/ui/tree/indexTreeModel.ts +++ b/src/vs/base/browser/ui/tree/indexTreeModel.ts @@ -42,6 +42,7 @@ export function getVisibleState(visibility: boolean | TreeVisibility): TreeVisib export interface IIndexTreeModelOptions { readonly collapseByDefault?: boolean; // defaults to false + readonly allowNonCollapsibleParents?: boolean; // defaults to false readonly filter?: ITreeFilter; readonly autoExpandSingleChildren?: boolean; } @@ -107,6 +108,7 @@ export class IndexTreeModel, TFilterData = voi readonly onDidChangeRenderNodeCount: Event> = this.eventBufferer.wrapEvent(this._onDidChangeRenderNodeCount.event); private collapseByDefault: boolean; + private allowNonCollapsibleParents: boolean; private filter?: ITreeFilter; private autoExpandSingleChildren: boolean; @@ -122,6 +124,7 @@ export class IndexTreeModel, TFilterData = voi options: IIndexTreeModelOptions = {} ) { this.collapseByDefault = typeof options.collapseByDefault === 'undefined' ? false : options.collapseByDefault; + this.allowNonCollapsibleParents = options.allowNonCollapsibleParents ?? false; this.filter = options.filter; this.autoExpandSingleChildren = typeof options.autoExpandSingleChildren === 'undefined' ? false : options.autoExpandSingleChildren; @@ -535,7 +538,10 @@ export class IndexTreeModel, TFilterData = voi } } - node.collapsible = node.collapsible || node.children.length > 0; + if (!this.allowNonCollapsibleParents) { + node.collapsible = node.collapsible || node.children.length > 0; + } + node.visibleChildrenCount = visibleChildrenCount; node.visible = visibility === TreeVisibility.Recurse ? visibleChildrenCount > 0 : (visibility === TreeVisibility.Visible); diff --git a/src/vs/base/browser/ui/tree/media/tree.css b/src/vs/base/browser/ui/tree/media/tree.css index a7c3befd964ec..58099a56c27c0 100644 --- a/src/vs/base/browser/ui/tree/media/tree.css +++ b/src/vs/base/browser/ui/tree/media/tree.css @@ -137,7 +137,7 @@ height: 0; z-index: 13; /* Settings editor uses z-index: 12 */ - /* TODO@benibenj temporary solution, all lists should provide their background */ + /* Backup color in case the tree does not provide the background color */ background-color: var(--vscode-sideBar-background); } @@ -147,7 +147,7 @@ opacity: 1 !important; /* Settings editor uses opacity < 1 */ overflow: hidden; - /* TODO@benibenj temporary solution, all lists should provide their background */ + /* Backup color in case the tree does not provide the background color */ background-color: var(--vscode-sideBar-background); } @@ -161,13 +161,12 @@ display: none; } -.monaco-list .monaco-scrollable-element .monaco-tree-sticky-container .monaco-tree-sticky-container-shadow{ +.monaco-list .monaco-scrollable-element .monaco-tree-sticky-container .monaco-tree-sticky-container-shadow { position: absolute; bottom: -3px; left: 0px; - height: 3px; + height: 0px; /* heigt is 3px and only set when there is a treeStickyScrollShadow color */ width: 100%; - box-shadow: var(--vscode-scrollbar-shadow) 0 6px 6px -6px inset; } .monaco-list .monaco-scrollable-element .monaco-tree-sticky-container[tabindex="0"]:focus{ diff --git a/src/vs/base/browser/ui/tree/objectTree.ts b/src/vs/base/browser/ui/tree/objectTree.ts index 894f74ce92191..aa46a9a641d8b 100644 --- a/src/vs/base/browser/ui/tree/objectTree.ts +++ b/src/vs/base/browser/ui/tree/objectTree.ts @@ -192,18 +192,20 @@ class CompressibleStickyScrollDelegate implements IStickyScrollD if (stickyNodes.length === 0) { throw new Error('Can\'t compress empty sticky nodes'); } - - if (!this.modelProvider().isCompressionEnabled()) { + const compressionModel = this.modelProvider(); + if (!compressionModel.isCompressionEnabled()) { return stickyNodes[0]; } // Collect all elements to be compressed const elements: T[] = []; - for (const stickyNode of stickyNodes) { - const compressedNode = this.modelProvider().getCompressedTreeNode(stickyNode.node.element); + for (let i = 0; i < stickyNodes.length; i++) { + const stickyNode = stickyNodes[i]; + const compressedNode = compressionModel.getCompressedTreeNode(stickyNode.node.element); if (compressedNode.element) { - if (compressedNode.element.incompressible) { + // if an element is incompressible, it can't be compressed with it's parent element + if (i !== 0 && compressedNode.element.incompressible) { break; } elements.push(...compressedNode.element.elements); diff --git a/src/vs/base/browser/window.ts b/src/vs/base/browser/window.ts index fe715d6f2c256..ab920e18349ac 100644 --- a/src/vs/base/browser/window.ts +++ b/src/vs/base/browser/window.ts @@ -20,13 +20,6 @@ export function ensureCodeWindow(targetWindow: Window, fallbackWindowId: number) // eslint-disable-next-line no-restricted-globals export const mainWindow = window as CodeWindow; -/** - * @deprecated to support multi-window scenarios, use `DOM.mainWindow` - * if you target the main global window or use helpers such as `DOM.getWindow()` - * or `DOM.getActiveWindow()` to obtain the correct window for the context you are in. - */ -export const $window = mainWindow; - export function isAuxiliaryWindow(obj: Window): obj is CodeWindow { if (obj === mainWindow) { return false; diff --git a/src/vs/base/common/arrays.ts b/src/vs/base/common/arrays.ts index 9b510f822513e..cc61b0ee8367b 100644 --- a/src/vs/base/common/arrays.ts +++ b/src/vs/base/common/arrays.ts @@ -8,15 +8,6 @@ import { CancellationError } from 'vs/base/common/errors'; import { ISplice } from 'vs/base/common/sequence'; import { findFirstIdxMonotonousOrArrLen } from './arraysFind'; -/** - * Returns the last element of an array. - * @param array The array. - * @param n Which element from the end (default is zero). - */ -export function tail(array: ArrayLike, n: number = 0): T { - return array[array.length - (1 + n)]; -} - export function tail2(arr: T[]): [T[], T] { if (arr.length === 0) { throw new Error('Invalid tail call'); @@ -859,3 +850,36 @@ export class CallbackIterable { return result; } } + +/** + * Represents a re-arrangement of items in an array. + */ +export class Permutation { + constructor(private readonly _indexMap: readonly number[]) { } + + /** + * Returns a permutation that sorts the given array according to the given compare function. + */ + public static createSortPermutation(arr: readonly T[], compareFn: (a: T, b: T) => number): Permutation { + const sortIndices = Array.from(arr.keys()).sort((index1, index2) => compareFn(arr[index1], arr[index2])); + return new Permutation(sortIndices); + } + + /** + * Returns a new array with the elements of the given array re-arranged according to this permutation. + */ + apply(arr: readonly T[]): T[] { + return arr.map((_, index) => arr[this._indexMap[index]]); + } + + /** + * Returns a new permutation that undoes the re-arrangement of this permutation. + */ + inverse(): Permutation { + const inverseIndexMap = this._indexMap.slice(); + for (let i = 0; i < this._indexMap.length; i++) { + inverseIndexMap[this._indexMap[i]] = i; + } + return new Permutation(inverseIndexMap); + } +} diff --git a/src/vs/base/common/assert.ts b/src/vs/base/common/assert.ts index 5c1bff2799031..4ded48fb1de86 100644 --- a/src/vs/base/common/assert.ts +++ b/src/vs/base/common/assert.ts @@ -35,9 +35,12 @@ export function assert(condition: boolean): void { } } +/** + * Like assert, but doesn't throw. + */ export function softAssert(condition: boolean): void { if (!condition) { - onUnexpectedError(new BugIndicatingError('Assertion Failed')); + onUnexpectedError(new BugIndicatingError('Soft Assertion Failed')); } } diff --git a/src/vs/base/common/cache.ts b/src/vs/base/common/cache.ts index 1e675c36e43b7..844a86b2584b2 100644 --- a/src/vs/base/common/cache.ts +++ b/src/vs/base/common/cache.ts @@ -39,17 +39,19 @@ export class Cache { /** * Uses a LRU cache to make a given parametrized function cached. * Caches just the last value. - * The key must be JSON serializable. */ export class LRUCachedFunction { private lastCache: TComputed | undefined = undefined; - private lastArgKey: string | undefined = undefined; + private lastArgKey: unknown | undefined = undefined; - constructor(private readonly fn: (arg: TArg) => TComputed) { + constructor( + private readonly fn: (arg: TArg) => TComputed, + private readonly _computeKey: (arg: TArg) => unknown = JSON.stringify, + ) { } public get(arg: TArg): TComputed { - const key = JSON.stringify(arg); + const key = this._computeKey(arg); if (this.lastArgKey !== key) { this.lastArgKey = key; this.lastCache = this.fn(arg); diff --git a/src/vs/base/common/codicons.ts b/src/vs/base/common/codicons.ts index c8ab637ebeb7c..6919e4934a21c 100644 --- a/src/vs/base/common/codicons.ts +++ b/src/vs/base/common/codicons.ts @@ -3,28 +3,9 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ import { ThemeIcon } from 'vs/base/common/themables'; -import { isString } from 'vs/base/common/types'; +import { register } from 'vs/base/common/codiconsUtil'; +import { codiconsLibrary } from 'vs/base/common/codiconsLibrary'; -const _codiconFontCharacters: { [id: string]: number } = Object.create(null); - -function register(id: string, fontCharacter: number | string): ThemeIcon { - if (isString(fontCharacter)) { - const val = _codiconFontCharacters[fontCharacter]; - if (val === undefined) { - throw new Error(`${id} references an unknown codicon: ${fontCharacter}`); - } - fontCharacter = val; - } - _codiconFontCharacters[id] = fontCharacter; - return { id }; -} - -/** - * Only to be used by the iconRegistry. - */ -export function getCodiconFontCharacters(): { [id: string]: number } { - return _codiconFontCharacters; -} /** * Only to be used by the iconRegistry. @@ -34,596 +15,50 @@ export function getAllCodicons(): ThemeIcon[] { } /** - * The Codicon library is a set of default icons that are built-in in VS Code. - * - * In the product (outside of base) Codicons should only be used as defaults. In order to have all icons in VS Code - * themeable, component should define new, UI component specific icons using `iconRegistry.registerIcon`. - * In that call a Codicon can be named as default. + * Derived icons, that could become separate icons. + * These mappings should be moved into the mapping file in the vscode-codicons repo at some point. */ -export const Codicon = { - - // built-in icons, with image name - add: register('add', 0xea60), - plus: register('plus', 0xea60), - gistNew: register('gist-new', 0xea60), - repoCreate: register('repo-create', 0xea60), - lightbulb: register('lightbulb', 0xea61), - lightBulb: register('light-bulb', 0xea61), - repo: register('repo', 0xea62), - repoDelete: register('repo-delete', 0xea62), - gistFork: register('gist-fork', 0xea63), - repoForked: register('repo-forked', 0xea63), - gitPullRequest: register('git-pull-request', 0xea64), - gitPullRequestAbandoned: register('git-pull-request-abandoned', 0xea64), - recordKeys: register('record-keys', 0xea65), - keyboard: register('keyboard', 0xea65), - tag: register('tag', 0xea66), - tagAdd: register('tag-add', 0xea66), - tagRemove: register('tag-remove', 0xea66), - gitPullRequestLabel: register('git-pull-request-label', 0xea66), - person: register('person', 0xea67), - personFollow: register('person-follow', 0xea67), - personOutline: register('person-outline', 0xea67), - personFilled: register('person-filled', 0xea67), - gitBranch: register('git-branch', 0xea68), - gitBranchCreate: register('git-branch-create', 0xea68), - gitBranchDelete: register('git-branch-delete', 0xea68), - sourceControl: register('source-control', 0xea68), - mirror: register('mirror', 0xea69), - mirrorPublic: register('mirror-public', 0xea69), - star: register('star', 0xea6a), - starAdd: register('star-add', 0xea6a), - starDelete: register('star-delete', 0xea6a), - starEmpty: register('star-empty', 0xea6a), - comment: register('comment', 0xea6b), - commentAdd: register('comment-add', 0xea6b), - alert: register('alert', 0xea6c), - warning: register('warning', 0xea6c), - search: register('search', 0xea6d), - searchSave: register('search-save', 0xea6d), - logOut: register('log-out', 0xea6e), - signOut: register('sign-out', 0xea6e), - logIn: register('log-in', 0xea6f), - signIn: register('sign-in', 0xea6f), - eye: register('eye', 0xea70), - eyeUnwatch: register('eye-unwatch', 0xea70), - eyeWatch: register('eye-watch', 0xea70), - circleFilled: register('circle-filled', 0xea71), - primitiveDot: register('primitive-dot', 0xea71), - closeDirty: register('close-dirty', 0xea71), - debugBreakpoint: register('debug-breakpoint', 0xea71), - debugBreakpointDisabled: register('debug-breakpoint-disabled', 0xea71), - debugBreakpointPending: register('debug-breakpoint-pending', 0xebd9), - debugHint: register('debug-hint', 0xea71), - primitiveSquare: register('primitive-square', 0xea72), - edit: register('edit', 0xea73), - pencil: register('pencil', 0xea73), - info: register('info', 0xea74), - issueOpened: register('issue-opened', 0xea74), - gistPrivate: register('gist-private', 0xea75), - gitForkPrivate: register('git-fork-private', 0xea75), - lock: register('lock', 0xea75), - mirrorPrivate: register('mirror-private', 0xea75), - close: register('close', 0xea76), - removeClose: register('remove-close', 0xea76), - x: register('x', 0xea76), - repoSync: register('repo-sync', 0xea77), - sync: register('sync', 0xea77), - clone: register('clone', 0xea78), - desktopDownload: register('desktop-download', 0xea78), - beaker: register('beaker', 0xea79), - microscope: register('microscope', 0xea79), - vm: register('vm', 0xea7a), - deviceDesktop: register('device-desktop', 0xea7a), - file: register('file', 0xea7b), - fileText: register('file-text', 0xea7b), - more: register('more', 0xea7c), - ellipsis: register('ellipsis', 0xea7c), - kebabHorizontal: register('kebab-horizontal', 0xea7c), - mailReply: register('mail-reply', 0xea7d), - reply: register('reply', 0xea7d), - organization: register('organization', 0xea7e), - organizationFilled: register('organization-filled', 0xea7e), - organizationOutline: register('organization-outline', 0xea7e), - newFile: register('new-file', 0xea7f), - fileAdd: register('file-add', 0xea7f), - newFolder: register('new-folder', 0xea80), - fileDirectoryCreate: register('file-directory-create', 0xea80), - trash: register('trash', 0xea81), - trashcan: register('trashcan', 0xea81), - history: register('history', 0xea82), - clock: register('clock', 0xea82), - folder: register('folder', 0xea83), - fileDirectory: register('file-directory', 0xea83), - symbolFolder: register('symbol-folder', 0xea83), - logoGithub: register('logo-github', 0xea84), - markGithub: register('mark-github', 0xea84), - github: register('github', 0xea84), - terminal: register('terminal', 0xea85), - console: register('console', 0xea85), - repl: register('repl', 0xea85), - zap: register('zap', 0xea86), - symbolEvent: register('symbol-event', 0xea86), - error: register('error', 0xea87), - stop: register('stop', 0xea87), - variable: register('variable', 0xea88), - symbolVariable: register('symbol-variable', 0xea88), - array: register('array', 0xea8a), - symbolArray: register('symbol-array', 0xea8a), - symbolModule: register('symbol-module', 0xea8b), - symbolPackage: register('symbol-package', 0xea8b), - symbolNamespace: register('symbol-namespace', 0xea8b), - symbolObject: register('symbol-object', 0xea8b), - symbolMethod: register('symbol-method', 0xea8c), - symbolFunction: register('symbol-function', 0xea8c), - symbolConstructor: register('symbol-constructor', 0xea8c), - symbolBoolean: register('symbol-boolean', 0xea8f), - symbolNull: register('symbol-null', 0xea8f), - symbolNumeric: register('symbol-numeric', 0xea90), - symbolNumber: register('symbol-number', 0xea90), - symbolStructure: register('symbol-structure', 0xea91), - symbolStruct: register('symbol-struct', 0xea91), - symbolParameter: register('symbol-parameter', 0xea92), - symbolTypeParameter: register('symbol-type-parameter', 0xea92), - symbolKey: register('symbol-key', 0xea93), - symbolText: register('symbol-text', 0xea93), - symbolReference: register('symbol-reference', 0xea94), - goToFile: register('go-to-file', 0xea94), - symbolEnum: register('symbol-enum', 0xea95), - symbolValue: register('symbol-value', 0xea95), - symbolRuler: register('symbol-ruler', 0xea96), - symbolUnit: register('symbol-unit', 0xea96), - activateBreakpoints: register('activate-breakpoints', 0xea97), - archive: register('archive', 0xea98), - arrowBoth: register('arrow-both', 0xea99), - arrowDown: register('arrow-down', 0xea9a), - arrowLeft: register('arrow-left', 0xea9b), - arrowRight: register('arrow-right', 0xea9c), - arrowSmallDown: register('arrow-small-down', 0xea9d), - arrowSmallLeft: register('arrow-small-left', 0xea9e), - arrowSmallRight: register('arrow-small-right', 0xea9f), - arrowSmallUp: register('arrow-small-up', 0xeaa0), - arrowUp: register('arrow-up', 0xeaa1), - bell: register('bell', 0xeaa2), - bold: register('bold', 0xeaa3), - book: register('book', 0xeaa4), - bookmark: register('bookmark', 0xeaa5), - debugBreakpointConditionalUnverified: register('debug-breakpoint-conditional-unverified', 0xeaa6), - debugBreakpointConditional: register('debug-breakpoint-conditional', 0xeaa7), - debugBreakpointConditionalDisabled: register('debug-breakpoint-conditional-disabled', 0xeaa7), - debugBreakpointDataUnverified: register('debug-breakpoint-data-unverified', 0xeaa8), - debugBreakpointData: register('debug-breakpoint-data', 0xeaa9), - debugBreakpointDataDisabled: register('debug-breakpoint-data-disabled', 0xeaa9), - debugBreakpointLogUnverified: register('debug-breakpoint-log-unverified', 0xeaaa), - debugBreakpointLog: register('debug-breakpoint-log', 0xeaab), - debugBreakpointLogDisabled: register('debug-breakpoint-log-disabled', 0xeaab), - briefcase: register('briefcase', 0xeaac), - broadcast: register('broadcast', 0xeaad), - browser: register('browser', 0xeaae), - bug: register('bug', 0xeaaf), - calendar: register('calendar', 0xeab0), - caseSensitive: register('case-sensitive', 0xeab1), - check: register('check', 0xeab2), - checklist: register('checklist', 0xeab3), - chevronDown: register('chevron-down', 0xeab4), - dropDownButton: register('drop-down-button', 0xeab4), - chevronLeft: register('chevron-left', 0xeab5), - chevronRight: register('chevron-right', 0xeab6), - chevronUp: register('chevron-up', 0xeab7), - chromeClose: register('chrome-close', 0xeab8), - chromeMaximize: register('chrome-maximize', 0xeab9), - chromeMinimize: register('chrome-minimize', 0xeaba), - chromeRestore: register('chrome-restore', 0xeabb), - circle: register('circle', 0xeabc), - circleOutline: register('circle-outline', 0xeabc), - debugBreakpointUnverified: register('debug-breakpoint-unverified', 0xeabc), - circleSlash: register('circle-slash', 0xeabd), - circuitBoard: register('circuit-board', 0xeabe), - clearAll: register('clear-all', 0xeabf), - clippy: register('clippy', 0xeac0), - closeAll: register('close-all', 0xeac1), - cloudDownload: register('cloud-download', 0xeac2), - cloudUpload: register('cloud-upload', 0xeac3), - code: register('code', 0xeac4), - collapseAll: register('collapse-all', 0xeac5), - colorMode: register('color-mode', 0xeac6), - commentDiscussion: register('comment-discussion', 0xeac7), - compareChanges: register('compare-changes', 0xeafd), - creditCard: register('credit-card', 0xeac9), - dash: register('dash', 0xeacc), - dashboard: register('dashboard', 0xeacd), - database: register('database', 0xeace), - debugContinue: register('debug-continue', 0xeacf), - debugDisconnect: register('debug-disconnect', 0xead0), - debugPause: register('debug-pause', 0xead1), - debugRestart: register('debug-restart', 0xead2), - debugStart: register('debug-start', 0xead3), - debugStepInto: register('debug-step-into', 0xead4), - debugStepOut: register('debug-step-out', 0xead5), - debugStepOver: register('debug-step-over', 0xead6), - debugStop: register('debug-stop', 0xead7), - debug: register('debug', 0xead8), - deviceCameraVideo: register('device-camera-video', 0xead9), - deviceCamera: register('device-camera', 0xeada), - deviceMobile: register('device-mobile', 0xeadb), - diffAdded: register('diff-added', 0xeadc), - diffIgnored: register('diff-ignored', 0xeadd), - diffModified: register('diff-modified', 0xeade), - diffRemoved: register('diff-removed', 0xeadf), - diffRenamed: register('diff-renamed', 0xeae0), - diff: register('diff', 0xeae1), - discard: register('discard', 0xeae2), - editorLayout: register('editor-layout', 0xeae3), - emptyWindow: register('empty-window', 0xeae4), - exclude: register('exclude', 0xeae5), - extensions: register('extensions', 0xeae6), - eyeClosed: register('eye-closed', 0xeae7), - fileBinary: register('file-binary', 0xeae8), - fileCode: register('file-code', 0xeae9), - fileMedia: register('file-media', 0xeaea), - filePdf: register('file-pdf', 0xeaeb), - fileSubmodule: register('file-submodule', 0xeaec), - fileSymlinkDirectory: register('file-symlink-directory', 0xeaed), - fileSymlinkFile: register('file-symlink-file', 0xeaee), - fileZip: register('file-zip', 0xeaef), - files: register('files', 0xeaf0), - filter: register('filter', 0xeaf1), - flame: register('flame', 0xeaf2), - foldDown: register('fold-down', 0xeaf3), - foldUp: register('fold-up', 0xeaf4), - fold: register('fold', 0xeaf5), - folderActive: register('folder-active', 0xeaf6), - folderOpened: register('folder-opened', 0xeaf7), - gear: register('gear', 0xeaf8), - gift: register('gift', 0xeaf9), - gistSecret: register('gist-secret', 0xeafa), - gist: register('gist', 0xeafb), - gitCommit: register('git-commit', 0xeafc), - gitCompare: register('git-compare', 0xeafd), - gitMerge: register('git-merge', 0xeafe), - githubAction: register('github-action', 0xeaff), - githubAlt: register('github-alt', 0xeb00), - globe: register('globe', 0xeb01), - grabber: register('grabber', 0xeb02), - graph: register('graph', 0xeb03), - gripper: register('gripper', 0xeb04), - heart: register('heart', 0xeb05), - home: register('home', 0xeb06), - horizontalRule: register('horizontal-rule', 0xeb07), - hubot: register('hubot', 0xeb08), - inbox: register('inbox', 0xeb09), - issueClosed: register('issue-closed', 0xeba4), - issueReopened: register('issue-reopened', 0xeb0b), - issues: register('issues', 0xeb0c), - italic: register('italic', 0xeb0d), - jersey: register('jersey', 0xeb0e), - json: register('json', 0xeb0f), - bracket: register('bracket', 0xeb0f), - kebabVertical: register('kebab-vertical', 0xeb10), - key: register('key', 0xeb11), - law: register('law', 0xeb12), - lightbulbAutofix: register('lightbulb-autofix', 0xeb13), - linkExternal: register('link-external', 0xeb14), - link: register('link', 0xeb15), - listOrdered: register('list-ordered', 0xeb16), - listUnordered: register('list-unordered', 0xeb17), - liveShare: register('live-share', 0xeb18), - loading: register('loading', 0xeb19), - location: register('location', 0xeb1a), - mailRead: register('mail-read', 0xeb1b), - mail: register('mail', 0xeb1c), - markdown: register('markdown', 0xeb1d), - megaphone: register('megaphone', 0xeb1e), - mention: register('mention', 0xeb1f), - milestone: register('milestone', 0xeb20), - gitPullRequestMilestone: register('git-pull-request-milestone', 0xeb20), - mortarBoard: register('mortar-board', 0xeb21), - move: register('move', 0xeb22), - multipleWindows: register('multiple-windows', 0xeb23), - mute: register('mute', 0xeb24), - noNewline: register('no-newline', 0xeb25), - note: register('note', 0xeb26), - octoface: register('octoface', 0xeb27), - openPreview: register('open-preview', 0xeb28), - package: register('package', 0xeb29), - paintcan: register('paintcan', 0xeb2a), - pin: register('pin', 0xeb2b), - play: register('play', 0xeb2c), - run: register('run', 0xeb2c), - plug: register('plug', 0xeb2d), - preserveCase: register('preserve-case', 0xeb2e), - preview: register('preview', 0xeb2f), - project: register('project', 0xeb30), - pulse: register('pulse', 0xeb31), - question: register('question', 0xeb32), - quote: register('quote', 0xeb33), - radioTower: register('radio-tower', 0xeb34), - reactions: register('reactions', 0xeb35), - references: register('references', 0xeb36), - refresh: register('refresh', 0xeb37), - regex: register('regex', 0xeb38), - remoteExplorer: register('remote-explorer', 0xeb39), - remote: register('remote', 0xeb3a), - remove: register('remove', 0xeb3b), - replaceAll: register('replace-all', 0xeb3c), - replace: register('replace', 0xeb3d), - repoClone: register('repo-clone', 0xeb3e), - repoForcePush: register('repo-force-push', 0xeb3f), - repoPull: register('repo-pull', 0xeb40), - repoPush: register('repo-push', 0xeb41), - report: register('report', 0xeb42), - requestChanges: register('request-changes', 0xeb43), - rocket: register('rocket', 0xeb44), - rootFolderOpened: register('root-folder-opened', 0xeb45), - rootFolder: register('root-folder', 0xeb46), - rss: register('rss', 0xeb47), - ruby: register('ruby', 0xeb48), - saveAll: register('save-all', 0xeb49), - saveAs: register('save-as', 0xeb4a), - save: register('save', 0xeb4b), - screenFull: register('screen-full', 0xeb4c), - screenNormal: register('screen-normal', 0xeb4d), - searchStop: register('search-stop', 0xeb4e), - server: register('server', 0xeb50), - settingsGear: register('settings-gear', 0xeb51), - settings: register('settings', 0xeb52), - shield: register('shield', 0xeb53), - smiley: register('smiley', 0xeb54), - sortPrecedence: register('sort-precedence', 0xeb55), - splitHorizontal: register('split-horizontal', 0xeb56), - splitVertical: register('split-vertical', 0xeb57), - squirrel: register('squirrel', 0xeb58), - starFull: register('star-full', 0xeb59), - starHalf: register('star-half', 0xeb5a), - symbolClass: register('symbol-class', 0xeb5b), - symbolColor: register('symbol-color', 0xeb5c), - symbolCustomColor: register('symbol-customcolor', 0xeb5c), - symbolConstant: register('symbol-constant', 0xeb5d), - symbolEnumMember: register('symbol-enum-member', 0xeb5e), - symbolField: register('symbol-field', 0xeb5f), - symbolFile: register('symbol-file', 0xeb60), - symbolInterface: register('symbol-interface', 0xeb61), - symbolKeyword: register('symbol-keyword', 0xeb62), - symbolMisc: register('symbol-misc', 0xeb63), - symbolOperator: register('symbol-operator', 0xeb64), - symbolProperty: register('symbol-property', 0xeb65), - wrench: register('wrench', 0xeb65), - wrenchSubaction: register('wrench-subaction', 0xeb65), - symbolSnippet: register('symbol-snippet', 0xeb66), - tasklist: register('tasklist', 0xeb67), - telescope: register('telescope', 0xeb68), - textSize: register('text-size', 0xeb69), - threeBars: register('three-bars', 0xeb6a), - thumbsdown: register('thumbsdown', 0xeb6b), - thumbsup: register('thumbsup', 0xeb6c), - tools: register('tools', 0xeb6d), - triangleDown: register('triangle-down', 0xeb6e), - triangleLeft: register('triangle-left', 0xeb6f), - triangleRight: register('triangle-right', 0xeb70), - triangleUp: register('triangle-up', 0xeb71), - twitter: register('twitter', 0xeb72), - unfold: register('unfold', 0xeb73), - unlock: register('unlock', 0xeb74), - unmute: register('unmute', 0xeb75), - unverified: register('unverified', 0xeb76), - verified: register('verified', 0xeb77), - versions: register('versions', 0xeb78), - vmActive: register('vm-active', 0xeb79), - vmOutline: register('vm-outline', 0xeb7a), - vmRunning: register('vm-running', 0xeb7b), - watch: register('watch', 0xeb7c), - whitespace: register('whitespace', 0xeb7d), - wholeWord: register('whole-word', 0xeb7e), - window: register('window', 0xeb7f), - wordWrap: register('word-wrap', 0xeb80), - zoomIn: register('zoom-in', 0xeb81), - zoomOut: register('zoom-out', 0xeb82), - listFilter: register('list-filter', 0xeb83), - listFlat: register('list-flat', 0xeb84), - listSelection: register('list-selection', 0xeb85), - selection: register('selection', 0xeb85), - listTree: register('list-tree', 0xeb86), - debugBreakpointFunctionUnverified: register('debug-breakpoint-function-unverified', 0xeb87), - debugBreakpointFunction: register('debug-breakpoint-function', 0xeb88), - debugBreakpointFunctionDisabled: register('debug-breakpoint-function-disabled', 0xeb88), - debugStackframeActive: register('debug-stackframe-active', 0xeb89), - circleSmallFilled: register('circle-small-filled', 0xeb8a), - debugStackframeDot: register('debug-stackframe-dot', 0xeb8a), - debugStackframe: register('debug-stackframe', 0xeb8b), - debugStackframeFocused: register('debug-stackframe-focused', 0xeb8b), - debugBreakpointUnsupported: register('debug-breakpoint-unsupported', 0xeb8c), - symbolString: register('symbol-string', 0xeb8d), - debugReverseContinue: register('debug-reverse-continue', 0xeb8e), - debugStepBack: register('debug-step-back', 0xeb8f), - debugRestartFrame: register('debug-restart-frame', 0xeb90), - callIncoming: register('call-incoming', 0xeb92), - callOutgoing: register('call-outgoing', 0xeb93), - menu: register('menu', 0xeb94), - expandAll: register('expand-all', 0xeb95), - feedback: register('feedback', 0xeb96), - gitPullRequestReviewer: register('git-pull-request-reviewer', 0xeb96), - groupByRefType: register('group-by-ref-type', 0xeb97), - ungroupByRefType: register('ungroup-by-ref-type', 0xeb98), - account: register('account', 0xeb99), - gitPullRequestAssignee: register('git-pull-request-assignee', 0xeb99), - bellDot: register('bell-dot', 0xeb9a), - debugConsole: register('debug-console', 0xeb9b), - library: register('library', 0xeb9c), - output: register('output', 0xeb9d), - runAll: register('run-all', 0xeb9e), - syncIgnored: register('sync-ignored', 0xeb9f), - pinned: register('pinned', 0xeba0), - githubInverted: register('github-inverted', 0xeba1), - debugAlt: register('debug-alt', 0xeb91), - serverProcess: register('server-process', 0xeba2), - serverEnvironment: register('server-environment', 0xeba3), - pass: register('pass', 0xeba4), - stopCircle: register('stop-circle', 0xeba5), - playCircle: register('play-circle', 0xeba6), - record: register('record', 0xeba7), - debugAltSmall: register('debug-alt-small', 0xeba8), - vmConnect: register('vm-connect', 0xeba9), - cloud: register('cloud', 0xebaa), - merge: register('merge', 0xebab), - exportIcon: register('export', 0xebac), - graphLeft: register('graph-left', 0xebad), - magnet: register('magnet', 0xebae), - notebook: register('notebook', 0xebaf), - redo: register('redo', 0xebb0), - checkAll: register('check-all', 0xebb1), - pinnedDirty: register('pinned-dirty', 0xebb2), - passFilled: register('pass-filled', 0xebb3), - circleLargeFilled: register('circle-large-filled', 0xebb4), - circleLarge: register('circle-large', 0xebb5), - circleLargeOutline: register('circle-large-outline', 0xebb5), - combine: register('combine', 0xebb6), - gather: register('gather', 0xebb6), - table: register('table', 0xebb7), - variableGroup: register('variable-group', 0xebb8), - typeHierarchy: register('type-hierarchy', 0xebb9), - typeHierarchySub: register('type-hierarchy-sub', 0xebba), - typeHierarchySuper: register('type-hierarchy-super', 0xebbb), - gitPullRequestCreate: register('git-pull-request-create', 0xebbc), - runAbove: register('run-above', 0xebbd), - runBelow: register('run-below', 0xebbe), - notebookTemplate: register('notebook-template', 0xebbf), - debugRerun: register('debug-rerun', 0xebc0), - workspaceTrusted: register('workspace-trusted', 0xebc1), - workspaceUntrusted: register('workspace-untrusted', 0xebc2), - workspaceUnspecified: register('workspace-unspecified', 0xebc3), - terminalCmd: register('terminal-cmd', 0xebc4), - terminalDebian: register('terminal-debian', 0xebc5), - terminalLinux: register('terminal-linux', 0xebc6), - terminalPowershell: register('terminal-powershell', 0xebc7), - terminalTmux: register('terminal-tmux', 0xebc8), - terminalUbuntu: register('terminal-ubuntu', 0xebc9), - terminalBash: register('terminal-bash', 0xebca), - arrowSwap: register('arrow-swap', 0xebcb), - copy: register('copy', 0xebcc), - personAdd: register('person-add', 0xebcd), - filterFilled: register('filter-filled', 0xebce), - wand: register('wand', 0xebcf), - debugLineByLine: register('debug-line-by-line', 0xebd0), - inspect: register('inspect', 0xebd1), - layers: register('layers', 0xebd2), - layersDot: register('layers-dot', 0xebd3), - layersActive: register('layers-active', 0xebd4), - compass: register('compass', 0xebd5), - compassDot: register('compass-dot', 0xebd6), - compassActive: register('compass-active', 0xebd7), - azure: register('azure', 0xebd8), - issueDraft: register('issue-draft', 0xebd9), - gitPullRequestClosed: register('git-pull-request-closed', 0xebda), - gitPullRequestDraft: register('git-pull-request-draft', 0xebdb), - debugAll: register('debug-all', 0xebdc), - debugCoverage: register('debug-coverage', 0xebdd), - runErrors: register('run-errors', 0xebde), - folderLibrary: register('folder-library', 0xebdf), - debugContinueSmall: register('debug-continue-small', 0xebe0), - beakerStop: register('beaker-stop', 0xebe1), - graphLine: register('graph-line', 0xebe2), - graphScatter: register('graph-scatter', 0xebe3), - pieChart: register('pie-chart', 0xebe4), - bracketDot: register('bracket-dot', 0xebe5), - bracketError: register('bracket-error', 0xebe6), - lockSmall: register('lock-small', 0xebe7), - azureDevops: register('azure-devops', 0xebe8), - verifiedFilled: register('verified-filled', 0xebe9), - newLine: register('newline', 0xebea), - layout: register('layout', 0xebeb), - layoutActivitybarLeft: register('layout-activitybar-left', 0xebec), - layoutActivitybarRight: register('layout-activitybar-right', 0xebed), - layoutPanelLeft: register('layout-panel-left', 0xebee), - layoutPanelCenter: register('layout-panel-center', 0xebef), - layoutPanelJustify: register('layout-panel-justify', 0xebf0), - layoutPanelRight: register('layout-panel-right', 0xebf1), - layoutPanel: register('layout-panel', 0xebf2), - layoutSidebarLeft: register('layout-sidebar-left', 0xebf3), - layoutSidebarRight: register('layout-sidebar-right', 0xebf4), - layoutStatusbar: register('layout-statusbar', 0xebf5), - layoutMenubar: register('layout-menubar', 0xebf6), - layoutCentered: register('layout-centered', 0xebf7), - layoutSidebarRightOff: register('layout-sidebar-right-off', 0xec00), - layoutPanelOff: register('layout-panel-off', 0xec01), - layoutSidebarLeftOff: register('layout-sidebar-left-off', 0xec02), - target: register('target', 0xebf8), - indent: register('indent', 0xebf9), - recordSmall: register('record-small', 0xebfa), - errorSmall: register('error-small', 0xebfb), - arrowCircleDown: register('arrow-circle-down', 0xebfc), - arrowCircleLeft: register('arrow-circle-left', 0xebfd), - arrowCircleRight: register('arrow-circle-right', 0xebfe), - arrowCircleUp: register('arrow-circle-up', 0xebff), - heartFilled: register('heart-filled', 0xec04), - map: register('map', 0xec05), - mapFilled: register('map-filled', 0xec06), - circleSmall: register('circle-small', 0xec07), - bellSlash: register('bell-slash', 0xec08), - bellSlashDot: register('bell-slash-dot', 0xec09), - commentUnresolved: register('comment-unresolved', 0xec0a), - gitPullRequestGoToChanges: register('git-pull-request-go-to-changes', 0xec0b), - gitPullRequestNewChanges: register('git-pull-request-new-changes', 0xec0c), - searchFuzzy: register('search-fuzzy', 0xec0d), - commentDraft: register('comment-draft', 0xec0e), - send: register('send', 0xec0f), - sparkle: register('sparkle', 0xec10), - insert: register('insert', 0xec11), - mic: register('mic', 0xec12), - thumbsDownFilled: register('thumbsdown-filled', 0xec13), - thumbsUpFilled: register('thumbsup-filled', 0xec14), - coffee: register('coffee', 0xec15), - snake: register('snake', 0xec16), - game: register('game', 0xec17), - vr: register('vr', 0xec18), - chip: register('chip', 0xec19), - piano: register('piano', 0xec1a), - music: register('music', 0xec1b), - micFilled: register('mic-filled', 0xec1c), - gitFetch: register('git-fetch', 0xec1d), - copilot: register('copilot', 0xec1e), - lightbulbSparkle: register('lightbulb-sparkle', 0xec1f), - lightbulbSparkleAutofix: register('lightbulb-sparkle-autofix', 0xec1f), - robot: register('robot', 0xec20), - sparkleFilled: register('sparkle-filled', 0xec21), - diffSingle: register('diff-single', 0xec22), - diffMultiple: register('diff-multiple', 0xec23), - surroundWith: register('surround-with', 0xec24), - gitStash: register('git-stash', 0xec26), - gitStashApply: register('git-stash-apply', 0xec27), - gitStashPop: register('git-stash-pop', 0xec28), - coverage: register('coverage', 0xec2e), - runAllCoverage: register('run-all-coverage', 0xec2d), - runCoverage: register('run-all-coverage', 0xec2c), - - // derived icons, that could become separate icons - +export const codiconsDerived = { dialogError: register('dialog-error', 'error'), dialogWarning: register('dialog-warning', 'warning'), dialogInfo: register('dialog-info', 'info'), dialogClose: register('dialog-close', 'close'), - treeItemExpanded: register('tree-item-expanded', 'chevron-down'), // collapsed is done with rotation - treeFilterOnTypeOn: register('tree-filter-on-type-on', 'list-filter'), treeFilterOnTypeOff: register('tree-filter-on-type-off', 'list-selection'), treeFilterClear: register('tree-filter-clear', 'close'), - treeItemLoading: register('tree-item-loading', 'loading'), - menuSelection: register('menu-selection', 'check'), menuSubmenu: register('menu-submenu', 'chevron-right'), - menuBarMore: register('menubar-more', 'more'), - scrollbarButtonLeft: register('scrollbar-button-left', 'triangle-left'), scrollbarButtonRight: register('scrollbar-button-right', 'triangle-right'), - scrollbarButtonUp: register('scrollbar-button-up', 'triangle-up'), scrollbarButtonDown: register('scrollbar-button-down', 'triangle-down'), - toolBarMore: register('toolbar-more', 'more'), - - quickInputBack: register('quick-input-back', 'arrow-left') + quickInputBack: register('quick-input-back', 'arrow-left'), + dropDownButton: register('drop-down-button', 0xeab4), + symbolCustomColor: register('symbol-customcolor', 0xeb5c), + exportIcon: register('export', 0xebac), + workspaceUnspecified: register('workspace-unspecified', 0xebc3), + newLine: register('newline', 0xebea), + thumbsDownFilled: register('thumbsdown-filled', 0xec13), + thumbsUpFilled: register('thumbsup-filled', 0xec14), + gitFetch: register('git-fetch', 0xec1d), + lightbulbSparkleAutofix: register('lightbulb-sparkle-autofix', 0xec1f), + debugBreakpointPending: register('debug-breakpoint-pending', 0xebd9), } as const; +/** + * The Codicon library is a set of default icons that are built-in in VS Code. + * + * In the product (outside of base) Codicons should only be used as defaults. In order to have all icons in VS Code + * themeable, component should define new, UI component specific icons using `iconRegistry.registerIcon`. + * In that call a Codicon can be named as default. + */ +export const Codicon = { + ...codiconsLibrary, + ...codiconsDerived + +} as const; diff --git a/src/vs/base/common/codiconsLibrary.ts b/src/vs/base/common/codiconsLibrary.ts new file mode 100644 index 0000000000000..c3528c268b989 --- /dev/null +++ b/src/vs/base/common/codiconsLibrary.ts @@ -0,0 +1,578 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +import { register } from 'vs/base/common/codiconsUtil'; + + +// This file is automatically generated by (microsoft/vscode-codicons)/scripts/export-to-ts.js +// Please don't edit it, as your changes will be overwritten. +// Instead, add mappings to codiconsDerived in codicons.ts. +export const codiconsLibrary = { + add: register('add', 0xea60), + plus: register('plus', 0xea60), + gistNew: register('gist-new', 0xea60), + repoCreate: register('repo-create', 0xea60), + lightbulb: register('lightbulb', 0xea61), + lightBulb: register('light-bulb', 0xea61), + repo: register('repo', 0xea62), + repoDelete: register('repo-delete', 0xea62), + gistFork: register('gist-fork', 0xea63), + repoForked: register('repo-forked', 0xea63), + gitPullRequest: register('git-pull-request', 0xea64), + gitPullRequestAbandoned: register('git-pull-request-abandoned', 0xea64), + recordKeys: register('record-keys', 0xea65), + keyboard: register('keyboard', 0xea65), + tag: register('tag', 0xea66), + gitPullRequestLabel: register('git-pull-request-label', 0xea66), + tagAdd: register('tag-add', 0xea66), + tagRemove: register('tag-remove', 0xea66), + person: register('person', 0xea67), + personFollow: register('person-follow', 0xea67), + personOutline: register('person-outline', 0xea67), + personFilled: register('person-filled', 0xea67), + gitBranch: register('git-branch', 0xea68), + gitBranchCreate: register('git-branch-create', 0xea68), + gitBranchDelete: register('git-branch-delete', 0xea68), + sourceControl: register('source-control', 0xea68), + mirror: register('mirror', 0xea69), + mirrorPublic: register('mirror-public', 0xea69), + star: register('star', 0xea6a), + starAdd: register('star-add', 0xea6a), + starDelete: register('star-delete', 0xea6a), + starEmpty: register('star-empty', 0xea6a), + comment: register('comment', 0xea6b), + commentAdd: register('comment-add', 0xea6b), + alert: register('alert', 0xea6c), + warning: register('warning', 0xea6c), + search: register('search', 0xea6d), + searchSave: register('search-save', 0xea6d), + logOut: register('log-out', 0xea6e), + signOut: register('sign-out', 0xea6e), + logIn: register('log-in', 0xea6f), + signIn: register('sign-in', 0xea6f), + eye: register('eye', 0xea70), + eyeUnwatch: register('eye-unwatch', 0xea70), + eyeWatch: register('eye-watch', 0xea70), + circleFilled: register('circle-filled', 0xea71), + primitiveDot: register('primitive-dot', 0xea71), + closeDirty: register('close-dirty', 0xea71), + debugBreakpoint: register('debug-breakpoint', 0xea71), + debugBreakpointDisabled: register('debug-breakpoint-disabled', 0xea71), + debugHint: register('debug-hint', 0xea71), + terminalDecorationSuccess: register('terminal-decoration-success', 0xea71), + primitiveSquare: register('primitive-square', 0xea72), + edit: register('edit', 0xea73), + pencil: register('pencil', 0xea73), + info: register('info', 0xea74), + issueOpened: register('issue-opened', 0xea74), + gistPrivate: register('gist-private', 0xea75), + gitForkPrivate: register('git-fork-private', 0xea75), + lock: register('lock', 0xea75), + mirrorPrivate: register('mirror-private', 0xea75), + close: register('close', 0xea76), + removeClose: register('remove-close', 0xea76), + x: register('x', 0xea76), + repoSync: register('repo-sync', 0xea77), + sync: register('sync', 0xea77), + clone: register('clone', 0xea78), + desktopDownload: register('desktop-download', 0xea78), + beaker: register('beaker', 0xea79), + microscope: register('microscope', 0xea79), + vm: register('vm', 0xea7a), + deviceDesktop: register('device-desktop', 0xea7a), + file: register('file', 0xea7b), + fileText: register('file-text', 0xea7b), + more: register('more', 0xea7c), + ellipsis: register('ellipsis', 0xea7c), + kebabHorizontal: register('kebab-horizontal', 0xea7c), + mailReply: register('mail-reply', 0xea7d), + reply: register('reply', 0xea7d), + organization: register('organization', 0xea7e), + organizationFilled: register('organization-filled', 0xea7e), + organizationOutline: register('organization-outline', 0xea7e), + newFile: register('new-file', 0xea7f), + fileAdd: register('file-add', 0xea7f), + newFolder: register('new-folder', 0xea80), + fileDirectoryCreate: register('file-directory-create', 0xea80), + trash: register('trash', 0xea81), + trashcan: register('trashcan', 0xea81), + history: register('history', 0xea82), + clock: register('clock', 0xea82), + folder: register('folder', 0xea83), + fileDirectory: register('file-directory', 0xea83), + symbolFolder: register('symbol-folder', 0xea83), + logoGithub: register('logo-github', 0xea84), + markGithub: register('mark-github', 0xea84), + github: register('github', 0xea84), + terminal: register('terminal', 0xea85), + console: register('console', 0xea85), + repl: register('repl', 0xea85), + zap: register('zap', 0xea86), + symbolEvent: register('symbol-event', 0xea86), + error: register('error', 0xea87), + stop: register('stop', 0xea87), + variable: register('variable', 0xea88), + symbolVariable: register('symbol-variable', 0xea88), + array: register('array', 0xea8a), + symbolArray: register('symbol-array', 0xea8a), + symbolModule: register('symbol-module', 0xea8b), + symbolPackage: register('symbol-package', 0xea8b), + symbolNamespace: register('symbol-namespace', 0xea8b), + symbolObject: register('symbol-object', 0xea8b), + symbolMethod: register('symbol-method', 0xea8c), + symbolFunction: register('symbol-function', 0xea8c), + symbolConstructor: register('symbol-constructor', 0xea8c), + symbolBoolean: register('symbol-boolean', 0xea8f), + symbolNull: register('symbol-null', 0xea8f), + symbolNumeric: register('symbol-numeric', 0xea90), + symbolNumber: register('symbol-number', 0xea90), + symbolStructure: register('symbol-structure', 0xea91), + symbolStruct: register('symbol-struct', 0xea91), + symbolParameter: register('symbol-parameter', 0xea92), + symbolTypeParameter: register('symbol-type-parameter', 0xea92), + symbolKey: register('symbol-key', 0xea93), + symbolText: register('symbol-text', 0xea93), + symbolReference: register('symbol-reference', 0xea94), + goToFile: register('go-to-file', 0xea94), + symbolEnum: register('symbol-enum', 0xea95), + symbolValue: register('symbol-value', 0xea95), + symbolRuler: register('symbol-ruler', 0xea96), + symbolUnit: register('symbol-unit', 0xea96), + activateBreakpoints: register('activate-breakpoints', 0xea97), + archive: register('archive', 0xea98), + arrowBoth: register('arrow-both', 0xea99), + arrowDown: register('arrow-down', 0xea9a), + arrowLeft: register('arrow-left', 0xea9b), + arrowRight: register('arrow-right', 0xea9c), + arrowSmallDown: register('arrow-small-down', 0xea9d), + arrowSmallLeft: register('arrow-small-left', 0xea9e), + arrowSmallRight: register('arrow-small-right', 0xea9f), + arrowSmallUp: register('arrow-small-up', 0xeaa0), + arrowUp: register('arrow-up', 0xeaa1), + bell: register('bell', 0xeaa2), + bold: register('bold', 0xeaa3), + book: register('book', 0xeaa4), + bookmark: register('bookmark', 0xeaa5), + debugBreakpointConditionalUnverified: register('debug-breakpoint-conditional-unverified', 0xeaa6), + debugBreakpointConditional: register('debug-breakpoint-conditional', 0xeaa7), + debugBreakpointConditionalDisabled: register('debug-breakpoint-conditional-disabled', 0xeaa7), + debugBreakpointDataUnverified: register('debug-breakpoint-data-unverified', 0xeaa8), + debugBreakpointData: register('debug-breakpoint-data', 0xeaa9), + debugBreakpointDataDisabled: register('debug-breakpoint-data-disabled', 0xeaa9), + debugBreakpointLogUnverified: register('debug-breakpoint-log-unverified', 0xeaaa), + debugBreakpointLog: register('debug-breakpoint-log', 0xeaab), + debugBreakpointLogDisabled: register('debug-breakpoint-log-disabled', 0xeaab), + briefcase: register('briefcase', 0xeaac), + broadcast: register('broadcast', 0xeaad), + browser: register('browser', 0xeaae), + bug: register('bug', 0xeaaf), + calendar: register('calendar', 0xeab0), + caseSensitive: register('case-sensitive', 0xeab1), + check: register('check', 0xeab2), + checklist: register('checklist', 0xeab3), + chevronDown: register('chevron-down', 0xeab4), + chevronLeft: register('chevron-left', 0xeab5), + chevronRight: register('chevron-right', 0xeab6), + chevronUp: register('chevron-up', 0xeab7), + chromeClose: register('chrome-close', 0xeab8), + chromeMaximize: register('chrome-maximize', 0xeab9), + chromeMinimize: register('chrome-minimize', 0xeaba), + chromeRestore: register('chrome-restore', 0xeabb), + circleOutline: register('circle-outline', 0xeabc), + circle: register('circle', 0xeabc), + debugBreakpointUnverified: register('debug-breakpoint-unverified', 0xeabc), + terminalDecorationIncomplete: register('terminal-decoration-incomplete', 0xeabc), + circleSlash: register('circle-slash', 0xeabd), + circuitBoard: register('circuit-board', 0xeabe), + clearAll: register('clear-all', 0xeabf), + clippy: register('clippy', 0xeac0), + closeAll: register('close-all', 0xeac1), + cloudDownload: register('cloud-download', 0xeac2), + cloudUpload: register('cloud-upload', 0xeac3), + code: register('code', 0xeac4), + collapseAll: register('collapse-all', 0xeac5), + colorMode: register('color-mode', 0xeac6), + commentDiscussion: register('comment-discussion', 0xeac7), + creditCard: register('credit-card', 0xeac9), + dash: register('dash', 0xeacc), + dashboard: register('dashboard', 0xeacd), + database: register('database', 0xeace), + debugContinue: register('debug-continue', 0xeacf), + debugDisconnect: register('debug-disconnect', 0xead0), + debugPause: register('debug-pause', 0xead1), + debugRestart: register('debug-restart', 0xead2), + debugStart: register('debug-start', 0xead3), + debugStepInto: register('debug-step-into', 0xead4), + debugStepOut: register('debug-step-out', 0xead5), + debugStepOver: register('debug-step-over', 0xead6), + debugStop: register('debug-stop', 0xead7), + debug: register('debug', 0xead8), + deviceCameraVideo: register('device-camera-video', 0xead9), + deviceCamera: register('device-camera', 0xeada), + deviceMobile: register('device-mobile', 0xeadb), + diffAdded: register('diff-added', 0xeadc), + diffIgnored: register('diff-ignored', 0xeadd), + diffModified: register('diff-modified', 0xeade), + diffRemoved: register('diff-removed', 0xeadf), + diffRenamed: register('diff-renamed', 0xeae0), + diff: register('diff', 0xeae1), + diffSidebyside: register('diff-sidebyside', 0xeae1), + discard: register('discard', 0xeae2), + editorLayout: register('editor-layout', 0xeae3), + emptyWindow: register('empty-window', 0xeae4), + exclude: register('exclude', 0xeae5), + extensions: register('extensions', 0xeae6), + eyeClosed: register('eye-closed', 0xeae7), + fileBinary: register('file-binary', 0xeae8), + fileCode: register('file-code', 0xeae9), + fileMedia: register('file-media', 0xeaea), + filePdf: register('file-pdf', 0xeaeb), + fileSubmodule: register('file-submodule', 0xeaec), + fileSymlinkDirectory: register('file-symlink-directory', 0xeaed), + fileSymlinkFile: register('file-symlink-file', 0xeaee), + fileZip: register('file-zip', 0xeaef), + files: register('files', 0xeaf0), + filter: register('filter', 0xeaf1), + flame: register('flame', 0xeaf2), + foldDown: register('fold-down', 0xeaf3), + foldUp: register('fold-up', 0xeaf4), + fold: register('fold', 0xeaf5), + folderActive: register('folder-active', 0xeaf6), + folderOpened: register('folder-opened', 0xeaf7), + gear: register('gear', 0xeaf8), + gift: register('gift', 0xeaf9), + gistSecret: register('gist-secret', 0xeafa), + gist: register('gist', 0xeafb), + gitCommit: register('git-commit', 0xeafc), + gitCompare: register('git-compare', 0xeafd), + compareChanges: register('compare-changes', 0xeafd), + gitMerge: register('git-merge', 0xeafe), + githubAction: register('github-action', 0xeaff), + githubAlt: register('github-alt', 0xeb00), + globe: register('globe', 0xeb01), + grabber: register('grabber', 0xeb02), + graph: register('graph', 0xeb03), + gripper: register('gripper', 0xeb04), + heart: register('heart', 0xeb05), + home: register('home', 0xeb06), + horizontalRule: register('horizontal-rule', 0xeb07), + hubot: register('hubot', 0xeb08), + inbox: register('inbox', 0xeb09), + issueReopened: register('issue-reopened', 0xeb0b), + issues: register('issues', 0xeb0c), + italic: register('italic', 0xeb0d), + jersey: register('jersey', 0xeb0e), + json: register('json', 0xeb0f), + kebabVertical: register('kebab-vertical', 0xeb10), + key: register('key', 0xeb11), + law: register('law', 0xeb12), + lightbulbAutofix: register('lightbulb-autofix', 0xeb13), + linkExternal: register('link-external', 0xeb14), + link: register('link', 0xeb15), + listOrdered: register('list-ordered', 0xeb16), + listUnordered: register('list-unordered', 0xeb17), + liveShare: register('live-share', 0xeb18), + loading: register('loading', 0xeb19), + location: register('location', 0xeb1a), + mailRead: register('mail-read', 0xeb1b), + mail: register('mail', 0xeb1c), + markdown: register('markdown', 0xeb1d), + megaphone: register('megaphone', 0xeb1e), + mention: register('mention', 0xeb1f), + milestone: register('milestone', 0xeb20), + gitPullRequestMilestone: register('git-pull-request-milestone', 0xeb20), + mortarBoard: register('mortar-board', 0xeb21), + move: register('move', 0xeb22), + multipleWindows: register('multiple-windows', 0xeb23), + mute: register('mute', 0xeb24), + noNewline: register('no-newline', 0xeb25), + note: register('note', 0xeb26), + octoface: register('octoface', 0xeb27), + openPreview: register('open-preview', 0xeb28), + package: register('package', 0xeb29), + paintcan: register('paintcan', 0xeb2a), + pin: register('pin', 0xeb2b), + play: register('play', 0xeb2c), + run: register('run', 0xeb2c), + plug: register('plug', 0xeb2d), + preserveCase: register('preserve-case', 0xeb2e), + preview: register('preview', 0xeb2f), + project: register('project', 0xeb30), + pulse: register('pulse', 0xeb31), + question: register('question', 0xeb32), + quote: register('quote', 0xeb33), + radioTower: register('radio-tower', 0xeb34), + reactions: register('reactions', 0xeb35), + references: register('references', 0xeb36), + refresh: register('refresh', 0xeb37), + regex: register('regex', 0xeb38), + remoteExplorer: register('remote-explorer', 0xeb39), + remote: register('remote', 0xeb3a), + remove: register('remove', 0xeb3b), + replaceAll: register('replace-all', 0xeb3c), + replace: register('replace', 0xeb3d), + repoClone: register('repo-clone', 0xeb3e), + repoForcePush: register('repo-force-push', 0xeb3f), + repoPull: register('repo-pull', 0xeb40), + repoPush: register('repo-push', 0xeb41), + report: register('report', 0xeb42), + requestChanges: register('request-changes', 0xeb43), + rocket: register('rocket', 0xeb44), + rootFolderOpened: register('root-folder-opened', 0xeb45), + rootFolder: register('root-folder', 0xeb46), + rss: register('rss', 0xeb47), + ruby: register('ruby', 0xeb48), + saveAll: register('save-all', 0xeb49), + saveAs: register('save-as', 0xeb4a), + save: register('save', 0xeb4b), + screenFull: register('screen-full', 0xeb4c), + screenNormal: register('screen-normal', 0xeb4d), + searchStop: register('search-stop', 0xeb4e), + server: register('server', 0xeb50), + settingsGear: register('settings-gear', 0xeb51), + settings: register('settings', 0xeb52), + shield: register('shield', 0xeb53), + smiley: register('smiley', 0xeb54), + sortPrecedence: register('sort-precedence', 0xeb55), + splitHorizontal: register('split-horizontal', 0xeb56), + splitVertical: register('split-vertical', 0xeb57), + squirrel: register('squirrel', 0xeb58), + starFull: register('star-full', 0xeb59), + starHalf: register('star-half', 0xeb5a), + symbolClass: register('symbol-class', 0xeb5b), + symbolColor: register('symbol-color', 0xeb5c), + symbolConstant: register('symbol-constant', 0xeb5d), + symbolEnumMember: register('symbol-enum-member', 0xeb5e), + symbolField: register('symbol-field', 0xeb5f), + symbolFile: register('symbol-file', 0xeb60), + symbolInterface: register('symbol-interface', 0xeb61), + symbolKeyword: register('symbol-keyword', 0xeb62), + symbolMisc: register('symbol-misc', 0xeb63), + symbolOperator: register('symbol-operator', 0xeb64), + symbolProperty: register('symbol-property', 0xeb65), + wrench: register('wrench', 0xeb65), + wrenchSubaction: register('wrench-subaction', 0xeb65), + symbolSnippet: register('symbol-snippet', 0xeb66), + tasklist: register('tasklist', 0xeb67), + telescope: register('telescope', 0xeb68), + textSize: register('text-size', 0xeb69), + threeBars: register('three-bars', 0xeb6a), + thumbsdown: register('thumbsdown', 0xeb6b), + thumbsup: register('thumbsup', 0xeb6c), + tools: register('tools', 0xeb6d), + triangleDown: register('triangle-down', 0xeb6e), + triangleLeft: register('triangle-left', 0xeb6f), + triangleRight: register('triangle-right', 0xeb70), + triangleUp: register('triangle-up', 0xeb71), + twitter: register('twitter', 0xeb72), + unfold: register('unfold', 0xeb73), + unlock: register('unlock', 0xeb74), + unmute: register('unmute', 0xeb75), + unverified: register('unverified', 0xeb76), + verified: register('verified', 0xeb77), + versions: register('versions', 0xeb78), + vmActive: register('vm-active', 0xeb79), + vmOutline: register('vm-outline', 0xeb7a), + vmRunning: register('vm-running', 0xeb7b), + watch: register('watch', 0xeb7c), + whitespace: register('whitespace', 0xeb7d), + wholeWord: register('whole-word', 0xeb7e), + window: register('window', 0xeb7f), + wordWrap: register('word-wrap', 0xeb80), + zoomIn: register('zoom-in', 0xeb81), + zoomOut: register('zoom-out', 0xeb82), + listFilter: register('list-filter', 0xeb83), + listFlat: register('list-flat', 0xeb84), + listSelection: register('list-selection', 0xeb85), + selection: register('selection', 0xeb85), + listTree: register('list-tree', 0xeb86), + debugBreakpointFunctionUnverified: register('debug-breakpoint-function-unverified', 0xeb87), + debugBreakpointFunction: register('debug-breakpoint-function', 0xeb88), + debugBreakpointFunctionDisabled: register('debug-breakpoint-function-disabled', 0xeb88), + debugStackframeActive: register('debug-stackframe-active', 0xeb89), + circleSmallFilled: register('circle-small-filled', 0xeb8a), + debugStackframeDot: register('debug-stackframe-dot', 0xeb8a), + terminalDecorationMark: register('terminal-decoration-mark', 0xeb8a), + debugStackframe: register('debug-stackframe', 0xeb8b), + debugStackframeFocused: register('debug-stackframe-focused', 0xeb8b), + debugBreakpointUnsupported: register('debug-breakpoint-unsupported', 0xeb8c), + symbolString: register('symbol-string', 0xeb8d), + debugReverseContinue: register('debug-reverse-continue', 0xeb8e), + debugStepBack: register('debug-step-back', 0xeb8f), + debugRestartFrame: register('debug-restart-frame', 0xeb90), + debugAlt: register('debug-alt', 0xeb91), + callIncoming: register('call-incoming', 0xeb92), + callOutgoing: register('call-outgoing', 0xeb93), + menu: register('menu', 0xeb94), + expandAll: register('expand-all', 0xeb95), + feedback: register('feedback', 0xeb96), + gitPullRequestReviewer: register('git-pull-request-reviewer', 0xeb96), + groupByRefType: register('group-by-ref-type', 0xeb97), + ungroupByRefType: register('ungroup-by-ref-type', 0xeb98), + account: register('account', 0xeb99), + gitPullRequestAssignee: register('git-pull-request-assignee', 0xeb99), + bellDot: register('bell-dot', 0xeb9a), + debugConsole: register('debug-console', 0xeb9b), + library: register('library', 0xeb9c), + output: register('output', 0xeb9d), + runAll: register('run-all', 0xeb9e), + syncIgnored: register('sync-ignored', 0xeb9f), + pinned: register('pinned', 0xeba0), + githubInverted: register('github-inverted', 0xeba1), + serverProcess: register('server-process', 0xeba2), + serverEnvironment: register('server-environment', 0xeba3), + pass: register('pass', 0xeba4), + issueClosed: register('issue-closed', 0xeba4), + stopCircle: register('stop-circle', 0xeba5), + playCircle: register('play-circle', 0xeba6), + record: register('record', 0xeba7), + debugAltSmall: register('debug-alt-small', 0xeba8), + vmConnect: register('vm-connect', 0xeba9), + cloud: register('cloud', 0xebaa), + merge: register('merge', 0xebab), + export: register('export', 0xebac), + graphLeft: register('graph-left', 0xebad), + magnet: register('magnet', 0xebae), + notebook: register('notebook', 0xebaf), + redo: register('redo', 0xebb0), + checkAll: register('check-all', 0xebb1), + pinnedDirty: register('pinned-dirty', 0xebb2), + passFilled: register('pass-filled', 0xebb3), + circleLargeFilled: register('circle-large-filled', 0xebb4), + circleLarge: register('circle-large', 0xebb5), + circleLargeOutline: register('circle-large-outline', 0xebb5), + combine: register('combine', 0xebb6), + gather: register('gather', 0xebb6), + table: register('table', 0xebb7), + variableGroup: register('variable-group', 0xebb8), + typeHierarchy: register('type-hierarchy', 0xebb9), + typeHierarchySub: register('type-hierarchy-sub', 0xebba), + typeHierarchySuper: register('type-hierarchy-super', 0xebbb), + gitPullRequestCreate: register('git-pull-request-create', 0xebbc), + runAbove: register('run-above', 0xebbd), + runBelow: register('run-below', 0xebbe), + notebookTemplate: register('notebook-template', 0xebbf), + debugRerun: register('debug-rerun', 0xebc0), + workspaceTrusted: register('workspace-trusted', 0xebc1), + workspaceUntrusted: register('workspace-untrusted', 0xebc2), + workspaceUnknown: register('workspace-unknown', 0xebc3), + terminalCmd: register('terminal-cmd', 0xebc4), + terminalDebian: register('terminal-debian', 0xebc5), + terminalLinux: register('terminal-linux', 0xebc6), + terminalPowershell: register('terminal-powershell', 0xebc7), + terminalTmux: register('terminal-tmux', 0xebc8), + terminalUbuntu: register('terminal-ubuntu', 0xebc9), + terminalBash: register('terminal-bash', 0xebca), + arrowSwap: register('arrow-swap', 0xebcb), + copy: register('copy', 0xebcc), + personAdd: register('person-add', 0xebcd), + filterFilled: register('filter-filled', 0xebce), + wand: register('wand', 0xebcf), + debugLineByLine: register('debug-line-by-line', 0xebd0), + inspect: register('inspect', 0xebd1), + layers: register('layers', 0xebd2), + layersDot: register('layers-dot', 0xebd3), + layersActive: register('layers-active', 0xebd4), + compass: register('compass', 0xebd5), + compassDot: register('compass-dot', 0xebd6), + compassActive: register('compass-active', 0xebd7), + azure: register('azure', 0xebd8), + issueDraft: register('issue-draft', 0xebd9), + gitPullRequestClosed: register('git-pull-request-closed', 0xebda), + gitPullRequestDraft: register('git-pull-request-draft', 0xebdb), + debugAll: register('debug-all', 0xebdc), + debugCoverage: register('debug-coverage', 0xebdd), + runErrors: register('run-errors', 0xebde), + folderLibrary: register('folder-library', 0xebdf), + debugContinueSmall: register('debug-continue-small', 0xebe0), + beakerStop: register('beaker-stop', 0xebe1), + graphLine: register('graph-line', 0xebe2), + graphScatter: register('graph-scatter', 0xebe3), + pieChart: register('pie-chart', 0xebe4), + bracket: register('bracket', 0xeb0f), + bracketDot: register('bracket-dot', 0xebe5), + bracketError: register('bracket-error', 0xebe6), + lockSmall: register('lock-small', 0xebe7), + azureDevops: register('azure-devops', 0xebe8), + verifiedFilled: register('verified-filled', 0xebe9), + newline: register('newline', 0xebea), + layout: register('layout', 0xebeb), + layoutActivitybarLeft: register('layout-activitybar-left', 0xebec), + layoutActivitybarRight: register('layout-activitybar-right', 0xebed), + layoutPanelLeft: register('layout-panel-left', 0xebee), + layoutPanelCenter: register('layout-panel-center', 0xebef), + layoutPanelJustify: register('layout-panel-justify', 0xebf0), + layoutPanelRight: register('layout-panel-right', 0xebf1), + layoutPanel: register('layout-panel', 0xebf2), + layoutSidebarLeft: register('layout-sidebar-left', 0xebf3), + layoutSidebarRight: register('layout-sidebar-right', 0xebf4), + layoutStatusbar: register('layout-statusbar', 0xebf5), + layoutMenubar: register('layout-menubar', 0xebf6), + layoutCentered: register('layout-centered', 0xebf7), + target: register('target', 0xebf8), + indent: register('indent', 0xebf9), + recordSmall: register('record-small', 0xebfa), + errorSmall: register('error-small', 0xebfb), + terminalDecorationError: register('terminal-decoration-error', 0xebfb), + arrowCircleDown: register('arrow-circle-down', 0xebfc), + arrowCircleLeft: register('arrow-circle-left', 0xebfd), + arrowCircleRight: register('arrow-circle-right', 0xebfe), + arrowCircleUp: register('arrow-circle-up', 0xebff), + layoutSidebarRightOff: register('layout-sidebar-right-off', 0xec00), + layoutPanelOff: register('layout-panel-off', 0xec01), + layoutSidebarLeftOff: register('layout-sidebar-left-off', 0xec02), + blank: register('blank', 0xec03), + heartFilled: register('heart-filled', 0xec04), + map: register('map', 0xec05), + mapHorizontal: register('map-horizontal', 0xec05), + foldHorizontal: register('fold-horizontal', 0xec05), + mapFilled: register('map-filled', 0xec06), + mapHorizontalFilled: register('map-horizontal-filled', 0xec06), + foldHorizontalFilled: register('fold-horizontal-filled', 0xec06), + circleSmall: register('circle-small', 0xec07), + bellSlash: register('bell-slash', 0xec08), + bellSlashDot: register('bell-slash-dot', 0xec09), + commentUnresolved: register('comment-unresolved', 0xec0a), + gitPullRequestGoToChanges: register('git-pull-request-go-to-changes', 0xec0b), + gitPullRequestNewChanges: register('git-pull-request-new-changes', 0xec0c), + searchFuzzy: register('search-fuzzy', 0xec0d), + commentDraft: register('comment-draft', 0xec0e), + send: register('send', 0xec0f), + sparkle: register('sparkle', 0xec10), + insert: register('insert', 0xec11), + mic: register('mic', 0xec12), + thumbsdownFilled: register('thumbsdown-filled', 0xec13), + thumbsupFilled: register('thumbsup-filled', 0xec14), + coffee: register('coffee', 0xec15), + snake: register('snake', 0xec16), + game: register('game', 0xec17), + vr: register('vr', 0xec18), + chip: register('chip', 0xec19), + piano: register('piano', 0xec1a), + music: register('music', 0xec1b), + micFilled: register('mic-filled', 0xec1c), + repoFetch: register('repo-fetch', 0xec1d), + copilot: register('copilot', 0xec1e), + lightbulbSparkle: register('lightbulb-sparkle', 0xec1f), + robot: register('robot', 0xec20), + sparkleFilled: register('sparkle-filled', 0xec21), + diffSingle: register('diff-single', 0xec22), + diffMultiple: register('diff-multiple', 0xec23), + surroundWith: register('surround-with', 0xec24), + share: register('share', 0xec25), + gitStash: register('git-stash', 0xec26), + gitStashApply: register('git-stash-apply', 0xec27), + gitStashPop: register('git-stash-pop', 0xec28), + vscode: register('vscode', 0xec29), + vscodeInsiders: register('vscode-insiders', 0xec2a), + codeOss: register('code-oss', 0xec2b), + runCoverage: register('run-coverage', 0xec2c), + runAllCoverage: register('run-all-coverage', 0xec2d), + coverage: register('coverage', 0xec2e), + githubProject: register('github-project', 0xec2f), + mapVertical: register('map-vertical', 0xec30), + foldVertical: register('fold-vertical', 0xec30), + mapVerticalFilled: register('map-vertical-filled', 0xec31), + foldVerticalFilled: register('fold-vertical-filled', 0xec31), +} as const; diff --git a/src/vs/base/common/codiconsUtil.ts b/src/vs/base/common/codiconsUtil.ts new file mode 100644 index 0000000000000..ce7f9b2dafb5d --- /dev/null +++ b/src/vs/base/common/codiconsUtil.ts @@ -0,0 +1,28 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +import { ThemeIcon } from 'vs/base/common/themables'; +import { isString } from 'vs/base/common/types'; + + +const _codiconFontCharacters: { [id: string]: number } = Object.create(null); + +export function register(id: string, fontCharacter: number | string): ThemeIcon { + if (isString(fontCharacter)) { + const val = _codiconFontCharacters[fontCharacter]; + if (val === undefined) { + throw new Error(`${id} references an unknown codicon: ${fontCharacter}`); + } + fontCharacter = val; + } + _codiconFontCharacters[id] = fontCharacter; + return { id }; +} + +/** + * Only to be used by the iconRegistry. + */ +export function getCodiconFontCharacters(): { [id: string]: number } { + return _codiconFontCharacters; +} diff --git a/src/vs/base/common/controlFlow.ts b/src/vs/base/common/controlFlow.ts new file mode 100644 index 0000000000000..2c4d020dd995d --- /dev/null +++ b/src/vs/base/common/controlFlow.ts @@ -0,0 +1,69 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +import { BugIndicatingError } from 'vs/base/common/errors'; + +/* + * This file contains helper classes to manage control flow. +*/ + +/** + * Prevents code from being re-entrant. +*/ +export class ReentrancyBarrier { + private _isOccupied = false; + + /** + * Calls `runner` if the barrier is not occupied. + * During the call, the barrier becomes occupied. + */ + public runExclusivelyOrSkip(runner: () => void): void { + if (this._isOccupied) { + return; + } + this._isOccupied = true; + try { + runner(); + } finally { + this._isOccupied = false; + } + } + + /** + * Calls `runner`. If the barrier is occupied, throws an error. + * During the call, the barrier becomes active. + */ + public runExclusivelyOrThrow(runner: () => void): void { + if (this._isOccupied) { + throw new BugIndicatingError(`ReentrancyBarrier: reentrant call detected!`); + } + this._isOccupied = true; + try { + runner(); + } finally { + this._isOccupied = false; + } + } + + /** + * Indicates if some runner occupies this barrier. + */ + public get isOccupied() { + return this._isOccupied; + } + + public makeExclusiveOrSkip(fn: TFunction): TFunction { + return ((...args: any[]) => { + if (this._isOccupied) { + return; + } + this._isOccupied = true; + try { + return fn(...args); + } finally { + this._isOccupied = false; + } + }) as any; + } +} diff --git a/src/vs/base/common/dataTransfer.ts b/src/vs/base/common/dataTransfer.ts index bed4238989755..9c9ac45640bbc 100644 --- a/src/vs/base/common/dataTransfer.ts +++ b/src/vs/base/common/dataTransfer.ts @@ -50,6 +50,7 @@ export interface IReadonlyVSDataTransfer extends Iterable value.trim()).filter(value => value.length > 0); + for (const value of values) { + switch (value) { + case 'Unity': { + const desktopSessionUnity = env['DESKTOP_SESSION']; + if (desktopSessionUnity && desktopSessionUnity.includes('gnome-fallback')) { + return DesktopEnvironment.GNOME; + } + + return DesktopEnvironment.UNITY; + } + case 'Deepin': + return DesktopEnvironment.DEEPIN; + case 'GNOME': + return DesktopEnvironment.GNOME; + case 'X-Cinnamon': + return DesktopEnvironment.CINNAMON; + case 'KDE': { + const kdeSession = env[kKDESessionEnvVar]; + if (kdeSession === '5') { return DesktopEnvironment.KDE5; } + if (kdeSession === '6') { return DesktopEnvironment.KDE6; } + return DesktopEnvironment.KDE4; + } + case 'Pantheon': + return DesktopEnvironment.PANTHEON; + case 'XFCE': + return DesktopEnvironment.XFCE; + case 'UKUI': + return DesktopEnvironment.UKUI; + case 'LXQt': + return DesktopEnvironment.LXQT; + } + } + } + + const desktopSession = env['DESKTOP_SESSION']; + if (desktopSession) { + switch (desktopSession) { + case 'deepin': + return DesktopEnvironment.DEEPIN; + case 'gnome': + case 'mate': + return DesktopEnvironment.GNOME; + case 'kde4': + case 'kde-plasma': + return DesktopEnvironment.KDE4; + case 'kde': + if (kKDESessionEnvVar in env) { + return DesktopEnvironment.KDE4; + } + return DesktopEnvironment.KDE3; + case 'xfce': + case 'xubuntu': + return DesktopEnvironment.XFCE; + case 'ukui': + return DesktopEnvironment.UKUI; + } + } + + if ('GNOME_DESKTOP_SESSION_ID' in env) { + return DesktopEnvironment.GNOME; + } + if ('KDE_FULL_SESSION' in env) { + if (kKDESessionEnvVar in env) { + return DesktopEnvironment.KDE4; + } + return DesktopEnvironment.KDE3; + } + + return DesktopEnvironment.UNKNOWN; +} diff --git a/src/vs/base/common/equals.ts b/src/vs/base/common/equals.ts new file mode 100644 index 0000000000000..02f943d69647f --- /dev/null +++ b/src/vs/base/common/equals.ts @@ -0,0 +1,38 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as arrays from 'vs/base/common/arrays'; + +export type EqualityComparer = (a: T, b: T) => boolean; +export const strictEquals: EqualityComparer = (a, b) => a === b; + +/** + * Checks if the items of two arrays are equal. + * By default, strict equality is used to compare elements, but a custom equality comparer can be provided. + */ +export function itemsEquals(itemEquals: EqualityComparer = strictEquals): EqualityComparer { + return (a, b) => arrays.equals(a, b, itemEquals); +} + +/** + * Two items are considered equal, if their stringified representations are equal. +*/ +export function jsonStringifyEquals(): EqualityComparer { + return (a, b) => JSON.stringify(a) === JSON.stringify(b); +} + +/** + * Uses `item.equals(other)` to determine equality. + */ +export function itemEquals(): EqualityComparer { + return (a, b) => a.equals(b); +} + +export function equalsIfDefined(v1: T | undefined, v2: T | undefined, equals: EqualityComparer): boolean { + if (!v1 || !v2) { + return v1 === v2; + } + return equals(v1, v2); +} diff --git a/src/vs/base/common/event.ts b/src/vs/base/common/event.ts index 7e5f3e90f2a8d..2ed7d930db146 100644 --- a/src/vs/base/common/event.ts +++ b/src/vs/base/common/event.ts @@ -13,19 +13,28 @@ import { StopWatch } from 'vs/base/common/stopwatch'; import { MicrotaskDelay } from 'vs/base/common/symbols'; +// ----------------------------------------------------------------------------------------------------------------------- +// Uncomment the next line to print warnings whenever a listener is GC'ed without having been disposed. This is a LEAK. +// ----------------------------------------------------------------------------------------------------------------------- +const _enableListenerGCedWarning = false + // || Boolean("TRUE") // causes a linter warning so that it cannot be pushed + ; + // ----------------------------------------------------------------------------------------------------------------------- // Uncomment the next line to print warnings whenever an emitter with listeners is disposed. That is a sign of code smell. // ----------------------------------------------------------------------------------------------------------------------- -const _enableDisposeWithListenerWarning = false; -// _enableDisposeWithListenerWarning = Boolean("TRUE"); // causes a linter warning so that it cannot be pushed +const _enableDisposeWithListenerWarning = false + // || Boolean("TRUE") // causes a linter warning so that it cannot be pushed + ; // ----------------------------------------------------------------------------------------------------------------------- // Uncomment the next line to print warnings whenever a snapshotted event is used repeatedly without cleanup. // See https://github.com/microsoft/vscode/issues/142851 // ----------------------------------------------------------------------------------------------------------------------- -const _enableSnapshotPotentialLeakWarning = false; -// _enableSnapshotPotentialLeakWarning = Boolean("TRUE"); // causes a linter warning so that it cannot be pushed +const _enableSnapshotPotentialLeakWarning = false + // || Boolean("TRUE") // causes a linter warning so that it cannot be pushed + ; /** * An event with zero or one parameters that can be subscribed to. The event is a function itself. @@ -911,6 +920,16 @@ const forEachListener = (listeners: ListenerOrListeners, fn: (c: ListenerC } }; + +const _listenerFinalizers = _enableListenerGCedWarning + ? new FinalizationRegistry(heldValue => { + if (typeof heldValue === 'string') { + console.warn('[LEAKING LISTENER] GC\'ed a listener that was NOT yet disposed. This is where is was created:'); + console.warn(heldValue); + } + }) + : undefined; + /** * The Emitter can be used to expose an Event to the public * to fire it from the insides. @@ -1054,13 +1073,23 @@ export class Emitter { this._size++; - const result = toDisposable(() => { removeMonitor?.(); this._removeListener(contained); }); + + const result = toDisposable(() => { + _listenerFinalizers?.unregister(result); + removeMonitor?.(); + this._removeListener(contained); + }); if (disposables instanceof DisposableStore) { disposables.add(result); } else if (Array.isArray(disposables)) { disposables.push(result); } + if (_listenerFinalizers) { + const stack = new Error().stack!.split('\n').slice(2).join('\n').trim(); + _listenerFinalizers.register(result, stack, result); + } + return result; }; diff --git a/src/vs/base/common/filters.ts b/src/vs/base/common/filters.ts index f7f0a43972f51..03cb85813cc83 100644 --- a/src/vs/base/common/filters.ts +++ b/src/vs/base/common/filters.ts @@ -280,8 +280,9 @@ export function matchesCamelCase(word: string, camelCaseWord: string): IMatch[] return null; } + // TODO: Consider removing this check if (camelCaseWord.length > 60) { - return null; + camelCaseWord = camelCaseWord.substring(0, 60); } const analysis = analyzeCamelCaseWord(camelCaseWord); diff --git a/src/vs/base/common/hierarchicalKind.ts b/src/vs/base/common/hierarchicalKind.ts new file mode 100644 index 0000000000000..a2edd6143754a --- /dev/null +++ b/src/vs/base/common/hierarchicalKind.ts @@ -0,0 +1,31 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +export class HierarchicalKind { + public static readonly sep = '.'; + + public static readonly None = new HierarchicalKind('@@none@@'); // Special kind that matches nothing + public static readonly Empty = new HierarchicalKind(''); + + constructor( + public readonly value: string + ) { } + + public equals(other: HierarchicalKind): boolean { + return this.value === other.value; + } + + public contains(other: HierarchicalKind): boolean { + return this.equals(other) || this.value === '' || other.value.startsWith(this.value + HierarchicalKind.sep); + } + + public intersects(other: HierarchicalKind): boolean { + return this.contains(other) || other.contains(this); + } + + public append(...parts: string[]): HierarchicalKind { + return new HierarchicalKind((this.value ? [this.value, ...parts] : parts).join(HierarchicalKind.sep)); + } +} diff --git a/src/vs/base/common/jsonSchema.ts b/src/vs/base/common/jsonSchema.ts index 81262c2f46aab..4216b0e5c0d80 100644 --- a/src/vs/base/common/jsonSchema.ts +++ b/src/vs/base/common/jsonSchema.ts @@ -99,3 +99,22 @@ export interface IJSONSchemaSnippet { body?: any; // a object that will be JSON stringified bodyText?: string; // an already stringified JSON object that can contain new lines (\n) and tabs (\t) } + +/** + * Converts a basic JSON schema to a TypeScript type. + * + * TODO: only supports basic schemas. Doesn't support all JSON schema features. + */ +export type SchemaToType = T extends { type: 'string' } + ? string + : T extends { type: 'number' } + ? number + : T extends { type: 'boolean' } + ? boolean + : T extends { type: 'null' } + ? null + : T extends { type: 'object'; properties: infer P } + ? { [K in keyof P]: SchemaToType } + : T extends { type: 'array'; items: infer I } + ? Array> + : never; diff --git a/src/vs/base/common/lifecycle.ts b/src/vs/base/common/lifecycle.ts index f298da62318a3..fcb5d2ec4e4bc 100644 --- a/src/vs/base/common/lifecycle.ts +++ b/src/vs/base/common/lifecycle.ts @@ -281,8 +281,8 @@ export interface IDisposable { /** * Check if `thing` is {@link IDisposable disposable}. */ -export function isDisposable(thing: E): thing is E & IDisposable { - return typeof (thing).dispose === 'function' && (thing).dispose.length === 0; +export function isDisposable(thing: E): thing is E & IDisposable { + return typeof thing === 'object' && thing !== null && typeof (thing).dispose === 'function' && (thing).dispose.length === 0; } /** @@ -561,7 +561,7 @@ export class MutableDisposable implements IDisposable { * exist and cannot be undefined. */ export class MandatoryMutableDisposable implements IDisposable { - private _disposable = new MutableDisposable(); + private readonly _disposable = new MutableDisposable(); private _isDisposed = false; constructor(initialValue: T) { diff --git a/src/vs/base/common/network.ts b/src/vs/base/common/network.ts index 3e0cc0071af57..3f017c151f3ed 100644 --- a/src/vs/base/common/network.ts +++ b/src/vs/base/common/network.ts @@ -7,6 +7,7 @@ import * as errors from 'vs/base/common/errors'; import * as platform from 'vs/base/common/platform'; import { equalsIgnoreCase, startsWithIgnoreCase } from 'vs/base/common/strings'; import { URI } from 'vs/base/common/uri'; +import * as paths from 'vs/base/common/path'; export namespace Schemas { @@ -111,6 +112,16 @@ export namespace Schemas { * Scheme used for the Source Control commit input's text document */ export const vscodeSourceControl = 'vscode-scm'; + + /** + * Scheme used for input box for creating comments. + */ + export const commentsInput = 'comment'; + + /** + * Scheme used for special rendering of settings in the release notes + */ + export const codeSetting = 'code-setting'; } export function matchesScheme(target: URI | string, scheme: string): boolean { @@ -134,7 +145,7 @@ class RemoteAuthoritiesImpl { private readonly _connectionTokens: { [authority: string]: string | undefined } = Object.create(null); private _preferredWebSchema: 'http' | 'https' = 'http'; private _delegate: ((uri: URI) => URI) | null = null; - private _remoteResourcesPath: string = `/${Schemas.vscodeRemoteResource}`; + private _serverRootPath: string = '/'; setPreferredWebSchema(schema: 'http' | 'https') { this._preferredWebSchema = schema; @@ -144,8 +155,16 @@ class RemoteAuthoritiesImpl { this._delegate = delegate; } - setServerRootPath(serverRootPath: string): void { - this._remoteResourcesPath = `${serverRootPath}/${Schemas.vscodeRemoteResource}`; + setServerRootPath(product: { quality?: string; commit?: string }, serverBasePath: string | undefined): void { + this._serverRootPath = getServerRootPath(product, serverBasePath); + } + + getServerRootPath(): string { + return this._serverRootPath; + } + + private get _remoteResourcesPath(): string { + return paths.posix.join(this._serverRootPath, Schemas.vscodeRemoteResource); } set(authority: string, host: string, port: number): void { @@ -192,6 +211,10 @@ class RemoteAuthoritiesImpl { export const RemoteAuthorities = new RemoteAuthoritiesImpl(); +export function getServerRootPath(product: { quality?: string; commit?: string }, basePath: string | undefined): string { + return paths.posix.join(basePath ?? '/', `${product.quality ?? 'oss'}-${product.commit ?? 'dev'}`); +} + /** * A string pointing to a path inside the app. It should not begin with ./ or ../ */ diff --git a/src/vs/base/common/observable.ts b/src/vs/base/common/observable.ts index 033cecf010aa5..155ad2aa4f6de 100644 --- a/src/vs/base/common/observable.ts +++ b/src/vs/base/common/observable.ts @@ -45,13 +45,24 @@ export { observableFromPromise, observableSignal, observableSignalFromEvent, - waitForState, wasEventTriggeredRecently, } from 'vs/base/common/observableInternal/utils'; +export { + ObservableLazy, + ObservableLazyPromise, + ObservablePromise, + PromiseResult, + waitForState, + derivedWithCancellationToken, +} from 'vs/base/common/observableInternal/promise'; import { ConsoleObservableLogger, setLogger } from 'vs/base/common/observableInternal/logging'; -const enableLogging = false; +// Remove "//" in the next line to enable logging +const enableLogging = false + // || Boolean("true") // done "weirdly" so that a lint warning prevents you from pushing this + ; + if (enableLogging) { setLogger(new ConsoleObservableLogger()); } diff --git a/src/vs/base/common/observableInternal/autorun.ts b/src/vs/base/common/observableInternal/autorun.ts index 6e7fdcf6d52a0..6c14cb20c5bd1 100644 --- a/src/vs/base/common/observableInternal/autorun.ts +++ b/src/vs/base/common/observableInternal/autorun.ts @@ -5,7 +5,8 @@ import { assertFn } from 'vs/base/common/assert'; import { DisposableStore, IDisposable, markAsDisposed, toDisposable, trackDisposable } from 'vs/base/common/lifecycle'; -import { IReader, IObservable, IObserver, IChangeContext, getFunctionName } from 'vs/base/common/observableInternal/base'; +import { IReader, IObservable, IObserver, IChangeContext } from 'vs/base/common/observableInternal/base'; +import { DebugNameData, IDebugNameData } from 'vs/base/common/observableInternal/debugName'; import { getLogger } from 'vs/base/common/observableInternal/logging'; /** @@ -13,15 +14,25 @@ import { getLogger } from 'vs/base/common/observableInternal/logging'; * {@link fn} should start with a JS Doc using `@description` to name the autorun. */ export function autorun(fn: (reader: IReader) => void): IDisposable { - return new AutorunObserver(undefined, fn, undefined, undefined); + return new AutorunObserver( + new DebugNameData(undefined, undefined, fn), + fn, + undefined, + undefined + ); } /** * Runs immediately and whenever a transaction ends and an observed observable changed. * {@link fn} should start with a JS Doc using `@description` to name the autorun. */ -export function autorunOpts(options: { debugName?: string | (() => string | undefined) }, fn: (reader: IReader) => void): IDisposable { - return new AutorunObserver(options.debugName, fn, undefined, undefined); +export function autorunOpts(options: IDebugNameData & {}, fn: (reader: IReader) => void): IDisposable { + return new AutorunObserver( + new DebugNameData(options.owner, options.debugName, options.debugReferenceFn ?? fn), + fn, + undefined, + undefined + ); } /** @@ -36,22 +47,25 @@ export function autorunOpts(options: { debugName?: string | (() => string | unde * @see autorun */ export function autorunHandleChanges( - options: { - debugName?: string | (() => string | undefined); + options: IDebugNameData & { createEmptyChangeSummary?: () => TChangeSummary; handleChange: (context: IChangeContext, changeSummary: TChangeSummary) => boolean; }, fn: (reader: IReader, changeSummary: TChangeSummary) => void ): IDisposable { - return new AutorunObserver(options.debugName, fn, options.createEmptyChangeSummary, options.handleChange); + return new AutorunObserver( + new DebugNameData(options.owner, options.debugName, options.debugReferenceFn ?? fn), + fn, + options.createEmptyChangeSummary, + options.handleChange + ); } /** * @see autorunHandleChanges (but with a disposable store that is cleared before the next run or on dispose) */ export function autorunWithStoreHandleChanges( - options: { - debugName?: string | (() => string | undefined); + options: IDebugNameData & { createEmptyChangeSummary?: () => TChangeSummary; handleChange: (context: IChangeContext, changeSummary: TChangeSummary) => boolean; }, @@ -60,7 +74,9 @@ export function autorunWithStoreHandleChanges( const store = new DisposableStore(); const disposable = autorunHandleChanges( { - debugName: options.debugName ?? (() => getFunctionName(fn)), + owner: options.owner, + debugName: options.debugName, + debugReferenceFn: options.debugReferenceFn, createEmptyChangeSummary: options.createEmptyChangeSummary, handleChange: options.handleChange, }, @@ -82,7 +98,9 @@ export function autorunWithStore(fn: (reader: IReader, store: DisposableStore) = const store = new DisposableStore(); const disposable = autorunOpts( { - debugName: () => getFunctionName(fn) || '(anonymous)', + owner: undefined, + debugName: undefined, + debugReferenceFn: fn, }, reader => { store.clear(); @@ -95,6 +113,20 @@ export function autorunWithStore(fn: (reader: IReader, store: DisposableStore) = }); } +export function autorunDelta( + observable: IObservable, + handler: (args: { lastValue: T | undefined; newValue: T }) => void +): IDisposable { + let _lastValue: T | undefined; + return autorunOpts({ debugReferenceFn: handler }, (reader) => { + const newValue = observable.read(reader); + const lastValue = _lastValue; + _lastValue = newValue; + handler({ lastValue, newValue }); + }); +} + + const enum AutorunState { /** * A dependency could have changed. @@ -118,21 +150,11 @@ export class AutorunObserver implements IObserver, IReader private changeSummary: TChangeSummary | undefined; public get debugName(): string { - if (typeof this._debugName === 'string') { - return this._debugName; - } - if (typeof this._debugName === 'function') { - const name = this._debugName(); - if (name !== undefined) { return name; } - } - const name = getFunctionName(this._runFn); - if (name !== undefined) { return name; } - - return '(anonymous)'; + return this._debugNameData.getDebugName(this) ?? '(anonymous)'; } constructor( - private readonly _debugName: string | (() => string | undefined) | undefined, + private readonly _debugNameData: DebugNameData, public readonly _runFn: (reader: IReader, changeSummary: TChangeSummary) => void, private readonly createChangeSummary: (() => TChangeSummary) | undefined, private readonly _handleChange: ((context: IChangeContext, summary: TChangeSummary) => boolean) | undefined, @@ -257,16 +279,3 @@ export class AutorunObserver implements IObserver, IReader export namespace autorun { export const Observer = AutorunObserver; } - -export function autorunDelta( - observable: IObservable, - handler: (args: { lastValue: T | undefined; newValue: T }) => void -): IDisposable { - let _lastValue: T | undefined; - return autorunOpts({ debugName: () => getFunctionName(handler) }, (reader) => { - const newValue = observable.read(reader); - const lastValue = _lastValue; - _lastValue = newValue; - handler({ lastValue, newValue }); - }); -} diff --git a/src/vs/base/common/observableInternal/base.ts b/src/vs/base/common/observableInternal/base.ts index 45e6f0ef2011f..9f8057ff07300 100644 --- a/src/vs/base/common/observableInternal/base.ts +++ b/src/vs/base/common/observableInternal/base.ts @@ -3,8 +3,10 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { strictEquals, EqualityComparer } from 'vs/base/common/equals'; import { DisposableStore, IDisposable } from 'vs/base/common/lifecycle'; import { keepObserved, recomputeInitiallyAndOnChange } from 'vs/base/common/observable'; +import { DebugNameData, IDebugNameData, Owner, getFunctionName } from 'vs/base/common/observableInternal/debugName'; import type { derivedOpts } from 'vs/base/common/observableInternal/derived'; import { getLogger } from 'vs/base/common/observableInternal/logging'; @@ -224,6 +226,7 @@ export abstract class ConvenientObservable implements IObservable fn(this.read(reader), reader), ); @@ -354,105 +357,6 @@ export class TransactionImpl implements ITransaction { } } -/** - * The owner object of an observable. - * Is only used for debugging purposes, such as computing a name for the observable by iterating over the fields of the owner. - */ -export type Owner = object | undefined; -export type DebugNameFn = string | (() => string | undefined); - -const countPerName = new Map(); -const cachedDebugName = new WeakMap(); - -export function getDebugName(self: object, debugNameFn: DebugNameFn | undefined, fn: Function | undefined, owner: Owner): string | undefined { - const cached = cachedDebugName.get(self); - if (cached) { - return cached; - } - - const dbgName = computeDebugName(self, debugNameFn, fn, owner); - if (dbgName) { - let count = countPerName.get(dbgName) ?? 0; - count++; - countPerName.set(dbgName, count); - const result = count === 1 ? dbgName : `${dbgName}#${count}`; - cachedDebugName.set(self, result); - return result; - } - return undefined; -} - -function computeDebugName(self: object, debugNameFn: DebugNameFn | undefined, fn: Function | undefined, owner: Owner): string | undefined { - const cached = cachedDebugName.get(self); - if (cached) { - return cached; - } - - const ownerStr = owner ? formatOwner(owner) + `.` : ''; - - let result: string | undefined; - if (debugNameFn !== undefined) { - if (typeof debugNameFn === 'function') { - result = debugNameFn(); - if (result !== undefined) { - return ownerStr + result; - } - } else { - return ownerStr + debugNameFn; - } - } - - if (fn !== undefined) { - result = getFunctionName(fn); - if (result !== undefined) { - return ownerStr + result; - } - } - - if (owner !== undefined) { - for (const key in owner) { - if ((owner as any)[key] === self) { - return ownerStr + key; - } - } - } - return undefined; -} - -const countPerClassName = new Map(); -const ownerId = new WeakMap(); - -function formatOwner(owner: object): string { - const id = ownerId.get(owner); - if (id) { - return id; - } - const className = getClassName(owner); - let count = countPerClassName.get(className) ?? 0; - count++; - countPerClassName.set(className, count); - const result = count === 1 ? className : `${className}#${count}`; - ownerId.set(owner, result); - return result; -} - -function getClassName(obj: object): string { - const ctor = obj.constructor; - if (ctor) { - return ctor.name; - } - return 'Object'; -} - -export function getFunctionName(fn: Function): string | undefined { - const fnSrc = fn.toString(); - // Pattern: /** @description ... */ - const regexp = /\/\*\*\s*@description\s*([^*]*)\*\//; - const match = regexp.exec(fnSrc); - const result = match ? match[1] : undefined; - return result?.trim(); -} - /** * A settable observable. */ @@ -468,11 +372,26 @@ export interface ISettableObservable extends IObservable(name: string, initialValue: T): ISettableObservable; export function observableValue(owner: object, initialValue: T): ISettableObservable; export function observableValue(nameOrOwner: string | object, initialValue: T): ISettableObservable { + let debugNameData: DebugNameData; if (typeof nameOrOwner === 'string') { - return new ObservableValue(undefined, nameOrOwner, initialValue); + debugNameData = new DebugNameData(undefined, nameOrOwner, undefined); } else { - return new ObservableValue(nameOrOwner, undefined, initialValue); + debugNameData = new DebugNameData(nameOrOwner, undefined, undefined); } + return new ObservableValue(debugNameData, initialValue, strictEquals); +} + +export function observableValueOpts( + options: IDebugNameData & { + equalsFn?: EqualityComparer; + }, + initialValue: T +): ISettableObservable { + return new ObservableValue( + new DebugNameData(options.owner, options.debugName, undefined), + initialValue, + options.equalsFn ?? strictEquals, + ); } export class ObservableValue @@ -481,23 +400,23 @@ export class ObservableValue protected _value: T; get debugName() { - return getDebugName(this, this._debugName, undefined, this._owner) ?? 'ObservableValue'; + return this._debugNameData.getDebugName(this) ?? 'ObservableValue'; } constructor( - private readonly _owner: Owner, - private readonly _debugName: string | undefined, - initialValue: T + private readonly _debugNameData: DebugNameData, + initialValue: T, + private readonly _equalityComparator: EqualityComparer, ) { super(); this._value = initialValue; } - public get(): T { + public override get(): T { return this._value; } public set(value: T, tx: ITransaction | undefined, change: TChange): void { - if (this._value === value) { + if (this._equalityComparator(this._value, value)) { return; } @@ -535,11 +454,13 @@ export class ObservableValue * When a new value is set, the previous value is disposed. */ export function disposableObservableValue(nameOrOwner: string | object, initialValue: T): ISettableObservable & IDisposable { + let debugNameData: DebugNameData; if (typeof nameOrOwner === 'string') { - return new DisposableObservableValue(undefined, nameOrOwner, initialValue); + debugNameData = new DebugNameData(undefined, nameOrOwner, undefined); } else { - return new DisposableObservableValue(nameOrOwner, undefined, initialValue); + debugNameData = new DebugNameData(nameOrOwner, undefined, undefined); } + return new DisposableObservableValue(debugNameData, initialValue, strictEquals); } export class DisposableObservableValue extends ObservableValue implements IDisposable { diff --git a/src/vs/base/common/observableInternal/debugName.ts b/src/vs/base/common/observableInternal/debugName.ts new file mode 100644 index 0000000000000..481d24f03777d --- /dev/null +++ b/src/vs/base/common/observableInternal/debugName.ts @@ -0,0 +1,145 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +export interface IDebugNameData { + /** + * The owner object of an observable. + * Used for debugging only, such as computing a name for the observable by iterating over the fields of the owner. + */ + readonly owner?: Owner | undefined; + + /** + * A string or function that returns a string that represents the name of the observable. + * Used for debugging only. + */ + readonly debugName?: DebugNameSource | undefined; + + /** + * A function that points to the defining function of the object. + * Used for debugging only. + */ + readonly debugReferenceFn?: Function | undefined; +} + +export class DebugNameData { + constructor( + public readonly owner: Owner | undefined, + public readonly debugNameSource: DebugNameSource | undefined, + public readonly referenceFn: Function | undefined, + ) { } + + public getDebugName(target: object): string | undefined { + return getDebugName(target, this); + } +} + +/** + * The owner object of an observable. + * Is only used for debugging purposes, such as computing a name for the observable by iterating over the fields of the owner. + */ +export type Owner = object | undefined; +export type DebugNameSource = string | (() => string | undefined); + +const countPerName = new Map(); +const cachedDebugName = new WeakMap(); + +export function getDebugName(target: object, data: DebugNameData): string | undefined { + const cached = cachedDebugName.get(target); + if (cached) { + return cached; + } + + const dbgName = computeDebugName(target, data); + if (dbgName) { + let count = countPerName.get(dbgName) ?? 0; + count++; + countPerName.set(dbgName, count); + const result = count === 1 ? dbgName : `${dbgName}#${count}`; + cachedDebugName.set(target, result); + return result; + } + return undefined; +} + +function computeDebugName(self: object, data: DebugNameData): string | undefined { + const cached = cachedDebugName.get(self); + if (cached) { + return cached; + } + + const ownerStr = data.owner ? formatOwner(data.owner) + `.` : ''; + + let result: string | undefined; + const debugNameSource = data.debugNameSource; + if (debugNameSource !== undefined) { + if (typeof debugNameSource === 'function') { + result = debugNameSource(); + if (result !== undefined) { + return ownerStr + result; + } + } else { + return ownerStr + debugNameSource; + } + } + + const referenceFn = data.referenceFn; + if (referenceFn !== undefined) { + result = getFunctionName(referenceFn); + if (result !== undefined) { + return ownerStr + result; + } + } + + if (data.owner !== undefined) { + const key = findKey(data.owner, self); + if (key !== undefined) { + return ownerStr + key; + } + } + return undefined; +} + +function findKey(obj: object, value: object): string | undefined { + for (const key in obj) { + if ((obj as any)[key] === value) { + return key; + } + } + return undefined; +} + +const countPerClassName = new Map(); +const ownerId = new WeakMap(); + +function formatOwner(owner: object): string { + const id = ownerId.get(owner); + if (id) { + return id; + } + const className = getClassName(owner); + let count = countPerClassName.get(className) ?? 0; + count++; + countPerClassName.set(className, count); + const result = count === 1 ? className : `${className}#${count}`; + ownerId.set(owner, result); + return result; +} + +function getClassName(obj: object): string { + const ctor = obj.constructor; + if (ctor) { + return ctor.name; + } + return 'Object'; +} + +export function getFunctionName(fn: Function): string | undefined { + const fnSrc = fn.toString(); + // Pattern: /** @description ... */ + const regexp = /\/\*\*\s*@description\s*([^*]*)\*\//; + const match = regexp.exec(fnSrc); + const result = match ? match[1] : undefined; + return result?.trim(); +} diff --git a/src/vs/base/common/observableInternal/derived.ts b/src/vs/base/common/observableInternal/derived.ts index 573b5ad5258ca..61a6138a0833e 100644 --- a/src/vs/base/common/observableInternal/derived.ts +++ b/src/vs/base/common/observableInternal/derived.ts @@ -4,13 +4,12 @@ *--------------------------------------------------------------------------------------------*/ import { assertFn } from 'vs/base/common/assert'; +import { strictEquals, EqualityComparer } from 'vs/base/common/equals'; import { DisposableStore, IDisposable } from 'vs/base/common/lifecycle'; -import { BaseObservable, DebugNameFn, IChangeContext, IObservable, IObserver, IReader, Owner, _setDerivedOpts, getDebugName, getFunctionName } from 'vs/base/common/observableInternal/base'; +import { BaseObservable, IChangeContext, IObservable, IObserver, IReader, _setDerivedOpts, } from 'vs/base/common/observableInternal/base'; +import { DebugNameData, IDebugNameData, Owner } from 'vs/base/common/observableInternal/debugName'; import { getLogger } from 'vs/base/common/observableInternal/logging'; -export type EqualityComparer = (a: T, b: T) => boolean; -const defaultEqualityComparer: EqualityComparer = (a, b) => a === b; - /** * Creates an observable that is derived from other observables. * The value is only recomputed when absolutely needed. @@ -18,26 +17,47 @@ const defaultEqualityComparer: EqualityComparer = (a, b) => a === b; * {@link computeFn} should start with a JS Doc using `@description` to name the derived. */ export function derived(computeFn: (reader: IReader) => T): IObservable; -export function derived(owner: object, computeFn: (reader: IReader) => T): IObservable; -export function derived(computeFnOrOwner: ((reader: IReader) => T) | object, computeFn?: ((reader: IReader) => T) | undefined): IObservable { +export function derived(owner: Owner, computeFn: (reader: IReader) => T): IObservable; +export function derived(computeFnOrOwner: ((reader: IReader) => T) | Owner, computeFn?: ((reader: IReader) => T) | undefined): IObservable { if (computeFn !== undefined) { - return new Derived(computeFnOrOwner, undefined, computeFn, undefined, undefined, undefined, defaultEqualityComparer); + return new Derived( + new DebugNameData(computeFnOrOwner, undefined, computeFn), + computeFn, + undefined, + undefined, + undefined, + strictEquals + ); } - return new Derived(undefined, undefined, computeFnOrOwner as any, undefined, undefined, undefined, defaultEqualityComparer); + return new Derived( + new DebugNameData(undefined, undefined, computeFnOrOwner as any), + computeFnOrOwner as any, + undefined, + undefined, + undefined, + strictEquals + ); } export function derivedOpts( - options: { - owner?: object; - debugName?: DebugNameFn; - equalityComparer?: EqualityComparer; + options: IDebugNameData & { + equalsFn?: EqualityComparer; onLastObserverRemoved?: (() => void); }, computeFn: (reader: IReader) => T ): IObservable { - return new Derived(options.owner, options.debugName, computeFn, undefined, undefined, options.onLastObserverRemoved, options.equalityComparer ?? defaultEqualityComparer); + return new Derived( + new DebugNameData(options.owner, options.debugName, options.debugReferenceFn), + computeFn, + undefined, + undefined, + options.onLastObserverRemoved, + options.equalsFn ?? strictEquals + ); } +_setDerivedOpts(derivedOpts); + /** * Represents an observable that is derived from other observables. * The value is only recomputed when absolutely needed. @@ -52,16 +72,21 @@ export function derivedOpts( * @see derived */ export function derivedHandleChanges( - options: { - owner?: object; - debugName?: string | (() => string); + options: IDebugNameData & { createEmptyChangeSummary: () => TChangeSummary; handleChange: (context: IChangeContext, changeSummary: TChangeSummary) => boolean; equalityComparer?: EqualityComparer; }, computeFn: (reader: IReader, changeSummary: TChangeSummary) => T ): IObservable { - return new Derived(options.owner, options.debugName, computeFn, options.createEmptyChangeSummary, options.handleChange, undefined, options.equalityComparer ?? defaultEqualityComparer); + return new Derived( + new DebugNameData(options.owner, options.debugName, undefined), + computeFn, + options.createEmptyChangeSummary, + options.handleChange, + undefined, + options.equalityComparer ?? strictEquals + ); } export function derivedWithStore(computeFn: (reader: IReader, store: DisposableStore) => T): IObservable; @@ -79,15 +104,14 @@ export function derivedWithStore(computeFnOrOwner: ((reader: IReader, store: const store = new DisposableStore(); return new Derived( - owner, - (() => getFunctionName(computeFn) ?? '(anonymous)'), + new DebugNameData(owner, undefined, computeFn), r => { store.clear(); return computeFn(r, store); }, undefined, undefined, () => store.dispose(), - defaultEqualityComparer + strictEquals ); } @@ -106,8 +130,7 @@ export function derivedDisposable(computeFnOr const store = new DisposableStore(); return new Derived( - owner, - (() => getFunctionName(computeFn) ?? '(anonymous)'), + new DebugNameData(owner, undefined, computeFn), r => { store.clear(); const result = computeFn(r); @@ -118,12 +141,10 @@ export function derivedDisposable(computeFnOr }, undefined, undefined, () => store.dispose(), - defaultEqualityComparer + strictEquals ); } -_setDerivedOpts(derivedOpts); - const enum DerivedState { /** Initial state, no previous value, recomputation needed */ initial = 0, @@ -155,12 +176,11 @@ export class Derived extends BaseObservable im private changeSummary: TChangeSummary | undefined = undefined; public override get debugName(): string { - return getDebugName(this, this._debugName, this._computeFn, this._owner) ?? '(anonymous)'; + return this._debugNameData.getDebugName(this) ?? '(anonymous)'; } constructor( - private readonly _owner: Owner, - private readonly _debugName: DebugNameFn | undefined, + private readonly _debugNameData: DebugNameData, public readonly _computeFn: (reader: IReader, changeSummary: TChangeSummary) => T, private readonly createChangeSummary: (() => TChangeSummary) | undefined, private readonly _handleChange: ((context: IChangeContext, summary: TChangeSummary) => boolean) | undefined, diff --git a/src/vs/base/common/observableInternal/promise.ts b/src/vs/base/common/observableInternal/promise.ts new file mode 100644 index 0000000000000..46d594914d849 --- /dev/null +++ b/src/vs/base/common/observableInternal/promise.ts @@ -0,0 +1,187 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +import { autorun } from 'vs/base/common/observableInternal/autorun'; +import { IObservable, IReader, observableValue, transaction } from './base'; +import { Derived, derived } from 'vs/base/common/observableInternal/derived'; +import { CancellationToken, CancellationTokenSource } from 'vs/base/common/cancellation'; +import { DebugNameData, Owner } from 'vs/base/common/observableInternal/debugName'; +import { strictEquals } from 'vs/base/common/equals'; + +export class ObservableLazy { + private readonly _value = observableValue(this, undefined); + + /** + * The cached value. + * Does not force a computation of the value. + */ + public get cachedValue(): IObservable { return this._value; } + + constructor(private readonly _computeValue: () => T) { + } + + /** + * Returns the cached value. + * Computes the value if the value has not been cached yet. + */ + public getValue() { + let v = this._value.get(); + if (!v) { + v = this._computeValue(); + this._value.set(v, undefined); + } + return v; + } +} + +/** + * A promise whose state is observable. + */ +export class ObservablePromise { + private readonly _value = observableValue | undefined>(this, undefined); + + /** + * The promise that this object wraps. + */ + public readonly promise: Promise; + + /** + * The current state of the promise. + * Is `undefined` if the promise didn't resolve yet. + */ + public readonly promiseResult: IObservable | undefined> = this._value; + + constructor(promise: Promise) { + this.promise = promise.then(value => { + transaction(tx => { + /** @description onPromiseResolved */ + this._value.set(new PromiseResult(value, undefined), tx); + }); + return value; + }, error => { + transaction(tx => { + /** @description onPromiseRejected */ + this._value.set(new PromiseResult(undefined, error), tx); + }); + throw error; + }); + } +} + +export class PromiseResult { + constructor( + /** + * The value of the resolved promise. + * Undefined if the promise rejected. + */ + public readonly data: T | undefined, + + /** + * The error in case of a rejected promise. + * Undefined if the promise resolved. + */ + public readonly error: unknown | undefined, + ) { + } + + /** + * Returns the value if the promise resolved, otherwise throws the error. + */ + public getDataOrThrow(): T { + if (this.error) { + throw this.error; + } + return this.data!; + } +} + +/** + * A lazy promise whose state is observable. + */ +export class ObservableLazyPromise { + private readonly _lazyValue = new ObservableLazy(() => new ObservablePromise(this._computePromise())); + + /** + * Does not enforce evaluation of the promise compute function. + * Is undefined if the promise has not been computed yet. + */ + public readonly cachedPromiseResult = derived(this, reader => this._lazyValue.cachedValue.read(reader)?.promiseResult.read(reader)); + + constructor(private readonly _computePromise: () => Promise) { + } + + public getPromise(): Promise { + return this._lazyValue.getValue().promise; + } +} + +/** + * Resolves the promise when the observables state matches the predicate. + */ +export function waitForState(observable: IObservable, predicate: (state: T) => state is TState, isError?: (state: T) => boolean | unknown | undefined): Promise; +export function waitForState(observable: IObservable, predicate: (state: T) => boolean, isError?: (state: T) => boolean | unknown | undefined): Promise; +export function waitForState(observable: IObservable, predicate: (state: T) => boolean, isError?: (state: T) => boolean | unknown | undefined): Promise { + return new Promise((resolve, reject) => { + let isImmediateRun = true; + let shouldDispose = false; + const stateObs = observable.map(state => { + /** @description waitForState.state */ + return { + isFinished: predicate(state), + error: isError ? isError(state) : false, + state + }; + }); + const d = autorun(reader => { + /** @description waitForState */ + const { isFinished, error, state } = stateObs.read(reader); + if (isFinished || error) { + if (isImmediateRun) { + // The variable `d` is not initialized yet + shouldDispose = true; + } else { + d.dispose(); + } + if (error) { + reject(error === true ? state : error); + } else { + resolve(state); + } + } + }); + isImmediateRun = false; + if (shouldDispose) { + d.dispose(); + } + }); +} + +export function derivedWithCancellationToken(computeFn: (reader: IReader, cancellationToken: CancellationToken) => T): IObservable; +export function derivedWithCancellationToken(owner: object, computeFn: (reader: IReader, cancellationToken: CancellationToken) => T): IObservable; +export function derivedWithCancellationToken(computeFnOrOwner: ((reader: IReader, cancellationToken: CancellationToken) => T) | object, computeFnOrUndefined?: ((reader: IReader, cancellationToken: CancellationToken) => T)): IObservable { + let computeFn: (reader: IReader, store: CancellationToken) => T; + let owner: Owner; + if (computeFnOrUndefined === undefined) { + computeFn = computeFnOrOwner as any; + owner = undefined; + } else { + owner = computeFnOrOwner; + computeFn = computeFnOrUndefined as any; + } + + let cancellationTokenSource: CancellationTokenSource | undefined = undefined; + return new Derived( + new DebugNameData(owner, undefined, computeFn), + r => { + if (cancellationTokenSource) { + cancellationTokenSource.dispose(true); + } + cancellationTokenSource = new CancellationTokenSource(); + return computeFn(r, cancellationTokenSource.token); + }, undefined, + undefined, + () => cancellationTokenSource?.dispose(), + strictEquals, + ); +} diff --git a/src/vs/base/common/observableInternal/utils.ts b/src/vs/base/common/observableInternal/utils.ts index 830785aab401f..a88b998339f2b 100644 --- a/src/vs/base/common/observableInternal/utils.ts +++ b/src/vs/base/common/observableInternal/utils.ts @@ -6,7 +6,8 @@ import { Event } from 'vs/base/common/event'; import { DisposableStore, IDisposable, toDisposable } from 'vs/base/common/lifecycle'; import { autorun } from 'vs/base/common/observableInternal/autorun'; -import { BaseObservable, ConvenientObservable, IObservable, IObserver, IReader, ITransaction, Owner, _setKeepObserved, _setRecomputeInitiallyAndOnChange, getDebugName, getFunctionName, observableValue, subtransaction, transaction } from 'vs/base/common/observableInternal/base'; +import { BaseObservable, ConvenientObservable, IObservable, IObserver, IReader, ITransaction, _setKeepObserved, _setRecomputeInitiallyAndOnChange, observableValue, subtransaction, transaction } from 'vs/base/common/observableInternal/base'; +import { DebugNameData, Owner, getFunctionName } from 'vs/base/common/observableInternal/debugName'; import { derived, derivedOpts } from 'vs/base/common/observableInternal/derived'; import { getLogger } from 'vs/base/common/observableInternal/logging'; @@ -50,32 +51,6 @@ export function observableFromPromise(promise: Promise): IObservable<{ val return observable; } -export function waitForState(observable: IObservable, predicate: (state: T) => state is TState): Promise; -export function waitForState(observable: IObservable, predicate: (state: T) => boolean): Promise; -export function waitForState(observable: IObservable, predicate: (state: T) => boolean): Promise { - return new Promise(resolve => { - let didRun = false; - let shouldDispose = false; - const stateObs = observable.map(state => ({ isFinished: predicate(state), state })); - const d = autorun(reader => { - /** @description waitForState */ - const { isFinished, state } = stateObs.read(reader); - if (isFinished) { - if (!didRun) { - shouldDispose = true; - } else { - d.dispose(); - } - resolve(state); - } - }); - didRun = true; - if (shouldDispose) { - d.dispose(); - } - }); -} - export function observableFromEvent( event: Event, getValue: (args: TArgs | undefined) => T @@ -249,7 +224,7 @@ export interface IObservableSignal extends IObservable { class ObservableSignal extends BaseObservable implements IObservableSignal { public get debugName() { - return getDebugName(this, this._debugName, undefined, this._owner) ?? 'Observable Signal'; + return new DebugNameData(this._owner, this._debugName, undefined).getDebugName(this) ?? 'Observable Signal'; } constructor( @@ -278,6 +253,9 @@ class ObservableSignal extends BaseObservable implements } } +/** + * @deprecated Use `debouncedObservable2` instead. + */ export function debouncedObservable(observable: IObservable, debounceMs: number, disposableStore: DisposableStore): IObservable { const debouncedObservable = observableValue('debounced', undefined); @@ -301,6 +279,48 @@ export function debouncedObservable(observable: IObservable, debounceMs: n return debouncedObservable; } +/** + * Creates an observable that debounces the input observable. + */ +export function debouncedObservable2(observable: IObservable, debounceMs: number): IObservable { + let hasValue = false; + let lastValue: T | undefined; + + let timeout: any = undefined; + + return observableFromEvent(cb => { + const d = autorun(reader => { + const value = observable.read(reader); + + if (!hasValue) { + hasValue = true; + lastValue = value; + } else { + if (timeout) { + clearTimeout(timeout); + } + timeout = setTimeout(() => { + lastValue = value; + cb(); + }, debounceMs); + } + }); + return { + dispose() { + d.dispose(); + hasValue = false; + lastValue = undefined; + }, + }; + }, () => { + if (hasValue) { + return lastValue!; + } else { + return observable.get(); + } + }); +} + export function wasEventTriggeredRecently(event: Event, timeoutMs: number, disposableStore: DisposableStore): IObservable { const observable = observableValue('triggeredRecently', false); @@ -352,7 +372,7 @@ export function recomputeInitiallyAndOnChange(observable: IObservable, han _setRecomputeInitiallyAndOnChange(recomputeInitiallyAndOnChange); -class KeepAliveObserver implements IObserver { +export class KeepAliveObserver implements IObserver { private _counter = 0; constructor( @@ -384,9 +404,9 @@ class KeepAliveObserver implements IObserver { } } -export function derivedObservableWithCache(computeFn: (reader: IReader, lastValue: T | undefined) => T): IObservable { +export function derivedObservableWithCache(owner: Owner, computeFn: (reader: IReader, lastValue: T | undefined) => T): IObservable { let lastValue: T | undefined = undefined; - const observable = derived(reader => { + const observable = derived(owner, reader => { lastValue = computeFn(reader, lastValue); return lastValue; }); @@ -415,7 +435,7 @@ export function derivedObservableWithWritableCache(owner: object, computeFn: export function mapObservableArrayCached(owner: Owner, items: IObservable, map: (input: TIn, store: DisposableStore) => TOut, keySelector?: (input: TIn) => TKey): IObservable { let m = new ArrayMap(map, keySelector); const self = derivedOpts({ - debugName: () => getDebugName(m, undefined, map, owner), + debugReferenceFn: map, owner, onLastObserverRemoved: () => { m.dispose(); diff --git a/src/vs/base/common/platform.ts b/src/vs/base/common/platform.ts index 3893fbc6fcd4e..2251c7db5ba04 100644 --- a/src/vs/base/common/platform.ts +++ b/src/vs/base/common/platform.ts @@ -45,6 +45,7 @@ export interface INodeProcess { arch: string; env: IProcessEnvironment; versions?: { + node?: string; electron?: string; chrome?: string; }; @@ -60,7 +61,7 @@ let nodeProcess: INodeProcess | undefined = undefined; if (typeof $globalThis.vscode !== 'undefined' && typeof $globalThis.vscode.process !== 'undefined') { // Native environment (sandboxed) nodeProcess = $globalThis.vscode.process; -} else if (typeof process !== 'undefined') { +} else if (typeof process !== 'undefined' && typeof process?.versions?.node === 'string') { // Native environment (non-sandboxed) nodeProcess = process; } diff --git a/src/vs/base/common/product.ts b/src/vs/base/common/product.ts index cbd573fdc722f..4ec1f7e80449c 100644 --- a/src/vs/base/common/product.ts +++ b/src/vs/base/common/product.ts @@ -113,7 +113,7 @@ export interface IProductConfiguration { readonly webExtensionTips?: readonly string[]; readonly languageExtensionTips?: readonly string[]; readonly trustedExtensionUrlPublicKeys?: IStringDictionary; - readonly trustedExtensionAuthAccess?: readonly string[]; + readonly trustedExtensionAuthAccess?: string[] | IStringDictionary; readonly trustedExtensionProtocolHandlers?: readonly string[]; readonly commandPaletteSuggestedCommandIds?: string[]; @@ -189,6 +189,7 @@ export interface IProductConfiguration { readonly commonlyUsedSettings?: string[]; readonly aiGeneratedWorkspaceTrust?: IAiGeneratedWorkspaceTrust; readonly gitHubEntitlement?: IGitHubEntitlement; + readonly chatWelcomeView?: IChatWelcomeView; } export interface ITunnelApplicationConfig { @@ -302,3 +303,9 @@ export interface IGitHubEntitlement { confirmationMessage: string; confirmationAction: string; } + +export interface IChatWelcomeView { + welcomeViewId: string; + welcomeViewTitle: string; + welcomeViewContent: string; +} diff --git a/src/vs/base/common/strings.ts b/src/vs/base/common/strings.ts index 230e6eb7bdd49..050de0ca18137 100644 --- a/src/vs/base/common/strings.ts +++ b/src/vs/base/common/strings.ts @@ -254,6 +254,15 @@ export function splitLines(str: string): string[] { return str.split(/\r\n|\r|\n/); } +export function splitLinesIncludeSeparators(str: string): string[] { + const linesWithSeparators: string[] = []; + const splitLinesAndSeparators = str.split(/(\r\n|\r|\n)/); + for (let i = 0; i < Math.ceil(splitLinesAndSeparators.length / 2); i++) { + linesWithSeparators.push(splitLinesAndSeparators[2 * i] + (splitLinesAndSeparators[2 * i + 1] ?? '')); + } + return linesWithSeparators; +} + /** * Returns first index of the string that is not whitespace. * If string is empty or contains only whitespaces, returns -1 @@ -757,14 +766,29 @@ export function lcut(text: string, n: number, prefix = '') { } // Escape codes, compiled from https://invisible-island.net/xterm/ctlseqs/ctlseqs.html#h3-Functions-using-CSI-_-ordered-by-the-final-character_s_ -const CSI_SEQUENCE = /(:?\x1b\[|\x9B)[=?>!]?[\d;:]*["$#'* ]?[a-zA-Z@^`{}|~]/g; - // Plus additional markers for custom `\x1b]...\x07` instructions. -const CSI_CUSTOM_SEQUENCE = /\x1b\].*?\x07/g; +const CSI_SEQUENCE = /(:?(:?\x1b\[|\x9B)[=?>!]?[\d;:]*["$#'* ]?[a-zA-Z@^`{}|~])|(:?\x1b\].*?\x07)/g; + +/** Iterates over parts of a string with CSI sequences */ +export function* forAnsiStringParts(str: string) { + let last = 0; + for (const match of str.matchAll(CSI_SEQUENCE)) { + if (last !== match.index) { + yield { isCode: false, str: str.substring(last, match.index) }; + } + + yield { isCode: true, str: match[0] }; + last = match.index + match[0].length; + } + + if (last !== str.length) { + yield { isCode: false, str: str.substring(last) }; + } +} export function removeAnsiEscapeCodes(str: string): string { if (str) { - str = str.replace(CSI_SEQUENCE, '').replace(CSI_CUSTOM_SEQUENCE, ''); + str = str.replace(CSI_SEQUENCE, ''); } return str; diff --git a/src/vs/base/common/types.ts b/src/vs/base/common/types.ts index 1b9a1c0baad63..1acab57b75806 100644 --- a/src/vs/base/common/types.ts +++ b/src/vs/base/common/types.ts @@ -243,3 +243,8 @@ export type DeepRequiredNonNullable = { export type DeepPartial = { [P in keyof T]?: T[P] extends object ? DeepPartial : Partial; }; + +/** + * Represents a type that is a partial version of a given type `T`, except a subset. + */ +export type PartialExcept = Partial> & Pick; diff --git a/src/vs/base/node/extpath.ts b/src/vs/base/node/extpath.ts index ee8f3f4eb31db..a7ec9cf6d360c 100644 --- a/src/vs/base/node/extpath.ts +++ b/src/vs/base/node/extpath.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import * as fs from 'fs'; +import { CancellationToken } from 'vs/base/common/cancellation'; import { basename, dirname, join, normalize, sep } from 'vs/base/common/path'; import { isLinux } from 'vs/base/common/platform'; import { rtrim } from 'vs/base/common/strings'; @@ -58,7 +59,7 @@ export function realcaseSync(path: string): string | null { return null; } -export async function realcase(path: string): Promise { +export async function realcase(path: string, token?: CancellationToken): Promise { if (isLinux) { // This method is unsupported on OS that have case sensitive // file system where the same path can exist in different forms @@ -73,11 +74,15 @@ export async function realcase(path: string): Promise { const name = (basename(path) /* can be '' for windows drive letters */ || path).toLowerCase(); try { + if (token?.isCancellationRequested) { + return null; + } + const entries = await Promises.readdir(dir); const found = entries.filter(e => e.toLowerCase() === name); // use a case insensitive search if (found.length === 1) { // on a case sensitive filesystem we cannot determine here, whether the file exists or not, hence we need the 'file exists' precondition - const prefix = await realcase(dir); // recurse + const prefix = await realcase(dir, token); // recurse if (prefix) { return join(prefix, found[0]); } @@ -85,7 +90,7 @@ export async function realcase(path: string): Promise { // must be a case sensitive $filesystem const ix = found.indexOf(name); if (ix >= 0) { // case sensitive - const prefix = await realcase(dir); // recurse + const prefix = await realcase(dir, token); // recurse if (prefix) { return join(prefix, found[ix]); } diff --git a/src/vs/base/node/osDisplayProtocolInfo.ts b/src/vs/base/node/osDisplayProtocolInfo.ts new file mode 100644 index 0000000000000..c028dc8853669 --- /dev/null +++ b/src/vs/base/node/osDisplayProtocolInfo.ts @@ -0,0 +1,78 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { constants as FSConstants } from 'fs'; +import { access } from 'fs/promises'; +import { join } from 'vs/base/common/path'; +import { env } from 'vs/base/common/process'; + +const XDG_SESSION_TYPE = 'XDG_SESSION_TYPE'; +const WAYLAND_DISPLAY = 'WAYLAND_DISPLAY'; +const XDG_RUNTIME_DIR = 'XDG_RUNTIME_DIR'; + +const enum DisplayProtocolType { + Wayland = 'wayland', + XWayland = 'xwayland', + X11 = 'x11', + Unknown = 'unknown' +} + +export async function getDisplayProtocol(errorLogger: (error: any) => void): Promise { + const xdgSessionType = env[XDG_SESSION_TYPE]; + + if (xdgSessionType) { + // If XDG_SESSION_TYPE is set, return its value if it's either 'wayland' or 'x11'. + // We assume that any value other than 'wayland' or 'x11' is an error or unexpected, + // hence 'unknown' is returned. + return xdgSessionType === DisplayProtocolType.Wayland || xdgSessionType === DisplayProtocolType.X11 ? xdgSessionType : DisplayProtocolType.Unknown; + } else { + const waylandDisplay = env[WAYLAND_DISPLAY]; + + if (!waylandDisplay) { + // If WAYLAND_DISPLAY is empty, then the session is x11. + return DisplayProtocolType.X11; + } else { + const xdgRuntimeDir = env[XDG_RUNTIME_DIR]; + + if (!xdgRuntimeDir) { + // If XDG_RUNTIME_DIR is empty, then the session can only be guessed. + return DisplayProtocolType.Unknown; + } else { + // Check for the presence of the file $XDG_RUNTIME_DIR/wayland-0. + const waylandServerPipe = join(xdgRuntimeDir, 'wayland-0'); + + try { + await access(waylandServerPipe, FSConstants.R_OK); + + // If the file exists, then the session is wayland. + return DisplayProtocolType.Wayland; + } catch (err) { + // If the file does not exist or an error occurs, we guess 'unknown' + // since WAYLAND_DISPLAY was set but no wayland-0 pipe could be confirmed. + errorLogger(err); + return DisplayProtocolType.Unknown; + } + } + } + } +} + + +export function getCodeDisplayProtocol(displayProtocol: DisplayProtocolType, ozonePlatform: string | undefined): DisplayProtocolType { + if (!ozonePlatform) { + return displayProtocol === DisplayProtocolType.Wayland ? DisplayProtocolType.XWayland : DisplayProtocolType.X11; + } else { + switch (ozonePlatform) { + case 'auto': + return displayProtocol; + case 'x11': + return displayProtocol === DisplayProtocolType.Wayland ? DisplayProtocolType.XWayland : DisplayProtocolType.X11; + case 'wayland': + return DisplayProtocolType.Wayland; + default: + return DisplayProtocolType.Unknown; + } + } +} diff --git a/src/vs/base/node/zip.ts b/src/vs/base/node/zip.ts index 3cb7bc9795b4f..c0d9b4b8ecbd1 100644 --- a/src/vs/base/node/zip.ts +++ b/src/vs/base/node/zip.ts @@ -164,7 +164,7 @@ async function openZip(zipFile: string, lazy: boolean = false): Promise const { open } = await import('yauzl'); return new Promise((resolve, reject) => { - open(zipFile, lazy ? { lazyEntries: true } : undefined!, (error?: Error, zipfile?: ZipFile) => { + open(zipFile, lazy ? { lazyEntries: true } : undefined!, (error: Error | null, zipfile?: ZipFile) => { if (error) { reject(toExtractError(error)); } else { @@ -176,7 +176,7 @@ async function openZip(zipFile: string, lazy: boolean = false): Promise function openZipStream(zipFile: ZipFile, entry: Entry): Promise { return new Promise((resolve, reject) => { - zipFile.openReadStream(entry, (error?: Error, stream?: Readable) => { + zipFile.openReadStream(entry, (error: Error | null, stream?: Readable) => { if (error) { reject(toExtractError(error)); } else { diff --git a/src/vs/base/parts/ipc/common/ipc.net.ts b/src/vs/base/parts/ipc/common/ipc.net.ts index 43363fba7ee5a..1fe8ee0780012 100644 --- a/src/vs/base/parts/ipc/common/ipc.net.ts +++ b/src/vs/base/parts/ipc/common/ipc.net.ts @@ -827,6 +827,7 @@ export class PersistentProtocol implements IMessagePassingProtocol { private _socket: ISocket; private _socketWriter: ProtocolWriter; private _socketReader: ProtocolReader; + // eslint-disable-next-line local/code-no-potentially-unsafe-disposables private _socketDisposables: DisposableStore; private readonly _loadEstimator: ILoadEstimator; diff --git a/src/vs/base/parts/ipc/common/ipc.ts b/src/vs/base/parts/ipc/common/ipc.ts index f943347519e1d..6530fac0d7b80 100644 --- a/src/vs/base/parts/ipc/common/ipc.ts +++ b/src/vs/base/parts/ipc/common/ipc.ts @@ -806,7 +806,7 @@ export class IPCServer implements IChannelServer, I return result; } - constructor(onDidClientConnect: Event) { + constructor(onDidClientConnect: Event, ipcLogger?: IIPCLogger | null, timeoutDelay?: number) { this.disposables.add(onDidClientConnect(({ protocol, onDidClientDisconnect }) => { const onFirstMessage = Event.once(protocol.onMessage); @@ -814,8 +814,8 @@ export class IPCServer implements IChannelServer, I const reader = new BufferReader(msg); const ctx = deserialize(reader) as TContext; - const channelServer = new ChannelServer(protocol, ctx); - const channelClient = new ChannelClient(protocol); + const channelServer = new ChannelServer(protocol, ctx, ipcLogger, timeoutDelay); + const channelClient = new ChannelClient(protocol, ipcLogger); this.channels.forEach((channel, name) => channelServer.registerChannel(name, channel)); @@ -1093,6 +1093,9 @@ export namespace ProxyChannel { // Buffer any event that should be supported by // iterating over all property keys and finding them + // However, this will not work for services that + // are lazy and use a Proxy within. For that we + // still need to check later (see below). const mapEventNameToEvent = new Map>(); for (const key in handler) { if (propertyIsEvent(key)) { @@ -1108,11 +1111,17 @@ export namespace ProxyChannel { return eventImpl as Event; } - if (propertyIsDynamicEvent(event)) { - const target = handler[event]; - if (typeof target === 'function') { + const target = handler[event]; + if (typeof target === 'function') { + if (propertyIsDynamicEvent(event)) { return target.call(handler, arg); } + + if (propertyIsEvent(event)) { + mapEventNameToEvent.set(event, Event.buffer(handler[event] as Event, true, undefined, disposables)); + + return mapEventNameToEvent.get(event) as Event; + } } throw new ErrorNoTelemetry(`Event not found: ${event}`); diff --git a/src/vs/base/parts/ipc/node/ipc.cp.ts b/src/vs/base/parts/ipc/node/ipc.cp.ts index 4fcad2758dab2..d51d77e3c81ae 100644 --- a/src/vs/base/parts/ipc/node/ipc.cp.ts +++ b/src/vs/base/parts/ipc/node/ipc.cp.ts @@ -207,7 +207,7 @@ export class Client implements IChannelClient, IDisposable { const onMessageEmitter = new Emitter(); const onRawMessage = Event.fromNodeEventEmitter(this.child, 'message', msg => msg); - onRawMessage(msg => { + const rawMessageDisposable = onRawMessage(msg => { // Handle remote console logs specially if (isRemoteConsoleLog(msg)) { @@ -233,6 +233,7 @@ export class Client implements IChannelClient, IDisposable { this.child.on('exit', (code: any, signal: any) => { process.removeListener('exit' as 'loaded', onExit); // https://github.com/electron/electron/issues/21475 + rawMessageDisposable.dispose(); this.activeRequests.forEach(r => dispose(r)); this.activeRequests.clear(); diff --git a/src/vs/base/test/browser/highlightedLabel.test.ts b/src/vs/base/test/browser/highlightedLabel.test.ts index 4f5eb5ca01519..fe2ceb43d61ff 100644 --- a/src/vs/base/test/browser/highlightedLabel.test.ts +++ b/src/vs/base/test/browser/highlightedLabel.test.ts @@ -61,5 +61,9 @@ suite('HighlightedLabel', () => { assert.deepStrictEqual(highlights, [{ start: 5, end: 8 }, { start: 10, end: 11 }]); }); + teardown(() => { + label.dispose(); + }); + ensureNoDisposablesAreLeakedInTestSuite(); }); diff --git a/src/vs/base/test/browser/ui/scrollbar/scrollableElement.test.ts b/src/vs/base/test/browser/ui/scrollbar/scrollableElement.test.ts index 14de7bb4599f2..2ea6a9c1df970 100644 --- a/src/vs/base/test/browser/ui/scrollbar/scrollableElement.test.ts +++ b/src/vs/base/test/browser/ui/scrollbar/scrollableElement.test.ts @@ -53,7 +53,7 @@ suite('MouseWheelClassifier', () => { classifier.accept(timestamp, deltaX, deltaY); const actual = classifier.isPhysicalMouseWheel(); - assert.strictEqual(actual, false); + assert.strictEqual(actual, false, `i = ${i}`); } }); @@ -142,7 +142,7 @@ suite('MouseWheelClassifier', () => { classifier.accept(timestamp, deltaX, deltaY); const actual = classifier.isPhysicalMouseWheel(); - assert.strictEqual(actual, false); + assert.strictEqual(actual, false, `i = ${i}`); } }); @@ -202,7 +202,8 @@ suite('MouseWheelClassifier', () => { classifier.accept(timestamp, deltaX, deltaY); const actual = classifier.isPhysicalMouseWheel(); - assert.strictEqual(actual, true); + assert.strictEqual(actual, true, `i = ${i}`); + } }); @@ -241,7 +242,8 @@ suite('MouseWheelClassifier', () => { classifier.accept(timestamp, deltaX, deltaY); const actual = classifier.isPhysicalMouseWheel(); - assert.strictEqual(actual, true); + assert.strictEqual(actual, true, `i = ${i}`); + } }); @@ -285,7 +287,7 @@ suite('MouseWheelClassifier', () => { classifier.accept(timestamp, deltaX, deltaY); const actual = classifier.isPhysicalMouseWheel(); - assert.strictEqual(actual, false); + assert.strictEqual(actual, false, `i = ${i}`); } }); @@ -374,7 +376,8 @@ suite('MouseWheelClassifier', () => { classifier.accept(timestamp, deltaX, deltaY); const actual = classifier.isPhysicalMouseWheel(); - assert.strictEqual(actual, true); + assert.strictEqual(actual, true, `i = ${i}`); + } }); @@ -464,7 +467,8 @@ suite('MouseWheelClassifier', () => { classifier.accept(timestamp, deltaX, deltaY); const actual = classifier.isPhysicalMouseWheel(); - assert.strictEqual(actual, true); + assert.strictEqual(actual, true, `i = ${i}`); + } }); @@ -518,7 +522,208 @@ suite('MouseWheelClassifier', () => { classifier.accept(timestamp, deltaX, deltaY); const actual = classifier.isPhysicalMouseWheel(); - assert.strictEqual(actual, true); + assert.strictEqual(actual, true, `i = ${i}`); + } + }); + + test('Linux Wayland - Logitech G Pro Wireless', () => { + const testData: IMouseWheelEvent[] = [ + [1707837460397, -1.5, 0], + [1707837460449, -1.5, 0], + [1707837460498, -1.5, 0], + [1707837460553, -1.5, 0], + [1707837460574, -1.5, 0], + [1707837460602, -1.5, 0], + [1707837460623, -1.5, 0], + [1707837460643, -1.5, 0], + [1707837460664, -1.5, 0], + [1707837460685, -1.5, 0], + [1707837460713, -1.5, 0], + [1707837460762, -1.5, 0], + [1707837460978, 1.5, 0], + [1707837460998, 1.5, 0], + [1707837461012, 1.5, 0], + [1707837461025, 1.5, 0], + [1707837461032, 1.5, 0], + [1707837461046, 1.5, 0], + [1707837461067, 1.5, 0], + [1707837461081, 1.5, 0], + [1707837461095, 1.5, 0], + [1707837461123, 1.5, 0], + [1707837461157, 1.5, 0], + [1707837461219, 1.5, 0], + [1707837461288, -1.5, 0], + [1707837461324, -1.5, 0], + [1707837461338, -1.5, 0], + [1707837461352, -1.5, 0], + [1707837461366, -1.5, 0], + [1707837461373, -1.5, 0], + [1707837461387, -1.5, 0], + [1707837461394, -1.5, 0], + [1707837461400, -1.5, 0], + [1707837461407, -1.5, 0], + [1707837461414, -1.5, 0], + [1707837461442, -1.5, 0], + [1707837461525, 1.5, 0], + [1707837461532, 1.5, 0], + [1707837461539, 1.5, 0], + [1707837461546, 1.5, 0], + [1707837461553, 1.5, 0], + [1707837461560, 1.5, 0], + [1707837461567, 1.5, 0], + [1707837461574, 1.5, 0], + [1707837461581, 1.5, 0], + [1707837461664, -1.5, 0], + [1707837461678, -1.5, 0], + [1707837461685, -1.5, 0], + [1707837461692, -1.5, 0], + [1707837461699, -1.5, 0], + [1707837461706, -1.5, 0], + [1707837461713, -1.5, 0], + [1707837461720, -1.5, 0], + [1707837461727, -1.5, 0], + [1707837461803, 1.5, 0], + [1707837461810, 1.5, 0], + [1707837461817, 1.5, 0], + [1707837461824, 1.5, 0], + [1707837461831, 1.5, 0], + [1707837461838, 1.5, 0], + [1707837461845, 1.5, 0], + [1707837461852, 3, 0], + [1707837461873, 1.5, 0], + [1707837461942, -1.5, 0], + [1707837461949, -1.5, 0], + [1707837461956, -1.5, 0], + [1707837461963, -1.5, 0], + [1707837461970, -1.5, 0], + [1707837461977, -3, 0], + [1707837461984, -1.5, 0], + [1707837461991, -1.5, 0], + [1707837462081, 1.5, 0], + [1707837462088, 1.5, 0], + [1707837462241, -1.5, 0], + [1707837462253, -1.5, 0], + [1707837462256, -1.5, 0], + [1707837462262, -1.5, 0], + [1707837462268, -1.5, 0], + [1707837462276, -1.5, 0], + [1707837462282, -4.5, 0], + [1707837462292, -3, 0], + [1707837462300, -1.5, 0], + [1707837462485, -1.5, 0], + [1707837462492, -1.5, 0], + [1707837462498, -1.5, 0], + [1707837462505, -1.5, 0], + [1707837462511, -1.5, 0], + [1707837462518, -3, 0], + [1707837462525, -3, 0], + [1707837462532, -1.5, 0], + [1707837462741, -1.5, 0], + [1707837462755, -1.5, 0], + [1707837462761, -1.5, 0], + [1707837462768, -1.5, 0], + [1707837462775, -1.5, 0], + [1707837462909, 1.5, 0], + [1707837462921, 1.5, 0], + [1707837462928, 1.5, 0], + [1707837462935, 3, 0], + [1707837462942, 3, 0], + [1707837462949, 1.5, 0], + [1707837462956, 1.5, 0], + [1707837462963, 1.5, 0], + [1707837462970, 1.5, 0], + [1707837463180, 1.5, 0], + [1707837463188, 1.5, 0], + [1707837463194, 1.5, 0], + [1707837463199, 1.5, 0], + [1707837463206, 1.5, 0], + [1707837463213, 1.5, 0], + [1707837463220, 1.5, 0], + [1707837463227, 1.5, 0], + [1707837463234, 1.5, 0], + [1707837463241, 1.5, 0], + [1707837463426, 1.5, 0], + [1707837463434, 1.5, 0], + [1707837463440, 1.5, 0], + [1707837463446, 1.5, 0], + [1707837463451, 1.5, 0], + [1707837463456, 1.5, 0], + [1707837463463, 1.5, 0], + [1707837463470, 1.5, 0], + [1707837463477, 1.5, 0], + [1707837463766, 1.5, 0], + [1707837463774, 1.5, 0], + [1707837463781, 1.5, 0], + [1707837463786, 1.5, 0], + [1707837463792, 1.5, 0], + [1707837463797, 1.5, 0], + [1707837463804, 1.5, 0], + [1707837463817, 1.5, 0], + [1707837463940, -1.5, 0], + [1707837463956, -1.5, 0], + [1707837463963, -1.5, 0], + [1707837463977, -1.5, 0], + [1707837463984, -1.5, 0], + [1707837463991, -3, 0], + [1707837463998, -1.5, 0], + [1707837464005, -1.5, 0], + [1707837464185, -1.5, 0], + [1707837464192, -1.5, 0], + [1707837464199, -1.5, 0], + [1707837464206, -1.5, 0], + [1707837464213, -1.5, 0], + [1707837464220, -3, 0], + [1707837464227, -1.5, 0], + [1707837464392, -1.5, 0], + [1707837464399, -1.5, 0], + [1707837464405, -1.5, 0], + [1707837464409, -1.5, 0], + [1707837464414, -1.5, 0], + [1707837464421, -1.5, 0], + [1707837464430, -1.5, 0], + [1707837464577, 1.5, 0], + [1707837464588, 1.5, 0], + [1707837464595, 1.5, 0], + [1707837464602, 1.5, 0], + [1707837464609, 1.5, 0], + [1707837464616, 1.5, 0], + [1707837464623, 3, 0], + [1707837464630, 1.5, 0], + [1707837464637, 1.5, 0], + [1707837464838, 1.5, 0], + [1707837464845, 1.5, 0], + [1707837464852, 1.5, 0], + [1707837464859, 1.5, 0], + [1707837464866, 3, 0], + [1707837464872, 1.5, 0], + [1707837464879, 1.5, 0], + [1707837464886, 1.5, 0], + [1707837464893, 1.5, 0], + [1707837465084, 1.5, 0], + [1707837465091, 1.5, 0], + [1707837465097, 1.5, 0], + [1707837465102, 1.5, 0], + [1707837465109, 1.5, 0], + [1707837465116, 1.5, 0], + [1707837465122, 1.5, 0], + [1707837465129, 1.5, 0], + [1707837465136, 1.5, 0], + [1707837465157, 1.5, 0], + ]; + + const classifier = new MouseWheelClassifier(); + for (let i = 0, len = testData.length; i < len; i++) { + const [timestamp, deltaY, deltaX] = testData[i]; + classifier.accept(timestamp, deltaX, deltaY); + + const actual = classifier.isPhysicalMouseWheel(); + + // Linux Wayland implementation depends on looking at the + // previous event. + if (i > 0) { + assert.strictEqual(actual, true, `i = ${i}`); + } } }); + }); diff --git a/src/vs/base/test/common/async.test.ts b/src/vs/base/test/common/async.test.ts index 4c2e74ce39b13..f5bb0232a7f7b 100644 --- a/src/vs/base/test/common/async.test.ts +++ b/src/vs/base/test/common/async.test.ts @@ -651,26 +651,28 @@ suite('Async', () => { }); test('order is kept', function () { - const queue = new async.Queue(); - - const res: number[] = []; - - const f1 = () => Promise.resolve(true).then(() => res.push(1)); - const f2 = () => async.timeout(10).then(() => res.push(2)); - const f3 = () => Promise.resolve(true).then(() => res.push(3)); - const f4 = () => async.timeout(20).then(() => res.push(4)); - const f5 = () => async.timeout(0).then(() => res.push(5)); - - queue.queue(f1); - queue.queue(f2); - queue.queue(f3); - queue.queue(f4); - return queue.queue(f5).then(() => { - assert.strictEqual(res[0], 1); - assert.strictEqual(res[1], 2); - assert.strictEqual(res[2], 3); - assert.strictEqual(res[3], 4); - assert.strictEqual(res[4], 5); + return runWithFakedTimers({}, () => { + const queue = new async.Queue(); + + const res: number[] = []; + + const f1 = () => Promise.resolve(true).then(() => res.push(1)); + const f2 = () => async.timeout(10).then(() => res.push(2)); + const f3 = () => Promise.resolve(true).then(() => res.push(3)); + const f4 = () => async.timeout(20).then(() => res.push(4)); + const f5 = () => async.timeout(0).then(() => res.push(5)); + + queue.queue(f1); + queue.queue(f2); + queue.queue(f3); + queue.queue(f4); + return queue.queue(f5).then(() => { + assert.strictEqual(res[0], 1); + assert.strictEqual(res[1], 2); + assert.strictEqual(res[2], 3); + assert.strictEqual(res[3], 4); + assert.strictEqual(res[4], 5); + }); }); }); diff --git a/src/vs/base/test/common/observable.test.ts b/src/vs/base/test/common/observable.test.ts index 9227c4655793d..426d7d4378c9e 100644 --- a/src/vs/base/test/common/observable.test.ts +++ b/src/vs/base/test/common/observable.test.ts @@ -5,7 +5,7 @@ import * as assert from 'assert'; import { Emitter, Event } from 'vs/base/common/event'; -import { ISettableObservable, autorun, derived, ITransaction, observableFromEvent, observableValue, transaction, keepObserved } from 'vs/base/common/observable'; +import { ISettableObservable, autorun, derived, ITransaction, observableFromEvent, observableValue, transaction, keepObserved, waitForState } from 'vs/base/common/observable'; import { BaseObservable, IObservable, IObserver } from 'vs/base/common/observableInternal/base'; import { ensureNoDisposablesAreLeakedInTestSuite } from 'vs/base/test/common/utils'; @@ -18,11 +18,15 @@ suite('observables', () => { suite('tutorial', () => { test('observable + autorun', () => { const log = new Log(); - // This creates a new observable value. The name is only used for debugging purposes. + // This creates a variable that stores a value and whose value changes can be observed. + // The name is only used for debugging purposes. // The second arg is the initial value. const myObservable = observableValue('myObservable', 0); - // This creates an autorun. The @description is only used for debugging purposes. + // This creates an autorun: It runs immediately and then again whenever any of the + // dependencies change. Dependencies are tracked by reading observables with the `reader` parameter. + // + // The @description is only used for debugging purposes. // The autorun has to be disposed! This is very important. ds.add(autorun(reader => { /** @description myAutorun */ @@ -31,7 +35,7 @@ suite('observables', () => { // Use the `reader` to read observable values and track the dependency to them. // If you use `observable.get()` instead of `observable.read(reader)`, you will just - // get the value and not track the dependency. + // get the value and not subscribe to it. log.log(`myAutorun.run(myObservable: ${myObservable.read(reader)})`); // Now that all dependencies are tracked, the autorun is re-run whenever any of the @@ -1099,6 +1103,97 @@ suite('observables', () => { 'myObservable.lastObserverRemoved', ]); }); + + suite('waitForState', () => { + test('resolve', async () => { + const log = new Log(); + const myObservable = new LoggingObservableValue('myObservable', { state: 'initializing' as 'initializing' | 'ready' | 'error' }, log); + + const p = waitForState(myObservable, p => p.state === 'ready', p => p.state === 'error').then(r => { + log.log(`resolved ${JSON.stringify(r)}`); + }, (err) => { + log.log(`rejected ${JSON.stringify(err)}`); + }); + + assert.deepStrictEqual(log.getAndClearEntries(), [ + 'myObservable.firstObserverAdded', + 'myObservable.get', + ]); + + myObservable.set({ state: 'ready' }, undefined); + + assert.deepStrictEqual(log.getAndClearEntries(), [ + 'myObservable.set (value [object Object])', + 'myObservable.get', + 'myObservable.lastObserverRemoved', + ]); + + await p; + + assert.deepStrictEqual(log.getAndClearEntries(), [ + 'resolved {\"state\":\"ready\"}', + ]); + }); + + test('resolveImmediate', async () => { + const log = new Log(); + const myObservable = new LoggingObservableValue('myObservable', { state: 'ready' as 'initializing' | 'ready' | 'error' }, log); + + const p = waitForState(myObservable, p => p.state === 'ready', p => p.state === 'error').then(r => { + log.log(`resolved ${JSON.stringify(r)}`); + }, (err) => { + log.log(`rejected ${JSON.stringify(err)}`); + }); + + assert.deepStrictEqual(log.getAndClearEntries(), [ + 'myObservable.firstObserverAdded', + 'myObservable.get', + 'myObservable.lastObserverRemoved', + ]); + + myObservable.set({ state: 'error' }, undefined); + + assert.deepStrictEqual(log.getAndClearEntries(), [ + 'myObservable.set (value [object Object])', + ]); + + await p; + + assert.deepStrictEqual(log.getAndClearEntries(), [ + 'resolved {\"state\":\"ready\"}', + ]); + }); + + test('reject', async () => { + const log = new Log(); + const myObservable = new LoggingObservableValue('myObservable', { state: 'initializing' as 'initializing' | 'ready' | 'error' }, log); + + const p = waitForState(myObservable, p => p.state === 'ready', p => p.state === 'error').then(r => { + log.log(`resolved ${JSON.stringify(r)}`); + }, (err) => { + log.log(`rejected ${JSON.stringify(err)}`); + }); + + assert.deepStrictEqual(log.getAndClearEntries(), [ + 'myObservable.firstObserverAdded', + 'myObservable.get', + ]); + + myObservable.set({ state: 'error' }, undefined); + + assert.deepStrictEqual(log.getAndClearEntries(), [ + 'myObservable.set (value [object Object])', + 'myObservable.get', + 'myObservable.lastObserverRemoved', + ]); + + await p; + + assert.deepStrictEqual(log.getAndClearEntries(), [ + 'rejected {\"state\":\"error\"}' + ]); + }); + }); }); export class LoggingObserver implements IObserver { diff --git a/src/vs/base/test/common/strings.test.ts b/src/vs/base/test/common/strings.test.ts index 766380ac8ec59..73c04ad723944 100644 --- a/src/vs/base/test/common/strings.test.ts +++ b/src/vs/base/test/common/strings.test.ts @@ -528,6 +528,14 @@ suite('Strings', () => { for (const sequence of sequences) { assert.strictEqual(strings.removeAnsiEscapeCodes(`hello${sequence}world`), 'helloworld', `expect to remove ${JSON.stringify(sequence)}`); } + + for (const sequence of sequences) { + assert.deepStrictEqual( + [...strings.forAnsiStringParts(`hello${sequence}world`)], + [{ isCode: false, str: 'hello' }, { isCode: true, str: sequence }, { isCode: false, str: 'world' }], + `expect to forAnsiStringParts ${JSON.stringify(sequence)}` + ); + } }); ensureNoDisposablesAreLeakedInTestSuite(); diff --git a/src/vs/code/electron-main/app.ts b/src/vs/code/electron-main/app.ts index 1dfa17a817cc0..4474ddb04ca2c 100644 --- a/src/vs/code/electron-main/app.ts +++ b/src/vs/code/electron-main/app.ts @@ -15,7 +15,7 @@ import { Event } from 'vs/base/common/event'; import { stripComments } from 'vs/base/common/json'; import { getPathLabel } from 'vs/base/common/labels'; import { Disposable, DisposableStore } from 'vs/base/common/lifecycle'; -import { Schemas } from 'vs/base/common/network'; +import { Schemas, VSCODE_AUTHORITY } from 'vs/base/common/network'; import { isAbsolute, join, posix } from 'vs/base/common/path'; import { IProcessEnvironment, isLinux, isLinuxSnap, isMacintosh, isWindows, OS } from 'vs/base/common/platform'; import { assertType } from 'vs/base/common/types'; @@ -118,8 +118,9 @@ import { ElectronPtyHostStarter } from 'vs/platform/terminal/electron-main/elect import { PtyHostService } from 'vs/platform/terminal/node/ptyHostService'; import { NODE_REMOTE_RESOURCE_CHANNEL_NAME, NODE_REMOTE_RESOURCE_IPC_METHOD_NAME, NodeRemoteResourceResponse, NodeRemoteResourceRouter } from 'vs/platform/remote/common/electronRemoteResources'; import { Lazy } from 'vs/base/common/lazy'; -import { IAuxiliaryWindowsMainService, isAuxiliaryWindow } from 'vs/platform/auxiliaryWindow/electron-main/auxiliaryWindows'; +import { IAuxiliaryWindowsMainService } from 'vs/platform/auxiliaryWindow/electron-main/auxiliaryWindows'; import { AuxiliaryWindowsMainService } from 'vs/platform/auxiliaryWindow/electron-main/auxiliaryWindowsMainService'; +import { normalizeNFC } from 'vs/base/common/normalization'; /** * The main VS Code application. There will only ever be one instance, @@ -193,7 +194,7 @@ export class CodeApplication extends Disposable { // Block all SVG requests from unsupported origins const supportedSvgSchemes = new Set([Schemas.file, Schemas.vscodeFileResource, Schemas.vscodeRemoteResource, Schemas.vscodeManagedRemoteResource, 'devtools']); - // But allow them if the are made from inside an webview + // But allow them if they are made from inside an webview const isSafeFrame = (requestFrame: WebFrameMain | undefined): boolean => { for (let frame: WebFrameMain | null | undefined = requestFrame; frame; frame = frame.parent) { if (frame.url.startsWith(`${Schemas.vscodeWebview}://`)) { @@ -392,7 +393,7 @@ export class CodeApplication extends Disposable { app.on('web-contents-created', (event, contents) => { // Auxiliary Window: delegate to `AuxiliaryWindow` class - if (isAuxiliaryWindow(contents)) { + if (contents?.opener?.url.startsWith(`${Schemas.vscodeFileResource}://${VSCODE_AUTHORITY}/`)) { this.logService.trace('[aux window] app.on("web-contents-created"): Registering auxiliary window'); this.auxiliaryWindowsMainService?.registerWindow(contents); @@ -435,6 +436,8 @@ export class CodeApplication extends Disposable { let macOpenFileURIs: IWindowOpenable[] = []; let runningTimeout: NodeJS.Timeout | undefined = undefined; app.on('open-file', (event, path) => { + path = normalizeNFC(path); // macOS only: normalize paths to NFC form + this.logService.trace('app#open-file: ', path); event.preventDefault(); @@ -1336,7 +1339,11 @@ export class CodeApplication extends Disposable { return windowsMainService.open({ context: OpenContext.DOCK, cli: args, - urisToOpen: macOpenFiles.map(path => (hasWorkspaceFileExtension(path) ? { workspaceUri: URI.file(path) } : { fileUri: URI.file(path) })), + urisToOpen: macOpenFiles.map(path => { + path = normalizeNFC(path); // macOS only: normalize paths to NFC form + + return (hasWorkspaceFileExtension(path) ? { workspaceUri: URI.file(path) } : { fileUri: URI.file(path) }); + }), noRecentEntry, waitMarkerFileURI, initialStartup: true, @@ -1349,7 +1356,7 @@ export class CodeApplication extends Disposable { return windowsMainService.open({ context, cli: args, - forceNewWindow: args['new-window'] || (!hasCliArgs && args['unity-launch']), + forceNewWindow: args['new-window'], diffMode: args.diff, mergeMode: args.merge, noRecentEntry, diff --git a/src/vs/code/electron-main/main.ts b/src/vs/code/electron-main/main.ts index c5466e20b4593..8548fa7ce4d87 100644 --- a/src/vs/code/electron-main/main.ts +++ b/src/vs/code/electron-main/main.ts @@ -71,6 +71,7 @@ import { LogService } from 'vs/platform/log/common/logService'; import { massageMessageBoxOptions } from 'vs/platform/dialogs/common/dialogs'; import { SaveStrategy, StateService } from 'vs/platform/state/node/stateService'; import { FileUserDataProvider } from 'vs/platform/userData/common/fileUserDataProvider'; +import { addUNCHostToAllowlist, getUNCHost } from 'vs/base/node/unc'; /** * The main VS Code entry point. @@ -249,8 +250,8 @@ class CodeMain { // Environment service (paths) Promise.all([ - environmentMainService.extensionsPath, - environmentMainService.codeCachePath, + this.allowWindowsUNCPath(environmentMainService.extensionsPath), // enable extension paths on UNC drives... + environmentMainService.codeCachePath, // ...other user-data-derived paths should already be enlisted from `main.js` environmentMainService.logsHome.with({ scheme: Schemas.file }).fsPath, userDataProfilesMainService.defaultProfile.globalStorageHome.with({ scheme: Schemas.file }).fsPath, environmentMainService.workspaceStorageHome.with({ scheme: Schemas.file }).fsPath, @@ -269,6 +270,17 @@ class CodeMain { userDataProfilesMainService.init(); } + private allowWindowsUNCPath(path: string): string { + if (isWindows) { + const host = getUNCHost(path); + if (host) { + addUNCHostToAllowlist(host); + } + } + + return path; + } + private async claimInstance(logService: ILogService, environmentMainService: IEnvironmentMainService, lifecycleMainService: ILifecycleMainService, instantiationService: IInstantiationService, productService: IProductService, retry: boolean): Promise { // Try to setup a server for running. If that succeeds it means diff --git a/src/vs/code/electron-sandbox/issue/issueReporterModel.ts b/src/vs/code/electron-sandbox/issue/issueReporterModel.ts index 74f993903e127..1541f98c812c4 100644 --- a/src/vs/code/electron-sandbox/issue/issueReporterModel.ts +++ b/src/vs/code/electron-sandbox/issue/issueReporterModel.ts @@ -29,6 +29,7 @@ export interface IssueReporterData { extensionsDisabled?: boolean; fileOnExtension?: boolean; fileOnMarketplace?: boolean; + fileOnProduct?: boolean; selectedExtension?: IssueReporterExtensionData; actualSearchResults?: ISettingSearchResult[]; query?: string; diff --git a/src/vs/code/electron-sandbox/issue/issueReporterPage.ts b/src/vs/code/electron-sandbox/issue/issueReporterPage.ts index bf5dca66aebb3..01e081224922a 100644 --- a/src/vs/code/electron-sandbox/issue/issueReporterPage.ts +++ b/src/vs/code/electron-sandbox/issue/issueReporterPage.ts @@ -83,6 +83,7 @@ export default (): string => ` +
diff --git a/src/vs/code/electron-sandbox/issue/issueReporterService.ts b/src/vs/code/electron-sandbox/issue/issueReporterService.ts index 82de2be993fd4..82ac5f4b25ec7 100644 --- a/src/vs/code/electron-sandbox/issue/issueReporterService.ts +++ b/src/vs/code/electron-sandbox/issue/issueReporterService.ts @@ -45,10 +45,12 @@ export class IssueReporter extends Disposable { private readonly issueReporterModel: IssueReporterModel; private numberOfSearchResultsDisplayed = 0; private receivedSystemInfo = false; - private receivedExtensionData = false; private receivedPerformanceInfo = false; private shouldQueueSearch = false; private hasBeenSubmitted = false; + private openReporter = false; + private loadingExtensionData = false; + private selectedExtension = ''; private delayedSubmit = new Delayer(300); private readonly previewButton!: Button; @@ -71,10 +73,18 @@ export class IssueReporter extends Disposable { selectedExtension: targetExtension }); + const fileOnMarketplace = configuration.data.issueSource === IssueSource.Marketplace; + const fileOnProduct = configuration.data.issueSource === IssueSource.VSCode; + this.issueReporterModel.update({ fileOnMarketplace, fileOnProduct }); + //TODO: Handle case where extension is not activated const issueReporterElement = this.getElementById('issue-reporter'); if (issueReporterElement) { this.previewButton = new Button(issueReporterElement, unthemedButtonStyles); + const issueRepoName = document.createElement('a'); + issueReporterElement.appendChild(issueRepoName); + issueRepoName.id = 'show-repo-name'; + issueRepoName.classList.add('hidden'); this.updatePreviewButtonState(); } @@ -116,7 +126,7 @@ export class IssueReporter extends Disposable { codiconStyleSheet.id = 'codiconStyles'; // TODO: Is there a way to use the IThemeService here instead - const iconsStyleSheet = getIconsStyleSheet(undefined); + const iconsStyleSheet = this._register(getIconsStyleSheet(undefined)); function updateAll() { codiconStyleSheet.textContent = iconsStyleSheet.getCSS(); } @@ -155,6 +165,7 @@ export class IssueReporter extends Disposable { } } + // TODO @justschen: After migration to Aux Window, switch to dedicated css. private applyStyles(styles: IssueReporterStyles) { const styleTag = document.createElement('style'); const content: string[] = []; @@ -252,50 +263,19 @@ export class IssueReporter extends Disposable { if (extension.uri) { const uri = URI.revive(extension.uri); extension.bugsUrl = uri.toString(); - } else { - const uri = await this.issueMainService.$getIssueReporterUri(extension.id); - extension.bugsUrl = uri.toString(true); } - - } catch (e) { - extension.hasIssueUriRequestHandler = false; - // The issue handler failed so fall back to old issue reporter experience. - this.renderBlocks(); - } - } - - private async getIssueDataFromExtension(extension: IssueReporterExtensionData): Promise { - try { - const data = await this.issueMainService.$getIssueReporterData(extension.id); - extension.extensionData = data; - this.receivedExtensionData = true; - this.issueReporterModel.update({ extensionData: data }); - return data; } catch (e) { - extension.hasIssueDataProviders = false; - // The issue handler failed so fall back to old issue reporter experience. this.renderBlocks(); - throw e; } } - private async getIssueTemplateFromExtension(extension: IssueReporterExtensionData): Promise { + private async sendReporterMenu(extension: IssueReporterExtensionData): Promise { try { - const data = await this.issueMainService.$getIssueReporterTemplate(extension.id); - extension.extensionTemplate = data; - return data; - } catch (e) { - throw e; - } - } - - private async getReporterStatus(extension: IssueReporterExtensionData): Promise { - try { - const data = await this.issueMainService.$getReporterStatus(extension.id, extension.name); + const data = await this.issueMainService.$sendReporterMenu(extension.id, extension.name); return data; } catch (e) { console.error(e); - return [false, false]; + return undefined; } } @@ -488,12 +468,37 @@ export class IssueReporter extends Disposable { this.previewButton.enabled = false; this.previewButton.label = localize('loadingData', "Loading data..."); } + + const issueRepoName = this.getElementById('show-repo-name')! as HTMLAnchorElement; + const selectedExtension = this.issueReporterModel.getData().selectedExtension; + if (selectedExtension && selectedExtension.uri) { + const urlString = URI.revive(selectedExtension.uri).toString(); + issueRepoName.href = urlString; + issueRepoName.addEventListener('click', (e) => this.openLink(e)); + issueRepoName.addEventListener('auxclick', (e) => this.openLink(e)); + const gitHubInfo = this.parseGitHubUrl(urlString); + issueRepoName.textContent = gitHubInfo ? gitHubInfo.owner + '/' + gitHubInfo.repositoryName : urlString; + Object.assign(issueRepoName.style, { + alignSelf: 'flex-end', + display: 'block', + fontSize: '13px', + marginBottom: '10px', + padding: '4px 0px', + textDecoration: 'none', + width: 'auto' + }); + show(issueRepoName); + } else { + // clear styles + issueRepoName.removeAttribute('style'); + hide(issueRepoName); + } } private isPreviewEnabled() { const issueType = this.issueReporterModel.getData().issueType; - if (this.issueReporterModel.getData().selectedExtension?.hasIssueDataProviders && !this.receivedExtensionData) { + if (this.loadingExtensionData) { return false; } @@ -522,10 +527,6 @@ export class IssueReporter extends Disposable { return selectedExtension && selectedExtension.bugsUrl; } - private getExtensionData(): string | undefined { - return this.issueReporterModel.getData().selectedExtension?.extensionData; - } - private searchVSCodeIssues(title: string, issueDescription?: string): void { if (title) { this.searchDuplicates(title, issueDescription); @@ -726,13 +727,17 @@ export class IssueReporter extends Disposable { private setSourceOptions(): void { const sourceSelect = this.getElementById('issue-source')! as HTMLSelectElement; - const { issueType, fileOnExtension, selectedExtension } = this.issueReporterModel.getData(); + const { issueType, fileOnExtension, selectedExtension, fileOnMarketplace, fileOnProduct } = this.issueReporterModel.getData(); let selected = sourceSelect.selectedIndex; if (selected === -1) { if (fileOnExtension !== undefined) { selected = fileOnExtension ? 2 : 1; } else if (selectedExtension?.isBuiltin) { selected = 1; + } else if (fileOnMarketplace) { + selected = 3; + } else if (fileOnProduct) { + selected = 1; } } @@ -795,24 +800,6 @@ export class IssueReporter extends Disposable { show(extensionSelector); } - if (fileOnExtension && selectedExtension?.hasIssueUriRequestHandler && !selectedExtension.hasIssueDataProviders) { - hide(titleTextArea); - hide(descriptionTextArea); - reset(descriptionTitle, localize('handlesIssuesElsewhere', "This extension handles issues outside of VS Code")); - reset(descriptionSubtitle, localize('elsewhereDescription', "The '{0}' extension prefers to use an external issue reporter. To be taken to that issue reporting experience, click the button below.", selectedExtension.displayName)); - this.previewButton.label = localize('openIssueReporter', "Open External Issue Reporter"); - return; - } - - if (fileOnExtension && selectedExtension?.hasIssueDataProviders) { - const data = this.getExtensionData(); - if (data) { - (extensionDataTextArea as HTMLElement).innerText = data.toString(); - } - (extensionDataTextArea as HTMLTextAreaElement).readOnly = true; - show(extensionDataBlock); - } - if (fileOnExtension && selectedExtension?.data) { const data = selectedExtension?.data; (extensionDataTextArea as HTMLElement).innerText = data.toString(); @@ -820,6 +807,17 @@ export class IssueReporter extends Disposable { show(extensionDataBlock); } + // only if we know comes from the open reporter command + if (fileOnExtension && this.openReporter) { + (extensionDataTextArea as HTMLTextAreaElement).readOnly = true; + setTimeout(() => { + // delay to make sure from command or not + if (this.openReporter) { + show(extensionDataBlock); + } + }, 100); + } + if (issueType === IssueType.Bug) { if (!fileOnMarketplace) { show(blockContainer); @@ -858,13 +856,24 @@ export class IssueReporter extends Disposable { private validateInput(inputId: string): boolean { const inputElement = (this.getElementById(inputId)); const inputValidationMessage = this.getElementById(`${inputId}-empty-error`); + const descriptionShortMessage = this.getElementById(`description-short-error`); if (!inputElement.value) { inputElement.classList.add('invalid-input'); inputValidationMessage?.classList.remove('hidden'); + descriptionShortMessage?.classList.add('hidden'); return false; - } else { + } else if (inputId === 'description' && inputElement.value.length < 10) { + inputElement.classList.add('invalid-input'); + descriptionShortMessage?.classList.remove('hidden'); + inputValidationMessage?.classList.add('hidden'); + return false; + } + else { inputElement.classList.remove('invalid-input'); inputValidationMessage?.classList.add('hidden'); + if (inputId === 'description') { + descriptionShortMessage?.classList.add('hidden'); + } return true; } } @@ -898,6 +907,7 @@ export class IssueReporter extends Disposable { const response = await fetch(url, init); if (!response.ok) { + console.error('Invalid GitHub URL provided.'); return false; } const result = await response.json(); @@ -908,18 +918,6 @@ export class IssueReporter extends Disposable { private async createIssue(): Promise { const selectedExtension = this.issueReporterModel.getData().selectedExtension; - const hasUri = selectedExtension?.hasIssueUriRequestHandler; - const hasData = selectedExtension?.hasIssueDataProviders; - // Short circuit if the extension provides a custom issue handler - if (hasUri && !hasData) { - const url = this.getExtensionBugsUrl(); - if (url) { - this.hasBeenSubmitted = true; - await this.nativeHostService.openExternal(url); - return true; - } - } - if (!this.validateInputs()) { // If inputs are invalid, set focus to the first one and add listeners on them // to detect further changes @@ -954,8 +952,9 @@ export class IssueReporter extends Disposable { const issueTitle = (this.getElementById('issue-title')).value; const issueBody = this.issueReporterModel.serialize(); - let issueUrl = hasUri ? this.getExtensionBugsUrl() : this.getIssueUrl(); + let issueUrl = this.getIssueUrl(); if (!issueUrl) { + console.error('No issue url found'); return false; } @@ -976,6 +975,7 @@ export class IssueReporter extends Disposable { try { url = await this.writeToClipboard(baseUrl, issueBody); } catch (_) { + console.error('Writing to clipboard failed'); return false; } } @@ -1012,6 +1012,8 @@ export class IssueReporter extends Disposable { owner: match[1], repositoryName: match[2] }; + } else { + console.error('No GitHub match'); } return undefined; @@ -1168,20 +1170,48 @@ export class IssueReporter extends Disposable { this.addEventListener('extension-selector', 'change', async (e: Event) => { this.clearExtensionData(); const selectedExtensionId = (e.target).value; + this.selectedExtension = selectedExtensionId; const extensions = this.issueReporterModel.getData().allExtensions; const matches = extensions.filter(extension => extension.id === selectedExtensionId); if (matches.length) { this.issueReporterModel.update({ selectedExtension: matches[0] }); const selectedExtension = this.issueReporterModel.getData().selectedExtension; if (selectedExtension) { - selectedExtension.data = undefined; - selectedExtension.uri = undefined; + const iconElement = document.createElement('span'); + iconElement.classList.add(...ThemeIcon.asClassNameArray(Codicon.loading), 'codicon-modifier-spin'); + this.setLoading(iconElement); + const openReporterData = await this.sendReporterMenu(selectedExtension); + if (openReporterData) { + if (this.selectedExtension === selectedExtensionId) { + this.removeLoading(iconElement, true); + this.configuration.data = openReporterData; + } else if (this.selectedExtension !== selectedExtensionId) { + } + } + else { + if (!this.loadingExtensionData) { + iconElement.classList.remove(...ThemeIcon.asClassNameArray(Codicon.loading), 'codicon-modifier-spin'); + } + this.removeLoading(iconElement); + // if not using command, should have no configuration data in fields we care about and check later. + this.clearExtensionData(); + + // case when previous extension was opened from normal openIssueReporter command + selectedExtension.data = undefined; + selectedExtension.uri = undefined; + } + if (this.selectedExtension === selectedExtensionId) { + // repopulates the fields with the new data given the selected extension. + this.updateExtensionStatus(matches[0]); + this.openReporter = false; + } + } else { + this.issueReporterModel.update({ selectedExtension: undefined }); + this.clearSearchResults(); + this.clearExtensionData(); + this.validateSelectedExtension(); + this.updateExtensionStatus(matches[0]); } - this.updateExtensionStatus(matches[0]); - } else { - this.issueReporterModel.update({ selectedExtension: undefined }); - this.clearSearchResults(); - this.validateSelectedExtension(); } }); } @@ -1193,12 +1223,15 @@ export class IssueReporter extends Disposable { private clearExtensionData(): void { this.issueReporterModel.update({ extensionData: undefined }); + this.configuration.data.issueBody = undefined; this.configuration.data.data = undefined; this.configuration.data.uri = undefined; } private async updateExtensionStatus(extension: IssueReporterExtensionData) { this.issueReporterModel.update({ selectedExtension: extension }); + + // uses this.configuuration.data to ensure that data is coming from `openReporter` command. const template = this.configuration.data.issueBody; if (template) { const descriptionTextArea = this.getElementById('description')!; @@ -1212,73 +1245,22 @@ export class IssueReporter extends Disposable { const data = this.configuration.data.data; if (data) { + this.issueReporterModel.update({ extensionData: data }); + extension.data = data; const extensionDataBlock = mainWindow.document.querySelector('.block-extension-data')!; show(extensionDataBlock); - this.issueReporterModel.update({ extensionData: data }); + this.renderBlocks(); } const uri = this.configuration.data.uri; if (uri) { + extension.uri = uri; this.updateIssueReporterUri(extension); } - // if extension does not have provider/handles, will check for either. If extension is already active, IPC will return [false, false] and will proceed as normal. - if (!extension.hasIssueDataProviders && !extension.hasIssueUriRequestHandler) { - const toActivate = await this.getReporterStatus(extension); - extension.hasIssueDataProviders = toActivate[0]; - extension.hasIssueUriRequestHandler = toActivate[1]; - this.renderBlocks(); - } - - if (extension.hasIssueUriRequestHandler && extension.hasIssueDataProviders) { - // update this first - const template = await this.getIssueTemplateFromExtension(extension); - const descriptionTextArea = this.getElementById('description')!; - const descriptionText = (descriptionTextArea as HTMLTextAreaElement).value; - if (descriptionText === '' || !descriptionText.includes(template)) { - const fullTextArea = descriptionText + (descriptionText === '' ? '' : '\n') + template; - (descriptionTextArea as HTMLTextAreaElement).value = fullTextArea; - this.issueReporterModel.update({ issueDescription: fullTextArea }); - } - const extensionDataBlock = mainWindow.document.querySelector('.block-extension-data')!; - show(extensionDataBlock); - - // Start loading for extension data. - const iconElement = document.createElement('span'); - iconElement.classList.add(...ThemeIcon.asClassNameArray(Codicon.loading), 'codicon-modifier-spin'); - this.setLoading(iconElement); - await this.getIssueDataFromExtension(extension); - this.removeLoading(iconElement); - - // then update this - this.updateIssueReporterUri(extension); - - } else if (extension.hasIssueUriRequestHandler) { - this.updateIssueReporterUri(extension); - } else if (extension.hasIssueDataProviders) { - const template = await this.getIssueTemplateFromExtension(extension); - const descriptionTextArea = this.getElementById('description')!; - const descriptionText = (descriptionTextArea as HTMLTextAreaElement).value; - if (descriptionText === '' || !descriptionText.includes(template)) { - const fullTextArea = descriptionText + (descriptionText === '' ? '' : '\n') + template; - (descriptionTextArea as HTMLTextAreaElement).value = fullTextArea; - this.issueReporterModel.update({ issueDescription: fullTextArea }); - } - const extensionDataBlock = mainWindow.document.querySelector('.block-extension-data')!; - show(extensionDataBlock); - - // Start loading for extension data. - const iconElement = document.createElement('span'); - iconElement.classList.add(...ThemeIcon.asClassNameArray(Codicon.loading), 'codicon-modifier-spin'); - this.setLoading(iconElement); - await this.getIssueDataFromExtension(extension); - this.removeLoading(iconElement); - } else { - this.validateSelectedExtension(); - this.issueReporterModel.update({ extensionData: extension.data ?? undefined }); - const title = (this.getElementById('issue-title')).value; - this.searchExtensionIssues(title); - } + this.validateSelectedExtension(); + const title = (this.getElementById('issue-title')).value; + this.searchExtensionIssues(title); this.updatePreviewButtonState(); this.renderBlocks(); @@ -1296,8 +1278,12 @@ export class IssueReporter extends Disposable { return; } + if (this.loadingExtensionData) { + return; + } + const hasValidGitHubUrl = this.getExtensionGitHubUrl(); - if (hasValidGitHubUrl || (extension.hasIssueUriRequestHandler && !extension.hasIssueDataProviders)) { + if (hasValidGitHubUrl) { this.previewButton.enabled = true; } else { this.setExtensionValidationMessage(); @@ -1307,7 +1293,8 @@ export class IssueReporter extends Disposable { private setLoading(element: HTMLElement) { // Show loading - this.receivedExtensionData = false; + this.openReporter = true; + this.loadingExtensionData = true; this.updatePreviewButtonState(); const extensionDataCaption = this.getElementById('extension-id')!; @@ -1318,12 +1305,17 @@ export class IssueReporter extends Disposable { const showLoading = this.getElementById('ext-loading')!; show(showLoading); + while (showLoading.firstChild) { + showLoading.removeChild(showLoading.firstChild); + } showLoading.append(element); this.renderBlocks(); } - private removeLoading(element: HTMLElement) { + private removeLoading(element: HTMLElement, fromReporter: boolean = false) { + this.openReporter = fromReporter; + this.loadingExtensionData = false; this.updatePreviewButtonState(); const extensionDataCaption = this.getElementById('extension-id')!; @@ -1334,7 +1326,10 @@ export class IssueReporter extends Disposable { const hideLoading = this.getElementById('ext-loading')!; hide(hideLoading); - hideLoading.removeChild(element); + if (hideLoading.firstChild) { + hideLoading.removeChild(element); + } + this.renderBlocks(); } private setExtensionValidationMessage(): void { diff --git a/src/vs/code/electron-sandbox/issue/media/issueReporter.css b/src/vs/code/electron-sandbox/issue/media/issueReporter.css index 8c45208640932..152d6c38bc8f7 100644 --- a/src/vs/code/electron-sandbox/issue/media/issueReporter.css +++ b/src/vs/code/electron-sandbox/issue/media/issueReporter.css @@ -72,7 +72,7 @@ textarea { width: auto; padding: 4px 10px; align-self: flex-end; - margin-bottom: 10px; + margin-bottom: 1em; font-size: 13px; } @@ -157,7 +157,8 @@ body { padding-bottom: 2em; display: flex; flex-direction: column; - height: 100%; + min-height: 100%; + overflow: visible; } .description-section { @@ -213,6 +214,10 @@ select, input, textarea { border-top: 0px !important; } +#issue-reporter .system-info { + margin-bottom: 10px; +} + input[type="checkbox"] { width: auto; @@ -364,6 +369,7 @@ a { } .issues-container > .issue > .issue-state { + display: flex; width: 77px; padding: 3px 6px; margin-right: 5px; @@ -373,8 +379,13 @@ a { } .issues-container > .issue .label { + padding-top: 2px; margin-left: 5px; width: 44px; text-overflow: ellipsis; overflow: hidden; } + +.issues-container > .issue .issue-icon{ + padding-top: 2px; +} diff --git a/src/vs/code/node/cli.ts b/src/vs/code/node/cli.ts index 578bf1a282c44..211d1dbf3e535 100644 --- a/src/vs/code/node/cli.ts +++ b/src/vs/code/node/cli.ts @@ -324,6 +324,7 @@ export async function main(argv: string[]): Promise { // to get better profile traces. Last, we listen on stdout for a signal that tells us to // stop profiling. if (args['prof-startup']) { + const profileHost = '127.0.0.1'; const portMain = await findFreePort(randomPort(), 10, 3000); const portRenderer = await findFreePort(portMain + 1, 10, 3000); const portExthost = await findFreePort(portRenderer + 1, 10, 3000); @@ -335,9 +336,9 @@ export async function main(argv: string[]): Promise { const filenamePrefix = randomPath(homedir(), 'prof'); - addArg(argv, `--inspect-brk=${portMain}`); - addArg(argv, `--remote-debugging-port=${portRenderer}`); - addArg(argv, `--inspect-brk-extensions=${portExthost}`); + addArg(argv, `--inspect-brk=${profileHost}:${portMain}`); + addArg(argv, `--remote-debugging-port=${profileHost}:${portRenderer}`); + addArg(argv, `--inspect-brk-extensions=${profileHost}:${portExthost}`); addArg(argv, `--prof-startup-prefix`, filenamePrefix); addArg(argv, `--no-cached-data`); @@ -351,7 +352,7 @@ export async function main(argv: string[]): Promise { let session: ProfilingSession; try { - session = await profiler.startProfiling(opts); + session = await profiler.startProfiling({ ...opts, host: profileHost }); } catch (err) { console.error(`FAILED to start profiling for '${name}' on port '${opts.port}'`); } diff --git a/src/vs/code/node/cliProcessMain.ts b/src/vs/code/node/cliProcessMain.ts index b91367f1fc27f..aea83578ee0ce 100644 --- a/src/vs/code/node/cliProcessMain.ts +++ b/src/vs/code/node/cliProcessMain.ts @@ -63,6 +63,7 @@ import { LogService } from 'vs/platform/log/common/logService'; import { LoggerService } from 'vs/platform/log/node/loggerService'; import { localize } from 'vs/nls'; import { FileUserDataProvider } from 'vs/platform/userData/common/fileUserDataProvider'; +import { addUNCHostToAllowlist, getUNCHost } from 'vs/base/node/unc'; class CliMain extends Disposable { @@ -121,8 +122,8 @@ class CliMain extends Disposable { // Init folders await Promise.all([ - environmentService.appSettingsHome.with({ scheme: Schemas.file }).fsPath, - environmentService.extensionsPath + this.allowWindowsUNCPath(environmentService.appSettingsHome.with({ scheme: Schemas.file }).fsPath), + this.allowWindowsUNCPath(environmentService.extensionsPath) ].map(path => path ? Promises.mkdir(path, { recursive: true }) : undefined)); // Logger @@ -233,6 +234,17 @@ class CliMain extends Disposable { return [new InstantiationService(services), appenders]; } + private allowWindowsUNCPath(path: string): string { + if (isWindows) { + const host = getUNCHost(path); + if (host) { + addUNCHostToAllowlist(host); + } + } + + return path; + } + private registerErrorHandler(logService: ILogService): void { // Install handler for unexpected errors diff --git a/src/vs/code/node/sharedProcess/sharedProcessMain.ts b/src/vs/code/node/sharedProcess/sharedProcessMain.ts index 82b79418af073..9a59ccce5c323 100644 --- a/src/vs/code/node/sharedProcess/sharedProcessMain.ts +++ b/src/vs/code/node/sharedProcess/sharedProcessMain.ts @@ -116,6 +116,9 @@ import { RemoteConnectionType } from 'vs/platform/remote/common/remoteAuthorityR import { nodeSocketFactory } from 'vs/platform/remote/node/nodeSocketFactory'; import { NativeEnvironmentService } from 'vs/platform/environment/node/environmentService'; import { SharedProcessRawConnection, SharedProcessLifecycle } from 'vs/platform/sharedProcess/common/sharedProcess'; +import { getOSReleaseInfo } from 'vs/base/node/osReleaseInfo'; +import { getDesktopEnvironment } from 'vs/base/common/desktopEnvironmentInfo'; +import { getCodeDisplayProtocol, getDisplayProtocol } from 'vs/base/node/osDisplayProtocolInfo'; class SharedProcessMain extends Disposable implements IClientConnectionFilter { @@ -172,6 +175,9 @@ class SharedProcessMain extends Disposable implements IClientConnectionFilter { // Report Profiles Info this.reportProfilesInfo(telemetryService, userDataProfilesService); this._register(userDataProfilesService.onDidChangeProfiles(() => this.reportProfilesInfo(telemetryService, userDataProfilesService))); + + // Report Client OS/DE Info + this.reportClientOSInfo(telemetryService, logService); }); // Instantiate Contributions @@ -458,6 +464,45 @@ class SharedProcessMain extends Disposable implements IClientConnectionFilter { }); } + private async reportClientOSInfo(telemetryService: ITelemetryService, logService: ILogService): Promise { + if (isLinux) { + const [releaseInfo, displayProtocol] = await Promise.all([ + getOSReleaseInfo(logService.error.bind(logService)), + getDisplayProtocol(logService.error.bind(logService)) + ]); + const desktopEnvironment = getDesktopEnvironment(); + const codeSessionType = getCodeDisplayProtocol(displayProtocol, this.configuration.args['ozone-platform']); + if (releaseInfo) { + type ClientPlatformInfoClassification = { + platformId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'A string identifying the operating system without any version information.' }; + platformVersionId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'A string identifying the operating system version excluding any name information or release code.' }; + platformIdLike: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'A string identifying the operating system the current OS derivate is closely related to.' }; + desktopEnvironment: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'A string identifying the desktop environment the user is using.' }; + displayProtocol: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'A string identifying the users display protocol type.' }; + codeDisplayProtocol: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'A string identifying the vscode display protocol type.' }; + owner: 'benibenj'; + comment: 'Provides insight into the distro and desktop environment information on Linux.'; + }; + type ClientPlatformInfoEvent = { + platformId: string; + platformVersionId: string | undefined; + platformIdLike: string | undefined; + desktopEnvironment: string | undefined; + displayProtocol: string | undefined; + codeDisplayProtocol: string | undefined; + }; + telemetryService.publicLog2('clientPlatformInfo', { + platformId: releaseInfo.id, + platformVersionId: releaseInfo.version_id, + platformIdLike: releaseInfo.id_like, + desktopEnvironment: desktopEnvironment, + displayProtocol: displayProtocol, + codeDisplayProtocol: codeSessionType + }); + } + } + } + handledClientConnection(e: MessageEvent): boolean { // This filter on message port messages will look for @@ -485,15 +530,24 @@ export async function main(configuration: ISharedProcessConfiguration): Promise< // create shared process and signal back to main that we are // ready to accept message ports as client connections - const sharedProcess = new SharedProcessMain(configuration); - process.parentPort.postMessage(SharedProcessLifecycle.ipcReady); + try { + const sharedProcess = new SharedProcessMain(configuration); + process.parentPort.postMessage(SharedProcessLifecycle.ipcReady); - // await initialization and signal this back to electron-main - await sharedProcess.init(); + // await initialization and signal this back to electron-main + await sharedProcess.init(); - process.parentPort.postMessage(SharedProcessLifecycle.initDone); + process.parentPort.postMessage(SharedProcessLifecycle.initDone); + } catch (error) { + process.parentPort.postMessage({ error: error.toString() }); + } } +const handle = setTimeout(() => { + process.parentPort.postMessage({ warning: '[SharedProcess] did not receive configuration within 30s...' }); +}, 30000); + process.parentPort.once('message', (e: Electron.MessageEvent) => { + clearTimeout(handle); main(e.data as ISharedProcessConfiguration); }); diff --git a/src/vs/editor/browser/controller/mouseTarget.ts b/src/vs/editor/browser/controller/mouseTarget.ts index 8598d9140b96b..4a1addb3fc0eb 100644 --- a/src/vs/editor/browser/controller/mouseTarget.ts +++ b/src/vs/editor/browser/controller/mouseTarget.ts @@ -21,6 +21,7 @@ import { AtomicTabMoveOperations, Direction } from 'vs/editor/common/cursor/curs import { PositionAffinity } from 'vs/editor/common/model'; import { InjectedText } from 'vs/editor/common/modelLineProjectionData'; import { Mutable } from 'vs/base/common/types'; +import { Lazy } from 'vs/base/common/lazy'; const enum HitTestResultType { Unknown, @@ -36,6 +37,9 @@ class UnknownHitTestResult { class ContentHitTestResult { readonly type = HitTestResultType.Content; + + get hitTarget(): HTMLElement { return this.spanNode; } + constructor( readonly position: Position, readonly spanNode: HTMLElement, @@ -404,26 +408,53 @@ abstract class BareHitTestRequest { class HitTestRequest extends BareHitTestRequest { private readonly _ctx: HitTestContext; - public readonly target: HTMLElement | null; - public readonly targetPath: Uint8Array; + private readonly _eventTarget: HTMLElement | null; + public readonly hitTestResult = new Lazy(() => MouseTargetFactory.doHitTest(this._ctx, this)); + private _useHitTestTarget: boolean; + private _targetPathCacheElement: HTMLElement | null = null; + private _targetPathCacheValue: Uint8Array = new Uint8Array(0); + + public get target(): HTMLElement | null { + if (this._useHitTestTarget) { + return this.hitTestResult.value.hitTarget; + } + return this._eventTarget; + } - constructor(ctx: HitTestContext, editorPos: EditorPagePosition, pos: PageCoordinates, relativePos: CoordinatesRelativeToEditor, target: HTMLElement | null) { + public get targetPath(): Uint8Array { + if (this._targetPathCacheElement !== this.target) { + this._targetPathCacheElement = this.target; + this._targetPathCacheValue = PartFingerprints.collect(this.target, this._ctx.viewDomNode); + } + return this._targetPathCacheValue; + } + + constructor(ctx: HitTestContext, editorPos: EditorPagePosition, pos: PageCoordinates, relativePos: CoordinatesRelativeToEditor, eventTarget: HTMLElement | null) { super(ctx, editorPos, pos, relativePos); this._ctx = ctx; + this._eventTarget = eventTarget; - if (target) { - this.target = target; - this.targetPath = PartFingerprints.collect(target, ctx.viewDomNode); - } else { - this.target = null; - this.targetPath = new Uint8Array(0); - } + // If no event target is passed in, we will use the hit test target + const hasEventTarget = Boolean(this._eventTarget); + this._useHitTestTarget = !hasEventTarget; } public override toString(): string { return `pos(${this.pos.x},${this.pos.y}), editorPos(${this.editorPos.x},${this.editorPos.y}), relativePos(${this.relativePos.x},${this.relativePos.y}), mouseVerticalOffset: ${this.mouseVerticalOffset}, mouseContentHorizontalOffset: ${this.mouseContentHorizontalOffset}\n\ttarget: ${this.target ? (this.target).outerHTML : null}`; } + public get wouldBenefitFromHitTestTargetSwitch(): boolean { + return ( + !this._useHitTestTarget + && this.hitTestResult.value.hitTarget !== null + && this.target !== this.hitTestResult.value.hitTarget + ); + } + + public switchToHitTestTarget(): void { + this._useHitTestTarget = true; + } + private _getMouseColumn(position: Position | null = null): number { if (position && position.column < this._ctx.viewModel.getLineMaxColumn(position.lineNumber)) { // Most likely, the line contains foreign decorations... @@ -459,10 +490,6 @@ class HitTestRequest extends BareHitTestRequest { public fulfillOverlayWidget(detail: string): IMouseTargetOverlayWidget { return MouseTarget.createOverlayWidget(this.target, this._getMouseColumn(), detail); } - - public withTarget(target: HTMLElement | null): HitTestRequest { - return new HitTestRequest(this._ctx, this.editorPos, this.pos, this.relativePos, target); - } } interface ResolvedHitTestRequest extends HitTestRequest { @@ -509,7 +536,7 @@ export class MouseTargetFactory { const ctx = new HitTestContext(this._context, this._viewHelper, lastRenderData); const request = new HitTestRequest(ctx, editorPos, pos, relativePos, target); try { - const r = MouseTargetFactory._createMouseTarget(ctx, request, false); + const r = MouseTargetFactory._createMouseTarget(ctx, request); if (r.type === MouseTargetType.CONTENT_TEXT) { // Snap to the nearest soft tab boundary if atomic soft tabs are enabled. @@ -528,24 +555,13 @@ export class MouseTargetFactory { } } - private static _createMouseTarget(ctx: HitTestContext, request: HitTestRequest, domHitTestExecuted: boolean): IMouseTarget { + private static _createMouseTarget(ctx: HitTestContext, request: HitTestRequest): IMouseTarget { // console.log(`${domHitTestExecuted ? '=>' : ''}CAME IN REQUEST: ${request}`); - // First ensure the request has a target if (request.target === null) { - if (domHitTestExecuted) { - // Still no target... and we have already executed hit test... - return request.fulfillUnknown(); - } - - const hitTestResult = MouseTargetFactory._doHitTest(ctx, request); - - if (hitTestResult.type === HitTestResultType.Content) { - return MouseTargetFactory.createMouseTargetFromHitTestPosition(ctx, request, hitTestResult.spanNode, hitTestResult.position, hitTestResult.injectedText); - } - - return this._createMouseTarget(ctx, request.withTarget(hitTestResult.hitTarget), true); + // No target + return request.fulfillUnknown(); } // we know for a fact that request.target is not null @@ -566,7 +582,7 @@ export class MouseTargetFactory { result = result || MouseTargetFactory._hitTestMargin(ctx, resolvedRequest); result = result || MouseTargetFactory._hitTestViewCursor(ctx, resolvedRequest); result = result || MouseTargetFactory._hitTestTextArea(ctx, resolvedRequest); - result = result || MouseTargetFactory._hitTestViewLines(ctx, resolvedRequest, domHitTestExecuted); + result = result || MouseTargetFactory._hitTestViewLines(ctx, resolvedRequest); result = result || MouseTargetFactory._hitTestScrollbar(ctx, resolvedRequest); return (result || request.fulfillUnknown()); @@ -704,7 +720,7 @@ export class MouseTargetFactory { return null; } - private static _hitTestViewLines(ctx: HitTestContext, request: ResolvedHitTestRequest, domHitTestExecuted: boolean): IMouseTarget | null { + private static _hitTestViewLines(ctx: HitTestContext, request: ResolvedHitTestRequest): IMouseTarget | null { if (!ElementPath.isChildOfViewLines(request.targetPath)) { return null; } @@ -721,36 +737,41 @@ export class MouseTargetFactory { return request.fulfillContentEmpty(new Position(lineCount, maxLineColumn), EMPTY_CONTENT_AFTER_LINES); } - if (domHitTestExecuted) { - // Check if we are hitting a view-line (can happen in the case of inline decorations on empty lines) - // See https://github.com/microsoft/vscode/issues/46942 - if (ElementPath.isStrictChildOfViewLines(request.targetPath)) { - const lineNumber = ctx.getLineNumberAtVerticalOffset(request.mouseVerticalOffset); - if (ctx.viewModel.getLineLength(lineNumber) === 0) { - const lineWidth = ctx.getLineWidth(lineNumber); - const detail = createEmptyContentDataInLines(request.mouseContentHorizontalOffset - lineWidth); - return request.fulfillContentEmpty(new Position(lineNumber, 1), detail); - } - + // Check if we are hitting a view-line (can happen in the case of inline decorations on empty lines) + // See https://github.com/microsoft/vscode/issues/46942 + if (ElementPath.isStrictChildOfViewLines(request.targetPath)) { + const lineNumber = ctx.getLineNumberAtVerticalOffset(request.mouseVerticalOffset); + if (ctx.viewModel.getLineLength(lineNumber) === 0) { const lineWidth = ctx.getLineWidth(lineNumber); - if (request.mouseContentHorizontalOffset >= lineWidth) { - const detail = createEmptyContentDataInLines(request.mouseContentHorizontalOffset - lineWidth); - const pos = new Position(lineNumber, ctx.viewModel.getLineMaxColumn(lineNumber)); - return request.fulfillContentEmpty(pos, detail); - } + const detail = createEmptyContentDataInLines(request.mouseContentHorizontalOffset - lineWidth); + return request.fulfillContentEmpty(new Position(lineNumber, 1), detail); } - // We have already executed hit test... - return request.fulfillUnknown(); + const lineWidth = ctx.getLineWidth(lineNumber); + if (request.mouseContentHorizontalOffset >= lineWidth) { + // TODO: This is wrong for RTL + const detail = createEmptyContentDataInLines(request.mouseContentHorizontalOffset - lineWidth); + const pos = new Position(lineNumber, ctx.viewModel.getLineMaxColumn(lineNumber)); + return request.fulfillContentEmpty(pos, detail); + } } - const hitTestResult = MouseTargetFactory._doHitTest(ctx, request); + // Do the hit test (if not already done) + const hitTestResult = request.hitTestResult.value; if (hitTestResult.type === HitTestResultType.Content) { return MouseTargetFactory.createMouseTargetFromHitTestPosition(ctx, request, hitTestResult.spanNode, hitTestResult.position, hitTestResult.injectedText); } - return this._createMouseTarget(ctx, request.withTarget(hitTestResult.hitTarget), true); + // We didn't hit content... + if (request.wouldBenefitFromHitTestTargetSwitch) { + // We actually hit something different... Give it one last change by trying again with this new target + request.switchToHitTestTarget(); + return this._createMouseTarget(ctx, request); + } + + // We have tried everything... + return request.fulfillUnknown(); } private static _hitTestMinimap(ctx: HitTestContext, request: ResolvedHitTestRequest): IMouseTarget | null { @@ -1019,7 +1040,7 @@ export class MouseTargetFactory { return position; } - private static _doHitTest(ctx: HitTestContext, request: BareHitTestRequest): HitTestResult { + public static doHitTest(ctx: HitTestContext, request: BareHitTestRequest): HitTestResult { let result: HitTestResult = new UnknownHitTestResult(); if (typeof (ctx.viewDomNode.ownerDocument).caretRangeFromPoint === 'function') { diff --git a/src/vs/editor/browser/controller/textAreaHandler.ts b/src/vs/editor/browser/controller/textAreaHandler.ts index f1661da1fc48e..c8e5b7e50a47c 100644 --- a/src/vs/editor/browser/controller/textAreaHandler.ts +++ b/src/vs/editor/browser/controller/textAreaHandler.ts @@ -490,7 +490,7 @@ export class TextAreaHandler extends ViewPart { private _getAndroidWordAtPosition(position: Position): [string, number] { const ANDROID_WORD_SEPARATORS = '`~!@#$%^&*()-=+[{]}\\|;:",.<>/?'; const lineContent = this._context.viewModel.getLineContent(position.lineNumber); - const wordSeparators = getMapForWordSeparators(ANDROID_WORD_SEPARATORS); + const wordSeparators = getMapForWordSeparators(ANDROID_WORD_SEPARATORS, []); let goingLeft = true; let startColumn = position.column; @@ -530,7 +530,7 @@ export class TextAreaHandler extends ViewPart { private _getWordBeforePosition(position: Position): string { const lineContent = this._context.viewModel.getLineContent(position.lineNumber); - const wordSeparators = getMapForWordSeparators(this._context.configuration.options.get(EditorOption.wordSeparators)); + const wordSeparators = getMapForWordSeparators(this._context.configuration.options.get(EditorOption.wordSeparators), []); let column = position.column; let distance = 0; diff --git a/src/vs/editor/browser/controller/textAreaInput.ts b/src/vs/editor/browser/controller/textAreaInput.ts index 88b4eb324e6af..88e783a1f7d0b 100644 --- a/src/vs/editor/browser/controller/textAreaInput.ts +++ b/src/vs/editor/browser/controller/textAreaInput.ts @@ -196,7 +196,7 @@ export class TextAreaInput extends Disposable { private readonly _asyncTriggerCut: RunOnceScheduler; - private _asyncFocusGainWriteScreenReaderContent: MutableDisposable = this._register(new MutableDisposable()); + private readonly _asyncFocusGainWriteScreenReaderContent: MutableDisposable = this._register(new MutableDisposable()); private _textAreaState: TextAreaState; diff --git a/src/vs/editor/browser/coreCommands.ts b/src/vs/editor/browser/coreCommands.ts index 927b24981dce5..ca0a4bb8a1f2c 100644 --- a/src/vs/editor/browser/coreCommands.ts +++ b/src/vs/editor/browser/coreCommands.ts @@ -397,7 +397,7 @@ export namespace CoreNavigationCommands { ] ); if (cursorStateChanged && args.revealType !== NavigationCommandRevealType.None) { - viewModel.revealPrimaryCursor(args.source, true, true); + viewModel.revealAllCursors(args.source, true, true); } } } @@ -609,7 +609,7 @@ export namespace CoreNavigationCommands { CursorChangeReason.Explicit, CursorMoveImpl._move(viewModel, viewModel.getCursorStates(), args) ); - viewModel.revealPrimaryCursor(source, true); + viewModel.revealAllCursors(source, true); } private static _move(viewModel: IViewModel, cursors: CursorState[], args: CursorMove_.ParsedArguments): PartialCursorState[] | null { @@ -678,7 +678,7 @@ export namespace CoreNavigationCommands { CursorChangeReason.Explicit, CursorMoveCommands.simpleMove(viewModel, viewModel.getCursorStates(), args.direction, args.select, args.value, args.unit) ); - viewModel.revealPrimaryCursor(dynamicArgs.source, true); + viewModel.revealAllCursors(dynamicArgs.source, true); } } @@ -993,7 +993,7 @@ export namespace CoreNavigationCommands { CursorChangeReason.Explicit, CursorMoveCommands.moveToBeginningOfLine(viewModel, viewModel.getCursorStates(), this._inSelectionMode) ); - viewModel.revealPrimaryCursor(args.source, true); + viewModel.revealAllCursors(args.source, true); } } @@ -1037,7 +1037,7 @@ export namespace CoreNavigationCommands { CursorChangeReason.Explicit, this._exec(viewModel.getCursorStates()) ); - viewModel.revealPrimaryCursor(args.source, true); + viewModel.revealAllCursors(args.source, true); } private _exec(cursors: CursorState[]): PartialCursorState[] { @@ -1095,7 +1095,7 @@ export namespace CoreNavigationCommands { CursorChangeReason.Explicit, CursorMoveCommands.moveToEndOfLine(viewModel, viewModel.getCursorStates(), this._inSelectionMode, args.sticky || false) ); - viewModel.revealPrimaryCursor(args.source, true); + viewModel.revealAllCursors(args.source, true); } } @@ -1173,7 +1173,7 @@ export namespace CoreNavigationCommands { CursorChangeReason.Explicit, this._exec(viewModel, viewModel.getCursorStates()) ); - viewModel.revealPrimaryCursor(args.source, true); + viewModel.revealAllCursors(args.source, true); } private _exec(viewModel: IViewModel, cursors: CursorState[]): PartialCursorState[] { @@ -1228,7 +1228,7 @@ export namespace CoreNavigationCommands { CursorChangeReason.Explicit, CursorMoveCommands.moveToBeginningOfBuffer(viewModel, viewModel.getCursorStates(), this._inSelectionMode) ); - viewModel.revealPrimaryCursor(args.source, true); + viewModel.revealAllCursors(args.source, true); } } @@ -1272,7 +1272,7 @@ export namespace CoreNavigationCommands { CursorChangeReason.Explicit, CursorMoveCommands.moveToEndOfBuffer(viewModel, viewModel.getCursorStates(), this._inSelectionMode) ); - viewModel.revealPrimaryCursor(args.source, true); + viewModel.revealAllCursors(args.source, true); } } @@ -1644,7 +1644,7 @@ export namespace CoreNavigationCommands { ] ); if (args.revealType !== NavigationCommandRevealType.None) { - viewModel.revealPrimaryCursor(args.source, true, true); + viewModel.revealAllCursors(args.source, true, true); } } } @@ -1710,7 +1710,7 @@ export namespace CoreNavigationCommands { ] ); if (args.revealType !== NavigationCommandRevealType.None) { - viewModel.revealPrimaryCursor(args.source, false, true); + viewModel.revealAllCursors(args.source, false, true); } } } @@ -1789,7 +1789,7 @@ export namespace CoreNavigationCommands { CursorMoveCommands.cancelSelection(viewModel, viewModel.getPrimaryCursorState()) ] ); - viewModel.revealPrimaryCursor(args.source, true); + viewModel.revealAllCursors(args.source, true); } }); @@ -1816,7 +1816,7 @@ export namespace CoreNavigationCommands { viewModel.getPrimaryCursorState() ] ); - viewModel.revealPrimaryCursor(args.source, true); + viewModel.revealAllCursors(args.source, true); status(nls.localize('removedCursor', "Removed secondary cursors")); } }); diff --git a/src/vs/editor/browser/editorBrowser.ts b/src/vs/editor/browser/editorBrowser.ts index ecedcdb42a9c0..7678c6e3b88f2 100644 --- a/src/vs/editor/browser/editorBrowser.ts +++ b/src/vs/editor/browser/editorBrowser.ts @@ -510,6 +510,18 @@ export interface IPartialEditorMouseEvent { export interface IPasteEvent { readonly range: Range; readonly languageId: string | null; + readonly clipboardEvent?: ClipboardEvent; +} + +/** + * @internal + */ +export interface PastePayload { + text: string; + pasteOnNewLine: boolean; + multicursorText: string[] | null; + mode: string | null; + clipboardEvent?: ClipboardEvent; } /** diff --git a/src/vs/editor/browser/editorDom.ts b/src/vs/editor/browser/editorDom.ts index f295dd8b43da4..a894d0034ecd4 100644 --- a/src/vs/editor/browser/editorDom.ts +++ b/src/vs/editor/browser/editorDom.ts @@ -359,7 +359,7 @@ export interface CssProperties { class RefCountedCssRule { private _referenceCount: number = 0; private _styleElement: HTMLStyleElement | undefined; - private _styleElementDisposables: DisposableStore; + private readonly _styleElementDisposables: DisposableStore; constructor( public readonly key: string, diff --git a/src/vs/editor/browser/services/editorWorkerService.ts b/src/vs/editor/browser/services/editorWorkerService.ts index f707c6fa25e84..aa1e41c89850a 100644 --- a/src/vs/editor/browser/services/editorWorkerService.ts +++ b/src/vs/editor/browser/services/editorWorkerService.ts @@ -29,7 +29,8 @@ import { IDocumentDiff, IDocumentDiffProviderOptions } from 'vs/editor/common/di import { ILinesDiffComputerOptions, MovedText } from 'vs/editor/common/diff/linesDiffComputer'; import { DetailedLineRangeMapping, RangeMapping, LineRangeMapping } from 'vs/editor/common/diff/rangeMapping'; import { LineRange } from 'vs/editor/common/core/lineRange'; -import { $window } from 'vs/base/browser/window'; +import { SectionHeader, FindSectionHeaderOptions } from 'vs/editor/common/services/findSectionHeaders'; +import { mainWindow } from 'vs/base/browser/window'; import { WindowIntervalTimer } from 'vs/base/browser/dom'; /** @@ -190,6 +191,10 @@ export class EditorWorkerService extends Disposable implements IEditorWorkerServ computeWordRanges(resource: URI, range: IRange): Promise<{ [word: string]: IRange[] } | null> { return this._workerManager.withWorker().then(client => client.computeWordRanges(resource, range)); } + + public findSectionHeaders(uri: URI, options: FindSectionHeaderOptions): Promise { + return this._workerManager.withWorker().then(client => client.findSectionHeaders(uri, options)); + } } class WordBasedCompletionItemProvider implements languages.CompletionItemProvider { @@ -283,7 +288,7 @@ class WorkerManager extends Disposable { this._lastWorkerUsedTime = (new Date()).getTime(); const stopWorkerInterval = this._register(new WindowIntervalTimer()); - stopWorkerInterval.cancelAndSet(() => this._checkStopIdleWorker(), Math.round(STOP_WORKER_DELTA_TIME_MS / 2), $window); + stopWorkerInterval.cancelAndSet(() => this._checkStopIdleWorker(), Math.round(STOP_WORKER_DELTA_TIME_MS / 2), mainWindow); this._register(this._modelService.onModelRemoved(_ => this._checkStopEmptyWorker())); } @@ -613,6 +618,12 @@ export class EditorWorkerClient extends Disposable implements IEditorWorkerClien }); } + public findSectionHeaders(uri: URI, options: FindSectionHeaderOptions): Promise { + return this._withSyncedResources([uri]).then(proxy => { + return proxy.findSectionHeaders(uri.toString(), options); + }); + } + override dispose(): void { super.dispose(); this._disposed = true; diff --git a/src/vs/editor/browser/widget/hoverWidget/hover.css b/src/vs/editor/browser/services/hoverService/hover.css similarity index 100% rename from src/vs/editor/browser/widget/hoverWidget/hover.css rename to src/vs/editor/browser/services/hoverService/hover.css diff --git a/src/vs/editor/browser/services/hoverService.ts b/src/vs/editor/browser/services/hoverService/hoverService.ts similarity index 57% rename from src/vs/editor/browser/services/hoverService.ts rename to src/vs/editor/browser/services/hoverService/hoverService.ts index 9dea5dab8125d..591eb952212fd 100644 --- a/src/vs/editor/browser/services/hoverService.ts +++ b/src/vs/editor/browser/services/hoverService/hoverService.ts @@ -6,12 +6,12 @@ import { InstantiationType, registerSingleton } from 'vs/platform/instantiation/common/extensions'; import { registerThemingParticipant } from 'vs/platform/theme/common/themeService'; import { editorHoverBorder } from 'vs/platform/theme/common/colorRegistry'; -import { IHoverService, IHoverOptions } from 'vs/platform/hover/browser/hover'; -import { IContextMenuService, IContextViewService } from 'vs/platform/contextview/browser/contextView'; +import { IHoverService } from 'vs/platform/hover/browser/hover'; +import { IContextMenuService } from 'vs/platform/contextview/browser/contextView'; import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; -import { HoverWidget } from 'vs/editor/browser/widget/hoverWidget/hoverWidget'; +import { HoverWidget } from 'vs/editor/browser/services/hoverService/hoverWidget'; import { IContextViewProvider, IDelegate } from 'vs/base/browser/ui/contextview/contextview'; -import { DisposableStore, IDisposable, toDisposable } from 'vs/base/common/lifecycle'; +import { Disposable, DisposableStore, IDisposable, toDisposable } from 'vs/base/common/lifecycle'; import { addDisposableListener, EventType, getActiveElement, isAncestorOfActiveElement, isAncestor, getWindow } from 'vs/base/browser/dom'; import { IKeybindingService } from 'vs/platform/keybinding/common/keybinding'; import { StandardKeyboardEvent } from 'vs/base/browser/keyboardEvent'; @@ -19,11 +19,16 @@ import { ResultKind } from 'vs/platform/keybinding/common/keybindingResolver'; import { IAccessibilityService } from 'vs/platform/accessibility/common/accessibility'; import { ILayoutService } from 'vs/platform/layout/browser/layoutService'; import { mainWindow } from 'vs/base/browser/window'; -import { IHoverWidget } from 'vs/base/browser/ui/iconLabel/iconHoverDelegate'; +import { ContextViewHandler } from 'vs/platform/contextview/browser/contextViewService'; +import type { IHoverOptions, IHoverWidget, IUpdatableHover, IUpdatableHoverContentOrFactory, IUpdatableHoverOptions } from 'vs/base/browser/ui/hover/hover'; +import type { IHoverDelegate, IHoverDelegateTarget } from 'vs/base/browser/ui/hover/hoverDelegate'; +import { UpdatableHoverWidget } from 'vs/editor/browser/services/hoverService/updatableHoverWidget'; +import { TimeoutTimer } from 'vs/base/common/async'; -export class HoverService implements IHoverService { +export class HoverService extends Disposable implements IHoverService { declare readonly _serviceBrand: undefined; + private _contextViewHandler: IContextViewProvider; private _currentHoverOptions: IHoverOptions | undefined; private _currentHover: HoverWidget | undefined; private _lastHoverOptions: IHoverOptions | undefined; @@ -32,13 +37,15 @@ export class HoverService implements IHoverService { constructor( @IInstantiationService private readonly _instantiationService: IInstantiationService, - @IContextViewService private readonly _contextViewService: IContextViewService, @IContextMenuService contextMenuService: IContextMenuService, @IKeybindingService private readonly _keybindingService: IKeybindingService, @ILayoutService private readonly _layoutService: ILayoutService, @IAccessibilityService private readonly _accessibilityService: IAccessibilityService ) { + super(); + contextMenuService.onDidShowContextMenu(() => this.hideHover()); + this._contextViewHandler = this._register(new ContextViewHandler(this._layoutService)); } showHover(options: IHoverOptions, focus?: boolean, skipLastFocusedUpdate?: boolean): IHoverWidget | undefined { @@ -78,18 +85,18 @@ export class HoverService implements IHoverService { this._currentHoverOptions = undefined; } hoverDisposables.dispose(); - }); + }, undefined, hoverDisposables); // Set the container explicitly to enable aux window support if (!options.container) { const targetElement = options.target instanceof HTMLElement ? options.target : options.target.targetElements[0]; options.container = this._layoutService.getContainer(getWindow(targetElement)); } - const provider = this._contextViewService as IContextViewProvider; - provider.showContextView( + + this._contextViewHandler.showContextView( new HoverContextViewDelegate(hover, focus), options.container ); - hover.onRequestLayout(() => provider.layout()); + hover.onRequestLayout(() => this._contextViewHandler.layout(), undefined, hoverDisposables); if (options.persistence?.sticky) { hoverDisposables.add(addDisposableListener(getWindow(options.container).document, EventType.MOUSE_DOWN, e => { if (!isAncestor(e.target as HTMLElement, hover.domNode)) { @@ -136,7 +143,7 @@ export class HoverService implements IHoverService { private doHideHover(): void { this._currentHover = undefined; this._currentHoverOptions = undefined; - this._contextViewService.hideContextView(); + this._contextViewHandler.hideContextView(); } private _intersectionChange(entries: IntersectionObserverEntry[], hover: IDisposable): void { @@ -179,6 +186,137 @@ export class HoverService implements IHoverService { } } } + + // TODO: Investigate performance of this function. There seems to be a lot of content created + // and thrown away on start up + setupUpdatableHover(hoverDelegate: IHoverDelegate, htmlElement: HTMLElement, content: IUpdatableHoverContentOrFactory, options?: IUpdatableHoverOptions | undefined): IUpdatableHover { + + htmlElement.setAttribute('custom-hover', 'true'); + + if (htmlElement.title !== '') { + console.warn('HTML element already has a title attribute, which will conflict with the custom hover. Please remove the title attribute.'); + console.trace('Stack trace:', htmlElement.title); + htmlElement.title = ''; + } + + let hoverPreparation: IDisposable | undefined; + let hoverWidget: UpdatableHoverWidget | undefined; + + const hideHover = (disposeWidget: boolean, disposePreparation: boolean) => { + const hadHover = hoverWidget !== undefined; + if (disposeWidget) { + hoverWidget?.dispose(); + hoverWidget = undefined; + } + if (disposePreparation) { + hoverPreparation?.dispose(); + hoverPreparation = undefined; + } + if (hadHover) { + hoverDelegate.onDidHideHover?.(); + hoverWidget = undefined; + } + }; + + const triggerShowHover = (delay: number, focus?: boolean, target?: IHoverDelegateTarget) => { + return new TimeoutTimer(async () => { + if (!hoverWidget || hoverWidget.isDisposed) { + hoverWidget = new UpdatableHoverWidget(hoverDelegate, target || htmlElement, delay > 0); + await hoverWidget.update(typeof content === 'function' ? content() : content, focus, options); + } + }, delay); + }; + + let isMouseDown = false; + const mouseDownEmitter = addDisposableListener(htmlElement, EventType.MOUSE_DOWN, () => { + isMouseDown = true; + hideHover(true, true); + }, true); + const mouseUpEmitter = addDisposableListener(htmlElement, EventType.MOUSE_UP, () => { + isMouseDown = false; + }, true); + const mouseLeaveEmitter = addDisposableListener(htmlElement, EventType.MOUSE_LEAVE, (e: MouseEvent) => { + isMouseDown = false; + hideHover(false, (e).fromElement === htmlElement); + }, true); + + const onMouseOver = (e: MouseEvent) => { + if (hoverPreparation) { + return; + } + + const toDispose: DisposableStore = new DisposableStore(); + + const target: IHoverDelegateTarget = { + targetElements: [htmlElement], + dispose: () => { } + }; + if (hoverDelegate.placement === undefined || hoverDelegate.placement === 'mouse') { + // track the mouse position + const onMouseMove = (e: MouseEvent) => { + target.x = e.x + 10; + if ((e.target instanceof HTMLElement) && getHoverTargetElement(e.target, htmlElement) !== htmlElement) { + hideHover(true, true); + } + }; + toDispose.add(addDisposableListener(htmlElement, EventType.MOUSE_MOVE, onMouseMove, true)); + } + + hoverPreparation = toDispose; + + if ((e.target instanceof HTMLElement) && getHoverTargetElement(e.target as HTMLElement, htmlElement) !== htmlElement) { + return; // Do not show hover when the mouse is over another hover target + } + + toDispose.add(triggerShowHover(hoverDelegate.delay, false, target)); + }; + const mouseOverDomEmitter = addDisposableListener(htmlElement, EventType.MOUSE_OVER, onMouseOver, true); + + const onFocus = () => { + if (isMouseDown || hoverPreparation) { + return; + } + const target: IHoverDelegateTarget = { + targetElements: [htmlElement], + dispose: () => { } + }; + const toDispose: DisposableStore = new DisposableStore(); + const onBlur = () => hideHover(true, true); + toDispose.add(addDisposableListener(htmlElement, EventType.BLUR, onBlur, true)); + toDispose.add(triggerShowHover(hoverDelegate.delay, false, target)); + hoverPreparation = toDispose; + }; + + // Do not show hover when focusing an input or textarea + let focusDomEmitter: undefined | IDisposable; + const tagName = htmlElement.tagName.toLowerCase(); + if (tagName !== 'input' && tagName !== 'textarea') { + focusDomEmitter = addDisposableListener(htmlElement, EventType.FOCUS, onFocus, true); + } + + const hover: IUpdatableHover = { + show: focus => { + hideHover(false, true); // terminate a ongoing mouse over preparation + triggerShowHover(0, focus); // show hover immediately + }, + hide: () => { + hideHover(true, true); + }, + update: async (newContent, hoverOptions) => { + content = newContent; + await hoverWidget?.update(content, undefined, hoverOptions); + }, + dispose: () => { + mouseOverDomEmitter.dispose(); + mouseLeaveEmitter.dispose(); + mouseDownEmitter.dispose(); + mouseUpEmitter.dispose(); + focusDomEmitter?.dispose(); + hideHover(true, true); + } + }; + return hover; + } } function getHoverOptionsIdentity(options: IHoverOptions | undefined): IHoverOptions | number | string | undefined { @@ -190,6 +328,9 @@ function getHoverOptionsIdentity(options: IHoverOptions | undefined): IHoverOpti class HoverContextViewDelegate implements IDelegate { + // Render over all other context views + public readonly layer = 1; + get anchorPosition() { return this._hover.anchor; } @@ -220,6 +361,14 @@ class HoverContextViewDelegate implements IDelegate { } } +function getHoverTargetElement(element: HTMLElement, stopElement?: HTMLElement): HTMLElement { + stopElement = stopElement ?? getWindow(element).document.body; + while (!element.hasAttribute('custom-hover') && element !== stopElement) { + element = element.parentElement!; + } + return element; +} + registerSingleton(IHoverService, HoverService, InstantiationType.Delayed); registerThemingParticipant((theme, collector) => { diff --git a/src/vs/editor/browser/widget/hoverWidget/hoverWidget.ts b/src/vs/editor/browser/services/hoverService/hoverWidget.ts similarity index 96% rename from src/vs/editor/browser/widget/hoverWidget/hoverWidget.ts rename to src/vs/editor/browser/services/hoverService/hoverWidget.ts index d6f6249675c3a..163633fa20633 100644 --- a/src/vs/editor/browser/widget/hoverWidget/hoverWidget.ts +++ b/src/vs/editor/browser/services/hoverService/hoverWidget.ts @@ -8,7 +8,6 @@ import { DisposableStore } from 'vs/base/common/lifecycle'; import { Event, Emitter } from 'vs/base/common/event'; import * as dom from 'vs/base/browser/dom'; import { IKeybindingService } from 'vs/platform/keybinding/common/keybinding'; -import { IHoverTarget, IHoverOptions } from 'vs/platform/hover/browser/hover'; import { KeyCode } from 'vs/base/common/keyCodes'; import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; import { EDITOR_FONT_DEFAULTS, IEditorOptions } from 'vs/editor/common/config/editorOptions'; @@ -23,7 +22,7 @@ import { localize } from 'vs/nls'; import { isMacintosh } from 'vs/base/common/platform'; import { IAccessibilityService } from 'vs/platform/accessibility/common/accessibility'; import { status } from 'vs/base/browser/ui/aria/aria'; -import { IHoverWidget } from 'vs/base/browser/ui/iconLabel/iconHoverDelegate'; +import type { IHoverOptions, IHoverTarget, IHoverWidget } from 'vs/base/browser/ui/hover/hover'; const $ = dom.$; type TargetRect = { @@ -331,10 +330,7 @@ export class HoverWidget extends Widget implements IHoverWidget { }; const targetBounds = this._target.targetElements.map(e => getZoomAccountedBoundingClientRect(e)); - const top = Math.min(...targetBounds.map(e => e.top)); - const right = Math.max(...targetBounds.map(e => e.right)); - const bottom = Math.max(...targetBounds.map(e => e.bottom)); - const left = Math.min(...targetBounds.map(e => e.left)); + const { top, right, bottom, left } = targetBounds[0]; const width = right - left; const height = bottom - top; @@ -472,9 +468,11 @@ export class HoverWidget extends Widget implements IHoverWidget { return; } + const hoverPointerOffset = (this._hoverPointer ? Constants.PointerSize : 0); + // When force position is enabled, restrict max width if (this._forcePosition) { - const padding = (this._hoverPointer ? Constants.PointerSize : 0) + Constants.HoverBorderWidth; + const padding = hoverPointerOffset + Constants.HoverBorderWidth; if (this._hoverPosition === HoverPosition.RIGHT) { this._hover.containerDomNode.style.maxWidth = `${this._targetDocumentElement.clientWidth - target.right - padding}px`; } else if (this._hoverPosition === HoverPosition.LEFT) { @@ -487,10 +485,10 @@ export class HoverWidget extends Widget implements IHoverWidget { if (this._hoverPosition === HoverPosition.RIGHT) { const roomOnRight = this._targetDocumentElement.clientWidth - target.right; // Hover on the right is going beyond window. - if (roomOnRight < this._hover.containerDomNode.clientWidth) { + if (roomOnRight < this._hover.containerDomNode.clientWidth + hoverPointerOffset) { const roomOnLeft = target.left; // There's enough room on the left, flip the hover position - if (roomOnLeft >= this._hover.containerDomNode.clientWidth) { + if (roomOnLeft >= this._hover.containerDomNode.clientWidth + hoverPointerOffset) { this._hoverPosition = HoverPosition.LEFT; } // Hover on the left would go beyond window too @@ -504,10 +502,10 @@ export class HoverWidget extends Widget implements IHoverWidget { const roomOnLeft = target.left; // Hover on the left is going beyond window. - if (roomOnLeft < this._hover.containerDomNode.clientWidth) { + if (roomOnLeft < this._hover.containerDomNode.clientWidth + hoverPointerOffset) { const roomOnRight = this._targetDocumentElement.clientWidth - target.right; // There's enough room on the right, flip the hover position - if (roomOnRight >= this._hover.containerDomNode.clientWidth) { + if (roomOnRight >= this._hover.containerDomNode.clientWidth + hoverPointerOffset) { this._hoverPosition = HoverPosition.RIGHT; } // Hover on the right would go beyond window too @@ -516,7 +514,7 @@ export class HoverWidget extends Widget implements IHoverWidget { } } // Hover on the left is going beyond window. - if (target.left - this._hover.containerDomNode.clientWidth <= this._targetDocumentElement.clientLeft) { + if (target.left - this._hover.containerDomNode.clientWidth - hoverPointerOffset <= this._targetDocumentElement.clientLeft) { this._hoverPosition = HoverPosition.RIGHT; } } @@ -529,10 +527,12 @@ export class HoverWidget extends Widget implements IHoverWidget { return; } + const hoverPointerOffset = (this._hoverPointer ? Constants.PointerSize : 0); + // Position hover on top of the target if (this._hoverPosition === HoverPosition.ABOVE) { // Hover on top is going beyond window - if (target.top - this._hover.containerDomNode.clientHeight < 0) { + if (target.top - this._hover.containerDomNode.clientHeight - hoverPointerOffset < 0) { this._hoverPosition = HoverPosition.BELOW; } } @@ -540,7 +540,7 @@ export class HoverWidget extends Widget implements IHoverWidget { // Position hover below the target else if (this._hoverPosition === HoverPosition.BELOW) { // Hover on bottom is going beyond window - if (target.bottom + this._hover.containerDomNode.clientHeight > this._targetWindow.innerHeight) { + if (target.bottom + this._hover.containerDomNode.clientHeight + hoverPointerOffset > this._targetWindow.innerHeight) { this._hoverPosition = HoverPosition.ABOVE; } } diff --git a/src/vs/editor/browser/services/hoverService/updatableHoverWidget.ts b/src/vs/editor/browser/services/hoverService/updatableHoverWidget.ts new file mode 100644 index 0000000000000..869e493c1f983 --- /dev/null +++ b/src/vs/editor/browser/services/hoverService/updatableHoverWidget.ts @@ -0,0 +1,109 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import type { IHoverWidget, IUpdatableHoverContent, IUpdatableHoverOptions } from 'vs/base/browser/ui/hover/hover'; +import type { IHoverDelegate, IHoverDelegateOptions, IHoverDelegateTarget } from 'vs/base/browser/ui/hover/hoverDelegate'; +import { HoverPosition } from 'vs/base/browser/ui/hover/hoverWidget'; +import { CancellationTokenSource } from 'vs/base/common/cancellation'; +import { isMarkdownString, type IMarkdownString } from 'vs/base/common/htmlContent'; +import { IDisposable } from 'vs/base/common/lifecycle'; +import { isFunction, isString } from 'vs/base/common/types'; +import { localize } from 'vs/nls'; + +type IUpdatableHoverResolvedContent = IMarkdownString | string | HTMLElement | undefined; + +export class UpdatableHoverWidget implements IDisposable { + + private _hoverWidget: IHoverWidget | undefined; + private _cancellationTokenSource: CancellationTokenSource | undefined; + + constructor(private hoverDelegate: IHoverDelegate, private target: IHoverDelegateTarget | HTMLElement, private fadeInAnimation: boolean) { + } + + async update(content: IUpdatableHoverContent, focus?: boolean, options?: IUpdatableHoverOptions): Promise { + if (this._cancellationTokenSource) { + // there's an computation ongoing, cancel it + this._cancellationTokenSource.dispose(true); + this._cancellationTokenSource = undefined; + } + if (this.isDisposed) { + return; + } + + let resolvedContent; + if (content === undefined || isString(content) || content instanceof HTMLElement) { + resolvedContent = content; + } else if (!isFunction(content.markdown)) { + resolvedContent = content.markdown ?? content.markdownNotSupportedFallback; + } else { + // compute the content, potentially long-running + + // show 'Loading' if no hover is up yet + if (!this._hoverWidget) { + this.show(localize('iconLabel.loading', "Loading..."), focus); + } + + // compute the content + this._cancellationTokenSource = new CancellationTokenSource(); + const token = this._cancellationTokenSource.token; + resolvedContent = await content.markdown(token); + if (resolvedContent === undefined) { + resolvedContent = content.markdownNotSupportedFallback; + } + + if (this.isDisposed || token.isCancellationRequested) { + // either the widget has been closed in the meantime + // or there has been a new call to `update` + return; + } + } + + this.show(resolvedContent, focus, options); + } + + private show(content: IUpdatableHoverResolvedContent, focus?: boolean, options?: IUpdatableHoverOptions): void { + const oldHoverWidget = this._hoverWidget; + + if (this.hasContent(content)) { + const hoverOptions: IHoverDelegateOptions = { + content, + target: this.target, + appearance: { + showPointer: this.hoverDelegate.placement === 'element', + skipFadeInAnimation: !this.fadeInAnimation || !!oldHoverWidget, // do not fade in if the hover is already showing + }, + position: { + hoverPosition: HoverPosition.BELOW, + }, + ...options + }; + + this._hoverWidget = this.hoverDelegate.showHover(hoverOptions, focus); + } + oldHoverWidget?.dispose(); + } + + private hasContent(content: IUpdatableHoverResolvedContent): content is NonNullable { + if (!content) { + return false; + } + + if (isMarkdownString(content)) { + return !!content.value; + } + + return true; + } + + get isDisposed() { + return this._hoverWidget?.isDisposed; + } + + dispose(): void { + this._hoverWidget?.dispose(); + this._cancellationTokenSource?.dispose(true); + this._cancellationTokenSource = undefined; + } +} diff --git a/src/vs/editor/browser/stableEditorScroll.ts b/src/vs/editor/browser/stableEditorScroll.ts index 9f145b5ea6463..fe02be8043019 100644 --- a/src/vs/editor/browser/stableEditorScroll.ts +++ b/src/vs/editor/browser/stableEditorScroll.ts @@ -62,3 +62,44 @@ export class StableEditorScrollState { editor.setScrollTop(editor.getScrollTop() + offset); } } + + +export class StableEditorBottomScrollState { + + public static capture(editor: ICodeEditor): StableEditorBottomScrollState { + if (editor.hasPendingScrollAnimation()) { + // Never mess with the scroll if there is a pending scroll animation + return new StableEditorBottomScrollState(editor.getScrollTop(), editor.getContentHeight(), null, 0); + } + + let visiblePosition: Position | null = null; + let visiblePositionScrollDelta = 0; + const visibleRanges = editor.getVisibleRanges(); + if (visibleRanges.length > 0) { + visiblePosition = visibleRanges.at(-1)!.getEndPosition(); + const visiblePositionScrollBottom = editor.getBottomForLineNumber(visiblePosition.lineNumber); + visiblePositionScrollDelta = (editor.getScrollTop() + editor.getLayoutInfo().height) - visiblePositionScrollBottom; + } + return new StableEditorBottomScrollState(editor.getScrollTop(), editor.getContentHeight(), visiblePosition, visiblePositionScrollDelta); + } + + constructor( + private readonly _initialScrollTop: number, + private readonly _initialContentHeight: number, + private readonly _visiblePosition: Position | null, + private readonly _visiblePositionScrollDelta: number, + ) { + } + + public restore(editor: ICodeEditor): void { + if (this._initialContentHeight === editor.getContentHeight() && this._initialScrollTop === editor.getScrollTop()) { + // The editor's content height and scroll top haven't changed, so we don't need to do anything + return; + } + + if (this._visiblePosition) { + const visiblePositionScrollBottom = editor.getBottomForLineNumber(this._visiblePosition.lineNumber); + editor.setScrollTop(visiblePositionScrollBottom - (this._visiblePositionScrollDelta + editor.getLayoutInfo().height)); + } + } +} diff --git a/src/vs/editor/browser/view.ts b/src/vs/editor/browser/view.ts index ba0b500876097..a803234c4b424 100644 --- a/src/vs/editor/browser/view.ts +++ b/src/vs/editor/browser/view.ts @@ -339,8 +339,9 @@ export class View extends ViewEventHandler { this._overflowGuardContainer.setWidth(layoutInfo.width); this._overflowGuardContainer.setHeight(layoutInfo.height); - this._linesContent.setWidth(1000000); - this._linesContent.setHeight(1000000); + // https://stackoverflow.com/questions/38905916/content-in-google-chrome-larger-than-16777216-px-not-being-rendered + this._linesContent.setWidth(16777216); + this._linesContent.setHeight(16777216); } private _getEditorClassName() { diff --git a/src/vs/editor/browser/view/viewLayer.ts b/src/vs/editor/browser/view/viewLayer.ts index c15239ec8b1a3..bbbb0dd9d7314 100644 --- a/src/vs/editor/browser/view/viewLayer.ts +++ b/src/vs/editor/browser/view/viewLayer.ts @@ -22,12 +22,12 @@ export interface IVisibleLine extends ILine { * Return null if the HTML should not be touched. * Return the new HTML otherwise. */ - renderLine(lineNumber: number, deltaTop: number, viewportData: ViewportData, sb: StringBuilder): boolean; + renderLine(lineNumber: number, deltaTop: number, lineHeight: number, viewportData: ViewportData, sb: StringBuilder): boolean; /** * Layout the line. */ - layoutLine(lineNumber: number, deltaTop: number): void; + layoutLine(lineNumber: number, deltaTop: number, lineHeight: number): void; } export interface ILine { @@ -465,7 +465,7 @@ class ViewLayerRenderer { for (let i = startIndex; i <= endIndex; i++) { const lineNumber = rendLineNumberStart + i; - lines[i].layoutLine(lineNumber, deltaTop[lineNumber - deltaLN]); + lines[i].layoutLine(lineNumber, deltaTop[lineNumber - deltaLN], this.viewportData.lineHeight); } } @@ -573,7 +573,7 @@ class ViewLayerRenderer { continue; } - const renderResult = line.renderLine(i + rendLineNumberStart, deltaTop[i], this.viewportData, sb); + const renderResult = line.renderLine(i + rendLineNumberStart, deltaTop[i], this.viewportData.lineHeight, this.viewportData, sb); if (!renderResult) { // line does not need rendering continue; @@ -603,7 +603,7 @@ class ViewLayerRenderer { continue; } - const renderResult = line.renderLine(i + rendLineNumberStart, deltaTop[i], this.viewportData, sb); + const renderResult = line.renderLine(i + rendLineNumberStart, deltaTop[i], this.viewportData.lineHeight, this.viewportData, sb); if (!renderResult) { // line does not need rendering continue; diff --git a/src/vs/editor/browser/view/viewOverlays.ts b/src/vs/editor/browser/view/viewOverlays.ts index 83a3cc05d6f98..1041fd58a5893 100644 --- a/src/vs/editor/browser/view/viewOverlays.ts +++ b/src/vs/editor/browser/view/viewOverlays.ts @@ -9,7 +9,6 @@ import { DynamicViewOverlay } from 'vs/editor/browser/view/dynamicViewOverlay'; import { IVisibleLine, IVisibleLinesHost, VisibleLinesCollection } from 'vs/editor/browser/view/viewLayer'; import { ViewPart } from 'vs/editor/browser/view/viewPart'; import { StringBuilder } from 'vs/editor/common/core/stringBuilder'; -import { IEditorConfiguration } from 'vs/editor/common/config/editorConfiguration'; import { RenderingContext, RestrictedRenderingContext } from 'vs/editor/browser/view/renderingContext'; import { ViewContext } from 'vs/editor/common/viewModel/viewContext'; import * as viewEvents from 'vs/editor/common/viewEvents'; @@ -71,7 +70,7 @@ export class ViewOverlays extends ViewPart implements IVisibleLinesHost | null; private _renderedContent: string | null; - private _lineHeight: number; - constructor(configuration: IEditorConfiguration, dynamicOverlays: DynamicViewOverlay[]) { - this._configuration = configuration; - this._lineHeight = this._configuration.options.get(EditorOption.lineHeight); + constructor(dynamicOverlays: DynamicViewOverlay[]) { this._dynamicOverlays = dynamicOverlays; this._domNode = null; @@ -180,11 +169,8 @@ export class ViewOverlayLine implements IVisibleLine { public onTokensChanged(): void { // Nothing } - public onConfigurationChanged(e: viewEvents.ViewConfigurationChangedEvent): void { - this._lineHeight = this._configuration.options.get(EditorOption.lineHeight); - } - public renderLine(lineNumber: number, deltaTop: number, viewportData: ViewportData, sb: StringBuilder): boolean { + public renderLine(lineNumber: number, deltaTop: number, lineHeight: number, viewportData: ViewportData, sb: StringBuilder): boolean { let result = ''; for (let i = 0, len = this._dynamicOverlays.length; i < len; i++) { const dynamicOverlay = this._dynamicOverlays[i]; @@ -198,10 +184,10 @@ export class ViewOverlayLine implements IVisibleLine { this._renderedContent = result; - sb.appendString('
'); sb.appendString(result); sb.appendString('
'); @@ -209,10 +195,10 @@ export class ViewOverlayLine implements IVisibleLine { return true; } - public layoutLine(lineNumber: number, deltaTop: number): void { + public layoutLine(lineNumber: number, deltaTop: number, lineHeight: number): void { if (this._domNode) { this._domNode.setTop(deltaTop); - this._domNode.setHeight(this._lineHeight); + this._domNode.setHeight(lineHeight); } } } diff --git a/src/vs/editor/browser/viewParts/currentLineHighlight/currentLineHighlight.css b/src/vs/editor/browser/viewParts/currentLineHighlight/currentLineHighlight.css index 2a0e39dffa7b1..403e255fac823 100644 --- a/src/vs/editor/browser/viewParts/currentLineHighlight/currentLineHighlight.css +++ b/src/vs/editor/browser/viewParts/currentLineHighlight/currentLineHighlight.css @@ -9,6 +9,7 @@ left: 0; top: 0; box-sizing: border-box; + height: 100%; } .monaco-editor .margin-view-overlays .current-line { @@ -17,8 +18,11 @@ left: 0; top: 0; box-sizing: border-box; + height: 100%; } -.monaco-editor .margin-view-overlays .current-line.current-line-margin.current-line-margin-both { +.monaco-editor + .margin-view-overlays + .current-line.current-line-margin.current-line-margin-both { border-right: 0; } diff --git a/src/vs/editor/browser/viewParts/currentLineHighlight/currentLineHighlight.ts b/src/vs/editor/browser/viewParts/currentLineHighlight/currentLineHighlight.ts index 64649e0b83597..b35970ee373ec 100644 --- a/src/vs/editor/browser/viewParts/currentLineHighlight/currentLineHighlight.ts +++ b/src/vs/editor/browser/viewParts/currentLineHighlight/currentLineHighlight.ts @@ -18,7 +18,6 @@ import { Position } from 'vs/editor/common/core/position'; export abstract class AbstractLineHighlightOverlay extends DynamicViewOverlay { private readonly _context: ViewContext; - protected _lineHeight: number; protected _renderLineHighlight: 'none' | 'gutter' | 'line' | 'all'; protected _wordWrap: boolean; protected _contentLeft: number; @@ -39,7 +38,6 @@ export abstract class AbstractLineHighlightOverlay extends DynamicViewOverlay { const options = this._context.configuration.options; const layoutInfo = options.get(EditorOption.layoutInfo); - this._lineHeight = options.get(EditorOption.lineHeight); this._renderLineHighlight = options.get(EditorOption.renderLineHighlight); this._renderLineHighlightOnlyWhenFocus = options.get(EditorOption.renderLineHighlightOnlyWhenFocus); this._wordWrap = layoutInfo.isViewportWrapping; @@ -89,7 +87,6 @@ export abstract class AbstractLineHighlightOverlay extends DynamicViewOverlay { public override onConfigurationChanged(e: viewEvents.ViewConfigurationChangedEvent): boolean { const options = this._context.configuration.options; const layoutInfo = options.get(EditorOption.layoutInfo); - this._lineHeight = options.get(EditorOption.lineHeight); this._renderLineHighlight = options.get(EditorOption.renderLineHighlight); this._renderLineHighlightOnlyWhenFocus = options.get(EditorOption.renderLineHighlightOnlyWhenFocus); this._wordWrap = layoutInfo.isViewportWrapping; @@ -208,7 +205,7 @@ export class CurrentLineHighlightOverlay extends AbstractLineHighlightOverlay { protected _renderOne(ctx: RenderingContext, exact: boolean): string { const className = 'current-line' + (this._shouldRenderInMargin() ? ' current-line-both' : '') + (exact ? ' current-line-exact' : ''); - return `
`; + return `
`; } protected _shouldRenderThis(): boolean { return this._shouldRenderInContent(); @@ -221,7 +218,7 @@ export class CurrentLineHighlightOverlay extends AbstractLineHighlightOverlay { export class CurrentLineMarginHighlightOverlay extends AbstractLineHighlightOverlay { protected _renderOne(ctx: RenderingContext, exact: boolean): string { const className = 'current-line' + (this._shouldRenderInMargin() ? ' current-line-margin' : '') + (this._shouldRenderOther() ? ' current-line-margin-both' : '') + (this._shouldRenderInMargin() && exact ? ' current-line-exact-margin' : ''); - return `
`; + return `
`; } protected _shouldRenderThis(): boolean { return true; diff --git a/src/vs/editor/browser/viewParts/decorations/decorations.css b/src/vs/editor/browser/viewParts/decorations/decorations.css index 37c39f620e8d0..4c755e2dbf89d 100644 --- a/src/vs/editor/browser/viewParts/decorations/decorations.css +++ b/src/vs/editor/browser/viewParts/decorations/decorations.css @@ -9,4 +9,5 @@ */ .monaco-editor .lines-content .cdr { position: absolute; -} \ No newline at end of file + height: 100%; +} diff --git a/src/vs/editor/browser/viewParts/decorations/decorations.ts b/src/vs/editor/browser/viewParts/decorations/decorations.ts index fe495466b1dda..a3baa51046420 100644 --- a/src/vs/editor/browser/viewParts/decorations/decorations.ts +++ b/src/vs/editor/browser/viewParts/decorations/decorations.ts @@ -15,7 +15,6 @@ import { ViewContext } from 'vs/editor/common/viewModel/viewContext'; export class DecorationsOverlay extends DynamicViewOverlay { private readonly _context: ViewContext; - private _lineHeight: number; private _typicalHalfwidthCharacterWidth: number; private _renderResult: string[] | null; @@ -23,7 +22,6 @@ export class DecorationsOverlay extends DynamicViewOverlay { super(); this._context = context; const options = this._context.configuration.options; - this._lineHeight = options.get(EditorOption.lineHeight); this._typicalHalfwidthCharacterWidth = options.get(EditorOption.fontInfo).typicalHalfwidthCharacterWidth; this._renderResult = null; @@ -40,7 +38,6 @@ export class DecorationsOverlay extends DynamicViewOverlay { public override onConfigurationChanged(e: viewEvents.ViewConfigurationChangedEvent): boolean { const options = this._context.configuration.options; - this._lineHeight = options.get(EditorOption.lineHeight); this._typicalHalfwidthCharacterWidth = options.get(EditorOption.fontInfo).typicalHalfwidthCharacterWidth; return true; } @@ -116,7 +113,6 @@ export class DecorationsOverlay extends DynamicViewOverlay { } private _renderWholeLineDecorations(ctx: RenderingContext, decorations: ViewModelDecoration[], output: string[]): void { - const lineHeight = String(this._lineHeight); const visibleStartLineNumber = ctx.visibleRange.startLineNumber; const visibleEndLineNumber = ctx.visibleRange.endLineNumber; @@ -130,9 +126,7 @@ export class DecorationsOverlay extends DynamicViewOverlay { const decorationOutput = ( '
' + + '" style="left:0;width:100%;">
' ); const startLineNumber = Math.max(d.range.startLineNumber, visibleStartLineNumber); @@ -145,7 +139,6 @@ export class DecorationsOverlay extends DynamicViewOverlay { } private _renderNormalDecorations(ctx: RenderingContext, decorations: ViewModelDecoration[], output: string[]): void { - const lineHeight = String(this._lineHeight); const visibleStartLineNumber = ctx.visibleRange.startLineNumber; let prevClassName: string | null = null; @@ -176,7 +169,7 @@ export class DecorationsOverlay extends DynamicViewOverlay { // flush previous decoration if (prevClassName !== null) { - this._renderNormalDecoration(ctx, prevRange!, prevClassName, prevShouldFillLineOnLineBreak, prevShowIfCollapsed, lineHeight, visibleStartLineNumber, output); + this._renderNormalDecoration(ctx, prevRange!, prevClassName, prevShouldFillLineOnLineBreak, prevShowIfCollapsed, visibleStartLineNumber, output); } prevClassName = className; @@ -186,11 +179,11 @@ export class DecorationsOverlay extends DynamicViewOverlay { } if (prevClassName !== null) { - this._renderNormalDecoration(ctx, prevRange!, prevClassName, prevShouldFillLineOnLineBreak, prevShowIfCollapsed, lineHeight, visibleStartLineNumber, output); + this._renderNormalDecoration(ctx, prevRange!, prevClassName, prevShouldFillLineOnLineBreak, prevShowIfCollapsed, visibleStartLineNumber, output); } } - private _renderNormalDecoration(ctx: RenderingContext, range: Range, className: string, shouldFillLineOnLineBreak: boolean, showIfCollapsed: boolean, lineHeight: string, visibleStartLineNumber: number, output: string[]): void { + private _renderNormalDecoration(ctx: RenderingContext, range: Range, className: string, shouldFillLineOnLineBreak: boolean, showIfCollapsed: boolean, visibleStartLineNumber: number, output: string[]): void { const linesVisibleRanges = ctx.linesVisibleRangesForRange(range, /*TODO@Alex*/className === 'findMatch'); if (!linesVisibleRanges) { return; @@ -222,12 +215,12 @@ export class DecorationsOverlay extends DynamicViewOverlay { + className + '" style="left:' + String(visibleRange.left) + + 'px;width:' + (expandToLeft ? - 'px;width:100%;height:' : - ('px;width:' + String(visibleRange.width) + 'px;height:') + '100%;' : + (String(visibleRange.width) + 'px;') ) - + lineHeight - + 'px;">' + + '">' ); output[lineIndex] += decorationOutput; } diff --git a/src/vs/editor/browser/viewParts/indentGuides/indentGuides.css b/src/vs/editor/browser/viewParts/indentGuides/indentGuides.css index ed1326697573c..6aacf7c2126b9 100644 --- a/src/vs/editor/browser/viewParts/indentGuides/indentGuides.css +++ b/src/vs/editor/browser/viewParts/indentGuides/indentGuides.css @@ -6,4 +6,5 @@ .monaco-editor .lines-content .core-guide { position: absolute; box-sizing: border-box; + height: 100%; } diff --git a/src/vs/editor/browser/viewParts/indentGuides/indentGuides.ts b/src/vs/editor/browser/viewParts/indentGuides/indentGuides.ts index a93cf75a530aa..50b0b2b86613f 100644 --- a/src/vs/editor/browser/viewParts/indentGuides/indentGuides.ts +++ b/src/vs/editor/browser/viewParts/indentGuides/indentGuides.ts @@ -22,7 +22,6 @@ export class IndentGuidesOverlay extends DynamicViewOverlay { private readonly _context: ViewContext; private _primaryPosition: Position | null; - private _lineHeight: number; private _spaceWidth: number; private _renderResult: string[] | null; private _maxIndentLeft: number; @@ -37,7 +36,6 @@ export class IndentGuidesOverlay extends DynamicViewOverlay { const wrappingInfo = options.get(EditorOption.wrappingInfo); const fontInfo = options.get(EditorOption.fontInfo); - this._lineHeight = options.get(EditorOption.lineHeight); this._spaceWidth = fontInfo.spaceWidth; this._maxIndentLeft = wrappingInfo.wrappingColumn === -1 ? -1 : (wrappingInfo.wrappingColumn * fontInfo.typicalHalfwidthCharacterWidth); this._bracketPairGuideOptions = options.get(EditorOption.guides); @@ -60,7 +58,6 @@ export class IndentGuidesOverlay extends DynamicViewOverlay { const wrappingInfo = options.get(EditorOption.wrappingInfo); const fontInfo = options.get(EditorOption.fontInfo); - this._lineHeight = options.get(EditorOption.lineHeight); this._spaceWidth = fontInfo.spaceWidth; this._maxIndentLeft = wrappingInfo.wrappingColumn === -1 ? -1 : (wrappingInfo.wrappingColumn * fontInfo.typicalHalfwidthCharacterWidth); this._bracketPairGuideOptions = options.get(EditorOption.guides); @@ -114,7 +111,6 @@ export class IndentGuidesOverlay extends DynamicViewOverlay { const visibleStartLineNumber = ctx.visibleRange.startLineNumber; const visibleEndLineNumber = ctx.visibleRange.endLineNumber; const scrollWidth = ctx.scrollWidth; - const lineHeight = this._lineHeight; const activeCursorPosition = this._primaryPosition; @@ -150,7 +146,7 @@ export class IndentGuidesOverlay extends DynamicViewOverlay { )?.left ?? (left + this._spaceWidth)) - left : this._spaceWidth; - result += `
`; + result += `
`; } output[lineIndex] = result; } diff --git a/src/vs/editor/browser/viewParts/lineNumbers/lineNumbers.css b/src/vs/editor/browser/viewParts/lineNumbers/lineNumbers.css index 774ffef273d6c..2961137b0324d 100644 --- a/src/vs/editor/browser/viewParts/lineNumbers/lineNumbers.css +++ b/src/vs/editor/browser/viewParts/lineNumbers/lineNumbers.css @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ .monaco-editor .margin-view-overlays .line-numbers { + bottom: 0; font-variant-numeric: tabular-nums; position: absolute; text-align: right; @@ -11,7 +12,6 @@ vertical-align: middle; box-sizing: border-box; cursor: default; - height: 100%; } .monaco-editor .relative-current-line-number { diff --git a/src/vs/editor/browser/viewParts/lineNumbers/lineNumbers.ts b/src/vs/editor/browser/viewParts/lineNumbers/lineNumbers.ts index 03336d3427856..dcebb27994e30 100644 --- a/src/vs/editor/browser/viewParts/lineNumbers/lineNumbers.ts +++ b/src/vs/editor/browser/viewParts/lineNumbers/lineNumbers.ts @@ -131,6 +131,10 @@ export class LineNumbersOverlay extends DynamicViewOverlay { if (modelLineNumber % 10 === 0) { return String(modelLineNumber); } + const finalLineNumber = this._context.viewModel.getLineCount(); + if (modelLineNumber === finalLineNumber) { + return String(modelLineNumber); + } return ''; } diff --git a/src/vs/editor/browser/viewParts/lines/viewLine.ts b/src/vs/editor/browser/viewParts/lines/viewLine.ts index e4174a2f2866a..9a5d2f556bf3b 100644 --- a/src/vs/editor/browser/viewParts/lines/viewLine.ts +++ b/src/vs/editor/browser/viewParts/lines/viewLine.ts @@ -151,7 +151,7 @@ export class ViewLine implements IVisibleLine { return false; } - public renderLine(lineNumber: number, deltaTop: number, viewportData: ViewportData, sb: StringBuilder): boolean { + public renderLine(lineNumber: number, deltaTop: number, lineHeight: number, viewportData: ViewportData, sb: StringBuilder): boolean { if (this._isMaybeInvalid === false) { // it appears that nothing relevant has changed return false; @@ -222,7 +222,7 @@ export class ViewLine implements IVisibleLine { sb.appendString('
'); @@ -255,10 +255,10 @@ export class ViewLine implements IVisibleLine { return true; } - public layoutLine(lineNumber: number, deltaTop: number): void { + public layoutLine(lineNumber: number, deltaTop: number, lineHeight: number): void { if (this._renderedViewLine && this._renderedViewLine.domNode) { this._renderedViewLine.domNode.setTop(deltaTop); - this._renderedViewLine.domNode.setHeight(this._options.lineHeight); + this._renderedViewLine.domNode.setHeight(lineHeight); } } diff --git a/src/vs/editor/browser/viewParts/lines/viewLines.css b/src/vs/editor/browser/viewParts/lines/viewLines.css index fe686d3e441dc..899c96b8cfcdb 100644 --- a/src/vs/editor/browser/viewParts/lines/viewLines.css +++ b/src/vs/editor/browser/viewParts/lines/viewLines.css @@ -63,6 +63,13 @@ width: 100%; } +/* There are view-lines in view-zones. We have to make sure this rule does not apply to them, as they don't set a line height */ +.monaco-editor .lines-content > .view-lines > .view-line > span { + top: 0; + bottom: 0; + position: absolute; +} + .monaco-editor .mtkw { color: var(--vscode-editorWhitespace-foreground) !important; } diff --git a/src/vs/editor/browser/viewParts/minimap/minimap.ts b/src/vs/editor/browser/viewParts/minimap/minimap.ts index 789b68836b988..427df7cd3d8f1 100644 --- a/src/vs/editor/browser/viewParts/minimap/minimap.ts +++ b/src/vs/editor/browser/viewParts/minimap/minimap.ts @@ -27,14 +27,16 @@ import { ViewContext } from 'vs/editor/common/viewModel/viewContext'; import { EditorTheme } from 'vs/editor/common/editorTheme'; import * as viewEvents from 'vs/editor/common/viewEvents'; import { ViewLineData, ViewModelDecoration } from 'vs/editor/common/viewModel'; -import { minimapSelection, minimapBackground, minimapForegroundOpacity } from 'vs/platform/theme/common/colorRegistry'; +import { minimapSelection, minimapBackground, minimapForegroundOpacity, editorForeground } from 'vs/platform/theme/common/colorRegistry'; import { ModelDecorationMinimapOptions } from 'vs/editor/common/model/textModel'; import { Selection } from 'vs/editor/common/core/selection'; import { Color } from 'vs/base/common/color'; import { GestureEvent, EventType, Gesture } from 'vs/base/browser/touch'; import { MinimapCharRendererFactory } from 'vs/editor/browser/viewParts/minimap/minimapCharRendererFactory'; -import { MinimapPosition, TextModelResolvedOptions } from 'vs/editor/common/model'; +import { MinimapPosition, MinimapSectionHeaderStyle, TextModelResolvedOptions } from 'vs/editor/common/model'; import { createSingleCallFunction } from 'vs/base/common/functional'; +import { LRUCache } from 'vs/base/common/map'; +import { DEFAULT_FONT_FAMILY } from 'vs/base/browser/fonts'; /** * The orthogonal distance to the slider at which dragging "resets". This implements "snapping" @@ -90,6 +92,9 @@ class MinimapOptions { public readonly fontScale: number; public readonly minimapLineHeight: number; public readonly minimapCharWidth: number; + public readonly sectionHeaderFontFamily: string; + public readonly sectionHeaderFontSize: number; + public readonly sectionHeaderFontColor: RGBA8; public readonly charRenderer: () => MinimapCharRenderer; public readonly defaultBackgroundColor: RGBA8; @@ -132,6 +137,9 @@ class MinimapOptions { this.fontScale = minimapLayout.minimapScale; this.minimapLineHeight = minimapLayout.minimapLineHeight; this.minimapCharWidth = Constants.BASE_CHAR_WIDTH * this.fontScale; + this.sectionHeaderFontFamily = DEFAULT_FONT_FAMILY; + this.sectionHeaderFontSize = minimapOpts.sectionHeaderFontSize * pixelRatio; + this.sectionHeaderFontColor = MinimapOptions._getSectionHeaderColor(theme, tokensColorTracker.getColor(ColorId.DefaultForeground)); this.charRenderer = createSingleCallFunction(() => MinimapCharRendererFactory.create(this.fontScale, fontInfo.fontFamily)); this.defaultBackgroundColor = tokensColorTracker.getColor(ColorId.DefaultBackground); @@ -155,6 +163,14 @@ class MinimapOptions { return 255; } + private static _getSectionHeaderColor(theme: EditorTheme, defaultForegroundColor: RGBA8): RGBA8 { + const themeColor = theme.getColor(editorForeground); + if (themeColor) { + return new RGBA8(themeColor.rgba.r, themeColor.rgba.g, themeColor.rgba.b, Math.round(255 * themeColor.rgba.a)); + } + return defaultForegroundColor; + } + public equals(other: MinimapOptions): boolean { return (this.renderMinimap === other.renderMinimap && this.size === other.size @@ -179,6 +195,7 @@ class MinimapOptions { && this.fontScale === other.fontScale && this.minimapLineHeight === other.minimapLineHeight && this.minimapCharWidth === other.minimapCharWidth + && this.sectionHeaderFontSize === other.sectionHeaderFontSize && this.defaultBackgroundColor && this.defaultBackgroundColor.equals(other.defaultBackgroundColor) && this.backgroundColor && this.backgroundColor.equals(other.backgroundColor) && this.foregroundAlpha === other.foregroundAlpha @@ -544,6 +561,8 @@ export interface IMinimapModel { getMinimapLinesRenderingData(startLineNumber: number, endLineNumber: number, needed: boolean[]): (ViewLineData | null)[]; getSelections(): Selection[]; getMinimapDecorationsInViewport(startLineNumber: number, endLineNumber: number): ViewModelDecoration[]; + getSectionHeaderDecorationsInViewport(startLineNumber: number, endLineNumber: number): ViewModelDecoration[]; + getSectionHeaderText(decoration: ViewModelDecoration, fitWidth: (s: string) => string): string | null; getOptions(): TextModelResolvedOptions; revealLineNumber(lineNumber: number): void; setScrollTop(scrollTop: number): void; @@ -697,7 +716,7 @@ class MinimapSamplingState { constructor( public readonly samplingRatio: number, - public readonly minimapLines: number[] + public readonly minimapLines: number[] // a map of 0-based minimap line indexes to 1-based view line numbers ) { } @@ -790,6 +809,8 @@ export class Minimap extends ViewPart implements IMinimapModel { private _samplingState: MinimapSamplingState | null; private _shouldCheckSampling: boolean; + private _sectionHeaderCache = new LRUCache(10, 1.5); + private _actual: InnerMinimap; constructor(context: ViewContext) { @@ -1037,15 +1058,8 @@ export class Minimap extends ViewPart implements IMinimapModel { } public getMinimapDecorationsInViewport(startLineNumber: number, endLineNumber: number): ViewModelDecoration[] { - let visibleRange: Range; - if (this._samplingState) { - const modelStartLineNumber = this._samplingState.minimapLines[startLineNumber - 1]; - const modelEndLineNumber = this._samplingState.minimapLines[endLineNumber - 1]; - visibleRange = new Range(modelStartLineNumber, 1, modelEndLineNumber, this._context.viewModel.getLineMaxColumn(modelEndLineNumber)); - } else { - visibleRange = new Range(startLineNumber, 1, endLineNumber, this._context.viewModel.getLineMaxColumn(endLineNumber)); - } - const decorations = this._context.viewModel.getMinimapDecorationsInRange(visibleRange); + const decorations = this._getMinimapDecorationsInViewport(startLineNumber, endLineNumber) + .filter(decoration => !decoration.options.minimap?.sectionHeaderStyle); if (this._samplingState) { const result: ViewModelDecoration[] = []; @@ -1063,6 +1077,41 @@ export class Minimap extends ViewPart implements IMinimapModel { return decorations; } + public getSectionHeaderDecorationsInViewport(startLineNumber: number, endLineNumber: number): ViewModelDecoration[] { + const minimapLineHeight = this.options.minimapLineHeight; + const sectionHeaderFontSize = this.options.sectionHeaderFontSize; + const headerHeightInMinimapLines = sectionHeaderFontSize / minimapLineHeight; + startLineNumber = Math.floor(Math.max(1, startLineNumber - headerHeightInMinimapLines)); + return this._getMinimapDecorationsInViewport(startLineNumber, endLineNumber) + .filter(decoration => !!decoration.options.minimap?.sectionHeaderStyle); + } + + private _getMinimapDecorationsInViewport(startLineNumber: number, endLineNumber: number) { + let visibleRange: Range; + if (this._samplingState) { + const modelStartLineNumber = this._samplingState.minimapLines[startLineNumber - 1]; + const modelEndLineNumber = this._samplingState.minimapLines[endLineNumber - 1]; + visibleRange = new Range(modelStartLineNumber, 1, modelEndLineNumber, this._context.viewModel.getLineMaxColumn(modelEndLineNumber)); + } else { + visibleRange = new Range(startLineNumber, 1, endLineNumber, this._context.viewModel.getLineMaxColumn(endLineNumber)); + } + return this._context.viewModel.getMinimapDecorationsInRange(visibleRange); + } + + public getSectionHeaderText(decoration: ViewModelDecoration, fitWidth: (s: string) => string): string | null { + const headerText = decoration.options.minimap?.sectionHeaderText; + if (!headerText) { + return null; + } + const cachedText = this._sectionHeaderCache.get(headerText); + if (cachedText) { + return cachedText; + } + const fittedText = fitWidth(headerText); + this._sectionHeaderCache.set(headerText, fittedText); + return fittedText; + } + public getOptions(): TextModelResolvedOptions { return this._context.viewModel.model.getOptions(); } @@ -1469,6 +1518,7 @@ class InnerMinimap extends Disposable { const lineOffsetMap = new ContiguousLineMap(layout.startLineNumber, layout.endLineNumber, null); this._renderSelectionsHighlights(canvasContext, selections, lineOffsetMap, layout, minimapLineHeight, tabSize, minimapCharWidth, canvasInnerWidth); this._renderDecorationsHighlights(canvasContext, decorations, lineOffsetMap, layout, minimapLineHeight, tabSize, minimapCharWidth, canvasInnerWidth); + this._renderSectionHeaders(layout); } } @@ -1735,6 +1785,110 @@ class InnerMinimap extends Disposable { canvasContext.fillRect(x, y, width, height); } + private _renderSectionHeaders(layout: MinimapLayout) { + const minimapLineHeight = this._model.options.minimapLineHeight; + const sectionHeaderFontSize = this._model.options.sectionHeaderFontSize; + const backgroundFillHeight = sectionHeaderFontSize * 1.5; + const { canvasInnerWidth } = this._model.options; + + const backgroundColor = this._model.options.backgroundColor; + const backgroundFill = `rgb(${backgroundColor.r} ${backgroundColor.g} ${backgroundColor.b} / .7)`; + const foregroundColor = this._model.options.sectionHeaderFontColor; + const foregroundFill = `rgb(${foregroundColor.r} ${foregroundColor.g} ${foregroundColor.b})`; + const separatorStroke = foregroundFill; + + const canvasContext = this._decorationsCanvas.domNode.getContext('2d')!; + canvasContext.font = sectionHeaderFontSize + 'px ' + this._model.options.sectionHeaderFontFamily; + canvasContext.strokeStyle = separatorStroke; + canvasContext.lineWidth = 0.2; + + const decorations = this._model.getSectionHeaderDecorationsInViewport(layout.startLineNumber, layout.endLineNumber); + decorations.sort((a, b) => a.range.startLineNumber - b.range.startLineNumber); + + const fitWidth = InnerMinimap._fitSectionHeader.bind(null, canvasContext, + canvasInnerWidth - MINIMAP_GUTTER_WIDTH); + + for (const decoration of decorations) { + const y = layout.getYForLineNumber(decoration.range.startLineNumber, minimapLineHeight) + sectionHeaderFontSize; + const backgroundFillY = y - sectionHeaderFontSize; + const separatorY = backgroundFillY + 2; + const headerText = this._model.getSectionHeaderText(decoration, fitWidth); + + InnerMinimap._renderSectionLabel( + canvasContext, + headerText, + decoration.options.minimap?.sectionHeaderStyle === MinimapSectionHeaderStyle.Underlined, + backgroundFill, + foregroundFill, + canvasInnerWidth, + backgroundFillY, + backgroundFillHeight, + y, + separatorY); + } + } + + private static _fitSectionHeader( + target: CanvasRenderingContext2D, + maxWidth: number, + headerText: string, + ): string { + if (!headerText) { + return headerText; + } + + const ellipsis = '…'; + const width = target.measureText(headerText).width; + const ellipsisWidth = target.measureText(ellipsis).width; + + if (width <= maxWidth || width <= ellipsisWidth) { + return headerText; + } + + const len = headerText.length; + const averageCharWidth = width / headerText.length; + const maxCharCount = Math.floor((maxWidth - ellipsisWidth) / averageCharWidth) - 1; + + // Find a halfway point that isn't after whitespace + let halfCharCount = Math.ceil(maxCharCount / 2); + while (halfCharCount > 0 && /\s/.test(headerText[halfCharCount - 1])) { + --halfCharCount; + } + + // Split with ellipsis + return headerText.substring(0, halfCharCount) + + ellipsis + headerText.substring(len - (maxCharCount - halfCharCount)); + } + + private static _renderSectionLabel( + target: CanvasRenderingContext2D, + headerText: string | null, + hasSeparatorLine: boolean, + backgroundFill: string, + foregroundFill: string, + minimapWidth: number, + backgroundFillY: number, + backgroundFillHeight: number, + textY: number, + separatorY: number + ): void { + if (headerText) { + target.fillStyle = backgroundFill; + target.fillRect(0, backgroundFillY, minimapWidth, backgroundFillHeight); + + target.fillStyle = foregroundFill; + target.fillText(headerText, MINIMAP_GUTTER_WIDTH, textY); + } + + if (hasSeparatorLine) { + target.beginPath(); + target.moveTo(0, separatorY); + target.lineTo(minimapWidth, separatorY); + target.closePath(); + target.stroke(); + } + } + private renderLines(layout: MinimapLayout): RenderData | null { const startLineNumber = layout.startLineNumber; const endLineNumber = layout.endLineNumber; diff --git a/src/vs/editor/browser/viewParts/overviewRuler/decorationsOverviewRuler.ts b/src/vs/editor/browser/viewParts/overviewRuler/decorationsOverviewRuler.ts index 1338656acab1b..0ca31065a4518 100644 --- a/src/vs/editor/browser/viewParts/overviewRuler/decorationsOverviewRuler.ts +++ b/src/vs/editor/browser/viewParts/overviewRuler/decorationsOverviewRuler.ts @@ -10,7 +10,7 @@ import { ViewPart } from 'vs/editor/browser/view/viewPart'; import { Position } from 'vs/editor/common/core/position'; import { IEditorConfiguration } from 'vs/editor/common/config/editorConfiguration'; import { TokenizationRegistry } from 'vs/editor/common/languages'; -import { editorCursorForeground, editorOverviewRulerBorder, editorOverviewRulerBackground } from 'vs/editor/common/core/editorColorRegistry'; +import { editorCursorForeground, editorOverviewRulerBorder, editorOverviewRulerBackground, editorMultiCursorSecondaryForeground, editorMultiCursorPrimaryForeground } from 'vs/editor/common/core/editorColorRegistry'; import { RenderingContext, RestrictedRenderingContext } from 'vs/editor/browser/view/renderingContext'; import { ViewContext } from 'vs/editor/common/viewModel/viewContext'; import { EditorTheme } from 'vs/editor/common/editorTheme'; @@ -29,7 +29,9 @@ class Settings { public readonly borderColor: string | null; public readonly hideCursor: boolean; - public readonly cursorColor: string | null; + public readonly cursorColorSingle: string | null; + public readonly cursorColorPrimary: string | null; + public readonly cursorColorSecondary: string | null; public readonly themeType: 'light' | 'dark' | 'hcLight' | 'hcDark'; public readonly backgroundColor: Color | null; @@ -55,8 +57,12 @@ class Settings { this.borderColor = borderColor ? borderColor.toString() : null; this.hideCursor = options.get(EditorOption.hideCursorInOverviewRuler); - const cursorColor = theme.getColor(editorCursorForeground); - this.cursorColor = cursorColor ? cursorColor.transparent(0.7).toString() : null; + const cursorColorSingle = theme.getColor(editorCursorForeground); + this.cursorColorSingle = cursorColorSingle ? cursorColorSingle.transparent(0.7).toString() : null; + const cursorColorPrimary = theme.getColor(editorMultiCursorPrimaryForeground); + this.cursorColorPrimary = cursorColorPrimary ? cursorColorPrimary.transparent(0.7).toString() : null; + const cursorColorSecondary = theme.getColor(editorMultiCursorSecondaryForeground); + this.cursorColorSecondary = cursorColorSecondary ? cursorColorSecondary.transparent(0.7).toString() : null; this.themeType = theme.type; @@ -189,7 +195,9 @@ class Settings { && this.renderBorder === other.renderBorder && this.borderColor === other.borderColor && this.hideCursor === other.hideCursor - && this.cursorColor === other.cursorColor + && this.cursorColorSingle === other.cursorColorSingle + && this.cursorColorPrimary === other.cursorColorPrimary + && this.cursorColorSecondary === other.cursorColorSecondary && this.themeType === other.themeType && Color.equals(this.backgroundColor, other.backgroundColor) && this.top === other.top @@ -213,6 +221,11 @@ const enum OverviewRulerLane { Full = 7 } +type Cursor = { + position: Position; + color: string | null; +}; + const enum ShouldRenderValue { NotNeeded = 0, Maybe = 1, @@ -226,10 +239,10 @@ export class DecorationsOverviewRuler extends ViewPart { private readonly _tokensColorTrackerListener: IDisposable; private readonly _domNode: FastDomNode; private _settings!: Settings; - private _cursorPositions: Position[]; + private _cursorPositions: Cursor[]; private _renderedDecorations: OverviewRulerDecorationsGroup[] = []; - private _renderedCursorPositions: Position[] = []; + private _renderedCursorPositions: Cursor[] = []; constructor(context: ViewContext) { super(context); @@ -249,7 +262,7 @@ export class DecorationsOverviewRuler extends ViewPart { } }); - this._cursorPositions = [new Position(1, 1)]; + this._cursorPositions = [{ position: new Position(1, 1), color: this._settings.cursorColorSingle }]; } public override dispose(): void { @@ -298,9 +311,13 @@ export class DecorationsOverviewRuler extends ViewPart { public override onCursorStateChanged(e: viewEvents.ViewCursorStateChangedEvent): boolean { this._cursorPositions = []; for (let i = 0, len = e.selections.length; i < len; i++) { - this._cursorPositions[i] = e.selections[i].getPosition(); + let color = this._settings.cursorColorSingle; + if (len > 1) { + color = i === 0 ? this._settings.cursorColorPrimary : this._settings.cursorColorSecondary; + } + this._cursorPositions.push({ position: e.selections[i].getPosition(), color }); } - this._cursorPositions.sort(Position.compare); + this._cursorPositions.sort((a, b) => Position.compare(a.position, b.position)); return this._markRenderingIsMaybeNeeded(); } public override onDecorationsChanged(e: viewEvents.ViewDecorationsChangedEvent): boolean { @@ -352,7 +369,7 @@ export class DecorationsOverviewRuler extends ViewPart { if (this._actualShouldRender === ShouldRenderValue.Maybe && !OverviewRulerDecorationsGroup.equalsArr(this._renderedDecorations, decorations)) { this._actualShouldRender = ShouldRenderValue.Needed; } - if (this._actualShouldRender === ShouldRenderValue.Maybe && !equals(this._renderedCursorPositions, this._cursorPositions, (a, b) => a.lineNumber === b.lineNumber)) { + if (this._actualShouldRender === ShouldRenderValue.Maybe && !equals(this._renderedCursorPositions, this._cursorPositions, (a, b) => a.position.lineNumber === b.position.lineNumber && a.color === b.color)) { this._actualShouldRender = ShouldRenderValue.Needed; } if (this._actualShouldRender === ShouldRenderValue.Maybe) { @@ -443,17 +460,21 @@ export class DecorationsOverviewRuler extends ViewPart { } // Draw cursors - if (!this._settings.hideCursor && this._settings.cursorColor) { + if (!this._settings.hideCursor) { const cursorHeight = (2 * this._settings.pixelRatio) | 0; const halfCursorHeight = (cursorHeight / 2) | 0; const cursorX = this._settings.x[OverviewRulerLane.Full]; const cursorW = this._settings.w[OverviewRulerLane.Full]; - canvasCtx.fillStyle = this._settings.cursorColor; let prevY1 = -100; let prevY2 = -100; + let prevColor: string | null = null; for (let i = 0, len = this._cursorPositions.length; i < len; i++) { - const cursor = this._cursorPositions[i]; + const color = this._cursorPositions[i].color; + if (!color) { + continue; + } + const cursor = this._cursorPositions[i].position; let yCenter = (viewLayout.getVerticalOffsetForLineNumber(cursor.lineNumber) * heightRatio) | 0; if (yCenter < halfCursorHeight) { @@ -464,9 +485,9 @@ export class DecorationsOverviewRuler extends ViewPart { const y1 = yCenter - halfCursorHeight; const y2 = y1 + cursorHeight; - if (y1 > prevY2 + 1) { + if (y1 > prevY2 + 1 || color !== prevColor) { // flush prev - if (i !== 0) { + if (i !== 0 && prevColor) { canvasCtx.fillRect(cursorX, prevY1, cursorW, prevY2 - prevY1); } prevY1 = y1; @@ -477,8 +498,12 @@ export class DecorationsOverviewRuler extends ViewPart { prevY2 = y2; } } + prevColor = color; + canvasCtx.fillStyle = color; + } + if (prevColor) { + canvasCtx.fillRect(cursorX, prevY1, cursorW, prevY2 - prevY1); } - canvasCtx.fillRect(cursorX, prevY1, cursorW, prevY2 - prevY1); } if (this._settings.renderBorder && this._settings.borderColor && this._settings.overviewRulerLanes > 0) { diff --git a/src/vs/editor/browser/viewParts/selections/selections.ts b/src/vs/editor/browser/viewParts/selections/selections.ts index efceef0e5c35e..d53a5126e62e1 100644 --- a/src/vs/editor/browser/viewParts/selections/selections.ts +++ b/src/vs/editor/browser/viewParts/selections/selections.ts @@ -68,7 +68,6 @@ export class SelectionsOverlay extends DynamicViewOverlay { private static readonly ROUNDED_PIECE_WIDTH = 10; private readonly _context: ViewContext; - private _lineHeight: number; private _roundedSelection: boolean; private _typicalHalfwidthCharacterWidth: number; private _selections: Range[]; @@ -78,7 +77,6 @@ export class SelectionsOverlay extends DynamicViewOverlay { super(); this._context = context; const options = this._context.configuration.options; - this._lineHeight = options.get(EditorOption.lineHeight); this._roundedSelection = options.get(EditorOption.roundedSelection); this._typicalHalfwidthCharacterWidth = options.get(EditorOption.fontInfo).typicalHalfwidthCharacterWidth; this._selections = []; @@ -96,7 +94,6 @@ export class SelectionsOverlay extends DynamicViewOverlay { public override onConfigurationChanged(e: viewEvents.ViewConfigurationChangedEvent): boolean { const options = this._context.configuration.options; - this._lineHeight = options.get(EditorOption.lineHeight); this._roundedSelection = options.get(EditorOption.roundedSelection); this._typicalHalfwidthCharacterWidth = options.get(EditorOption.fontInfo).typicalHalfwidthCharacterWidth; return true; @@ -255,19 +252,16 @@ export class SelectionsOverlay extends DynamicViewOverlay { return linesVisibleRanges; } - private _createSelectionPiece(top: number, height: string, className: string, left: number, width: number): string { + private _createSelectionPiece(top: number, bottom: number, className: string, left: number, width: number): string { return ( '
' + + '" style="' + + 'top:' + top.toString() + 'px;' + + 'bottom:' + bottom.toString() + 'px;' + + 'left:' + left.toString() + 'px;' + + 'width:' + width.toString() + 'px;' + + '">
' ); } @@ -277,8 +271,6 @@ export class SelectionsOverlay extends DynamicViewOverlay { } const visibleRangesHaveStyle = !!visibleRanges[0].ranges[0].startStyle; - const fullLineHeight = (this._lineHeight).toString(); - const reducedLineHeight = (this._lineHeight - 1).toString(); const firstLineNumber = visibleRanges[0].lineNumber; const lastLineNumber = visibleRanges[visibleRanges.length - 1].lineNumber; @@ -288,8 +280,8 @@ export class SelectionsOverlay extends DynamicViewOverlay { const lineNumber = lineVisibleRanges.lineNumber; const lineIndex = lineNumber - visibleStartLineNumber; - const lineHeight = hasMultipleSelections ? (lineNumber === lastLineNumber || lineNumber === firstLineNumber ? reducedLineHeight : fullLineHeight) : fullLineHeight; const top = hasMultipleSelections ? (lineNumber === firstLineNumber ? 1 : 0) : 0; + const bottom = hasMultipleSelections ? (lineNumber !== firstLineNumber && lineNumber === lastLineNumber ? 1 : 0) : 0; let innerCornerOutput = ''; let restOfSelectionOutput = ''; @@ -304,7 +296,7 @@ export class SelectionsOverlay extends DynamicViewOverlay { // Reverse rounded corner to the left // First comes the selection (blue layer) - innerCornerOutput += this._createSelectionPiece(top, lineHeight, SelectionsOverlay.SELECTION_CLASS_NAME, visibleRange.left - SelectionsOverlay.ROUNDED_PIECE_WIDTH, SelectionsOverlay.ROUNDED_PIECE_WIDTH); + innerCornerOutput += this._createSelectionPiece(top, bottom, SelectionsOverlay.SELECTION_CLASS_NAME, visibleRange.left - SelectionsOverlay.ROUNDED_PIECE_WIDTH, SelectionsOverlay.ROUNDED_PIECE_WIDTH); // Second comes the background (white layer) with inverse border radius let className = SelectionsOverlay.EDITOR_BACKGROUND_CLASS_NAME; @@ -314,13 +306,13 @@ export class SelectionsOverlay extends DynamicViewOverlay { if (startStyle.bottom === CornerStyle.INTERN) { className += ' ' + SelectionsOverlay.SELECTION_BOTTOM_RIGHT; } - innerCornerOutput += this._createSelectionPiece(top, lineHeight, className, visibleRange.left - SelectionsOverlay.ROUNDED_PIECE_WIDTH, SelectionsOverlay.ROUNDED_PIECE_WIDTH); + innerCornerOutput += this._createSelectionPiece(top, bottom, className, visibleRange.left - SelectionsOverlay.ROUNDED_PIECE_WIDTH, SelectionsOverlay.ROUNDED_PIECE_WIDTH); } if (endStyle.top === CornerStyle.INTERN || endStyle.bottom === CornerStyle.INTERN) { // Reverse rounded corner to the right // First comes the selection (blue layer) - innerCornerOutput += this._createSelectionPiece(top, lineHeight, SelectionsOverlay.SELECTION_CLASS_NAME, visibleRange.left + visibleRange.width, SelectionsOverlay.ROUNDED_PIECE_WIDTH); + innerCornerOutput += this._createSelectionPiece(top, bottom, SelectionsOverlay.SELECTION_CLASS_NAME, visibleRange.left + visibleRange.width, SelectionsOverlay.ROUNDED_PIECE_WIDTH); // Second comes the background (white layer) with inverse border radius let className = SelectionsOverlay.EDITOR_BACKGROUND_CLASS_NAME; @@ -330,7 +322,7 @@ export class SelectionsOverlay extends DynamicViewOverlay { if (endStyle.bottom === CornerStyle.INTERN) { className += ' ' + SelectionsOverlay.SELECTION_BOTTOM_LEFT; } - innerCornerOutput += this._createSelectionPiece(top, lineHeight, className, visibleRange.left + visibleRange.width, SelectionsOverlay.ROUNDED_PIECE_WIDTH); + innerCornerOutput += this._createSelectionPiece(top, bottom, className, visibleRange.left + visibleRange.width, SelectionsOverlay.ROUNDED_PIECE_WIDTH); } } @@ -351,7 +343,7 @@ export class SelectionsOverlay extends DynamicViewOverlay { className += ' ' + SelectionsOverlay.SELECTION_BOTTOM_RIGHT; } } - restOfSelectionOutput += this._createSelectionPiece(top, lineHeight, className, visibleRange.left, visibleRange.width); + restOfSelectionOutput += this._createSelectionPiece(top, bottom, className, visibleRange.left, visibleRange.width); } output2[lineIndex][0] += innerCornerOutput; diff --git a/src/vs/editor/browser/viewParts/viewCursors/viewCursor.ts b/src/vs/editor/browser/viewParts/viewCursors/viewCursor.ts index 36506e9fed0d8..4502698cfc516 100644 --- a/src/vs/editor/browser/viewParts/viewCursors/viewCursor.ts +++ b/src/vs/editor/browser/viewParts/viewCursors/viewCursor.ts @@ -35,6 +35,12 @@ class ViewCursorRenderData { ) { } } +export enum CursorPlurality { + Single, + MultiPrimary, + MultiSecondary, +} + export class ViewCursor { private readonly _context: ViewContext; private readonly _domNode: FastDomNode; @@ -47,11 +53,12 @@ export class ViewCursor { private _isVisible: boolean; private _position: Position; + private _pluralityClass: string; private _lastRenderedContent: string; private _renderData: ViewCursorRenderData | null; - constructor(context: ViewContext) { + constructor(context: ViewContext, plurality: CursorPlurality) { this._context = context; const options = this._context.configuration.options; const fontInfo = options.get(EditorOption.fontInfo); @@ -73,6 +80,8 @@ export class ViewCursor { this._domNode.setDisplay('none'); this._position = new Position(1, 1); + this._pluralityClass = ''; + this.setPlurality(plurality); this._lastRenderedContent = ''; this._renderData = null; @@ -86,6 +95,23 @@ export class ViewCursor { return this._position; } + public setPlurality(plurality: CursorPlurality) { + switch (plurality) { + default: + case CursorPlurality.Single: + this._pluralityClass = ''; + break; + + case CursorPlurality.MultiPrimary: + this._pluralityClass = 'cursor-primary'; + break; + + case CursorPlurality.MultiSecondary: + this._pluralityClass = 'cursor-secondary'; + break; + } + } + public show(): void { if (!this._isVisible) { this._domNode.setVisibility('inherit'); @@ -229,7 +255,7 @@ export class ViewCursor { this._domNode.domNode.textContent = this._lastRenderedContent; } - this._domNode.setClassName(`cursor ${MOUSE_CURSOR_TEXT_CSS_CLASS_NAME} ${this._renderData.textContentClassName}`); + this._domNode.setClassName(`cursor ${this._pluralityClass} ${MOUSE_CURSOR_TEXT_CSS_CLASS_NAME} ${this._renderData.textContentClassName}`); this._domNode.setDisplay('block'); this._domNode.setTop(this._renderData.top); diff --git a/src/vs/editor/browser/viewParts/viewCursors/viewCursors.ts b/src/vs/editor/browser/viewParts/viewCursors/viewCursors.ts index 1be969b7497f3..ceb27ce5ea3fb 100644 --- a/src/vs/editor/browser/viewParts/viewCursors/viewCursors.ts +++ b/src/vs/editor/browser/viewParts/viewCursors/viewCursors.ts @@ -7,10 +7,14 @@ import 'vs/css!./viewCursors'; import { FastDomNode, createFastDomNode } from 'vs/base/browser/fastDomNode'; import { IntervalTimer, TimeoutTimer } from 'vs/base/common/async'; import { ViewPart } from 'vs/editor/browser/view/viewPart'; -import { IViewCursorRenderData, ViewCursor } from 'vs/editor/browser/viewParts/viewCursors/viewCursor'; +import { IViewCursorRenderData, ViewCursor, CursorPlurality } from 'vs/editor/browser/viewParts/viewCursors/viewCursor'; import { TextEditorCursorBlinkingStyle, TextEditorCursorStyle, EditorOption } from 'vs/editor/common/config/editorOptions'; import { Position } from 'vs/editor/common/core/position'; -import { editorCursorBackground, editorCursorForeground } from 'vs/editor/common/core/editorColorRegistry'; +import { + editorCursorBackground, editorCursorForeground, + editorMultiCursorPrimaryForeground, editorMultiCursorPrimaryBackground, + editorMultiCursorSecondaryForeground, editorMultiCursorSecondaryBackground +} from 'vs/editor/common/core/editorColorRegistry'; import { RenderingContext, RestrictedRenderingContext } from 'vs/editor/browser/view/renderingContext'; import { ViewContext } from 'vs/editor/common/viewModel/viewContext'; import * as viewEvents from 'vs/editor/common/viewEvents'; @@ -57,7 +61,7 @@ export class ViewCursors extends ViewPart { this._isVisible = false; - this._primaryCursor = new ViewCursor(this._context); + this._primaryCursor = new ViewCursor(this._context, CursorPlurality.Single); this._secondaryCursors = []; this._renderData = []; @@ -88,6 +92,7 @@ export class ViewCursors extends ViewPart { } // --- begin event handlers + public override onCompositionStart(e: viewEvents.ViewCompositionStartEvent): boolean { this._isComposingInput = true; this._updateBlinking(); @@ -120,6 +125,7 @@ export class ViewCursors extends ViewPart { this._secondaryCursors.length !== secondaryPositions.length || (this._cursorSmoothCaretAnimation === 'explicit' && reason !== CursorChangeReason.Explicit) ); + this._primaryCursor.setPlurality(secondaryPositions.length ? CursorPlurality.MultiPrimary : CursorPlurality.Single); this._primaryCursor.onCursorPositionChanged(position, pauseAnimation); this._updateBlinking(); @@ -127,7 +133,7 @@ export class ViewCursors extends ViewPart { // Create new cursors const addCnt = secondaryPositions.length - this._secondaryCursors.length; for (let i = 0; i < addCnt; i++) { - const newCursor = new ViewCursor(this._context); + const newCursor = new ViewCursor(this._context, CursorPlurality.MultiSecondary); this._domNode.domNode.insertBefore(newCursor.getDomNode().domNode, this._primaryCursor.getDomNode().domNode.nextSibling); this._secondaryCursors.push(newCursor); } @@ -160,7 +166,6 @@ export class ViewCursors extends ViewPart { return true; } - public override onDecorationsChanged(e: viewEvents.ViewDecorationsChangedEvent): boolean { // true for inline decorations that can end up relayouting text return true; @@ -263,6 +268,7 @@ export class ViewCursors extends ViewPart { } } } + // --- end blinking logic private _updateDomClassName(): void { @@ -375,16 +381,29 @@ export class ViewCursors extends ViewPart { } registerThemingParticipant((theme, collector) => { - const caret = theme.getColor(editorCursorForeground); - if (caret) { - let caretBackground = theme.getColor(editorCursorBackground); - if (!caretBackground) { - caretBackground = caret.opposite(); - } - collector.addRule(`.monaco-editor .cursors-layer .cursor { background-color: ${caret}; border-color: ${caret}; color: ${caretBackground}; }`); - if (isHighContrast(theme.type)) { - collector.addRule(`.monaco-editor .cursors-layer.has-selection .cursor { border-left: 1px solid ${caretBackground}; border-right: 1px solid ${caretBackground}; }`); + type CursorTheme = { + foreground: string; + background: string; + class: string; + }; + + const cursorThemes: CursorTheme[] = [ + { class: '.cursor', foreground: editorCursorForeground, background: editorCursorBackground }, + { class: '.cursor-primary', foreground: editorMultiCursorPrimaryForeground, background: editorMultiCursorPrimaryBackground }, + { class: '.cursor-secondary', foreground: editorMultiCursorSecondaryForeground, background: editorMultiCursorSecondaryBackground }, + ]; + + for (const cursorTheme of cursorThemes) { + const caret = theme.getColor(cursorTheme.foreground); + if (caret) { + let caretBackground = theme.getColor(cursorTheme.background); + if (!caretBackground) { + caretBackground = caret.opposite(); + } + collector.addRule(`.monaco-editor .cursors-layer ${cursorTheme.class} { background-color: ${caret}; border-color: ${caret}; color: ${caretBackground}; }`); + if (isHighContrast(theme.type)) { + collector.addRule(`.monaco-editor .cursors-layer.has-selection ${cursorTheme.class} { border-left: 1px solid ${caretBackground}; border-right: 1px solid ${caretBackground}; }`); + } } } - }); diff --git a/src/vs/editor/browser/viewParts/whitespace/whitespace.ts b/src/vs/editor/browser/viewParts/whitespace/whitespace.ts index 489293a01b80b..3bd29fc5e1ecb 100644 --- a/src/vs/editor/browser/viewParts/whitespace/whitespace.ts +++ b/src/vs/editor/browser/viewParts/whitespace/whitespace.ts @@ -235,7 +235,7 @@ export class WhitespaceOverlay extends DynamicViewOverlay { if (USE_SVG) { maxLeft = Math.round(maxLeft + spaceWidth); return ( - `` + `` + result + `` ); diff --git a/src/vs/editor/browser/widget/codeEditorContributions.ts b/src/vs/editor/browser/widget/codeEditor/codeEditorContributions.ts similarity index 96% rename from src/vs/editor/browser/widget/codeEditorContributions.ts rename to src/vs/editor/browser/widget/codeEditor/codeEditorContributions.ts index f556e1440f1c5..840bcc4f5258a 100644 --- a/src/vs/editor/browser/widget/codeEditorContributions.ts +++ b/src/vs/editor/browser/widget/codeEditor/codeEditorContributions.ts @@ -5,7 +5,7 @@ import { getWindow, runWhenWindowIdle } from 'vs/base/browser/dom'; import { onUnexpectedError } from 'vs/base/common/errors'; -import { Disposable, DisposableMap } from 'vs/base/common/lifecycle'; +import { Disposable, DisposableMap, IDisposable } from 'vs/base/common/lifecycle'; import { ICodeEditor } from 'vs/editor/browser/editorBrowser'; import { EditorContributionInstantiation, IEditorContributionDescription } from 'vs/editor/browser/editorExtensions'; import { IEditorContribution } from 'vs/editor/common/editorCommon'; @@ -111,10 +111,10 @@ export class CodeEditorContributions extends Disposable { this._instantiateSome(EditorContributionInstantiation.BeforeFirstInteraction); } - public onAfterModelAttached(): void { - this._register(runWhenWindowIdle(getWindow(this._editor?.getDomNode()), () => { + public onAfterModelAttached(): IDisposable { + return runWhenWindowIdle(getWindow(this._editor?.getDomNode()), () => { this._instantiateSome(EditorContributionInstantiation.AfterFirstRender); - }, 50)); + }, 50); } private _instantiateSome(instantiation: EditorContributionInstantiation): void { diff --git a/src/vs/editor/browser/widget/codeEditorWidget.ts b/src/vs/editor/browser/widget/codeEditor/codeEditorWidget.ts similarity index 98% rename from src/vs/editor/browser/widget/codeEditorWidget.ts rename to src/vs/editor/browser/widget/codeEditor/codeEditorWidget.ts index 1255cd1a598fb..108aa52fa8aec 100644 --- a/src/vs/editor/browser/widget/codeEditorWidget.ts +++ b/src/vs/editor/browser/widget/codeEditor/codeEditorWidget.ts @@ -4,7 +4,6 @@ *--------------------------------------------------------------------------------------------*/ import 'vs/editor/browser/services/markerDecorations'; - import * as dom from 'vs/base/browser/dom'; import { IKeyboardEvent } from 'vs/base/browser/keyboardEvent'; import { IMouseWheelEvent } from 'vs/base/browser/mouseEvent'; @@ -14,7 +13,7 @@ import { Emitter, EmitterOptions, Event, EventDeliveryQueue, createEventDelivery import { hash } from 'vs/base/common/hash'; import { Disposable, DisposableStore, IDisposable, dispose } from 'vs/base/common/lifecycle'; import { Schemas } from 'vs/base/common/network'; -import 'vs/css!./media/editor'; +import 'vs/css!./editor'; import { applyFontInfo } from 'vs/editor/browser/config/domFontInfo'; import { EditorConfiguration, IEditorConstructionOptions } from 'vs/editor/browser/config/editorConfiguration'; import { TabFocus } from 'vs/editor/browser/config/tabFocus'; @@ -25,7 +24,7 @@ import { IContentWidgetData, IGlyphMarginWidgetData, IOverlayWidgetData, View } import { DOMLineBreaksComputerFactory } from 'vs/editor/browser/view/domLineBreaksComputer'; import { ICommandDelegate } from 'vs/editor/browser/view/viewController'; import { ViewUserInputEvents } from 'vs/editor/browser/view/viewUserInputEvents'; -import { CodeEditorContributions } from 'vs/editor/browser/widget/codeEditorContributions'; +import { CodeEditorContributions } from 'vs/editor/browser/widget/codeEditor/codeEditorContributions'; import { IEditorConfiguration } from 'vs/editor/common/config/editorConfiguration'; import { ConfigurationChangedEvent, EditorLayoutInfo, EditorOption, FindComputedEditorOptionValueById, IComputedEditorOptions, IEditorOptions, filterValidationDecorations } from 'vs/editor/common/config/editorOptions'; import { CursorColumns } from 'vs/editor/common/core/cursorColumns'; @@ -61,51 +60,6 @@ import { INotificationService, Severity } from 'vs/platform/notification/common/ import { editorErrorForeground, editorHintForeground, editorInfoForeground, editorWarningForeground } from 'vs/platform/theme/common/colorRegistry'; import { IThemeService, registerThemingParticipant } from 'vs/platform/theme/common/themeService'; -let EDITOR_ID = 0; - -export interface ICodeEditorWidgetOptions { - /** - * Is this a simple widget (not a real code editor)? - * Defaults to false. - */ - isSimpleWidget?: boolean; - - /** - * Contributions to instantiate. - * When provided, only the contributions included will be instantiated. - * To include the defaults, those must be provided as well via [...EditorExtensionsRegistry.getEditorContributions()] - * Defaults to EditorExtensionsRegistry.getEditorContributions(). - */ - contributions?: IEditorContributionDescription[]; - - /** - * Telemetry data associated with this CodeEditorWidget. - * Defaults to null. - */ - telemetryData?: object; -} - -class ModelData { - constructor( - public readonly model: ITextModel, - public readonly viewModel: ViewModel, - public readonly view: View, - public readonly hasRealView: boolean, - public readonly listenersToRemove: IDisposable[], - public readonly attachedView: IAttachedView, - ) { - } - - public dispose(): void { - dispose(this.listenersToRemove); - this.model.onBeforeDetached(this.attachedView); - if (this.hasRealView) { - this.view.dispose(); - } - this.viewModel.dispose(); - } -} - export class CodeEditorWidget extends Disposable implements editorBrowser.ICodeEditor { private static readonly dropIntoEditorDecorationOptions = ModelDecorationOptions.register({ @@ -242,6 +196,7 @@ export class CodeEditorWidget extends Disposable implements editorBrowser.ICodeE private readonly _overflowWidgetsDomNode: HTMLElement | undefined; private readonly _id: number; private readonly _configuration: IEditorConfiguration; + private _contributionsDisposable: IDisposable | undefined; protected readonly _actions = new Map(); @@ -463,7 +418,7 @@ export class CodeEditorWidget extends Disposable implements editorBrowser.ICodeE if (!this._modelData) { return null; } - return WordOperations.getWordAtPosition(this._modelData.model, this._configuration.options.get(EditorOption.wordSeparators), position); + return WordOperations.getWordAtPosition(this._modelData.model, this._configuration.options.get(EditorOption.wordSeparators), this._configuration.options.get(EditorOption.wordSegmenterLocales), position); } public getValue(options: { preserveBOM: boolean; lineEnding: string } | null = null): string { @@ -523,7 +478,7 @@ export class CodeEditorWidget extends Disposable implements editorBrowser.ICodeE this._onDidChangeModel.fire(e); this._postDetachModelCleanup(detachedModel); - this._contributions.onAfterModelAttached(); + this._contributionsDisposable = this._contributions.onAfterModelAttached(); } private _removeDecorationTypes(): void { @@ -1087,8 +1042,8 @@ export class CodeEditorWidget extends Disposable implements editorBrowser.ICodeE return; } case editorCommon.Handler.Paste: { - const args = >payload; - this._paste(source, args.text || '', args.pasteOnNewLine || false, args.multicursorText || null, args.mode || null); + const args = >payload; + this._paste(source, args.text || '', args.pasteOnNewLine || false, args.multicursorText || null, args.mode || null, args.clipboardEvent); return; } case editorCommon.Handler.Cut: @@ -1153,8 +1108,8 @@ export class CodeEditorWidget extends Disposable implements editorBrowser.ICodeE this._modelData.viewModel.compositionType(text, replacePrevCharCnt, replaceNextCharCnt, positionDelta, source); } - private _paste(source: string | null | undefined, text: string, pasteOnNewLine: boolean, multicursorText: string[] | null, mode: string | null): void { - if (!this._modelData || text.length === 0) { + private _paste(source: string | null | undefined, text: string, pasteOnNewLine: boolean, multicursorText: string[] | null, mode: string | null, clipboardEvent?: ClipboardEvent): void { + if (!this._modelData) { return; } const viewModel = this._modelData.viewModel; @@ -1163,6 +1118,7 @@ export class CodeEditorWidget extends Disposable implements editorBrowser.ICodeE const endPosition = viewModel.getSelection().getStartPosition(); if (source === 'keyboard') { this._onDidPaste.fire({ + clipboardEvent, range: new Range(startPosition.lineNumber, startPosition.column, endPosition.lineNumber, endPosition.column), languageId: mode }); @@ -1810,7 +1766,7 @@ export class CodeEditorWidget extends Disposable implements editorBrowser.ICodeE } else { commandDelegate = { paste: (text: string, pasteOnNewLine: boolean, multicursorText: string[] | null, mode: string | null) => { - const payload: editorCommon.PastePayload = { text, pasteOnNewLine, multicursorText, mode }; + const payload: editorBrowser.PastePayload = { text, pasteOnNewLine, multicursorText, mode }; this._commandService.executeCommand(editorCommon.Handler.Paste, payload); }, type: (text: string) => { @@ -1871,6 +1827,8 @@ export class CodeEditorWidget extends Disposable implements editorBrowser.ICodeE } private _detachModel(): ITextModel | null { + this._contributionsDisposable?.dispose(); + this._contributionsDisposable = undefined; if (!this._modelData) { return null; } @@ -1887,7 +1845,6 @@ export class CodeEditorWidget extends Disposable implements editorBrowser.ICodeE if (this._bannerDomNode && this._domElement.contains(this._bannerDomNode)) { this._domElement.removeChild(this._bannerDomNode); } - return model; } @@ -1930,6 +1887,51 @@ export class CodeEditorWidget extends Disposable implements editorBrowser.ICodeE } } +let EDITOR_ID = 0; + +export interface ICodeEditorWidgetOptions { + /** + * Is this a simple widget (not a real code editor)? + * Defaults to false. + */ + isSimpleWidget?: boolean; + + /** + * Contributions to instantiate. + * When provided, only the contributions included will be instantiated. + * To include the defaults, those must be provided as well via [...EditorExtensionsRegistry.getEditorContributions()] + * Defaults to EditorExtensionsRegistry.getEditorContributions(). + */ + contributions?: IEditorContributionDescription[]; + + /** + * Telemetry data associated with this CodeEditorWidget. + * Defaults to null. + */ + telemetryData?: object; +} + +class ModelData { + constructor( + public readonly model: ITextModel, + public readonly viewModel: ViewModel, + public readonly view: View, + public readonly hasRealView: boolean, + public readonly listenersToRemove: IDisposable[], + public readonly attachedView: IAttachedView, + ) { + } + + public dispose(): void { + dispose(this.listenersToRemove); + this.model.onBeforeDetached(this.attachedView); + if (this.hasRealView) { + this.view.dispose(); + } + this.viewModel.dispose(); + } +} + const enum BooleanEventValue { NotSet, False, @@ -2095,7 +2097,7 @@ export class EditorModeContext extends Disposable { private readonly _hasMultipleDocumentSelectionFormattingProvider: IContextKey; private readonly _hasSignatureHelpProvider: IContextKey; private readonly _hasInlayHintsProvider: IContextKey; - private readonly _isInWalkThrough: IContextKey; + private readonly _isInEmbeddedEditor: IContextKey; constructor( private readonly _editor: CodeEditorWidget, @@ -2123,7 +2125,7 @@ export class EditorModeContext extends Disposable { this._hasDocumentSelectionFormattingProvider = EditorContextKeys.hasDocumentSelectionFormattingProvider.bindTo(_contextKeyService); this._hasMultipleDocumentFormattingProvider = EditorContextKeys.hasMultipleDocumentFormattingProvider.bindTo(_contextKeyService); this._hasMultipleDocumentSelectionFormattingProvider = EditorContextKeys.hasMultipleDocumentSelectionFormattingProvider.bindTo(_contextKeyService); - this._isInWalkThrough = EditorContextKeys.isInWalkThroughSnippet.bindTo(_contextKeyService); + this._isInEmbeddedEditor = EditorContextKeys.isInEmbeddedEditor.bindTo(_contextKeyService); const update = () => this._update(); @@ -2174,7 +2176,7 @@ export class EditorModeContext extends Disposable { this._hasDocumentFormattingProvider.reset(); this._hasDocumentSelectionFormattingProvider.reset(); this._hasSignatureHelpProvider.reset(); - this._isInWalkThrough.reset(); + this._isInEmbeddedEditor.reset(); }); } @@ -2204,7 +2206,7 @@ export class EditorModeContext extends Disposable { this._hasDocumentSelectionFormattingProvider.set(this._languageFeaturesService.documentRangeFormattingEditProvider.has(model)); this._hasMultipleDocumentFormattingProvider.set(this._languageFeaturesService.documentFormattingEditProvider.all(model).length + this._languageFeaturesService.documentRangeFormattingEditProvider.all(model).length > 1); this._hasMultipleDocumentSelectionFormattingProvider.set(this._languageFeaturesService.documentRangeFormattingEditProvider.all(model).length > 1); - this._isInWalkThrough.set(model.uri.scheme === Schemas.walkThroughSnippet); + this._isInEmbeddedEditor.set(model.uri.scheme === Schemas.walkThroughSnippet || model.uri.scheme === Schemas.vscodeChatCodeBlock); }); } } diff --git a/src/vs/editor/browser/widget/media/editor.css b/src/vs/editor/browser/widget/codeEditor/editor.css similarity index 92% rename from src/vs/editor/browser/widget/media/editor.css rename to src/vs/editor/browser/widget/codeEditor/editor.css index 1d60940158ad4..09c4a32f14151 100644 --- a/src/vs/editor/browser/widget/media/editor.css +++ b/src/vs/editor/browser/widget/codeEditor/editor.css @@ -56,6 +56,15 @@ top: 0; } +.monaco-editor .view-overlays > div, .monaco-editor .margin-view-overlays > div { + position: absolute; + width: 100%; +} + +.monaco-editor .view-overlays > div > div, .monaco-editor .margin-view-overlays > div > div { + bottom: 0; +} + /* .monaco-editor .auto-closed-character { opacity: 0.3; diff --git a/src/vs/editor/browser/widget/embeddedCodeEditorWidget.ts b/src/vs/editor/browser/widget/codeEditor/embeddedCodeEditorWidget.ts similarity index 60% rename from src/vs/editor/browser/widget/embeddedCodeEditorWidget.ts rename to src/vs/editor/browser/widget/codeEditor/embeddedCodeEditorWidget.ts index 8d63ee6f754b8..9fb3a8e69c2b9 100644 --- a/src/vs/editor/browser/widget/embeddedCodeEditorWidget.ts +++ b/src/vs/editor/browser/widget/codeEditor/embeddedCodeEditorWidget.ts @@ -4,24 +4,20 @@ *--------------------------------------------------------------------------------------------*/ import * as objects from 'vs/base/common/objects'; -import { ICodeEditor, IDiffEditorConstructionOptions } from 'vs/editor/browser/editorBrowser'; +import { ICodeEditor } from 'vs/editor/browser/editorBrowser'; import { ICodeEditorService } from 'vs/editor/browser/services/codeEditorService'; -import { CodeEditorWidget, ICodeEditorWidgetOptions } from 'vs/editor/browser/widget/codeEditorWidget'; -import { DiffEditorWidget, IDiffCodeEditorWidgetOptions } from 'vs/editor/browser/widget/diffEditor/diffEditorWidget'; -import { ConfigurationChangedEvent, IDiffEditorOptions, IEditorOptions } from 'vs/editor/common/config/editorOptions'; +import { CodeEditorWidget, ICodeEditorWidgetOptions } from 'vs/editor/browser/widget/codeEditor/codeEditorWidget'; +import { ConfigurationChangedEvent, IEditorOptions } from 'vs/editor/common/config/editorOptions'; import { ILanguageConfigurationService } from 'vs/editor/common/languages/languageConfigurationRegistry'; import { ILanguageFeaturesService } from 'vs/editor/common/services/languageFeatures'; import { IAccessibilityService } from 'vs/platform/accessibility/common/accessibility'; -import { IAudioCueService } from 'vs/platform/audioCues/browser/audioCueService'; import { ICommandService } from 'vs/platform/commands/common/commands'; import { IContextKeyService } from 'vs/platform/contextkey/common/contextkey'; import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; import { INotificationService } from 'vs/platform/notification/common/notification'; -import { IEditorProgressService } from 'vs/platform/progress/common/progress'; import { IThemeService } from 'vs/platform/theme/common/themeService'; export class EmbeddedCodeEditorWidget extends CodeEditorWidget { - private readonly _parentEditor: ICodeEditor; private readonly _overwriteOptions: IEditorOptions; @@ -38,7 +34,7 @@ export class EmbeddedCodeEditorWidget extends CodeEditorWidget { @INotificationService notificationService: INotificationService, @IAccessibilityService accessibilityService: IAccessibilityService, @ILanguageConfigurationService languageConfigurationService: ILanguageConfigurationService, - @ILanguageFeaturesService languageFeaturesService: ILanguageFeaturesService, + @ILanguageFeaturesService languageFeaturesService: ILanguageFeaturesService ) { super(domElement, { ...parentEditor.getRawOptions(), overflowWidgetsDomNode: parentEditor.getOverflowWidgetsDomNode() }, codeEditorWidgetOptions, instantiationService, codeEditorService, commandService, contextKeyService, themeService, notificationService, accessibilityService, languageConfigurationService, languageFeaturesService); @@ -65,45 +61,3 @@ export class EmbeddedCodeEditorWidget extends CodeEditorWidget { super.updateOptions(this._overwriteOptions); } } - -export class EmbeddedDiffEditorWidget extends DiffEditorWidget { - - private readonly _parentEditor: ICodeEditor; - private readonly _overwriteOptions: IDiffEditorOptions; - - constructor( - domElement: HTMLElement, - options: Readonly, - codeEditorWidgetOptions: IDiffCodeEditorWidgetOptions, - parentEditor: ICodeEditor, - @IContextKeyService contextKeyService: IContextKeyService, - @IInstantiationService instantiationService: IInstantiationService, - @ICodeEditorService codeEditorService: ICodeEditorService, - @IAudioCueService audioCueService: IAudioCueService, - @IEditorProgressService editorProgressService: IEditorProgressService, - ) { - super(domElement, parentEditor.getRawOptions(), codeEditorWidgetOptions, contextKeyService, instantiationService, codeEditorService, audioCueService, editorProgressService); - - this._parentEditor = parentEditor; - this._overwriteOptions = options; - - // Overwrite parent's options - super.updateOptions(this._overwriteOptions); - - this._register(parentEditor.onDidChangeConfiguration(e => this._onParentConfigurationChanged(e))); - } - - getParentEditor(): ICodeEditor { - return this._parentEditor; - } - - private _onParentConfigurationChanged(e: ConfigurationChangedEvent): void { - super.updateOptions(this._parentEditor.getRawOptions()); - super.updateOptions(this._overwriteOptions); - } - - override updateOptions(newOptions: IEditorOptions): void { - objects.mixin(this._overwriteOptions, newOptions, true); - super.updateOptions(this._overwriteOptions); - } -} diff --git a/src/vs/editor/browser/widget/diffEditor/commands.ts b/src/vs/editor/browser/widget/diffEditor/commands.ts new file mode 100644 index 0000000000000..cdfff8f57fe1e --- /dev/null +++ b/src/vs/editor/browser/widget/diffEditor/commands.ts @@ -0,0 +1,276 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { getActiveElement } from 'vs/base/browser/dom'; +import { Codicon } from 'vs/base/common/codicons'; +import { KeyCode, KeyMod } from 'vs/base/common/keyCodes'; +import { ICodeEditor, IDiffEditor } from 'vs/editor/browser/editorBrowser'; +import { EditorAction2, ServicesAccessor } from 'vs/editor/browser/editorExtensions'; +import { ICodeEditorService } from 'vs/editor/browser/services/codeEditorService'; +import { DiffEditorWidget } from 'vs/editor/browser/widget/diffEditor/diffEditorWidget'; +import { EditorContextKeys } from 'vs/editor/common/editorContextKeys'; +import { localize2 } from 'vs/nls'; +import { ILocalizedString } from 'vs/platform/action/common/action'; +import { Action2, MenuId } from 'vs/platform/actions/common/actions'; +import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; +import { ContextKeyExpr } from 'vs/platform/contextkey/common/contextkey'; +import { KeybindingWeight } from 'vs/platform/keybinding/common/keybindingsRegistry'; +import './registrations.contribution'; +import { DiffEditorSelectionHunkToolbarContext } from 'vs/editor/browser/widget/diffEditor/features/gutterFeature'; +import { URI } from 'vs/base/common/uri'; + +export class ToggleCollapseUnchangedRegions extends Action2 { + constructor() { + super({ + id: 'diffEditor.toggleCollapseUnchangedRegions', + title: localize2('toggleCollapseUnchangedRegions', 'Toggle Collapse Unchanged Regions'), + icon: Codicon.map, + toggled: ContextKeyExpr.has('config.diffEditor.hideUnchangedRegions.enabled'), + precondition: ContextKeyExpr.has('isInDiffEditor'), + menu: { + when: ContextKeyExpr.has('isInDiffEditor'), + id: MenuId.EditorTitle, + order: 22, + group: 'navigation', + }, + }); + } + + run(accessor: ServicesAccessor, ...args: unknown[]): void { + const configurationService = accessor.get(IConfigurationService); + const newValue = !configurationService.getValue('diffEditor.hideUnchangedRegions.enabled'); + configurationService.updateValue('diffEditor.hideUnchangedRegions.enabled', newValue); + } +} + +export class ToggleShowMovedCodeBlocks extends Action2 { + constructor() { + super({ + id: 'diffEditor.toggleShowMovedCodeBlocks', + title: localize2('toggleShowMovedCodeBlocks', 'Toggle Show Moved Code Blocks'), + precondition: ContextKeyExpr.has('isInDiffEditor'), + }); + } + + run(accessor: ServicesAccessor, ...args: unknown[]): void { + const configurationService = accessor.get(IConfigurationService); + const newValue = !configurationService.getValue('diffEditor.experimental.showMoves'); + configurationService.updateValue('diffEditor.experimental.showMoves', newValue); + } +} + +export class ToggleUseInlineViewWhenSpaceIsLimited extends Action2 { + constructor() { + super({ + id: 'diffEditor.toggleUseInlineViewWhenSpaceIsLimited', + title: localize2('toggleUseInlineViewWhenSpaceIsLimited', 'Toggle Use Inline View When Space Is Limited'), + precondition: ContextKeyExpr.has('isInDiffEditor'), + }); + } + + run(accessor: ServicesAccessor, ...args: unknown[]): void { + const configurationService = accessor.get(IConfigurationService); + const newValue = !configurationService.getValue('diffEditor.useInlineViewWhenSpaceIsLimited'); + configurationService.updateValue('diffEditor.useInlineViewWhenSpaceIsLimited', newValue); + } +} + +const diffEditorCategory: ILocalizedString = localize2('diffEditor', "Diff Editor"); + +export class SwitchSide extends EditorAction2 { + constructor() { + super({ + id: 'diffEditor.switchSide', + title: localize2('switchSide', 'Switch Side'), + icon: Codicon.arrowSwap, + precondition: ContextKeyExpr.has('isInDiffEditor'), + f1: true, + category: diffEditorCategory, + }); + } + + runEditorCommand(accessor: ServicesAccessor, editor: ICodeEditor, arg?: { dryRun: boolean }): unknown { + const diffEditor = findFocusedDiffEditor(accessor); + if (diffEditor instanceof DiffEditorWidget) { + if (arg && arg.dryRun) { + return { destinationSelection: diffEditor.mapToOtherSide().destinationSelection }; + } else { + diffEditor.switchSide(); + } + } + return undefined; + } +} +export class ExitCompareMove extends EditorAction2 { + constructor() { + super({ + id: 'diffEditor.exitCompareMove', + title: localize2('exitCompareMove', 'Exit Compare Move'), + icon: Codicon.close, + precondition: EditorContextKeys.comparingMovedCode, + f1: false, + category: diffEditorCategory, + keybinding: { + weight: 10000, + primary: KeyCode.Escape, + } + }); + } + + runEditorCommand(accessor: ServicesAccessor, editor: ICodeEditor, ...args: unknown[]): void { + const diffEditor = findFocusedDiffEditor(accessor); + if (diffEditor instanceof DiffEditorWidget) { + diffEditor.exitCompareMove(); + } + } +} + +export class CollapseAllUnchangedRegions extends EditorAction2 { + constructor() { + super({ + id: 'diffEditor.collapseAllUnchangedRegions', + title: localize2('collapseAllUnchangedRegions', 'Collapse All Unchanged Regions'), + icon: Codicon.fold, + precondition: ContextKeyExpr.has('isInDiffEditor'), + f1: true, + category: diffEditorCategory, + }); + } + + runEditorCommand(accessor: ServicesAccessor, editor: ICodeEditor, ...args: unknown[]): void { + const diffEditor = findFocusedDiffEditor(accessor); + if (diffEditor instanceof DiffEditorWidget) { + diffEditor.collapseAllUnchangedRegions(); + } + } +} + +export class ShowAllUnchangedRegions extends EditorAction2 { + constructor() { + super({ + id: 'diffEditor.showAllUnchangedRegions', + title: localize2('showAllUnchangedRegions', 'Show All Unchanged Regions'), + icon: Codicon.unfold, + precondition: ContextKeyExpr.has('isInDiffEditor'), + f1: true, + category: diffEditorCategory, + }); + } + + runEditorCommand(accessor: ServicesAccessor, editor: ICodeEditor, ...args: unknown[]): void { + const diffEditor = findFocusedDiffEditor(accessor); + if (diffEditor instanceof DiffEditorWidget) { + diffEditor.showAllUnchangedRegions(); + } + } +} + +export class RevertHunkOrSelection extends Action2 { + constructor() { + super({ + id: 'diffEditor.revert', + title: localize2('revert', 'Revert'), + f1: false, + category: diffEditorCategory, + }); + } + + run(accessor: ServicesAccessor, arg: DiffEditorSelectionHunkToolbarContext): unknown { + const diffEditor = findDiffEditor(accessor, arg.originalUri, arg.modifiedUri); + if (diffEditor instanceof DiffEditorWidget) { + diffEditor.revertRangeMappings(arg.mapping.innerChanges ?? []); + } + return undefined; + } +} + +const accessibleDiffViewerCategory: ILocalizedString = localize2('accessibleDiffViewer', "Accessible Diff Viewer"); + +export class AccessibleDiffViewerNext extends Action2 { + public static id = 'editor.action.accessibleDiffViewer.next'; + + constructor() { + super({ + id: AccessibleDiffViewerNext.id, + title: localize2('editor.action.accessibleDiffViewer.next', 'Go to Next Difference'), + category: accessibleDiffViewerCategory, + precondition: ContextKeyExpr.has('isInDiffEditor'), + keybinding: { + primary: KeyCode.F7, + weight: KeybindingWeight.EditorContrib + }, + f1: true, + }); + } + + public override run(accessor: ServicesAccessor): void { + const diffEditor = findFocusedDiffEditor(accessor); + diffEditor?.accessibleDiffViewerNext(); + } +} + +export class AccessibleDiffViewerPrev extends Action2 { + public static id = 'editor.action.accessibleDiffViewer.prev'; + + constructor() { + super({ + id: AccessibleDiffViewerPrev.id, + title: localize2('editor.action.accessibleDiffViewer.prev', 'Go to Previous Difference'), + category: accessibleDiffViewerCategory, + precondition: ContextKeyExpr.has('isInDiffEditor'), + keybinding: { + primary: KeyMod.Shift | KeyCode.F7, + weight: KeybindingWeight.EditorContrib + }, + f1: true, + }); + } + + public override run(accessor: ServicesAccessor): void { + const diffEditor = findFocusedDiffEditor(accessor); + diffEditor?.accessibleDiffViewerPrev(); + } +} + +export function findDiffEditor(accessor: ServicesAccessor, originalUri: URI, modifiedUri: URI): IDiffEditor | null { + const codeEditorService = accessor.get(ICodeEditorService); + const diffEditors = codeEditorService.listDiffEditors(); + + return diffEditors.find(diffEditor => { + const modified = diffEditor.getModifiedEditor(); + const original = diffEditor.getOriginalEditor(); + + return modified && modified.getModel()?.uri.toString() === modifiedUri.toString() + && original && original.getModel()?.uri.toString() === originalUri.toString(); + }) || null; +} + +export function findFocusedDiffEditor(accessor: ServicesAccessor): IDiffEditor | null { + const codeEditorService = accessor.get(ICodeEditorService); + const diffEditors = codeEditorService.listDiffEditors(); + + const activeElement = getActiveElement(); + if (activeElement) { + for (const d of diffEditors) { + const container = d.getContainerDomNode(); + if (isElementOrParentOf(container, activeElement)) { + return d; + } + } + } + + return null; +} + +function isElementOrParentOf(elementOrParent: Element, element: Element): boolean { + let e: Element | null = element; + while (e) { + if (e === elementOrParent) { + return true; + } + e = e.parentElement; + } + return false; +} diff --git a/src/vs/editor/browser/widget/diffEditor/components/accessibleDiffViewer.css b/src/vs/editor/browser/widget/diffEditor/components/accessibleDiffViewer.css index cd20cfb12f304..640909467f4bd 100644 --- a/src/vs/editor/browser/widget/diffEditor/components/accessibleDiffViewer.css +++ b/src/vs/editor/browser/widget/diffEditor/components/accessibleDiffViewer.css @@ -3,53 +3,57 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -.monaco-diff-editor .diff-review-line-number { - text-align: right; - display: inline-block; - color: var(--vscode-editorLineNumber-foreground); +.monaco-component.diff-review { + user-select: none; + -webkit-user-select: none; + z-index: 99; } .monaco-diff-editor .diff-review { position: absolute; - user-select: none; - -webkit-user-select: none; - z-index: 99; + +} + +.monaco-component.diff-review .diff-review-line-number { + text-align: right; + display: inline-block; + color: var(--vscode-editorLineNumber-foreground); } -.monaco-diff-editor .diff-review-summary { +.monaco-component.diff-review .diff-review-summary { padding-left: 10px; } -.monaco-diff-editor .diff-review-shadow { +.monaco-component.diff-review .diff-review-shadow { position: absolute; box-shadow: var(--vscode-scrollbar-shadow) 0 -6px 6px -6px inset; } -.monaco-diff-editor .diff-review-row { +.monaco-component.diff-review .diff-review-row { white-space: pre; } -.monaco-diff-editor .diff-review-table { +.monaco-component.diff-review .diff-review-table { display: table; min-width: 100%; } -.monaco-diff-editor .diff-review-row { +.monaco-component.diff-review .diff-review-row { display: table-row; width: 100%; } -.monaco-diff-editor .diff-review-spacer { +.monaco-component.diff-review .diff-review-spacer { display: inline-block; width: 10px; vertical-align: middle; } -.monaco-diff-editor .diff-review-spacer > .codicon { +.monaco-component.diff-review .diff-review-spacer > .codicon { font-size: 9px !important; } -.monaco-diff-editor .diff-review-actions { +.monaco-component.diff-review .diff-review-actions { display: inline-block; position: absolute; right: 10px; @@ -57,12 +61,12 @@ z-index: 100; } -.monaco-diff-editor .diff-review-actions .action-label { +.monaco-component.diff-review .diff-review-actions .action-label { width: 16px; height: 16px; margin: 2px 0; } -.monaco-diff-editor .revertButton { +.monaco-component.diff-review .revertButton { cursor: pointer; } diff --git a/src/vs/editor/browser/widget/diffEditor/components/accessibleDiffViewer.ts b/src/vs/editor/browser/widget/diffEditor/components/accessibleDiffViewer.ts index 786a8c26192a8..3c27606e5d69e 100644 --- a/src/vs/editor/browser/widget/diffEditor/components/accessibleDiffViewer.ts +++ b/src/vs/editor/browser/widget/diffEditor/components/accessibleDiffViewer.ts @@ -15,7 +15,6 @@ import { Disposable, DisposableStore, toDisposable } from 'vs/base/common/lifecy import { IObservable, ITransaction, autorun, autorunWithStore, derived, derivedWithStore, observableValue, subtransaction, transaction } from 'vs/base/common/observable'; import { ThemeIcon } from 'vs/base/common/themables'; import { applyFontInfo } from 'vs/editor/browser/config/domFontInfo'; -import { DiffEditorEditors } from 'vs/editor/browser/widget/diffEditor/components/diffEditorEditors'; import { applyStyle } from 'vs/editor/browser/widget/diffEditor/utils'; import { EditorFontLigatures, EditorOption, IComputedEditorOptions } from 'vs/editor/common/config/editorOptions'; import { LineRange } from 'vs/editor/common/core/lineRange'; @@ -30,15 +29,37 @@ import { LineTokens } from 'vs/editor/common/tokens/lineTokens'; import { RenderLineInput, renderViewLine2 } from 'vs/editor/common/viewLayout/viewLineRenderer'; import { ViewLineRenderingData } from 'vs/editor/common/viewModel'; import { localize } from 'vs/nls'; -import { AudioCue, IAudioCueService } from 'vs/platform/audioCues/browser/audioCueService'; +import { AccessibilitySignal, IAccessibilitySignalService } from 'vs/platform/accessibilitySignal/browser/accessibilitySignalService'; import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; import { registerIcon } from 'vs/platform/theme/common/iconRegistry'; import 'vs/css!./accessibleDiffViewer'; +import { DiffEditorEditors } from 'vs/editor/browser/widget/diffEditor/components/diffEditorEditors'; const accessibleDiffViewerInsertIcon = registerIcon('diff-review-insert', Codicon.add, localize('accessibleDiffViewerInsertIcon', 'Icon for \'Insert\' in accessible diff viewer.')); const accessibleDiffViewerRemoveIcon = registerIcon('diff-review-remove', Codicon.remove, localize('accessibleDiffViewerRemoveIcon', 'Icon for \'Remove\' in accessible diff viewer.')); const accessibleDiffViewerCloseIcon = registerIcon('diff-review-close', Codicon.close, localize('accessibleDiffViewerCloseIcon', 'Icon for \'Close\' in accessible diff viewer.')); +export interface IAccessibleDiffViewerModel { + getOriginalModel(): ITextModel; + getOriginalOptions(): IComputedEditorOptions; + + /** + * Should do: `setSelection`, `revealLine` and `focus` + */ + originalReveal(range: Range): void; + + getModifiedModel(): ITextModel; + getModifiedOptions(): IComputedEditorOptions; + /** + * Should do: `setSelection`, `revealLine` and `focus` + */ + modifiedReveal(range?: Range): void; + modifiedSetSelection(range: Range): void; + modifiedFocus(): void; + + getModifiedPosition(): Position | undefined; +} + export class AccessibleDiffViewer extends Disposable { public static _ttPolicy = createTrustedTypesPolicy('diffReview', { createHTML: value => value }); @@ -50,7 +71,7 @@ export class AccessibleDiffViewer extends Disposable { private readonly _width: IObservable, private readonly _height: IObservable, private readonly _diffs: IObservable, - private readonly _editors: DiffEditorEditors, + private readonly _models: IAccessibleDiffViewerModel, @IInstantiationService private readonly _instantiationService: IInstantiationService, ) { super(); @@ -62,8 +83,8 @@ export class AccessibleDiffViewer extends Disposable { if (!visible) { return null; } - const model = store.add(this._instantiationService.createInstance(ViewModel, this._diffs, this._editors, this._setVisible, this._canClose)); - const view = store.add(this._instantiationService.createInstance(View, this._parentNode, model, this._width, this._height, this._editors)); + const model = store.add(this._instantiationService.createInstance(ViewModel, this._diffs, this._models, this._setVisible, this._canClose)); + const view = store.add(this._instantiationService.createInstance(View, this._parentNode, model, this._width, this._height, this._models)); return { model, view, }; }).recomputeInitiallyAndOnChange(this._store); @@ -106,10 +127,10 @@ class ViewModel extends Disposable { constructor( private readonly _diffs: IObservable, - private readonly _editors: DiffEditorEditors, + private readonly _models: IAccessibleDiffViewerModel, private readonly _setVisible: (visible: boolean, tx: ITransaction | undefined) => void, public readonly canClose: IObservable, - @IAudioCueService private readonly _audioCueService: IAudioCueService, + @IAccessibilitySignalService private readonly _accessibilitySignalService: IAccessibilitySignalService, ) { super(); @@ -123,12 +144,12 @@ class ViewModel extends Disposable { const groups = computeViewElementGroups( diffs, - this._editors.original.getModel()!.getLineCount(), - this._editors.modified.getModel()!.getLineCount() + this._models.getOriginalModel().getLineCount(), + this._models.getModifiedModel().getLineCount() ); transaction(tx => { - const p = this._editors.modified.getPosition(); + const p = this._models.getModifiedPosition(); if (p) { const nextGroup = groups.findIndex(g => p?.lineNumber < g.range.modified.endLineNumberExclusive); if (nextGroup !== -1) { @@ -143,9 +164,9 @@ class ViewModel extends Disposable { /** @description play audio-cue for diff */ const currentViewItem = this.currentElement.read(reader); if (currentViewItem?.type === LineType.Deleted) { - this._audioCueService.playAudioCue(AudioCue.diffLineDeleted, { source: 'accessibleDiffViewer.currentElementChanged' }); + this._accessibilitySignalService.playSignal(AccessibilitySignal.diffLineDeleted, { source: 'accessibleDiffViewer.currentElementChanged' }); } else if (currentViewItem?.type === LineType.Added) { - this._audioCueService.playAudioCue(AudioCue.diffLineInserted, { source: 'accessibleDiffViewer.currentElementChanged' }); + this._accessibilitySignalService.playSignal(AccessibilitySignal.diffLineInserted, { source: 'accessibleDiffViewer.currentElementChanged' }); } })); @@ -155,7 +176,7 @@ class ViewModel extends Disposable { const currentViewItem = this.currentElement.read(reader); if (currentViewItem && currentViewItem.type !== LineType.Header) { const lineNumber = currentViewItem.modifiedLineNumber ?? currentViewItem.diff.modified.startLineNumber; - this._editors.modified.setSelection(Range.fromPositions(new Position(lineNumber, 1))); + this._models.modifiedSetSelection(Range.fromPositions(new Position(lineNumber, 1))); } })); } @@ -194,27 +215,27 @@ class ViewModel extends Disposable { } revealCurrentElementInEditor(): void { + if (!this.canClose.get()) { return; } this._setVisible(false, undefined); const curElem = this.currentElement.get(); if (curElem) { if (curElem.type === LineType.Deleted) { - this._editors.original.setSelection(Range.fromPositions(new Position(curElem.originalLineNumber, 1))); - this._editors.original.revealLine(curElem.originalLineNumber); - this._editors.original.focus(); + this._models.originalReveal(Range.fromPositions(new Position(curElem.originalLineNumber, 1))); } else { - if (curElem.type !== LineType.Header) { - this._editors.modified.setSelection(Range.fromPositions(new Position(curElem.modifiedLineNumber, 1))); - this._editors.modified.revealLine(curElem.modifiedLineNumber); - } - this._editors.modified.focus(); + this._models.modifiedReveal( + curElem.type !== LineType.Header + ? Range.fromPositions(new Position(curElem.modifiedLineNumber, 1)) + : undefined + ); } } } close(): void { + if (!this.canClose.get()) { return; } this._setVisible(false, undefined); - this._editors.modified.focus(); + this._models.modifiedFocus(); } } @@ -327,13 +348,13 @@ class View extends Disposable { private readonly _model: ViewModel, private readonly _width: IObservable, private readonly _height: IObservable, - private readonly _editors: DiffEditorEditors, + private readonly _models: IAccessibleDiffViewerModel, @ILanguageService private readonly _languageService: ILanguageService, ) { super(); this.domNode = this._element; - this.domNode.className = 'diff-review monaco-editor-background'; + this.domNode.className = 'monaco-component diff-review monaco-editor-background'; const actionBarContainer = document.createElement('div'); actionBarContainer.className = 'diff-review-actions'; @@ -360,6 +381,12 @@ class View extends Disposable { this._scrollbar = this._register(new DomScrollableElement(this._content, {})); reset(this.domNode, this._scrollbar.getDomNode(), actionBarContainer); + this._register(autorun(r => { + this._height.read(r); + this._width.read(r); + this._scrollbar.scanDomNode(); + })); + this._register(toDisposable(() => { reset(this.domNode); })); this._register(applyStyle(this.domNode, { width: this._width, height: this._height })); @@ -412,8 +439,8 @@ class View extends Disposable { } private _render(store: DisposableStore): void { - const originalOptions = this._editors.original.getOptions(); - const modifiedOptions = this._editors.modified.getOptions(); + const originalOptions = this._models.getOriginalOptions(); + const modifiedOptions = this._models.getModifiedOptions(); const container = document.createElement('div'); container.className = 'diff-review-table'; @@ -423,8 +450,8 @@ class View extends Disposable { reset(this._content, container); - const originalModel = this._editors.original.getModel(); - const modifiedModel = this._editors.modified.getModel(); + const originalModel = this._models.getOriginalModel(); + const modifiedModel = this._models.getModifiedModel(); if (!originalModel || !modifiedModel) { return; } @@ -659,3 +686,49 @@ class View extends Disposable { return r.html; } } + +export class AccessibleDiffViewerModelFromEditors implements IAccessibleDiffViewerModel { + constructor(private readonly editors: DiffEditorEditors) { } + + getOriginalModel(): ITextModel { + return this.editors.original.getModel()!; + } + + getOriginalOptions(): IComputedEditorOptions { + return this.editors.original.getOptions(); + } + + originalReveal(range: Range): void { + this.editors.original.revealRange(range); + this.editors.original.setSelection(range); + this.editors.original.focus(); + } + + getModifiedModel(): ITextModel { + return this.editors.modified.getModel()!; + } + + getModifiedOptions(): IComputedEditorOptions { + return this.editors.modified.getOptions(); + } + + modifiedReveal(range?: Range | undefined): void { + if (range) { + this.editors.modified.revealRange(range); + this.editors.modified.setSelection(range); + } + this.editors.modified.focus(); + } + + modifiedSetSelection(range: Range): void { + this.editors.modified.setSelection(range); + } + + modifiedFocus(): void { + this.editors.modified.focus(); + } + + getModifiedPosition(): Position | undefined { + return this.editors.modified.getPosition() ?? undefined; + } +} diff --git a/src/vs/editor/browser/widget/diffEditor/components/diffEditorEditors.ts b/src/vs/editor/browser/widget/diffEditor/components/diffEditorEditors.ts index 1f26c2cad8b16..9a23f163d0a04 100644 --- a/src/vs/editor/browser/widget/diffEditor/components/diffEditorEditors.ts +++ b/src/vs/editor/browser/widget/diffEditor/components/diffEditorEditors.ts @@ -3,60 +3,56 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { Emitter } from 'vs/base/common/event'; +import { Emitter, Event } from 'vs/base/common/event'; import { Disposable } from 'vs/base/common/lifecycle'; -import { IObservable, IReader, autorunHandleChanges, observableFromEvent } from 'vs/base/common/observable'; +import { IReader, autorunHandleChanges, derived, derivedOpts, observableFromEvent } from 'vs/base/common/observable'; import { IEditorConstructionOptions } from 'vs/editor/browser/config/editorConfiguration'; import { IDiffEditorConstructionOptions } from 'vs/editor/browser/editorBrowser'; -import { CodeEditorWidget, ICodeEditorWidgetOptions } from 'vs/editor/browser/widget/codeEditorWidget'; +import { CodeEditorWidget, ICodeEditorWidgetOptions } from 'vs/editor/browser/widget/codeEditor/codeEditorWidget'; +import { IDiffCodeEditorWidgetOptions } from 'vs/editor/browser/widget/diffEditor/diffEditorWidget'; import { OverviewRulerFeature } from 'vs/editor/browser/widget/diffEditor/features/overviewRulerFeature'; import { EditorOptions, IEditorOptions } from 'vs/editor/common/config/editorOptions'; +import { Position } from 'vs/editor/common/core/position'; import { IContentSizeChangedEvent } from 'vs/editor/common/editorCommon'; import { localize } from 'vs/nls'; import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; import { IKeybindingService } from 'vs/platform/keybinding/common/keybinding'; import { DiffEditorOptions } from '../diffEditorOptions'; -import { ITextModel } from 'vs/editor/common/model'; -import { IDiffCodeEditorWidgetOptions } from 'vs/editor/browser/widget/diffEditor/diffEditorWidget'; -import { Selection } from 'vs/editor/common/core/selection'; -import { Position } from 'vs/editor/common/core/position'; export class DiffEditorEditors extends Disposable { - public readonly modified: CodeEditorWidget; - public readonly original: CodeEditorWidget; + public readonly original = this._register(this._createLeftHandSideEditor(this._options.editorOptions.get(), this._argCodeEditorWidgetOptions.originalEditor || {})); + public readonly modified = this._register(this._createRightHandSideEditor(this._options.editorOptions.get(), this._argCodeEditorWidgetOptions.modifiedEditor || {})); private readonly _onDidContentSizeChange = this._register(new Emitter()); public get onDidContentSizeChange() { return this._onDidContentSizeChange.event; } - public readonly modifiedScrollTop: IObservable; - public readonly modifiedScrollHeight: IObservable; + public readonly modifiedScrollTop = observableFromEvent(this.modified.onDidScrollChange, () => /** @description modified.getScrollTop */ this.modified.getScrollTop()); + public readonly modifiedScrollHeight = observableFromEvent(this.modified.onDidScrollChange, () => /** @description modified.getScrollHeight */ this.modified.getScrollHeight()); + + public readonly modifiedModel = observableFromEvent(this.modified.onDidChangeModel, () => /** @description modified.model */ this.modified.getModel()); - public readonly modifiedModel: IObservable; + public readonly modifiedSelections = observableFromEvent(this.modified.onDidChangeCursorSelection, () => this.modified.getSelections() ?? []); + public readonly modifiedCursor = derivedOpts({ owner: this, equalsFn: Position.equals }, reader => this.modifiedSelections.read(reader)[0]?.getPosition() ?? new Position(1, 1)); - public readonly modifiedSelections: IObservable; - public readonly modifiedCursor: IObservable; + public readonly originalCursor = observableFromEvent(this.original.onDidChangeCursorPosition, () => this.original.getPosition() ?? new Position(1, 1)); + + public readonly isOriginalFocused = observableFromEvent(Event.any(this.original.onDidFocusEditorWidget, this.original.onDidBlurEditorWidget), () => this.original.hasWidgetFocus()); + public readonly isModifiedFocused = observableFromEvent(Event.any(this.modified.onDidFocusEditorWidget, this.modified.onDidBlurEditorWidget), () => this.modified.hasWidgetFocus()); + + public readonly isFocused = derived(this, reader => this.isOriginalFocused.read(reader) || this.isModifiedFocused.read(reader)); constructor( private readonly originalEditorElement: HTMLElement, private readonly modifiedEditorElement: HTMLElement, private readonly _options: DiffEditorOptions, - codeEditorWidgetOptions: IDiffCodeEditorWidgetOptions, + private _argCodeEditorWidgetOptions: IDiffCodeEditorWidgetOptions, private readonly _createInnerEditor: (instantiationService: IInstantiationService, container: HTMLElement, options: Readonly, editorWidgetOptions: ICodeEditorWidgetOptions) => CodeEditorWidget, @IInstantiationService private readonly _instantiationService: IInstantiationService, - @IKeybindingService private readonly _keybindingService: IKeybindingService, + @IKeybindingService private readonly _keybindingService: IKeybindingService ) { super(); - this.original = this._register(this._createLeftHandSideEditor(_options.editorOptions.get(), codeEditorWidgetOptions.originalEditor || {})); - this.modified = this._register(this._createRightHandSideEditor(_options.editorOptions.get(), codeEditorWidgetOptions.modifiedEditor || {})); - - this.modifiedModel = observableFromEvent(this.modified.onDidChangeModel, () => /** @description modified.model */ this.modified.getModel()); - - this.modifiedScrollTop = observableFromEvent(this.modified.onDidScrollChange, () => /** @description modified.getScrollTop */ this.modified.getScrollTop()); - this.modifiedScrollHeight = observableFromEvent(this.modified.onDidScrollChange, () => /** @description modified.getScrollHeight */ this.modified.getScrollHeight()); - - this.modifiedSelections = observableFromEvent(this.modified.onDidChangeCursorSelection, () => this.modified.getSelections() ?? []); - this.modifiedCursor = observableFromEvent(this.modified.onDidChangeCursorPosition, () => this.modified.getPosition() ?? new Position(1, 1)); + this._argCodeEditorWidgetOptions = null as any; this._register(autorunHandleChanges({ createEmptyChangeSummary: () => ({} as IDiffEditorConstructionOptions), diff --git a/src/vs/editor/browser/widget/diffEditor/components/diffEditorViewZones/diffEditorViewZones.ts b/src/vs/editor/browser/widget/diffEditor/components/diffEditorViewZones/diffEditorViewZones.ts index ac5b8886ac3fa..23a75bac47d12 100644 --- a/src/vs/editor/browser/widget/diffEditor/components/diffEditorViewZones/diffEditorViewZones.ts +++ b/src/vs/editor/browser/widget/diffEditor/components/diffEditorViewZones/diffEditorViewZones.ts @@ -12,7 +12,7 @@ import { IObservable, autorun, derived, derivedWithStore, observableFromEvent, o import { ThemeIcon } from 'vs/base/common/themables'; import { assertIsDefined } from 'vs/base/common/types'; import { applyFontInfo } from 'vs/editor/browser/config/domFontInfo'; -import { CodeEditorWidget } from 'vs/editor/browser/widget/codeEditorWidget'; +import { CodeEditorWidget } from 'vs/editor/browser/widget/codeEditor/codeEditorWidget'; import { diffDeleteDecoration, diffRemoveIcon } from 'vs/editor/browser/widget/diffEditor/registrations.contribution'; import { DiffEditorEditors } from 'vs/editor/browser/widget/diffEditor/components/diffEditorEditors'; import { DiffEditorViewModel, DiffMapping } from 'vs/editor/browser/widget/diffEditor/diffEditorViewModel'; @@ -312,7 +312,7 @@ export class DiffEditorViewZones extends Disposable { } let marginDomNode: HTMLElement | undefined = undefined; - if (a.diff && a.diff.modified.isEmpty && this._options.shouldRenderRevertArrows.read(reader)) { + if (a.diff && a.diff.modified.isEmpty && this._options.shouldRenderOldRevertArrows.read(reader)) { marginDomNode = createViewZoneMarginArrow(); } diff --git a/src/vs/editor/browser/widget/diffEditor/components/diffEditorViewZones/inlineDiffDeletedCodeMargin.ts b/src/vs/editor/browser/widget/diffEditor/components/diffEditorViewZones/inlineDiffDeletedCodeMargin.ts index ced12b99f7bd0..f6d88c3afe73b 100644 --- a/src/vs/editor/browser/widget/diffEditor/components/diffEditorViewZones/inlineDiffDeletedCodeMargin.ts +++ b/src/vs/editor/browser/widget/diffEditor/components/diffEditorViewZones/inlineDiffDeletedCodeMargin.ts @@ -10,7 +10,7 @@ import { Disposable } from 'vs/base/common/lifecycle'; import { isIOS } from 'vs/base/common/platform'; import { ThemeIcon } from 'vs/base/common/themables'; import { IEditorMouseEvent, MouseTargetType } from 'vs/editor/browser/editorBrowser'; -import { CodeEditorWidget } from 'vs/editor/browser/widget/codeEditorWidget'; +import { CodeEditorWidget } from 'vs/editor/browser/widget/codeEditor/codeEditorWidget'; import { DiffEditorWidget } from 'vs/editor/browser/widget/diffEditor/diffEditorWidget'; import { EditorOption } from 'vs/editor/common/config/editorOptions'; import { DetailedLineRangeMapping } from 'vs/editor/common/diff/rangeMapping'; diff --git a/src/vs/editor/browser/widget/diffEditor/delegatingEditorImpl.ts b/src/vs/editor/browser/widget/diffEditor/delegatingEditorImpl.ts index ff3b66c5e09fc..96a85f35eefc0 100644 --- a/src/vs/editor/browser/widget/diffEditor/delegatingEditorImpl.ts +++ b/src/vs/editor/browser/widget/diffEditor/delegatingEditorImpl.ts @@ -5,7 +5,7 @@ import { Emitter } from 'vs/base/common/event'; import { Disposable } from 'vs/base/common/lifecycle'; -import { CodeEditorWidget } from 'vs/editor/browser/widget/codeEditorWidget'; +import { CodeEditorWidget } from 'vs/editor/browser/widget/codeEditor/codeEditorWidget'; import { IEditorOptions } from 'vs/editor/common/config/editorOptions'; import { IDimension } from 'vs/editor/common/core/dimension'; import { IPosition, Position } from 'vs/editor/common/core/position'; diff --git a/src/vs/editor/browser/widget/diffEditor/diffEditor.contribution.ts b/src/vs/editor/browser/widget/diffEditor/diffEditor.contribution.ts index 2cc7ffacdc44b..75017bce4c331 100644 --- a/src/vs/editor/browser/widget/diffEditor/diffEditor.contribution.ts +++ b/src/vs/editor/browser/widget/diffEditor/diffEditor.contribution.ts @@ -3,83 +3,17 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { getActiveElement } from 'vs/base/browser/dom'; import { Codicon } from 'vs/base/common/codicons'; -import { KeyCode, KeyMod } from 'vs/base/common/keyCodes'; -import { ICodeEditor, IDiffEditor } from 'vs/editor/browser/editorBrowser'; -import { EditorAction2, ServicesAccessor } from 'vs/editor/browser/editorExtensions'; -import { ICodeEditorService } from 'vs/editor/browser/services/codeEditorService'; -import { DiffEditorWidget } from 'vs/editor/browser/widget/diffEditor/diffEditorWidget'; +import { AccessibleDiffViewerNext, AccessibleDiffViewerPrev, CollapseAllUnchangedRegions, ExitCompareMove, RevertHunkOrSelection, ShowAllUnchangedRegions, SwitchSide, ToggleCollapseUnchangedRegions, ToggleShowMovedCodeBlocks, ToggleUseInlineViewWhenSpaceIsLimited } from 'vs/editor/browser/widget/diffEditor/commands'; import { EditorContextKeys } from 'vs/editor/common/editorContextKeys'; -import { localize, localize2 } from 'vs/nls'; -import { ILocalizedString } from 'vs/platform/action/common/action'; -import { Action2, MenuId, MenuRegistry, registerAction2 } from 'vs/platform/actions/common/actions'; +import { localize } from 'vs/nls'; +import { MenuId, MenuRegistry, registerAction2 } from 'vs/platform/actions/common/actions'; import { CommandsRegistry } from 'vs/platform/commands/common/commands'; -import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; import { ContextKeyEqualsExpr, ContextKeyExpr } from 'vs/platform/contextkey/common/contextkey'; -import { KeybindingWeight } from 'vs/platform/keybinding/common/keybindingsRegistry'; import './registrations.contribution'; -export class ToggleCollapseUnchangedRegions extends Action2 { - constructor() { - super({ - id: 'diffEditor.toggleCollapseUnchangedRegions', - title: localize2('toggleCollapseUnchangedRegions', 'Toggle Collapse Unchanged Regions'), - icon: Codicon.map, - toggled: ContextKeyExpr.has('config.diffEditor.hideUnchangedRegions.enabled'), - precondition: ContextKeyExpr.has('isInDiffEditor'), - menu: { - when: ContextKeyExpr.has('isInDiffEditor'), - id: MenuId.EditorTitle, - order: 22, - group: 'navigation', - }, - }); - } - - run(accessor: ServicesAccessor, ...args: unknown[]): void { - const configurationService = accessor.get(IConfigurationService); - const newValue = !configurationService.getValue('diffEditor.hideUnchangedRegions.enabled'); - configurationService.updateValue('diffEditor.hideUnchangedRegions.enabled', newValue); - } -} - registerAction2(ToggleCollapseUnchangedRegions); - -export class ToggleShowMovedCodeBlocks extends Action2 { - constructor() { - super({ - id: 'diffEditor.toggleShowMovedCodeBlocks', - title: localize2('toggleShowMovedCodeBlocks', 'Toggle Show Moved Code Blocks'), - precondition: ContextKeyExpr.has('isInDiffEditor'), - }); - } - - run(accessor: ServicesAccessor, ...args: unknown[]): void { - const configurationService = accessor.get(IConfigurationService); - const newValue = !configurationService.getValue('diffEditor.experimental.showMoves'); - configurationService.updateValue('diffEditor.experimental.showMoves', newValue); - } -} - registerAction2(ToggleShowMovedCodeBlocks); - -export class ToggleUseInlineViewWhenSpaceIsLimited extends Action2 { - constructor() { - super({ - id: 'diffEditor.toggleUseInlineViewWhenSpaceIsLimited', - title: localize2('toggleUseInlineViewWhenSpaceIsLimited', 'Toggle Use Inline View When Space Is Limited'), - precondition: ContextKeyExpr.has('isInDiffEditor'), - }); - } - - run(accessor: ServicesAccessor, ...args: unknown[]): void { - const configurationService = accessor.get(IConfigurationService); - const newValue = !configurationService.getValue('diffEditor.useInlineViewWhenSpaceIsLimited'); - configurationService.updateValue('diffEditor.useInlineViewWhenSpaceIsLimited', newValue); - } -} - registerAction2(ToggleUseInlineViewWhenSpaceIsLimited); MenuRegistry.appendMenuItem(MenuId.EditorTitle, { @@ -110,130 +44,41 @@ MenuRegistry.appendMenuItem(MenuId.EditorTitle, { when: ContextKeyExpr.has('isInDiffEditor'), }); -const diffEditorCategory: ILocalizedString = localize2('diffEditor', "Diff Editor"); - -export class SwitchSide extends EditorAction2 { - constructor() { - super({ - id: 'diffEditor.switchSide', - title: localize2('switchSide', 'Switch Side'), - icon: Codicon.arrowSwap, - precondition: ContextKeyExpr.has('isInDiffEditor'), - f1: true, - category: diffEditorCategory, - }); - } +registerAction2(RevertHunkOrSelection); + +for (const ctx of [ + { icon: Codicon.arrowRight, key: EditorContextKeys.diffEditorInlineMode.toNegated() }, + { icon: Codicon.discard, key: EditorContextKeys.diffEditorInlineMode } +]) { + MenuRegistry.appendMenuItem(MenuId.DiffEditorHunkToolbar, { + command: { + id: new RevertHunkOrSelection().desc.id, + title: localize('revertHunk', "Revert Block"), + icon: ctx.icon, + }, + when: ContextKeyExpr.and(EditorContextKeys.diffEditorModifiedWritable, ctx.key), + order: 5, + group: 'primary', + }); + + MenuRegistry.appendMenuItem(MenuId.DiffEditorSelectionToolbar, { + command: { + id: new RevertHunkOrSelection().desc.id, + title: localize('revertSelection', "Revert Selection"), + icon: ctx.icon, + }, + when: ContextKeyExpr.and(EditorContextKeys.diffEditorModifiedWritable, ctx.key), + order: 5, + group: 'primary', + }); - runEditorCommand(accessor: ServicesAccessor, editor: ICodeEditor, arg?: { dryRun: boolean }): unknown { - const diffEditor = findFocusedDiffEditor(accessor); - if (diffEditor instanceof DiffEditorWidget) { - if (arg && arg.dryRun) { - return { destinationSelection: diffEditor.mapToOtherSide().destinationSelection }; - } else { - diffEditor.switchSide(); - } - } - return undefined; - } } registerAction2(SwitchSide); - -export class ExitCompareMove extends EditorAction2 { - constructor() { - super({ - id: 'diffEditor.exitCompareMove', - title: localize2('exitCompareMove', 'Exit Compare Move'), - icon: Codicon.close, - precondition: EditorContextKeys.comparingMovedCode, - f1: false, - category: diffEditorCategory, - keybinding: { - weight: 10000, - primary: KeyCode.Escape, - } - }); - } - - runEditorCommand(accessor: ServicesAccessor, editor: ICodeEditor, ...args: unknown[]): void { - const diffEditor = findFocusedDiffEditor(accessor); - if (diffEditor instanceof DiffEditorWidget) { - diffEditor.exitCompareMove(); - } - } -} - registerAction2(ExitCompareMove); - -export class CollapseAllUnchangedRegions extends EditorAction2 { - constructor() { - super({ - id: 'diffEditor.collapseAllUnchangedRegions', - title: localize2('collapseAllUnchangedRegions', 'Collapse All Unchanged Regions'), - icon: Codicon.fold, - precondition: ContextKeyExpr.has('isInDiffEditor'), - f1: true, - category: diffEditorCategory, - }); - } - - runEditorCommand(accessor: ServicesAccessor, editor: ICodeEditor, ...args: unknown[]): void { - const diffEditor = findFocusedDiffEditor(accessor); - if (diffEditor instanceof DiffEditorWidget) { - diffEditor.collapseAllUnchangedRegions(); - } - } -} - registerAction2(CollapseAllUnchangedRegions); - -export class ShowAllUnchangedRegions extends EditorAction2 { - constructor() { - super({ - id: 'diffEditor.showAllUnchangedRegions', - title: localize2('showAllUnchangedRegions', 'Show All Unchanged Regions'), - icon: Codicon.unfold, - precondition: ContextKeyExpr.has('isInDiffEditor'), - f1: true, - category: diffEditorCategory, - }); - } - - runEditorCommand(accessor: ServicesAccessor, editor: ICodeEditor, ...args: unknown[]): void { - const diffEditor = findFocusedDiffEditor(accessor); - if (diffEditor instanceof DiffEditorWidget) { - diffEditor.showAllUnchangedRegions(); - } - } -} - registerAction2(ShowAllUnchangedRegions); -const accessibleDiffViewerCategory: ILocalizedString = localize2('accessibleDiffViewer', "Accessible Diff Viewer"); - -export class AccessibleDiffViewerNext extends Action2 { - public static id = 'editor.action.accessibleDiffViewer.next'; - - constructor() { - super({ - id: AccessibleDiffViewerNext.id, - title: localize2('editor.action.accessibleDiffViewer.next', 'Go to Next Difference'), - category: accessibleDiffViewerCategory, - precondition: ContextKeyExpr.has('isInDiffEditor'), - keybinding: { - primary: KeyCode.F7, - weight: KeybindingWeight.EditorContrib - }, - f1: true, - }); - } - - public override run(accessor: ServicesAccessor): void { - const diffEditor = findFocusedDiffEditor(accessor); - diffEditor?.accessibleDiffViewerNext(); - } -} - MenuRegistry.appendMenuItem(MenuId.EditorTitle, { command: { id: AccessibleDiffViewerNext.id, @@ -248,56 +93,6 @@ MenuRegistry.appendMenuItem(MenuId.EditorTitle, { ), }); -export class AccessibleDiffViewerPrev extends Action2 { - public static id = 'editor.action.accessibleDiffViewer.prev'; - - constructor() { - super({ - id: AccessibleDiffViewerPrev.id, - title: localize2('editor.action.accessibleDiffViewer.prev', 'Go to Previous Difference'), - category: accessibleDiffViewerCategory, - precondition: ContextKeyExpr.has('isInDiffEditor'), - keybinding: { - primary: KeyMod.Shift | KeyCode.F7, - weight: KeybindingWeight.EditorContrib - }, - f1: true, - }); - } - - public override run(accessor: ServicesAccessor): void { - const diffEditor = findFocusedDiffEditor(accessor); - diffEditor?.accessibleDiffViewerPrev(); - } -} - -export function findFocusedDiffEditor(accessor: ServicesAccessor): IDiffEditor | null { - const codeEditorService = accessor.get(ICodeEditorService); - const diffEditors = codeEditorService.listDiffEditors(); - - const activeElement = getActiveElement(); - if (activeElement) { - for (const d of diffEditors) { - const container = d.getContainerDomNode(); - if (isElementOrParentOf(container, activeElement)) { - return d; - } - } - } - - return null; -} - -function isElementOrParentOf(elementOrParent: Element, element: Element): boolean { - let e: Element | null = element; - while (e) { - if (e === elementOrParent) { - return true; - } - e = e.parentElement; - } - return false; -} CommandsRegistry.registerCommandAlias('editor.action.diffReview.next', AccessibleDiffViewerNext.id); registerAction2(AccessibleDiffViewerNext); diff --git a/src/vs/editor/browser/widget/diffEditor/diffEditorOptions.ts b/src/vs/editor/browser/widget/diffEditor/diffEditorOptions.ts index 1db9c84ce242e..f5bbdb1b43faa 100644 --- a/src/vs/editor/browser/widget/diffEditor/diffEditorOptions.ts +++ b/src/vs/editor/browser/widget/diffEditor/diffEditorOptions.ts @@ -3,10 +3,11 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { IObservable, ISettableObservable, derived, observableValue } from 'vs/base/common/observable'; +import { IObservable, ISettableObservable, derived, observableFromEvent, observableValue } from 'vs/base/common/observable'; import { Constants } from 'vs/base/common/uint'; import { diffEditorDefaultOptions } from 'vs/editor/common/config/diffEditor'; import { IDiffEditorBaseOptions, IDiffEditorOptions, IEditorOptions, ValidDiffEditorBaseOptions, clampedFloat, clampedInt, boolean as validateBooleanOption, stringSet as validateStringSetOption } from 'vs/editor/common/config/editorOptions'; +import { IAccessibilityService } from 'vs/platform/accessibility/common/accessibility'; export class DiffEditorOptions { private readonly _options: ISettableObservable, { changedOptions: IDiffEditorOptions }>; @@ -15,8 +16,11 @@ export class DiffEditorOptions { private readonly _diffEditorWidth = observableValue(this, 0); + private readonly _screenReaderMode = observableFromEvent(this._accessibilityService.onDidChangeScreenReaderOptimized, () => this._accessibilityService.isScreenReaderOptimized()); + constructor( options: Readonly, + @IAccessibilityService private readonly _accessibilityService: IAccessibilityService, ) { const optionsCopy = { ...options, ...validateDiffEditorOptions(options, diffEditorDefaultOptions) }; this._options = observableValue(this, optionsCopy); @@ -28,16 +32,19 @@ export class DiffEditorOptions { public readonly renderOverviewRuler = derived(this, reader => this._options.read(reader).renderOverviewRuler); public readonly renderSideBySide = derived(this, reader => this._options.read(reader).renderSideBySide - && !(this._options.read(reader).useInlineViewWhenSpaceIsLimited && this.couldShowInlineViewBecauseOfSize.read(reader)) + && !(this._options.read(reader).useInlineViewWhenSpaceIsLimited && this.couldShowInlineViewBecauseOfSize.read(reader) && !this._screenReaderMode.read(reader)) ); public readonly readOnly = derived(this, reader => this._options.read(reader).readOnly); - public readonly shouldRenderRevertArrows = derived(this, reader => { + public readonly shouldRenderOldRevertArrows = derived(this, reader => { if (!this._options.read(reader).renderMarginRevertIcon) { return false; } if (!this.renderSideBySide.read(reader)) { return false; } if (this.readOnly.read(reader)) { return false; } + if (this.shouldRenderGutterMenu.read(reader)) { return false; } return true; }); + + public readonly shouldRenderGutterMenu = derived(this, reader => this._options.read(reader).renderGutterMenu); public readonly renderIndicators = derived(this, reader => this._options.read(reader).renderIndicators); public readonly enableSplitViewResizing = derived(this, reader => this._options.read(reader).enableSplitViewResizing); public readonly splitViewDefaultRatio = derived(this, reader => this._options.read(reader).splitViewDefaultRatio); @@ -99,5 +106,6 @@ function validateDiffEditorOptions(options: Readonly, defaul onlyShowAccessibleDiffViewer: validateBooleanOption(options.onlyShowAccessibleDiffViewer, defaults.onlyShowAccessibleDiffViewer), renderSideBySideInlineBreakpoint: clampedInt(options.renderSideBySideInlineBreakpoint, defaults.renderSideBySideInlineBreakpoint, 0, Constants.MAX_SAFE_SMALL_INTEGER), useInlineViewWhenSpaceIsLimited: validateBooleanOption(options.useInlineViewWhenSpaceIsLimited, defaults.useInlineViewWhenSpaceIsLimited), + renderGutterMenu: validateBooleanOption(options.renderGutterMenu, defaults.renderGutterMenu), }; } diff --git a/src/vs/editor/browser/widget/diffEditor/diffEditorViewModel.ts b/src/vs/editor/browser/widget/diffEditor/diffEditorViewModel.ts index bd065a313f78b..b1520c8821f0c 100644 --- a/src/vs/editor/browser/widget/diffEditor/diffEditorViewModel.ts +++ b/src/vs/editor/browser/widget/diffEditor/diffEditorViewModel.ts @@ -298,7 +298,10 @@ export class DiffEditorViewModel extends Disposable implements IDiffEditorViewMo if (this._cancellationTokenSource.token.isCancellationRequested) { return; } - + if (model.original.isDisposed() || model.modified.isDisposed()) { + // TODO@hediet fishy? + return; + } result = normalizeDocumentDiff(result, model.original, model.modified); result = applyOriginalEdits(result, originalTextEditInfos, model.original, model.modified) ?? result; result = applyModifiedEdits(result, modifiedTextEditInfos, model.original, model.modified) ?? result; diff --git a/src/vs/editor/browser/widget/diffEditor/diffEditorWidget.ts b/src/vs/editor/browser/widget/diffEditor/diffEditorWidget.ts index 3fd6e543a6103..ecdc471e69eea 100644 --- a/src/vs/editor/browser/widget/diffEditor/diffEditorWidget.ts +++ b/src/vs/editor/browser/widget/diffEditor/diffEditorWidget.ts @@ -16,35 +16,36 @@ import { ICodeEditor, IDiffEditor, IDiffEditorConstructionOptions } from 'vs/edi import { EditorExtensionsRegistry, IDiffEditorContributionDescription } from 'vs/editor/browser/editorExtensions'; import { ICodeEditorService } from 'vs/editor/browser/services/codeEditorService'; import { StableEditorScrollState } from 'vs/editor/browser/stableEditorScroll'; -import { CodeEditorWidget, ICodeEditorWidgetOptions } from 'vs/editor/browser/widget/codeEditorWidget'; -import { AccessibleDiffViewer } from 'vs/editor/browser/widget/diffEditor/components/accessibleDiffViewer'; +import { CodeEditorWidget, ICodeEditorWidgetOptions } from 'vs/editor/browser/widget/codeEditor/codeEditorWidget'; +import { AccessibleDiffViewer, AccessibleDiffViewerModelFromEditors } from 'vs/editor/browser/widget/diffEditor/components/accessibleDiffViewer'; import { DiffEditorDecorations } from 'vs/editor/browser/widget/diffEditor/components/diffEditorDecorations'; import { DiffEditorSash } from 'vs/editor/browser/widget/diffEditor/components/diffEditorSash'; -import { HideUnchangedRegionsFeature } from 'vs/editor/browser/widget/diffEditor/features/hideUnchangedRegionsFeature'; import { DiffEditorViewZones } from 'vs/editor/browser/widget/diffEditor/components/diffEditorViewZones/diffEditorViewZones'; +import { HideUnchangedRegionsFeature } from 'vs/editor/browser/widget/diffEditor/features/hideUnchangedRegionsFeature'; import { MovedBlocksLinesFeature } from 'vs/editor/browser/widget/diffEditor/features/movedBlocksLinesFeature'; import { OverviewRulerFeature } from 'vs/editor/browser/widget/diffEditor/features/overviewRulerFeature'; +import { RevertButtonsFeature } from 'vs/editor/browser/widget/diffEditor/features/revertButtonsFeature'; import { CSSStyle, ObservableElementSizeObserver, applyStyle, applyViewZones, bindContextKey, readHotReloadableExport, translatePosition } from 'vs/editor/browser/widget/diffEditor/utils'; import { IDiffEditorOptions } from 'vs/editor/common/config/editorOptions'; import { IDimension } from 'vs/editor/common/core/dimension'; import { Position } from 'vs/editor/common/core/position'; import { Range } from 'vs/editor/common/core/range'; -import { CursorChangeReason } from 'vs/editor/common/cursorEvents'; +import { CursorChangeReason, ICursorPositionChangedEvent } from 'vs/editor/common/cursorEvents'; import { IDiffComputationResult, ILineChange } from 'vs/editor/common/diff/legacyLinesDiffComputer'; -import { DetailedLineRangeMapping, RangeMapping } from 'vs/editor/common/diff/rangeMapping'; +import { LineRangeMapping, RangeMapping } from 'vs/editor/common/diff/rangeMapping'; import { EditorType, IDiffEditorModel, IDiffEditorViewModel, IDiffEditorViewState } from 'vs/editor/common/editorCommon'; import { EditorContextKeys } from 'vs/editor/common/editorContextKeys'; import { IIdentifiedSingleEditOperation } from 'vs/editor/common/model'; -import { AudioCue, IAudioCueService } from 'vs/platform/audioCues/browser/audioCueService'; +import { AccessibilitySignal, IAccessibilitySignalService } from 'vs/platform/accessibilitySignal/browser/accessibilitySignalService'; import { IContextKeyService } from 'vs/platform/contextkey/common/contextkey'; import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; import { ServiceCollection } from 'vs/platform/instantiation/common/serviceCollection'; import { IEditorProgressService } from 'vs/platform/progress/common/progress'; -import { DelegatingEditor } from './delegatingEditorImpl'; import { DiffEditorEditors } from './components/diffEditorEditors'; +import { DelegatingEditor } from './delegatingEditorImpl'; import { DiffEditorOptions } from './diffEditorOptions'; import { DiffEditorViewModel, DiffMapping, DiffState } from './diffEditorViewModel'; -import { RevertButtonsFeature } from 'vs/editor/browser/widget/diffEditor/features/revertButtonsFeature'; +import { DiffEditorGutter } from 'vs/editor/browser/widget/diffEditor/features/gutterFeature'; export interface IDiffCodeEditorWidgetOptions { originalEditor?: ICodeEditorWidgetOptions; @@ -56,8 +57,8 @@ export class DiffEditorWidget extends DelegatingEditor implements IDiffEditor { private readonly elements = h('div.monaco-diff-editor.side-by-side', { style: { position: 'relative', height: '100%' } }, [ h('div.noModificationsOverlay@overlay', { style: { position: 'absolute', height: '100%', visibility: 'hidden', } }, [$('span', {}, 'No Changes')]), - h('div.editor.original@original', { style: { position: 'absolute', height: '100%' } }), - h('div.editor.modified@modified', { style: { position: 'absolute', height: '100%' } }), + h('div.editor.original@original', { style: { position: 'absolute', height: '100%', } }), + h('div.editor.modified@modified', { style: { position: 'absolute', height: '100%', } }), h('div.accessibleDiffViewer@accessibleDiffViewer', { style: { position: 'absolute', height: '100%' } }), ]); private readonly _diffModel = observableValue(this, undefined); @@ -72,6 +73,9 @@ export class DiffEditorWidget extends DelegatingEditor implements IDiffEditor { ); private readonly _rootSizeObserver: ObservableElementSizeObserver; + /** + * Is undefined if and only if side-by-side + */ private readonly _sash: IObservable; private readonly _boundarySashes = observableValue(this, undefined); @@ -88,6 +92,8 @@ export class DiffEditorWidget extends DelegatingEditor implements IDiffEditor { private readonly _overviewRulerPart: IObservable; private readonly _movedBlocksLinesPart = observableValue(this, undefined); + private readonly _gutter: IObservable; + public get collapseUnchangedRegions() { return this._options.hideUnchangedRegions.get(); } constructor( @@ -97,7 +103,7 @@ export class DiffEditorWidget extends DelegatingEditor implements IDiffEditor { @IContextKeyService private readonly _parentContextKeyService: IContextKeyService, @IInstantiationService private readonly _parentInstantiationService: IInstantiationService, @ICodeEditorService codeEditorService: ICodeEditorService, - @IAudioCueService private readonly _audioCueService: IAudioCueService, + @IAccessibilitySignalService private readonly _accessibilitySignalService: IAccessibilitySignalService, @IEditorProgressService private readonly _editorProgressService: IEditorProgressService, ) { super(); @@ -111,7 +117,7 @@ export class DiffEditorWidget extends DelegatingEditor implements IDiffEditor { this._rootSizeObserver = this._register(new ObservableElementSizeObserver(this.elements.root, options.dimension)); this._rootSizeObserver.setAutomaticLayout(options.automaticLayout ?? false); - this._options = new DiffEditorOptions(options); + this._options = this._instantiationService.createInstance(DiffEditorOptions, options); this._register(autorun(reader => { this._options.setWidth(this._rootSizeObserver.width.read(reader)); })); @@ -126,6 +132,9 @@ export class DiffEditorWidget extends DelegatingEditor implements IDiffEditor { this._register(bindContextKey(EditorContextKeys.diffEditorRenderSideBySideInlineBreakpointReached, this._contextKeyService, reader => this._options.couldShowInlineViewBecauseOfSize.read(reader) )); + this._register(bindContextKey(EditorContextKeys.diffEditorInlineMode, this._contextKeyService, + reader => !this._options.renderSideBySide.read(reader) + )); this._register(bindContextKey(EditorContextKeys.hasChanges, this._contextKeyService, reader => (this._diffModel.read(reader)?.diff.read(reader)?.mappings.length ?? 0) > 0 @@ -140,6 +149,19 @@ export class DiffEditorWidget extends DelegatingEditor implements IDiffEditor { (i, c, o, o2) => this._createInnerEditor(i, c, o, o2), )); + this._register(bindContextKey(EditorContextKeys.diffEditorOriginalWritable, this._contextKeyService, + reader => this._options.originalEditable.read(reader) + )); + this._register(bindContextKey(EditorContextKeys.diffEditorModifiedWritable, this._contextKeyService, + reader => !this._options.readOnly.read(reader) + )); + this._register(bindContextKey(EditorContextKeys.diffEditorOriginalUri, this._contextKeyService, + reader => this._diffModel.read(reader)?.model.original.uri.toString() ?? '' + )); + this._register(bindContextKey(EditorContextKeys.diffEditorModifiedUri, this._contextKeyService, + reader => this._diffModel.read(reader)?.model.modified.uri.toString() ?? '' + )); + this._overviewRulerPart = derivedDisposable(this, reader => !this._options.renderOverviewRuler.read(reader) ? undefined @@ -233,7 +255,7 @@ export class DiffEditorWidget extends DelegatingEditor implements IDiffEditor { this._rootSizeObserver.width, this._rootSizeObserver.height, this._diffModel.map((m, r) => m?.diff.read(r)?.mappings.map(m => m.lineRangeMapping)), - this._editors, + new AccessibleDiffViewerModelFromEditors(this._editors), ) ).recomputeInitiallyAndOnChange(this._store); @@ -245,6 +267,17 @@ export class DiffEditorWidget extends DelegatingEditor implements IDiffEditor { codeEditorService.addDiffEditor(this); + this._gutter = derivedDisposable(this, reader => { + return this._options.shouldRenderGutterMenu.read(reader) + ? this._instantiationService.createInstance( + readHotReloadableExport(DiffEditorGutter, reader), + this.elements.root, + this._diffModel, + this._editors + ) + : undefined; + }); + this._register(recomputeInitiallyAndOnChange(this._layoutInfo)); derivedDisposable(this, reader => /** @description MovedBlocksLinesPart */ @@ -267,18 +300,9 @@ export class DiffEditorWidget extends DelegatingEditor implements IDiffEditor { ), })); - this._register(Event.runAndSubscribe(this._editors.modified.onDidChangeCursorPosition, (e) => { - if (e?.reason === CursorChangeReason.Explicit) { - const diff = this._diffModel.get()?.diff.get()?.mappings.find(m => m.lineRangeMapping.modified.contains(e.position.lineNumber)); - if (diff?.lineRangeMapping.modified.isEmpty) { - this._audioCueService.playAudioCue(AudioCue.diffLineDeleted, { source: 'diffEditor.cursorPositionChanged' }); - } else if (diff?.lineRangeMapping.original.isEmpty) { - this._audioCueService.playAudioCue(AudioCue.diffLineInserted, { source: 'diffEditor.cursorPositionChanged' }); - } else if (diff) { - this._audioCueService.playAudioCue(AudioCue.diffLineModified, { source: 'diffEditor.cursorPositionChanged' }); - } - } - })); + this._register(Event.runAndSubscribe(this._editors.modified.onDidChangeCursorPosition, e => this._handleCursorPositionChange(e, true))); + this._register(Event.runAndSubscribe(this._editors.original.onDidChangeCursorPosition, e => this._handleCursorPositionChange(e, false))); + const isInitializingDiff = this._diffModel.map(this, (m, reader) => { /** @isInitializingDiff isDiffUpToDate */ @@ -299,7 +323,9 @@ export class DiffEditorWidget extends DelegatingEditor implements IDiffEditor { } })); - this._register(new RevertButtonsFeature(this._editors, this._diffModel, this._options, this)); + this._register(autorunWithStore((reader, store) => { + store.add(new (readHotReloadableExport(RevertButtonsFeature, reader))(this._editors, this._diffModel, this._options, this)); + })); } public getViewWidth(): number { @@ -316,23 +342,49 @@ export class DiffEditorWidget extends DelegatingEditor implements IDiffEditor { } private readonly _layoutInfo = derived(this, reader => { - const width = this._rootSizeObserver.width.read(reader); - const height = this._rootSizeObserver.height.read(reader); - const sashLeft = this._sash.read(reader)?.sashLeft.read(reader); + const fullWidth = this._rootSizeObserver.width.read(reader); + const fullHeight = this._rootSizeObserver.height.read(reader); - const originalWidth = sashLeft ?? Math.max(5, this._editors.original.getLayoutInfo().decorationsLeft); - const modifiedWidth = width - originalWidth - (this._overviewRulerPart.read(reader)?.width ?? 0); + const sash = this._sash.read(reader); - const movedBlocksLinesWidth = this._movedBlocksLinesPart.read(reader)?.width.read(reader) ?? 0; - const originalWidthWithoutMovedBlockLines = originalWidth - movedBlocksLinesWidth; - this.elements.original.style.width = originalWidthWithoutMovedBlockLines + 'px'; - this.elements.original.style.left = '0px'; + const gutter = this._gutter.read(reader); + const gutterWidth = gutter?.width.read(reader) ?? 0; - this.elements.modified.style.width = modifiedWidth + 'px'; - this.elements.modified.style.left = originalWidth + 'px'; + const overviewRulerPartWidth = this._overviewRulerPart.read(reader)?.width ?? 0; + + let originalLeft: number, originalWidth: number, modifiedLeft: number, modifiedWidth: number, gutterLeft: number; + + const sideBySide = !!sash; + if (sideBySide) { + const sashLeft = sash.sashLeft.read(reader); + const movedBlocksLinesWidth = this._movedBlocksLinesPart.read(reader)?.width.read(reader) ?? 0; - this._editors.original.layout({ width: originalWidthWithoutMovedBlockLines, height }, true); - this._editors.modified.layout({ width: modifiedWidth, height }, true); + originalLeft = 0; + originalWidth = sashLeft - gutterWidth - movedBlocksLinesWidth; + + gutterLeft = sashLeft - gutterWidth; + + modifiedLeft = sashLeft; + modifiedWidth = fullWidth - modifiedLeft - overviewRulerPartWidth; + } else { + gutterLeft = 0; + + originalLeft = gutterWidth; + originalWidth = Math.max(5, this._editors.original.getLayoutInfo().decorationsLeft); + + modifiedLeft = gutterWidth + originalWidth; + modifiedWidth = fullWidth - modifiedLeft - overviewRulerPartWidth; + } + + this.elements.original.style.left = originalLeft + 'px'; + this.elements.original.style.width = originalWidth + 'px'; + this._editors.original.layout({ width: originalWidth, height: fullHeight }, true); + + gutter?.layout(gutterLeft); + + this.elements.modified.style.left = modifiedLeft + 'px'; + this.elements.modified.style.width = modifiedWidth + 'px'; + this._editors.modified.layout({ width: modifiedWidth, height: fullHeight }, true); return { modifiedEditor: this._editors.modified.getLayoutInfo(), @@ -477,12 +529,7 @@ export class DiffEditorWidget extends DelegatingEditor implements IDiffEditor { }; } - revert(diff: DetailedLineRangeMapping): void { - if (diff.innerChanges) { - this.revertRangeMappings(diff.innerChanges); - return; - } - + revert(diff: LineRangeMapping): void { const model = this._diffModel.get(); if (!model || !model.isDiffUpToDate.get()) { return; } @@ -528,11 +575,11 @@ export class DiffEditorWidget extends DelegatingEditor implements IDiffEditor { this._goTo(diff); if (diff.lineRangeMapping.modified.isEmpty) { - this._audioCueService.playAudioCue(AudioCue.diffLineDeleted, { source: 'diffEditor.goToDiff' }); + this._accessibilitySignalService.playSignal(AccessibilitySignal.diffLineDeleted, { source: 'diffEditor.goToDiff' }); } else if (diff.lineRangeMapping.original.isEmpty) { - this._audioCueService.playAudioCue(AudioCue.diffLineInserted, { source: 'diffEditor.goToDiff' }); + this._accessibilitySignalService.playSignal(AccessibilitySignal.diffLineInserted, { source: 'diffEditor.goToDiff' }); } else if (diff) { - this._audioCueService.playAudioCue(AudioCue.diffLineModified, { source: 'diffEditor.goToDiff' }); + this._accessibilitySignalService.playSignal(AccessibilitySignal.diffLineModified, { source: 'diffEditor.goToDiff' }); } } @@ -613,6 +660,19 @@ export class DiffEditorWidget extends DelegatingEditor implements IDiffEditor { } }); } + + private _handleCursorPositionChange(e: ICursorPositionChangedEvent | undefined, isModifiedEditor: boolean): void { + if (e?.reason === CursorChangeReason.Explicit) { + const diff = this._diffModel.get()?.diff.get()?.mappings.find(m => isModifiedEditor ? m.lineRangeMapping.modified.contains(e.position.lineNumber) : m.lineRangeMapping.original.contains(e.position.lineNumber)); + if (diff?.lineRangeMapping.modified.isEmpty) { + this._accessibilitySignalService.playSignal(AccessibilitySignal.diffLineDeleted, { source: 'diffEditor.cursorPositionChanged' }); + } else if (diff?.lineRangeMapping.original.isEmpty) { + this._accessibilitySignalService.playSignal(AccessibilitySignal.diffLineInserted, { source: 'diffEditor.cursorPositionChanged' }); + } else if (diff) { + this._accessibilitySignalService.playSignal(AccessibilitySignal.diffLineModified, { source: 'diffEditor.cursorPositionChanged' }); + } + } + } } function toLineChanges(state: DiffState): ILineChange[] { diff --git a/src/vs/editor/browser/widget/diffEditor/diffProviderFactoryService.ts b/src/vs/editor/browser/widget/diffEditor/diffProviderFactoryService.ts index d9a4c6317df64..c7d37ef0c4ec1 100644 --- a/src/vs/editor/browser/widget/diffEditor/diffProviderFactoryService.ts +++ b/src/vs/editor/browser/widget/diffEditor/diffProviderFactoryService.ts @@ -115,9 +115,9 @@ export class WorkerBasedDocumentDiffProvider implements IDocumentDiffProvider, I }, { owner: 'hediet'; - timeMs: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'To understand if the new diff algorithm is slower/faster than the old one' }; - timedOut: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'To understand how often the new diff algorithm times out' }; - detectedMoves: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'To understand how often the new diff algorithm detects moves' }; + timeMs: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'To understand if the new diff algorithm is slower/faster than the old one' }; + timedOut: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'To understand how often the new diff algorithm times out' }; + detectedMoves: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'To understand how often the new diff algorithm detects moves' }; comment: 'This event gives insight about the performance of the new diff algorithm.'; }>('diffEditor.computeDiff', { diff --git a/src/vs/editor/browser/widget/diffEditor/embeddedDiffEditorWidget.ts b/src/vs/editor/browser/widget/diffEditor/embeddedDiffEditorWidget.ts new file mode 100644 index 0000000000000..9156c17ead354 --- /dev/null +++ b/src/vs/editor/browser/widget/diffEditor/embeddedDiffEditorWidget.ts @@ -0,0 +1,55 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as objects from 'vs/base/common/objects'; +import { ICodeEditor, IDiffEditorConstructionOptions } from 'vs/editor/browser/editorBrowser'; +import { ICodeEditorService } from 'vs/editor/browser/services/codeEditorService'; +import { DiffEditorWidget, IDiffCodeEditorWidgetOptions } from 'vs/editor/browser/widget/diffEditor/diffEditorWidget'; +import { ConfigurationChangedEvent, IDiffEditorOptions, IEditorOptions } from 'vs/editor/common/config/editorOptions'; +import { IAccessibilitySignalService } from 'vs/platform/accessibilitySignal/browser/accessibilitySignalService'; +import { IContextKeyService } from 'vs/platform/contextkey/common/contextkey'; +import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; +import { IEditorProgressService } from 'vs/platform/progress/common/progress'; +export class EmbeddedDiffEditorWidget extends DiffEditorWidget { + + private readonly _parentEditor: ICodeEditor; + private readonly _overwriteOptions: IDiffEditorOptions; + + constructor( + domElement: HTMLElement, + options: Readonly, + codeEditorWidgetOptions: IDiffCodeEditorWidgetOptions, + parentEditor: ICodeEditor, + @IContextKeyService contextKeyService: IContextKeyService, + @IInstantiationService instantiationService: IInstantiationService, + @ICodeEditorService codeEditorService: ICodeEditorService, + @IAccessibilitySignalService accessibilitySignalService: IAccessibilitySignalService, + @IEditorProgressService editorProgressService: IEditorProgressService + ) { + super(domElement, parentEditor.getRawOptions(), codeEditorWidgetOptions, contextKeyService, instantiationService, codeEditorService, accessibilitySignalService, editorProgressService); + + this._parentEditor = parentEditor; + this._overwriteOptions = options; + + // Overwrite parent's options + super.updateOptions(this._overwriteOptions); + + this._register(parentEditor.onDidChangeConfiguration(e => this._onParentConfigurationChanged(e))); + } + + getParentEditor(): ICodeEditor { + return this._parentEditor; + } + + private _onParentConfigurationChanged(e: ConfigurationChangedEvent): void { + super.updateOptions(this._parentEditor.getRawOptions()); + super.updateOptions(this._overwriteOptions); + } + + override updateOptions(newOptions: IEditorOptions): void { + objects.mixin(this._overwriteOptions, newOptions, true); + super.updateOptions(this._overwriteOptions); + } +} diff --git a/src/vs/editor/browser/widget/diffEditor/features/gutterFeature.ts b/src/vs/editor/browser/widget/diffEditor/features/gutterFeature.ts new file mode 100644 index 0000000000000..f40c3cc84bcfd --- /dev/null +++ b/src/vs/editor/browser/widget/diffEditor/features/gutterFeature.ts @@ -0,0 +1,293 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { EventType, addDisposableListener, h } from 'vs/base/browser/dom'; +import { IMouseWheelEvent } from 'vs/base/browser/mouseEvent'; +import { ActionsOrientation } from 'vs/base/browser/ui/actionbar/actionbar'; +import { HoverPosition } from 'vs/base/browser/ui/hover/hoverWidget'; +import { Disposable } from 'vs/base/common/lifecycle'; +import { IObservable, autorun, autorunWithStore, derived, observableFromEvent, observableValue } from 'vs/base/common/observable'; +import { URI } from 'vs/base/common/uri'; +import { DiffEditorEditors } from 'vs/editor/browser/widget/diffEditor/components/diffEditorEditors'; +import { DiffEditorViewModel } from 'vs/editor/browser/widget/diffEditor/diffEditorViewModel'; +import { appendRemoveOnDispose, applyStyle } from 'vs/editor/browser/widget/diffEditor/utils'; +import { EditorGutter, IGutterItemInfo, IGutterItemView } from 'vs/editor/browser/widget/diffEditor/utils/editorGutter'; +import { ActionRunnerWithContext } from 'vs/editor/browser/widget/multiDiffEditor/utils'; +import { EditorOption } from 'vs/editor/common/config/editorOptions'; +import { LineRange, LineRangeSet } from 'vs/editor/common/core/lineRange'; +import { OffsetRange } from 'vs/editor/common/core/offsetRange'; +import { Range } from 'vs/editor/common/core/range'; +import { SingleTextEdit, TextEdit } from 'vs/editor/common/core/textEdit'; +import { DetailedLineRangeMapping } from 'vs/editor/common/diff/rangeMapping'; +import { TextModelText } from 'vs/editor/common/model/textModelText'; +import { HiddenItemStrategy, MenuWorkbenchToolBar } from 'vs/platform/actions/browser/toolbar'; +import { IMenuService, MenuId } from 'vs/platform/actions/common/actions'; +import { IContextKeyService } from 'vs/platform/contextkey/common/contextkey'; +import { WorkbenchHoverDelegate } from 'vs/platform/hover/browser/hover'; +import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; + +const emptyArr: never[] = []; +const width = 35; + +export class DiffEditorGutter extends Disposable { + private readonly _menu = this._register(this._menuService.createMenu(MenuId.DiffEditorHunkToolbar, this._contextKeyService)); + private readonly _actions = observableFromEvent(this._menu.onDidChange, () => this._menu.getActions()); + private readonly _hasActions = this._actions.map(a => a.length > 0); + + public readonly width = derived(this, reader => this._hasActions.read(reader) ? width : 0); + + private readonly elements = h('div.gutter@gutter', { style: { position: 'absolute', height: '100%', width: width + 'px' } }, []); + + constructor( + diffEditorRoot: HTMLDivElement, + private readonly _diffModel: IObservable, + private readonly _editors: DiffEditorEditors, + @IInstantiationService private readonly _instantiationService: IInstantiationService, + @IContextKeyService private readonly _contextKeyService: IContextKeyService, + @IMenuService private readonly _menuService: IMenuService, + ) { + super(); + + this._register(appendRemoveOnDispose(diffEditorRoot, this.elements.root)); + + this._register(addDisposableListener(this.elements.root, 'click', () => { + this._editors.modified.focus(); + })); + + this._register(applyStyle(this.elements.root, { display: this._hasActions.map(a => a ? 'block' : 'none') })); + + this._register(new EditorGutter(this._editors.modified, this.elements.root, { + getIntersectingGutterItems: (range, reader) => { + const model = this._diffModel.read(reader); + if (!model) { + return []; + } + const diffs = model.diff.read(reader); + if (!diffs) { return []; } + + const selection = this._selectedDiffs.read(reader); + if (selection.length > 0) { + const m = DetailedLineRangeMapping.fromRangeMappings(selection.flatMap(s => s.rangeMappings)); + return [ + new DiffGutterItem( + m, + true, + MenuId.DiffEditorSelectionToolbar, + undefined, + model.model.original.uri, + model.model.modified.uri, + )]; + } + + const currentDiff = this._currentDiff.read(reader); + + return diffs.mappings.map(m => new DiffGutterItem( + m.lineRangeMapping.withInnerChangesFromLineRanges(), + m.lineRangeMapping === currentDiff?.lineRangeMapping, + MenuId.DiffEditorHunkToolbar, + undefined, + model.model.original.uri, + model.model.modified.uri, + )); + }, + createView: (item, target) => { + return this._instantiationService.createInstance(DiffToolBar, item, target, this); + }, + })); + + this._register(addDisposableListener(this.elements.gutter, EventType.MOUSE_WHEEL, (e: IMouseWheelEvent) => { + if (this._editors.modified.getOption(EditorOption.scrollbar).handleMouseWheel) { + this._editors.modified.delegateScrollFromMouseWheelEvent(e); + } + }, { passive: false })); + } + + public computeStagedValue(mapping: DetailedLineRangeMapping): string { + const c = mapping.innerChanges ?? []; + const edit = new TextEdit(c.map(c => new SingleTextEdit(c.originalRange, this._editors.modifiedModel.get()!.getValueInRange(c.modifiedRange)))); + const value = edit.apply(new TextModelText(this._editors.original.getModel()!)); + return value; + } + + private readonly _currentDiff = derived(this, (reader) => { + const model = this._diffModel.read(reader); + if (!model) { + return undefined; + } + const mappings = model.diff.read(reader)?.mappings; + + const cursorPosition = this._editors.modifiedCursor.read(reader); + if (!cursorPosition) { return undefined; } + + return mappings?.find(m => m.lineRangeMapping.modified.contains(cursorPosition.lineNumber)); + }); + + private readonly _selectedDiffs = derived(this, (reader) => { + /** @description selectedDiffs */ + const model = this._diffModel.read(reader); + const diff = model?.diff.read(reader); + // Return `emptyArr` because it is a constant. [] is always a new array and would trigger a change. + if (!diff) { return emptyArr; } + + const selections = this._editors.modifiedSelections.read(reader); + if (selections.every(s => s.isEmpty())) { return emptyArr; } + + const selectedLineNumbers = new LineRangeSet(selections.map(s => LineRange.fromRangeInclusive(s))); + + const selectedMappings = diff.mappings.filter(m => + m.lineRangeMapping.innerChanges && selectedLineNumbers.intersects(m.lineRangeMapping.modified) + ); + const result = selectedMappings.map(mapping => ({ + mapping, + rangeMappings: mapping.lineRangeMapping.innerChanges!.filter( + c => selections.some(s => Range.areIntersecting(c.modifiedRange, s)) + ) + })); + if (result.length === 0 || result.every(r => r.rangeMappings.length === 0)) { return emptyArr; } + return result; + }); + + layout(left: number) { + this.elements.gutter.style.left = left + 'px'; + } +} + +class DiffGutterItem implements IGutterItemInfo { + constructor( + public readonly mapping: DetailedLineRangeMapping, + public readonly showAlways: boolean, + public readonly menuId: MenuId, + public readonly rangeOverride: LineRange | undefined, + public readonly originalUri: URI, + public readonly modifiedUri: URI, + ) { + } + get id(): string { return this.mapping.modified.toString(); } + get range(): LineRange { return this.rangeOverride ?? this.mapping.modified; } +} + + +class DiffToolBar extends Disposable implements IGutterItemView { + private readonly _elements = h('div.gutterItem', { style: { height: '20px', width: '34px' } }, [ + h('div.background@background', {}, []), + h('div.buttons@buttons', {}, []), + ]); + + private readonly _showAlways = this._item.map(this, item => item.showAlways); + private readonly _menuId = this._item.map(this, item => item.menuId); + + private readonly _isSmall = observableValue(this, false); + + constructor( + private readonly _item: IObservable, + target: HTMLElement, + gutter: DiffEditorGutter, + @IInstantiationService instantiationService: IInstantiationService + ) { + super(); + + const hoverDelegate = this._register(instantiationService.createInstance( + WorkbenchHoverDelegate, + 'element', + true, + { position: { hoverPosition: HoverPosition.RIGHT } } + )); + + this._register(appendRemoveOnDispose(target, this._elements.root)); + + this._register(autorun(reader => { + /** @description update showAlways */ + const showAlways = this._showAlways.read(reader); + this._elements.root.classList.toggle('noTransition', true); + this._elements.root.classList.toggle('showAlways', showAlways); + setTimeout(() => { + this._elements.root.classList.toggle('noTransition', false); + }, 0); + })); + + + this._register(autorunWithStore((reader, store) => { + this._elements.buttons.replaceChildren(); + const i = store.add(instantiationService.createInstance(MenuWorkbenchToolBar, this._elements.buttons, this._menuId.read(reader), { + orientation: ActionsOrientation.VERTICAL, + hoverDelegate, + toolbarOptions: { + primaryGroup: g => g.startsWith('primary'), + }, + overflowBehavior: { maxItems: this._isSmall.read(reader) ? 1 : 3 }, + hiddenItemStrategy: HiddenItemStrategy.Ignore, + actionRunner: new ActionRunnerWithContext(() => { + const item = this._item.get(); + const mapping = item.mapping; + return { + mapping, + originalWithModifiedChanges: gutter.computeStagedValue(mapping), + originalUri: item.originalUri, + modifiedUri: item.modifiedUri, + } satisfies DiffEditorSelectionHunkToolbarContext; + }), + menuOptions: { + shouldForwardArgs: true, + }, + })); + store.add(i.onDidChangeMenuItems(() => { + if (this._lastItemRange) { + this.layout(this._lastItemRange, this._lastViewRange!); + } + })); + })); + } + + private _lastItemRange: OffsetRange | undefined = undefined; + private _lastViewRange: OffsetRange | undefined = undefined; + + layout(itemRange: OffsetRange, viewRange: OffsetRange): void { + this._lastItemRange = itemRange; + this._lastViewRange = viewRange; + + let itemHeight = this._elements.buttons.clientHeight; + this._isSmall.set(this._item.get().mapping.original.startLineNumber === 1 && itemRange.length < 30, undefined); + // Item might have changed + itemHeight = this._elements.buttons.clientHeight; + + this._elements.root.style.top = itemRange.start + 'px'; + this._elements.root.style.height = itemRange.length + 'px'; + + const middleHeight = itemRange.length / 2 - itemHeight / 2; + + const margin = itemHeight; + + let effectiveCheckboxTop = itemRange.start + middleHeight; + + const preferredViewPortRange = OffsetRange.tryCreate( + margin, + viewRange.endExclusive - margin - itemHeight + ); + + const preferredParentRange = OffsetRange.tryCreate( + itemRange.start + margin, + itemRange.endExclusive - itemHeight - margin + ); + + if (preferredParentRange && preferredViewPortRange && preferredParentRange.start < preferredParentRange.endExclusive) { + effectiveCheckboxTop = preferredViewPortRange!.clip(effectiveCheckboxTop); + effectiveCheckboxTop = preferredParentRange!.clip(effectiveCheckboxTop); + } + + this._elements.buttons.style.top = `${effectiveCheckboxTop - itemRange.start}px`; + } +} + +export interface DiffEditorSelectionHunkToolbarContext { + mapping: DetailedLineRangeMapping; + + /** + * The original text with the selected modified changes applied. + */ + originalWithModifiedChanges: string; + + modifiedUri: URI; + originalUri: URI; +} diff --git a/src/vs/editor/browser/widget/diffEditor/features/hideUnchangedRegionsFeature.ts b/src/vs/editor/browser/widget/diffEditor/features/hideUnchangedRegionsFeature.ts index fb45a24f3c0ef..248f2b46d8168 100644 --- a/src/vs/editor/browser/widget/diffEditor/features/hideUnchangedRegionsFeature.ts +++ b/src/vs/editor/browser/widget/diffEditor/features/hideUnchangedRegionsFeature.ts @@ -59,27 +59,25 @@ export class HideUnchangedRegionsFeature extends Disposable { super(); this._register(this._editors.original.onDidChangeCursorPosition(e => { - if (e.reason === CursorChangeReason.Explicit) { - const m = this._diffModel.get(); - transaction(tx => { - for (const s of this._editors.original.getSelections() || []) { - m?.ensureOriginalLineIsVisible(s.getStartPosition().lineNumber, RevealPreference.FromCloserSide, tx); - m?.ensureOriginalLineIsVisible(s.getEndPosition().lineNumber, RevealPreference.FromCloserSide, tx); - } - }); - } + if (e.reason === CursorChangeReason.ContentFlush) { return; } + const m = this._diffModel.get(); + transaction(tx => { + for (const s of this._editors.original.getSelections() || []) { + m?.ensureOriginalLineIsVisible(s.getStartPosition().lineNumber, RevealPreference.FromCloserSide, tx); + m?.ensureOriginalLineIsVisible(s.getEndPosition().lineNumber, RevealPreference.FromCloserSide, tx); + } + }); })); this._register(this._editors.modified.onDidChangeCursorPosition(e => { - if (e.reason === CursorChangeReason.Explicit) { - const m = this._diffModel.get(); - transaction(tx => { - for (const s of this._editors.modified.getSelections() || []) { - m?.ensureModifiedLineIsVisible(s.getStartPosition().lineNumber, RevealPreference.FromCloserSide, tx); - m?.ensureModifiedLineIsVisible(s.getEndPosition().lineNumber, RevealPreference.FromCloserSide, tx); - } - }); - } + if (e.reason === CursorChangeReason.ContentFlush) { return; } + const m = this._diffModel.get(); + transaction(tx => { + for (const s of this._editors.modified.getSelections() || []) { + m?.ensureModifiedLineIsVisible(s.getStartPosition().lineNumber, RevealPreference.FromCloserSide, tx); + m?.ensureModifiedLineIsVisible(s.getEndPosition().lineNumber, RevealPreference.FromCloserSide, tx); + } + }); })); const unchangedRegions = this._diffModel.map((m, reader) => { @@ -268,6 +266,7 @@ class CollapsedCodeOverlayWidget extends ViewZoneOverlayWidget { } this._register(autorun(reader => { + /** @description Update CollapsedCodeOverlayWidget canMove* css classes */ const isFullyRevealed = this._unchangedRegion.visibleLineCountTop.read(reader) + this._unchangedRegion.visibleLineCountBottom.read(reader) === this._unchangedRegion.lineCount; this._nodes.bottom.classList.toggle('canMoveTop', !isFullyRevealed); @@ -348,9 +347,13 @@ class CollapsedCodeOverlayWidget extends ViewZoneOverlayWidget { didMove = didMove || Math.abs(delta) > 2; const lineDelta = Math.round(delta / editor.getOption(EditorOption.lineHeight)); const newVal = Math.max(0, Math.min(cur - lineDelta, this._unchangedRegion.getMaxVisibleLineCountBottom())); - const top = editor.getTopForLineNumber(this._unchangedRegionRange.endLineNumberExclusive); + const top = this._unchangedRegionRange.endLineNumberExclusive > editor.getModel()!.getLineCount() + ? editor.getContentHeight() + : editor.getTopForLineNumber(this._unchangedRegionRange.endLineNumberExclusive); this._unchangedRegion.visibleLineCountBottom.set(newVal, undefined); - const top2 = editor.getTopForLineNumber(this._unchangedRegionRange.endLineNumberExclusive); + const top2 = this._unchangedRegionRange.endLineNumberExclusive > editor.getModel()!.getLineCount() + ? editor.getContentHeight() + : editor.getTopForLineNumber(this._unchangedRegionRange.endLineNumberExclusive); editor.setScrollTop(editor.getScrollTop() + (top2 - top)); }); diff --git a/src/vs/editor/browser/widget/diffEditor/features/movedBlocksLinesFeature.ts b/src/vs/editor/browser/widget/diffEditor/features/movedBlocksLinesFeature.ts index 5fa2d56fb0a6c..af2b6a8899584 100644 --- a/src/vs/editor/browser/widget/diffEditor/features/movedBlocksLinesFeature.ts +++ b/src/vs/editor/browser/widget/diffEditor/features/movedBlocksLinesFeature.ts @@ -83,8 +83,6 @@ export class MovedBlocksLinesFeature extends Disposable { } })); - const originalCursorPosition = observableFromEvent(this._editors.original.onDidChangeCursorPosition, () => this._editors.original.getPosition()); - const modifiedCursorPosition = observableFromEvent(this._editors.modified.onDidChangeCursorPosition, () => this._editors.modified.getPosition()); const originalHasFocus = observableSignalFromEvent( 'original.onDidFocusEditorWidget', e => this._editors.original.onDidFocusEditorWidget(() => setTimeout(() => e(undefined), 0)) @@ -115,14 +113,14 @@ export class MovedBlocksLinesFeature extends Disposable { let movedText: MovedText | undefined = undefined; if (diff && lastChangedEditor === 'original') { - const originalPos = originalCursorPosition.read(reader); + const originalPos = this._editors.originalCursor.read(reader); if (originalPos) { movedText = diff.movedTexts.find(m => m.lineRangeMapping.original.contains(originalPos.lineNumber)); } } if (diff && lastChangedEditor === 'modified') { - const modifiedPos = modifiedCursorPosition.read(reader); + const modifiedPos = this._editors.modifiedCursor.read(reader); if (modifiedPos) { movedText = diff.movedTexts.find(m => m.lineRangeMapping.modified.contains(modifiedPos.lineNumber)); } diff --git a/src/vs/editor/browser/widget/diffEditor/features/overviewRulerFeature.ts b/src/vs/editor/browser/widget/diffEditor/features/overviewRulerFeature.ts index bd1997491e147..8141cd9452cf0 100644 --- a/src/vs/editor/browser/widget/diffEditor/features/overviewRulerFeature.ts +++ b/src/vs/editor/browser/widget/diffEditor/features/overviewRulerFeature.ts @@ -10,7 +10,7 @@ import { ScrollbarState } from 'vs/base/browser/ui/scrollbar/scrollbarState'; import { Color } from 'vs/base/common/color'; import { Disposable } from 'vs/base/common/lifecycle'; import { IObservable, autorun, autorunWithStore, derived, observableFromEvent, observableSignalFromEvent } from 'vs/base/common/observable'; -import { CodeEditorWidget } from 'vs/editor/browser/widget/codeEditorWidget'; +import { CodeEditorWidget } from 'vs/editor/browser/widget/codeEditor/codeEditorWidget'; import { DiffEditorEditors } from 'vs/editor/browser/widget/diffEditor/components/diffEditorEditors'; import { DiffEditorViewModel } from 'vs/editor/browser/widget/diffEditor/diffEditorViewModel'; import { appendRemoveOnDispose } from 'vs/editor/browser/widget/diffEditor/utils'; diff --git a/src/vs/editor/browser/widget/diffEditor/features/revertButtonsFeature.ts b/src/vs/editor/browser/widget/diffEditor/features/revertButtonsFeature.ts index 084706bf613a6..b2a7d382320f1 100644 --- a/src/vs/editor/browser/widget/diffEditor/features/revertButtonsFeature.ts +++ b/src/vs/editor/browser/widget/diffEditor/features/revertButtonsFeature.ts @@ -15,10 +15,12 @@ import { DiffEditorViewModel } from 'vs/editor/browser/widget/diffEditor/diffEdi import { DiffEditorWidget } from 'vs/editor/browser/widget/diffEditor/diffEditorWidget'; import { LineRange, LineRangeSet } from 'vs/editor/common/core/lineRange'; import { Range } from 'vs/editor/common/core/range'; -import { RangeMapping } from 'vs/editor/common/diff/rangeMapping'; +import { LineRangeMapping, RangeMapping } from 'vs/editor/common/diff/rangeMapping'; import { GlyphMarginLane } from 'vs/editor/common/model'; import { localize } from 'vs/nls'; +const emptyArr: never[] = []; + export class RevertButtonsFeature extends Disposable { constructor( private readonly _editors: DiffEditorEditors, @@ -28,58 +30,41 @@ export class RevertButtonsFeature extends Disposable { ) { super(); - const emptyArr: never[] = []; - const selectedDiffs = derived(this, (reader) => { - /** @description selectedDiffs */ - const model = this._diffModel.read(reader); - const diff = model?.diff.read(reader); - if (!diff) { return emptyArr; } - - const selections = this._editors.modifiedSelections.read(reader); - - if (selections.every(s => s.isEmpty())) { - return emptyArr; - } - - const lineRanges = new LineRangeSet(selections.map(s => LineRange.fromRangeInclusive(s))); - - const mappings = diff.mappings.filter(m => m.lineRangeMapping.innerChanges && lineRanges.intersects(m.lineRangeMapping.modified)); - - const result = mappings.map(mapping => ({ - mapping, - rangeMappings: mapping.lineRangeMapping.innerChanges!.filter(c => selections.some(s => Range.areIntersecting(c.modifiedRange, s))) - })); - if (result.length === 0 || result.every(r => r.rangeMappings.length === 0)) { return emptyArr; } - return result; - }); - this._register(autorunWithStore((reader, store) => { + if (!this._options.shouldRenderOldRevertArrows.read(reader)) { return; } const model = this._diffModel.read(reader); const diff = model?.diff.read(reader); if (!model || !diff) { return; } - const movedTextToCompare = this._diffModel.read(reader)!.movedTextToCompare.read(reader); - if (movedTextToCompare) { return; } - if (!this._options.shouldRenderRevertArrows.read(reader)) { return; } + if (model.movedTextToCompare.read(reader)) { return; } const glyphWidgetsModified: IGlyphMarginWidget[] = []; - const selectedDiffs_ = selectedDiffs.read(reader); - const diffsSet = new Set(selectedDiffs_.map(d => d.mapping)); + const selectedDiffs = this._selectedDiffs.read(reader); + const selectedDiffsSet = new Set(selectedDiffs.map(d => d.mapping)); - if (selectedDiffs_.length > 0) { + if (selectedDiffs.length > 0) { + // The button to revert the selection const selections = this._editors.modifiedSelections.read(reader); - const btn = store.add(new RevertButton(selections[selections.length - 1].positionLineNumber, this._widget, selectedDiffs_.flatMap(d => d.rangeMappings), true)); + const btn = store.add(new RevertButton( + selections[selections.length - 1].positionLineNumber, + this._widget, + selectedDiffs.flatMap(d => d.rangeMappings), + true + )); this._editors.modified.addGlyphMarginWidget(btn); glyphWidgetsModified.push(btn); } for (const m of diff.mappings) { - if (diffsSet.has(m)) { - continue; - } + if (selectedDiffsSet.has(m)) { continue; } if (!m.lineRangeMapping.modified.isEmpty && m.lineRangeMapping.innerChanges) { - const btn = store.add(new RevertButton(m.lineRangeMapping.modified.startLineNumber, this._widget, m.lineRangeMapping.innerChanges, false)); + const btn = store.add(new RevertButton( + m.lineRangeMapping.modified.startLineNumber, + this._widget, + m.lineRangeMapping, + false + )); this._editors.modified.addGlyphMarginWidget(btn); glyphWidgetsModified.push(btn); } @@ -92,6 +77,31 @@ export class RevertButtonsFeature extends Disposable { })); })); } + + private readonly _selectedDiffs = derived(this, (reader) => { + /** @description selectedDiffs */ + const model = this._diffModel.read(reader); + const diff = model?.diff.read(reader); + // Return `emptyArr` because it is a constant. [] is always a new array and would trigger a change. + if (!diff) { return emptyArr; } + + const selections = this._editors.modifiedSelections.read(reader); + if (selections.every(s => s.isEmpty())) { return emptyArr; } + + const selectedLineNumbers = new LineRangeSet(selections.map(s => LineRange.fromRangeInclusive(s))); + + const selectedMappings = diff.mappings.filter(m => + m.lineRangeMapping.innerChanges && selectedLineNumbers.intersects(m.lineRangeMapping.modified) + ); + const result = selectedMappings.map(mapping => ({ + mapping, + rangeMappings: mapping.lineRangeMapping.innerChanges!.filter( + c => selections.some(s => Range.areIntersecting(c.modifiedRange, s)) + ) + })); + if (result.length === 0 || result.every(r => r.rangeMappings.length === 0)) { return emptyArr; } + return result; + }); } export class RevertButton extends Disposable implements IGlyphMarginWidget { @@ -102,7 +112,7 @@ export class RevertButton extends Disposable implements IGlyphMarginWidget { getId(): string { return this._id; } private readonly _domNode = h('div.revertButton', { - title: this._selection + title: this._revertSelection ? localize('revertSelectedChanges', 'Revert Selected Changes') : localize('revertChange', 'Revert Change') }, @@ -112,8 +122,8 @@ export class RevertButton extends Disposable implements IGlyphMarginWidget { constructor( private readonly _lineNumber: number, private readonly _widget: DiffEditorWidget, - private readonly _diffs: RangeMapping[], - private readonly _selection: boolean, + private readonly _diffs: RangeMapping[] | LineRangeMapping, + private readonly _revertSelection: boolean, ) { super(); @@ -132,7 +142,11 @@ export class RevertButton extends Disposable implements IGlyphMarginWidget { })); this._register(addDisposableListener(this._domNode, EventType.CLICK, (e) => { - this._widget.revertRangeMappings(this._diffs); + if (this._diffs instanceof LineRangeMapping) { + this._widget.revert(this._diffs); + } else { + this._widget.revertRangeMappings(this._diffs); + } e.stopPropagation(); e.preventDefault(); })); diff --git a/src/vs/editor/browser/widget/diffEditor/style.css b/src/vs/editor/browser/widget/diffEditor/style.css index 032ff0f19d76b..49ad115e36bc5 100644 --- a/src/vs/editor/browser/widget/diffEditor/style.css +++ b/src/vs/editor/browser/widget/diffEditor/style.css @@ -294,6 +294,11 @@ border-left: 1px solid var(--vscode-diffEditor-border); } +.monaco-diff-editor.side-by-side .editor.original { + box-shadow: 6px 0 5px -5px var(--vscode-scrollbar-shadow); + border-right: 1px solid var(--vscode-diffEditor-border); +} + .monaco-diff-editor .diffViewport { background: var(--vscode-scrollbarSlider-background); } @@ -316,3 +321,74 @@ ); background-size: 8px 8px; } + +.monaco-diff-editor .gutter { + position: relative; + overflow: hidden; + flex-shrink: 0; + flex-grow: 0; + + .gutterItem { + opacity: 0; + transition: opacity 0.7s; + + &.showAlways { + opacity: 1; + transition: none; + } + + &.noTransition { + transition: none; + } + } + + &:hover .gutterItem { + opacity: 1; + transition: opacity 0.1s ease-in-out; + } + + .gutterItem { + .background { + position: absolute; + height: 100%; + left: 50%; + width: 1px; + + border-left: 2px var(--vscode-menu-border) solid; + } + + .buttons { + position: absolute; + /*height: 100%;*/ + width: 100%; + + display: flex; + justify-content: center; + align-items: center; + + .monaco-toolbar { + height: fit-content; + .monaco-action-bar { + line-height: 1; + + .actions-container { + width: fit-content; + border-radius: 4px; + border: 1px var(--vscode-menu-border) solid; + background: var(--vscode-editor-background); + + .action-item { + &:hover { + background: var(--vscode-toolbar-hoverBackground); + } + + .action-label { + padding: 0.5px 1px; + } + } + } + } + } + } + } +} diff --git a/src/vs/editor/browser/widget/diffEditor/utils.ts b/src/vs/editor/browser/widget/diffEditor/utils.ts index 0109a630f203a..a1e263948f24e 100644 --- a/src/vs/editor/browser/widget/diffEditor/utils.ts +++ b/src/vs/editor/browser/widget/diffEditor/utils.ts @@ -15,7 +15,7 @@ import { Position } from 'vs/editor/common/core/position'; import { Range } from 'vs/editor/common/core/range'; import { DetailedLineRangeMapping } from 'vs/editor/common/diff/rangeMapping'; import { IModelDeltaDecoration } from 'vs/editor/common/model'; -import { LengthObj } from 'vs/editor/common/model/bracketPairsTextModelPart/bracketPairsTree/length'; +import { TextLength } from 'vs/editor/common/core/textLength'; import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; import { ContextKeyValue, RawContextKey, IContextKeyService } from 'vs/platform/contextkey/common/contextkey'; @@ -421,29 +421,21 @@ export function translatePosition(posInOriginal: Position, mappings: DetailedLin return innerMapping.modifiedRange; } else { const l = lengthBetweenPositions(innerMapping.originalRange.getEndPosition(), posInOriginal); - return Range.fromPositions(addLength(innerMapping.modifiedRange.getEndPosition(), l)); + return Range.fromPositions(l.addToPosition(innerMapping.modifiedRange.getEndPosition())); } } -function lengthBetweenPositions(position1: Position, position2: Position): LengthObj { +function lengthBetweenPositions(position1: Position, position2: Position): TextLength { if (position1.lineNumber === position2.lineNumber) { - return new LengthObj(0, position2.column - position1.column); + return new TextLength(0, position2.column - position1.column); } else { - return new LengthObj(position2.lineNumber - position1.lineNumber, position2.column - 1); - } -} - -function addLength(position: Position, length: LengthObj): Position { - if (length.lineCount === 0) { - return new Position(position.lineNumber, position.column + length.columnCount); - } else { - return new Position(position.lineNumber + length.lineCount, length.columnCount + 1); + return new TextLength(position2.lineNumber - position1.lineNumber, position2.column - 1); } } export function bindContextKey(key: RawContextKey, service: IContextKeyService, computeValue: (reader: IReader) => T): IDisposable { const boundKey = key.bindTo(service); - return autorunOpts({ debugName: () => `Update ${key.key}` }, reader => { + return autorunOpts({ debugName: () => `Set Context Key "${key.key}"` }, reader => { boundKey.set(computeValue(reader)); }); } diff --git a/src/vs/editor/browser/widget/diffEditor/utils/editorGutter.ts b/src/vs/editor/browser/widget/diffEditor/utils/editorGutter.ts new file mode 100644 index 0000000000000..1c3341a73ef14 --- /dev/null +++ b/src/vs/editor/browser/widget/diffEditor/utils/editorGutter.ts @@ -0,0 +1,166 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { h, reset } from 'vs/base/browser/dom'; +import { Disposable, IDisposable, toDisposable } from 'vs/base/common/lifecycle'; +import { autorun, IObservable, IReader, ISettableObservable, observableFromEvent, observableSignal, observableSignalFromEvent, observableValue, transaction } from 'vs/base/common/observable'; +import { CodeEditorWidget } from 'vs/editor/browser/widget/codeEditor/codeEditorWidget'; +import { LineRange } from 'vs/editor/common/core/lineRange'; +import { OffsetRange } from 'vs/editor/common/core/offsetRange'; + +export class EditorGutter extends Disposable { + private readonly scrollTop = observableFromEvent( + this._editor.onDidScrollChange, + (e) => /** @description editor.onDidScrollChange */ this._editor.getScrollTop() + ); + private readonly isScrollTopZero = this.scrollTop.map((scrollTop) => /** @description isScrollTopZero */ scrollTop === 0); + private readonly modelAttached = observableFromEvent( + this._editor.onDidChangeModel, + (e) => /** @description editor.onDidChangeModel */ this._editor.hasModel() + ); + + private readonly editorOnDidChangeViewZones = observableSignalFromEvent('onDidChangeViewZones', this._editor.onDidChangeViewZones); + private readonly editorOnDidContentSizeChange = observableSignalFromEvent('onDidContentSizeChange', this._editor.onDidContentSizeChange); + private readonly domNodeSizeChanged = observableSignal('domNodeSizeChanged'); + + constructor( + private readonly _editor: CodeEditorWidget, + private readonly _domNode: HTMLElement, + private readonly itemProvider: IGutterItemProvider + ) { + super(); + this._domNode.className = 'gutter monaco-editor'; + const scrollDecoration = this._domNode.appendChild( + h('div.scroll-decoration', { role: 'presentation', ariaHidden: 'true', style: { width: '100%' } }) + .root + ); + + const o = new ResizeObserver(() => { + transaction(tx => { + /** @description ResizeObserver: size changed */ + this.domNodeSizeChanged.trigger(tx); + }); + }); + o.observe(this._domNode); + this._register(toDisposable(() => o.disconnect())); + + this._register(autorun(reader => { + /** @description update scroll decoration */ + scrollDecoration.className = this.isScrollTopZero.read(reader) ? '' : 'scroll-decoration'; + })); + + this._register(autorun(reader => /** @description EditorGutter.Render */ this.render(reader))); + } + + override dispose(): void { + super.dispose(); + + reset(this._domNode); + } + + private readonly views = new Map(); + + private render(reader: IReader): void { + if (!this.modelAttached.read(reader)) { + return; + } + + this.domNodeSizeChanged.read(reader); + this.editorOnDidChangeViewZones.read(reader); + this.editorOnDidContentSizeChange.read(reader); + + const scrollTop = this.scrollTop.read(reader); + + const visibleRanges = this._editor.getVisibleRanges(); + const unusedIds = new Set(this.views.keys()); + + const viewRange = OffsetRange.ofStartAndLength(0, this._domNode.clientHeight); + + if (!viewRange.isEmpty) { + for (const visibleRange of visibleRanges) { + const visibleRange2 = new LineRange( + visibleRange.startLineNumber, + visibleRange.endLineNumber + 1 + ); + + const gutterItems = this.itemProvider.getIntersectingGutterItems( + visibleRange2, + reader + ); + + transaction(tx => { + /** EditorGutter.render */ + + for (const gutterItem of gutterItems) { + if (!gutterItem.range.intersect(visibleRange2)) { + continue; + } + + unusedIds.delete(gutterItem.id); + let view = this.views.get(gutterItem.id); + if (!view) { + const viewDomNode = document.createElement('div'); + this._domNode.appendChild(viewDomNode); + const gutterItemObs = observableValue('item', gutterItem); + const itemView = this.itemProvider.createView( + gutterItemObs, + viewDomNode + ); + view = new ManagedGutterItemView(gutterItemObs, itemView, viewDomNode); + this.views.set(gutterItem.id, view); + } else { + view.item.set(gutterItem, tx); + } + + const top = + gutterItem.range.startLineNumber <= this._editor.getModel()!.getLineCount() + ? this._editor.getTopForLineNumber(gutterItem.range.startLineNumber, true) - scrollTop + : this._editor.getBottomForLineNumber(gutterItem.range.startLineNumber - 1, false) - scrollTop; + const bottom = gutterItem.range.isEmpty + // Don't trust that `getBottomForLineNumber` for the previous line equals `getTopForLineNumber` for the current one. + ? top + : (this._editor.getBottomForLineNumber(gutterItem.range.endLineNumberExclusive - 1, true) - scrollTop); + + const height = bottom - top; + view.domNode.style.top = `${top}px`; + view.domNode.style.height = `${height}px`; + + view.gutterItemView.layout(OffsetRange.ofStartAndLength(top, height), viewRange); + } + }); + } + } + + for (const id of unusedIds) { + const view = this.views.get(id)!; + view.gutterItemView.dispose(); + this._domNode.removeChild(view.domNode); + this.views.delete(id); + } + } +} + +class ManagedGutterItemView { + constructor( + public readonly item: ISettableObservable, + public readonly gutterItemView: IGutterItemView, + public readonly domNode: HTMLDivElement, + ) { } +} + +export interface IGutterItemProvider { + getIntersectingGutterItems(range: LineRange, reader: IReader): TItem[]; + + createView(item: IObservable, target: HTMLElement): IGutterItemView; +} + +export interface IGutterItemInfo { + id: string; + range: LineRange; +} + +export interface IGutterItemView extends IDisposable { + layout(itemRange: OffsetRange, viewRange: OffsetRange): void; +} diff --git a/src/vs/editor/browser/widget/multiDiffEditorWidget/colors.ts b/src/vs/editor/browser/widget/multiDiffEditor/colors.ts similarity index 100% rename from src/vs/editor/browser/widget/multiDiffEditorWidget/colors.ts rename to src/vs/editor/browser/widget/multiDiffEditor/colors.ts diff --git a/src/vs/editor/browser/widget/multiDiffEditorWidget/diffEditorItemTemplate.ts b/src/vs/editor/browser/widget/multiDiffEditor/diffEditorItemTemplate.ts similarity index 88% rename from src/vs/editor/browser/widget/multiDiffEditorWidget/diffEditorItemTemplate.ts rename to src/vs/editor/browser/widget/multiDiffEditor/diffEditorItemTemplate.ts index da71ca719f758..c6f3ca9de9efc 100644 --- a/src/vs/editor/browser/widget/multiDiffEditorWidget/diffEditorItemTemplate.ts +++ b/src/vs/editor/browser/widget/multiDiffEditor/diffEditorItemTemplate.ts @@ -10,8 +10,8 @@ import { autorun, derived, observableFromEvent } from 'vs/base/common/observable import { IObservable, globalTransaction, observableValue } from 'vs/base/common/observableInternal/base'; import { ICodeEditor } from 'vs/editor/browser/editorBrowser'; import { DiffEditorWidget } from 'vs/editor/browser/widget/diffEditor/diffEditorWidget'; -import { DocumentDiffItemViewModel } from 'vs/editor/browser/widget/multiDiffEditorWidget/multiDiffEditorViewModel'; -import { IWorkbenchUIElementFactory } from 'vs/editor/browser/widget/multiDiffEditorWidget/workbenchUIElementFactory'; +import { DocumentDiffItemViewModel } from 'vs/editor/browser/widget/multiDiffEditor/multiDiffEditorViewModel'; +import { IWorkbenchUIElementFactory } from 'vs/editor/browser/widget/multiDiffEditor/workbenchUIElementFactory'; import { IDiffEditorOptions } from 'vs/editor/common/config/editorOptions'; import { OffsetRange } from 'vs/editor/common/core/offsetRange'; import { MenuWorkbenchToolBar } from 'vs/platform/actions/browser/toolbar'; @@ -24,6 +24,7 @@ import { createActionViewItem } from 'vs/platform/actions/browser/menuEntryActio export class TemplateData implements IObjectData { constructor( public readonly viewModel: DocumentDiffItemViewModel, + public readonly deltaScrollVertical: (delta: number) => void, ) { } @@ -116,15 +117,15 @@ export class DiffEditorItemTemplate extends Disposable implements IPooledObject< this._elements.editor.style.display = this._collapsed.read(reader) ? 'none' : 'block'; })); - this.editor.getModifiedEditor().onDidLayoutChange(e => { + this._register(this.editor.getModifiedEditor().onDidLayoutChange(e => { const width = this.editor.getModifiedEditor().getLayoutInfo().contentWidth; this._modifiedWidth.set(width, undefined); - }); + })); - this.editor.getOriginalEditor().onDidLayoutChange(e => { + this._register(this.editor.getOriginalEditor().onDidLayoutChange(e => { const width = this.editor.getOriginalEditor().getLayoutInfo().contentWidth; this._originalWidth.set(width, undefined); - }); + })); this._register(this.editor.onDidContentSizeChange(e => { globalTransaction(tx => { @@ -134,6 +135,18 @@ export class DiffEditorItemTemplate extends Disposable implements IPooledObject< }); })); + this._register(this.editor.getOriginalEditor().onDidScrollChange(e => { + if (this._isSettingScrollTop) { + return; + } + + if (!e.scrollTopChanged || !this._data) { + return; + } + const delta = e.scrollTop - this._lastScrollTop; + this._data.deltaScrollVertical(delta); + })); + this._register(autorun(reader => { const isFocused = this.isFocused.read(reader); this._elements.root.classList.toggle('focused', isFocused); @@ -148,7 +161,7 @@ export class DiffEditorItemTemplate extends Disposable implements IPooledObject< shouldForwardArgs: true, }, toolbarOptions: { primaryGroup: g => g.startsWith('navigation') }, - actionViewItemProvider: action => createActionViewItem(_instantiationService, action), + actionViewItemProvider: (action, options) => createActionViewItem(_instantiationService, action, options), })); } @@ -162,7 +175,10 @@ export class DiffEditorItemTemplate extends Disposable implements IPooledObject< private readonly _dataStore = new DisposableStore(); + private _data: TemplateData | undefined; + public setData(data: TemplateData): void { + this._data = data; function updateOptions(options: IDiffEditorOptions): IDiffEditorOptions { return { ...options, @@ -220,7 +236,10 @@ export class DiffEditorItemTemplate extends Disposable implements IPooledObject< }); } - private readonly _headerHeight = /*this._elements.header.clientHeight*/ 48; + private readonly _headerHeight = /*this._elements.header.clientHeight*/ 40; + + private _lastScrollTop = -1; + private _isSettingScrollTop = false; public render(verticalRange: OffsetRange, width: number, editorScroll: number, viewPort: OffsetRange): void { this._elements.root.style.visibility = 'visible'; @@ -230,7 +249,8 @@ export class DiffEditorItemTemplate extends Disposable implements IPooledObject< this._elements.root.style.position = 'absolute'; // For sticky scroll - const delta = Math.max(0, Math.min(verticalRange.length - this._headerHeight, viewPort.start - verticalRange.start)); + const maxDelta = verticalRange.length - this._headerHeight; + const delta = Math.max(0, Math.min(viewPort.start - verticalRange.start, maxDelta)); this._elements.header.style.transform = `translateY(${delta}px)`; globalTransaction(tx => { @@ -239,9 +259,16 @@ export class DiffEditorItemTemplate extends Disposable implements IPooledObject< height: verticalRange.length - this._outerEditorHeight, }); }); - this.editor.getOriginalEditor().setScrollTop(editorScroll); + try { + this._isSettingScrollTop = true; + this._lastScrollTop = editorScroll; + this.editor.getOriginalEditor().setScrollTop(editorScroll); + } finally { + this._isSettingScrollTop = false; + } this._elements.header.classList.toggle('shadow', delta > 0 || editorScroll > 0); + this._elements.header.classList.toggle('collapsed', delta === maxDelta); } public hide(): void { @@ -258,6 +285,6 @@ function isFocused(editor: ICodeEditor): IObservable { store.add(editor.onDidBlurEditorWidget(() => h(false))); return store; }, - () => editor.hasWidgetFocus() + () => editor.hasTextFocus() ); } diff --git a/src/vs/editor/browser/widget/multiDiffEditorWidget/model.ts b/src/vs/editor/browser/widget/multiDiffEditor/model.ts similarity index 100% rename from src/vs/editor/browser/widget/multiDiffEditorWidget/model.ts rename to src/vs/editor/browser/widget/multiDiffEditor/model.ts diff --git a/src/vs/editor/browser/widget/multiDiffEditorWidget/multiDiffEditorViewModel.ts b/src/vs/editor/browser/widget/multiDiffEditor/multiDiffEditorViewModel.ts similarity index 95% rename from src/vs/editor/browser/widget/multiDiffEditorWidget/multiDiffEditorViewModel.ts rename to src/vs/editor/browser/widget/multiDiffEditor/multiDiffEditorViewModel.ts index 3a26a82c526ab..1fec3c57c96fa 100644 --- a/src/vs/editor/browser/widget/multiDiffEditorWidget/multiDiffEditorViewModel.ts +++ b/src/vs/editor/browser/widget/multiDiffEditor/multiDiffEditorViewModel.ts @@ -8,7 +8,7 @@ import { observableFromEvent, observableValue, transaction } from 'vs/base/commo import { mapObservableArrayCached } from 'vs/base/common/observableInternal/utils'; import { DiffEditorOptions } from 'vs/editor/browser/widget/diffEditor/diffEditorOptions'; import { DiffEditorViewModel } from 'vs/editor/browser/widget/diffEditor/diffEditorViewModel'; -import { IDocumentDiffItem, IMultiDiffEditorModel, LazyPromise } from 'vs/editor/browser/widget/multiDiffEditorWidget/model'; +import { IDocumentDiffItem, IMultiDiffEditorModel, LazyPromise } from 'vs/editor/browser/widget/multiDiffEditor/model'; import { IDiffEditorOptions } from 'vs/editor/common/config/editorOptions'; import { Selection } from 'vs/editor/common/core/selection'; import { IDiffEditorViewModel } from 'vs/editor/common/editorCommon'; @@ -87,7 +87,7 @@ export class DocumentDiffItemViewModel extends Disposable { }; } - const options = new DiffEditorOptions(updateOptions(this.entry.value!.options || {})); + const options = this._instantiationService.createInstance(DiffEditorOptions, updateOptions(this.entry.value!.options || {})); if (this.entry.value!.onOptionsDidChange) { this._register(this.entry.value!.onOptionsDidChange(() => { options.updateOptions(updateOptions(this.entry.value!.options || {})); diff --git a/src/vs/editor/browser/widget/multiDiffEditorWidget/multiDiffEditorWidget.ts b/src/vs/editor/browser/widget/multiDiffEditor/multiDiffEditorWidget.ts similarity index 82% rename from src/vs/editor/browser/widget/multiDiffEditorWidget/multiDiffEditorWidget.ts rename to src/vs/editor/browser/widget/multiDiffEditor/multiDiffEditorWidget.ts index 938edfad4b33d..496b002489b50 100644 --- a/src/vs/editor/browser/widget/multiDiffEditorWidget/multiDiffEditorWidget.ts +++ b/src/vs/editor/browser/widget/multiDiffEditor/multiDiffEditorWidget.ts @@ -7,17 +7,19 @@ import { Dimension } from 'vs/base/browser/dom'; import { Disposable } from 'vs/base/common/lifecycle'; import { derived, derivedWithStore, observableValue, recomputeInitiallyAndOnChange } from 'vs/base/common/observable'; import { readHotReloadableExport } from 'vs/editor/browser/widget/diffEditor/utils'; -import { IMultiDiffEditorModel } from 'vs/editor/browser/widget/multiDiffEditorWidget/model'; -import { IMultiDiffEditorViewState, MultiDiffEditorWidgetImpl } from 'vs/editor/browser/widget/multiDiffEditorWidget/multiDiffEditorWidgetImpl'; +import { IMultiDiffEditorModel } from 'vs/editor/browser/widget/multiDiffEditor/model'; +import { IMultiDiffEditorViewState, IMultiDiffResourceId, MultiDiffEditorWidgetImpl } from 'vs/editor/browser/widget/multiDiffEditor/multiDiffEditorWidgetImpl'; import { MultiDiffEditorViewModel } from './multiDiffEditorViewModel'; import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; import './colors'; -import { DiffEditorItemTemplate } from 'vs/editor/browser/widget/multiDiffEditorWidget/diffEditorItemTemplate'; -import { IWorkbenchUIElementFactory } from 'vs/editor/browser/widget/multiDiffEditorWidget/workbenchUIElementFactory'; +import { DiffEditorItemTemplate } from 'vs/editor/browser/widget/multiDiffEditor/diffEditorItemTemplate'; +import { IWorkbenchUIElementFactory } from 'vs/editor/browser/widget/multiDiffEditor/workbenchUIElementFactory'; import { Event } from 'vs/base/common/event'; import { URI } from 'vs/base/common/uri'; import { IDiffEditor } from 'vs/editor/common/editorCommon'; import { ICodeEditor } from 'vs/editor/browser/editorBrowser'; +import { DiffEditorWidget } from 'vs/editor/browser/widget/diffEditor/diffEditorWidget'; +import { Range } from 'vs/editor/common/core/range'; export class MultiDiffEditorWidget extends Disposable { private readonly _dimension = observableValue(this, undefined); @@ -44,6 +46,10 @@ export class MultiDiffEditorWidget extends Disposable { this._register(recomputeInitiallyAndOnChange(this._widgetImpl)); } + public reveal(resource: IMultiDiffResourceId, options?: RevealOptions): void { + this._widgetImpl.get().reveal(resource, options); + } + public createViewModel(model: IMultiDiffEditorModel): MultiDiffEditorViewModel { return new MultiDiffEditorViewModel(model, this._instantiationService); } @@ -58,7 +64,7 @@ export class MultiDiffEditorWidget extends Disposable { private readonly _activeControl = derived(this, (reader) => this._widgetImpl.read(reader).activeControl.read(reader)); - public getActiveControl(): any | undefined { + public getActiveControl(): DiffEditorWidget | undefined { return this._activeControl.get(); } @@ -76,3 +82,8 @@ export class MultiDiffEditorWidget extends Disposable { return this._widgetImpl.get().tryGetCodeEditor(resource); } } + +export interface RevealOptions { + range?: Range; + highlight: boolean; +} diff --git a/src/vs/editor/browser/widget/multiDiffEditorWidget/multiDiffEditorWidgetImpl.ts b/src/vs/editor/browser/widget/multiDiffEditor/multiDiffEditorWidgetImpl.ts similarity index 86% rename from src/vs/editor/browser/widget/multiDiffEditorWidget/multiDiffEditorWidgetImpl.ts rename to src/vs/editor/browser/widget/multiDiffEditor/multiDiffEditorWidgetImpl.ts index 129d91151b1ad..9a264e2d34123 100644 --- a/src/vs/editor/browser/widget/multiDiffEditorWidget/multiDiffEditorWidgetImpl.ts +++ b/src/vs/editor/browser/widget/multiDiffEditor/multiDiffEditorWidgetImpl.ts @@ -10,21 +10,25 @@ import { Disposable, IReference, toDisposable } from 'vs/base/common/lifecycle'; import { IObservable, IReader, autorun, autorunWithStore, derived, derivedObservableWithCache, derivedWithStore, observableFromEvent, observableValue } from 'vs/base/common/observable'; import { ITransaction, disposableObservableValue, globalTransaction, transaction } from 'vs/base/common/observableInternal/base'; import { Scrollable, ScrollbarVisibility } from 'vs/base/common/scrollable'; +import { URI } from 'vs/base/common/uri'; import 'vs/css!./style'; +import { ICodeEditor } from 'vs/editor/browser/editorBrowser'; import { ObservableElementSizeObserver } from 'vs/editor/browser/widget/diffEditor/utils'; -import { IWorkbenchUIElementFactory } from 'vs/editor/browser/widget/multiDiffEditorWidget/workbenchUIElementFactory'; +import { RevealOptions } from 'vs/editor/browser/widget/multiDiffEditor/multiDiffEditorWidget'; +import { IWorkbenchUIElementFactory } from 'vs/editor/browser/widget/multiDiffEditor/workbenchUIElementFactory'; import { OffsetRange } from 'vs/editor/common/core/offsetRange'; +import { IRange } from 'vs/editor/common/core/range'; +import { ISelection, Selection } from 'vs/editor/common/core/selection'; +import { IDiffEditor } from 'vs/editor/common/editorCommon'; +import { EditorContextKeys } from 'vs/editor/common/editorContextKeys'; +import { ContextKeyValue, IContextKeyService } from 'vs/platform/contextkey/common/contextkey'; +import { ITextEditorOptions } from 'vs/platform/editor/common/editor'; import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; +import { ServiceCollection } from 'vs/platform/instantiation/common/serviceCollection'; import { DiffEditorItemTemplate, TemplateData } from './diffEditorItemTemplate'; import { DocumentDiffItemViewModel, MultiDiffEditorViewModel } from './multiDiffEditorViewModel'; import { ObjectPool } from './objectPool'; -import { ContextKeyValue, IContextKeyService } from 'vs/platform/contextkey/common/contextkey'; -import { ServiceCollection } from 'vs/platform/instantiation/common/serviceCollection'; -import { EditorContextKeys } from 'vs/editor/common/editorContextKeys'; -import { ISelection, Selection } from 'vs/editor/common/core/selection'; -import { URI } from 'vs/base/common/uri'; -import { ICodeEditor } from 'vs/editor/browser/editorBrowser'; -import { IDiffEditor } from 'vs/editor/common/editorCommon'; +import { BugIndicatingError } from 'vs/base/common/errors'; export class MultiDiffEditorWidgetImpl extends Disposable { private readonly _elements = h('div.monaco-component.multiDiffEditor', [ @@ -73,7 +77,9 @@ export class MultiDiffEditorWidgetImpl extends Disposable { } const items = vm.items.read(reader); return items.map(d => { - const item = store.add(new VirtualizedViewItem(d, this._objectPool, this.scrollLeft)); + const item = store.add(new VirtualizedViewItem(d, this._objectPool, this.scrollLeft, delta => { + this._scrollableElement.setScrollPosition({ scrollTop: this._scrollableElement.getScrollPosition().scrollTop + delta }); + })); const data = this._lastDocStates?.[item.getKey()]; if (data) { transaction(tx => { @@ -89,7 +95,7 @@ export class MultiDiffEditorWidgetImpl extends Disposable { private readonly _totalHeight = this._viewItems.map(this, (items, reader) => items.reduce((r, i) => r + i.contentHeight.read(reader) + this._spaceBetweenPx, 0)); public readonly activeDiffItem = derived(this, reader => this._viewItems.read(reader).find(i => i.template.read(reader)?.isFocused.read(reader))); - public readonly lastActiveDiffItem = derivedObservableWithCache((reader, lastValue) => this.activeDiffItem.read(reader) ?? lastValue); + public readonly lastActiveDiffItem = derivedObservableWithCache(this, (reader, lastValue) => this.activeDiffItem.read(reader) ?? lastValue); public readonly activeControl = derived(this, reader => this.lastActiveDiffItem.read(reader)?.template.read(reader)?.editor); private readonly _contextKeyService = this._register(this._parentContextKeyService.createScoped(this._element)); @@ -186,6 +192,29 @@ export class MultiDiffEditorWidgetImpl extends Disposable { this._scrollableElement.setScrollPosition({ scrollLeft: scrollState.left, scrollTop: scrollState.top }); } + public reveal(resource: IMultiDiffResourceId, options?: RevealOptions): void { + const viewItems = this._viewItems.get(); + const index = viewItems.findIndex( + (item) => item.viewModel.originalUri?.toString() === resource.original?.toString() + && item.viewModel.modifiedUri?.toString() === resource.modified?.toString() + ); + if (index === -1) { + throw new BugIndicatingError('Resource not found in diff editor'); + } + let scrollTop = 0; + for (let i = 0; i < index; i++) { + scrollTop += viewItems[i].contentHeight.get() + this._spaceBetweenPx; + } + this._scrollableElement.setScrollPosition({ scrollTop }); + + const diffEditor = viewItems[index].template.get()?.editor; + const editor = 'original' in resource ? diffEditor?.getOriginalEditor() : diffEditor?.getModifiedEditor(); + if (editor && options?.range) { + editor.revealRangeInCenter(options.range); + highlightRange(editor, options.range); + } + } + public getViewState(): IMultiDiffEditorViewState { return { scrollState: { @@ -269,6 +298,16 @@ export class MultiDiffEditorWidgetImpl extends Disposable { } } +function highlightRange(targetEditor: ICodeEditor, range: IRange) { + const modelNow = targetEditor.getModel(); + const decorations = targetEditor.createDecorationsCollection([{ range, options: { description: 'symbol-navigate-action-highlight', className: 'symbolHighlight' } }]); + setTimeout(() => { + if (targetEditor.getModel() === modelNow) { + decorations.clear(); + } + }, 350); +} + export interface IMultiDiffEditorViewState { scrollState: { top: number; left: number }; docStates?: Record; @@ -279,6 +318,19 @@ interface IMultiDiffDocState { selections?: ISelection[]; } +export interface IMultiDiffEditorOptions extends ITextEditorOptions { + viewState?: IMultiDiffEditorOptionsViewState; +} + +export interface IMultiDiffEditorOptionsViewState { + revealData?: { + resource: IMultiDiffResourceId; + range?: IRange; + }; +} + +export type IMultiDiffResourceId = { original: URI | undefined; modified: URI | undefined }; + class VirtualizedViewItem extends Disposable { private readonly _templateRef = this._register(disposableObservableValue | undefined>(this, undefined)); @@ -295,6 +347,7 @@ class VirtualizedViewItem extends Disposable { public readonly viewModel: DocumentDiffItemViewModel, private readonly _objectPool: ObjectPool, private readonly _scrollLeft: IObservable, + private readonly _deltaScrollVertical: (delta: number) => void, ) { super(); @@ -385,7 +438,7 @@ class VirtualizedViewItem extends Disposable { let ref = this._templateRef.get(); if (!ref) { - ref = this._objectPool.getUnusedObj(new TemplateData(this.viewModel)); + ref = this._objectPool.getUnusedObj(new TemplateData(this.viewModel, this._deltaScrollVertical)); this._templateRef.set(ref, undefined); const selections = this.viewModel.lastTemplateData.get().selections; diff --git a/src/vs/editor/browser/widget/multiDiffEditorWidget/objectPool.ts b/src/vs/editor/browser/widget/multiDiffEditor/objectPool.ts similarity index 100% rename from src/vs/editor/browser/widget/multiDiffEditorWidget/objectPool.ts rename to src/vs/editor/browser/widget/multiDiffEditor/objectPool.ts diff --git a/src/vs/editor/browser/widget/multiDiffEditorWidget/style.css b/src/vs/editor/browser/widget/multiDiffEditor/style.css similarity index 94% rename from src/vs/editor/browser/widget/multiDiffEditorWidget/style.css rename to src/vs/editor/browser/widget/multiDiffEditor/style.css index ca41df09a0406..c540a46b3f158 100644 --- a/src/vs/editor/browser/widget/multiDiffEditorWidget/style.css +++ b/src/vs/editor/browser/widget/multiDiffEditor/style.css @@ -7,6 +7,10 @@ background: var(--vscode-multiDiffEditor-background); overflow-y: hidden; + .focused { + --vscode-multiDiffEditor-border: var(--vscode-focusBorder); + } + .multiDiffEntry { display: flex; flex-direction: column; @@ -27,9 +31,13 @@ z-index: 1000; background: var(--vscode-editor-background); + &:not(.collapsed) .header-content { + border-bottom: 1px solid var(--vscode-sideBarSectionHeader-border); + } + .header-content { margin: 8px 8px 0px 8px; - padding: 8px 5px; + padding: 4px 5px; border-top: 1px solid var(--vscode-multiDiffEditor-border); border-right: 1px solid var(--vscode-multiDiffEditor-border); @@ -43,8 +51,6 @@ color: var(--vscode-foreground); background: var(--vscode-multiDiffEditor-headerBackground); - border-bottom: 1px solid var(--vscode-sideBarSectionHeader-border); - &.shadow { box-shadow: var(--vscode-scrollbar-shadow) 0px 6px 6px -6px; } diff --git a/src/vs/editor/browser/widget/multiDiffEditorWidget/utils.ts b/src/vs/editor/browser/widget/multiDiffEditor/utils.ts similarity index 81% rename from src/vs/editor/browser/widget/multiDiffEditorWidget/utils.ts rename to src/vs/editor/browser/widget/multiDiffEditor/utils.ts index 43449e5827d66..be9240267e1a5 100644 --- a/src/vs/editor/browser/widget/multiDiffEditorWidget/utils.ts +++ b/src/vs/editor/browser/widget/multiDiffEditor/utils.ts @@ -6,11 +6,12 @@ import { ActionRunner, IAction } from 'vs/base/common/actions'; export class ActionRunnerWithContext extends ActionRunner { - constructor(private readonly _getContext: () => any) { + constructor(private readonly _getContext: () => unknown) { super(); } protected override runAction(action: IAction, _context?: unknown): Promise { - return super.runAction(action, this._getContext()); + const ctx = this._getContext(); + return super.runAction(action, ctx); } } diff --git a/src/vs/editor/browser/widget/multiDiffEditorWidget/workbenchUIElementFactory.ts b/src/vs/editor/browser/widget/multiDiffEditor/workbenchUIElementFactory.ts similarity index 100% rename from src/vs/editor/browser/widget/multiDiffEditorWidget/workbenchUIElementFactory.ts rename to src/vs/editor/browser/widget/multiDiffEditor/workbenchUIElementFactory.ts diff --git a/src/vs/editor/common/commands/trimTrailingWhitespaceCommand.ts b/src/vs/editor/common/commands/trimTrailingWhitespaceCommand.ts index ebceaddc032df..343a5739f1988 100644 --- a/src/vs/editor/common/commands/trimTrailingWhitespaceCommand.ts +++ b/src/vs/editor/common/commands/trimTrailingWhitespaceCommand.ts @@ -9,6 +9,7 @@ import { Position } from 'vs/editor/common/core/position'; import { Range } from 'vs/editor/common/core/range'; import { Selection } from 'vs/editor/common/core/selection'; import { ICommand, ICursorStateComputerData, IEditOperationBuilder } from 'vs/editor/common/editorCommon'; +import { StandardTokenType } from 'vs/editor/common/encodedTokenAttributes'; import { ITextModel } from 'vs/editor/common/model'; export class TrimTrailingWhitespaceCommand implements ICommand { @@ -16,15 +17,17 @@ export class TrimTrailingWhitespaceCommand implements ICommand { private readonly _selection: Selection; private _selectionId: string | null; private readonly _cursors: Position[]; + private readonly _trimInRegexesAndStrings: boolean; - constructor(selection: Selection, cursors: Position[]) { + constructor(selection: Selection, cursors: Position[], trimInRegexesAndStrings: boolean) { this._selection = selection; this._cursors = cursors; this._selectionId = null; + this._trimInRegexesAndStrings = trimInRegexesAndStrings; } public getEditOperations(model: ITextModel, builder: IEditOperationBuilder): void { - const ops = trimTrailingWhitespace(model, this._cursors); + const ops = trimTrailingWhitespace(model, this._cursors, this._trimInRegexesAndStrings); for (let i = 0, len = ops.length; i < len; i++) { const op = ops[i]; @@ -42,7 +45,7 @@ export class TrimTrailingWhitespaceCommand implements ICommand { /** * Generate commands for trimming trailing whitespace on a model and ignore lines on which cursors are sitting. */ -export function trimTrailingWhitespace(model: ITextModel, cursors: Position[]): ISingleEditOperation[] { +export function trimTrailingWhitespace(model: ITextModel, cursors: Position[], trimInRegexesAndStrings: boolean): ISingleEditOperation[] { // Sort cursors ascending cursors.sort((a, b) => { if (a.lineNumber === b.lineNumber) { @@ -96,6 +99,22 @@ export function trimTrailingWhitespace(model: ITextModel, cursors: Position[]): continue; } + if (!trimInRegexesAndStrings) { + if (!model.tokenization.hasAccurateTokensForLine(lineNumber)) { + // We don't want to force line tokenization, as that can be expensive, but we also don't want to trim + // trailing whitespace in lines that are not tokenized yet, as that can be wrong and trim whitespace from + // lines that the user requested we don't. So we bail out if the tokens are not accurate for this line. + continue; + } + + const lineTokens = model.tokenization.getLineTokens(lineNumber); + const fromColumnType = lineTokens.getStandardTokenType(lineTokens.findTokenIndexAtOffset(fromColumn)); + + if (fromColumnType === StandardTokenType.String || fromColumnType === StandardTokenType.RegEx) { + continue; + } + } + fromColumn = Math.max(minEditColumn, fromColumn); r[rLen++] = EditOperation.delete(new Range( lineNumber, fromColumn, diff --git a/src/vs/editor/common/config/diffEditor.ts b/src/vs/editor/common/config/diffEditor.ts index 2a62c47984884..2d0a312357e1c 100644 --- a/src/vs/editor/common/config/diffEditor.ts +++ b/src/vs/editor/common/config/diffEditor.ts @@ -10,6 +10,7 @@ export const diffEditorDefaultOptions = { splitViewDefaultRatio: 0.5, renderSideBySide: true, renderMarginRevertIcon: true, + renderGutterMenu: true, maxComputationTime: 5000, maxFileSize: 50, ignoreTrimWhitespace: true, diff --git a/src/vs/editor/common/config/editorConfigurationSchema.ts b/src/vs/editor/common/config/editorConfigurationSchema.ts index 3b22985f00d72..ab2b8cc70c62e 100644 --- a/src/vs/editor/common/config/editorConfigurationSchema.ts +++ b/src/vs/editor/common/config/editorConfigurationSchema.ts @@ -175,6 +175,11 @@ const editorConfiguration: IConfigurationNode = { default: diffEditorDefaultOptions.renderMarginRevertIcon, description: nls.localize('renderMarginRevertIcon', "When enabled, the diff editor shows arrows in its glyph margin to revert changes.") }, + 'diffEditor.renderGutterMenu': { + type: 'boolean', + default: diffEditorDefaultOptions.renderGutterMenu, + description: nls.localize('renderGutterMenu', "When enabled, the diff editor shows a special gutter for revert and stage actions.") + }, 'diffEditor.ignoreTrimWhitespace': { type: 'boolean', default: diffEditorDefaultOptions.ignoreTrimWhitespace, diff --git a/src/vs/editor/common/config/editorOptions.ts b/src/vs/editor/common/config/editorOptions.ts index c0225ebc0b8b8..fab223456feba 100644 --- a/src/vs/editor/common/config/editorOptions.ts +++ b/src/vs/editor/common/config/editorOptions.ts @@ -75,6 +75,13 @@ export interface IEditorOptions { * Defaults to empty array. */ rulers?: (number | IRulerOption)[]; + /** + * Locales used for segmenting lines into words when doing word related navigations or operations. + * + * Specify the BCP 47 language tag of the word you wish to recognize (e.g., ja, zh-CN, zh-Hant-TW, etc.). + * Defaults to empty array + */ + wordSegmenterLocales?: string | string[]; /** * A string containing the word separators used when doing word navigation. * Defaults to `~!@#$%^&*()-=+[{]}\\|;:\'",.<>/? @@ -428,6 +435,7 @@ export interface IEditorOptions { */ suggest?: ISuggestOptions; inlineSuggest?: IInlineSuggestOptions; + experimentalInlineEdit?: IInlineEditOptions; /** * Smart select options. */ @@ -802,6 +810,10 @@ export interface IDiffEditorBaseOptions { * Default to true. */ renderMarginRevertIcon?: boolean; + /** + * Indicates if the gutter menu should be rendered. + */ + renderGutterMenu?: boolean; /** * Original model should be editable? * Defaults to false. @@ -2748,7 +2760,7 @@ export type EditorLightbulbOptions = Readonly> class EditorLightbulb extends BaseEditorOption { constructor() { - const defaults: EditorLightbulbOptions = { enabled: ShowLightbulbIconMode.OnCode }; + const defaults: EditorLightbulbOptions = { enabled: ShowLightbulbIconMode.On }; super( EditorOption.lightbulb, 'lightbulb', defaults, { @@ -2810,7 +2822,7 @@ export type EditorStickyScrollOptions = Readonly { constructor() { - const defaults: EditorStickyScrollOptions = { enabled: false, maxLineCount: 5, defaultModel: 'outlineModel', scrollWithEditor: true }; + const defaults: EditorStickyScrollOptions = { enabled: true, maxLineCount: 5, defaultModel: 'outlineModel', scrollWithEditor: true }; super( EditorOption.stickyScroll, 'stickyScroll', defaults, { @@ -2824,7 +2836,7 @@ class EditorStickyScroll extends BaseEditorOption(input.defaultModel, this.defaultValue.defaultModel, ['outlineModel', 'foldingProviderModel', 'indentationModel']), scrollWithEditor: boolean(input.scrollWithEditor, this.defaultValue.scrollWithEditor) }; @@ -3047,6 +3059,18 @@ export interface IEditorMinimapOptions { * Relative size of the font in the minimap. Defaults to 1. */ scale?: number; + /** + * Whether to show named regions as section headers. Defaults to true. + */ + showRegionSectionHeaders?: boolean; + /** + * Whether to show MARK: comments as section headers. Defaults to true. + */ + showMarkSectionHeaders?: boolean; + /** + * Font size of section headers. Defaults to 9. + */ + sectionHeaderFontSize?: number; } /** @@ -3066,6 +3090,9 @@ class EditorMinimap extends BaseEditorOption>; + +class InlineEditorEdit extends BaseEditorOption { + constructor() { + const defaults: InternalInlineEditOptions = { + enabled: false, + showToolbar: 'onHover', + fontFamily: 'default', + keepOnBlur: false, + backgroundColoring: false, + }; + + super( + EditorOption.inlineEdit, 'experimentalInlineEdit', defaults, + { + 'editor.experimentalInlineEdit.enabled': { + type: 'boolean', + default: defaults.enabled, + description: nls.localize('inlineEdit.enabled', "Controls whether to show inline edits in the editor.") + }, + 'editor.experimentalInlineEdit.showToolbar': { + type: 'string', + default: defaults.showToolbar, + enum: ['always', 'onHover', 'never'], + enumDescriptions: [ + nls.localize('inlineEdit.showToolbar.always', "Show the inline edit toolbar whenever an inline suggestion is shown."), + nls.localize('inlineEdit.showToolbar.onHover', "Show the inline edit toolbar when hovering over an inline suggestion."), + nls.localize('inlineEdit.showToolbar.never', "Never show the inline edit toolbar."), + ], + description: nls.localize('inlineEdit.showToolbar', "Controls when to show the inline edit toolbar."), + }, + 'editor.experimentalInlineEdit.fontFamily': { + type: 'string', + default: defaults.fontFamily, + description: nls.localize('inlineEdit.fontFamily', "Controls the font family of the inline edit.") + }, + 'editor.experimentalInlineEdit.backgroundColoring': { + type: 'boolean', + default: defaults.backgroundColoring, + description: nls.localize('inlineEdit.backgroundColoring', "Controls whether to color the background of inline edits.") + }, + } + ); + } + + public validate(_input: any): InternalInlineEditOptions { + if (!_input || typeof _input !== 'object') { + return this.defaultValue; + } + const input = _input as IInlineEditOptions; + return { + enabled: boolean(input.enabled, this.defaultValue.enabled), + showToolbar: stringSet(input.showToolbar, this.defaultValue.showToolbar, ['always', 'onHover', 'never']), + fontFamily: EditorStringOption.string(input.fontFamily, this.defaultValue.fontFamily), + keepOnBlur: boolean(input.keepOnBlur, this.defaultValue.keepOnBlur), + backgroundColoring: boolean(input.backgroundColoring, this.defaultValue.backgroundColoring) + }; + } +} + //#region bracketPairColorization export interface IBracketPairColorizationOptions { @@ -4806,6 +4935,63 @@ class SmartSelect extends BaseEditorOption { + constructor() { + const defaults: string[] = []; + + super( + EditorOption.wordSegmenterLocales, 'wordSegmenterLocales', defaults, + { + anyOf: [ + { + description: nls.localize('wordSegmenterLocales', "Locales to be used for word segmentation when doing word related navigations or operations. Specify the BCP 47 language tag of the word you wish to recognize (e.g., ja, zh-CN, zh-Hant-TW, etc.)."), + type: 'string', + }, { + description: nls.localize('wordSegmenterLocales', "Locales to be used for word segmentation when doing word related navigations or operations. Specify the BCP 47 language tag of the word you wish to recognize (e.g., ja, zh-CN, zh-Hant-TW, etc.)."), + type: 'array', + items: { + type: 'string' + } + } + ] + } + ); + } + + public validate(input: any): string[] { + if (typeof input === 'string') { + input = [input]; + } + if (Array.isArray(input)) { + const validLocales: string[] = []; + for (const locale of input) { + if (typeof locale === 'string') { + try { + if (Intl.Segmenter.supportedLocalesOf(locale).length > 0) { + validLocales.push(locale); + } + } catch { + // ignore invalid locales + } + } + } + return validLocales; + } + + return this.defaultValue; + } +} + + //#endregion //#region wrappingIndent @@ -5132,6 +5318,7 @@ export const enum EditorOption { hover, inDiffEditor, inlineSuggest, + inlineEdit, letterSpacing, lightbulb, lineDecorationsWidth, @@ -5198,6 +5385,7 @@ export const enum EditorOption { useShadowDOM, useTabStops, wordBreak, + wordSegmenterLocales, wordSeparators, wordWrap, wordWrapBreakAfterCharacters, @@ -5450,7 +5638,7 @@ export const EditorOptions = { nls.localize('cursorSurroundingLinesStyle.default', "`cursorSurroundingLines` is enforced only when triggered via the keyboard or API."), nls.localize('cursorSurroundingLinesStyle.all', "`cursorSurroundingLines` is enforced always.") ], - markdownDescription: nls.localize('cursorSurroundingLinesStyle', "Controls when `#cursorSurroundingLines#` should be enforced.") + markdownDescription: nls.localize('cursorSurroundingLinesStyle', "Controls when `#editor.cursorSurroundingLines#` should be enforced.") } )), cursorWidth: register(new EditorIntOption( @@ -5835,6 +6023,7 @@ export const EditorOptions = { )), suggest: register(new EditorSuggest()), inlineSuggest: register(new InlineEditorSuggest()), + inlineEdit: register(new InlineEditorEdit()), inlineCompletionsAccessibilityVerbose: register(new EditorBooleanOption(EditorOption.inlineCompletionsAccessibilityVerbose, 'inlineCompletionsAccessibilityVerbose', false, { description: nls.localize('inlineCompletionsAccessibilityVerbose', "Controls whether the accessibility hint should be provided to screen reader users when an inline completion is shown.") })), suggestFontSize: register(new EditorIntOption( @@ -5900,7 +6089,7 @@ export const EditorOptions = { )), useTabStops: register(new EditorBooleanOption( EditorOption.useTabStops, 'useTabStops', true, - { description: nls.localize('useTabStops', "Inserting and deleting whitespace follows tab stops.") } + { description: nls.localize('useTabStops', "Spaces and tabs are inserted and deleted in alignment with tab stops.") } )), wordBreak: register(new EditorStringEnumOption( EditorOption.wordBreak, 'wordBreak', @@ -5914,6 +6103,7 @@ export const EditorOptions = { description: nls.localize('wordBreak', "Controls the word break rules used for Chinese/Japanese/Korean (CJK) text.") } )), + wordSegmenterLocales: register(new WordSegmenterLocales()), wordSeparators: register(new EditorStringOption( EditorOption.wordSeparators, 'wordSeparators', USUAL_WORD_SEPARATORS, { description: nls.localize('wordSeparators', "Characters that will be used as word separators when doing word related navigations or operations.") } diff --git a/src/vs/editor/common/core/editorColorRegistry.ts b/src/vs/editor/common/core/editorColorRegistry.ts index 95b738b7b6f63..88e0419268fe0 100644 --- a/src/vs/editor/common/core/editorColorRegistry.ts +++ b/src/vs/editor/common/core/editorColorRegistry.ts @@ -20,6 +20,10 @@ export const editorSymbolHighlightBorder = registerColor('editor.symbolHighlight export const editorCursorForeground = registerColor('editorCursor.foreground', { dark: '#AEAFAD', light: Color.black, hcDark: Color.white, hcLight: '#0F4A85' }, nls.localize('caret', 'Color of the editor cursor.')); export const editorCursorBackground = registerColor('editorCursor.background', null, nls.localize('editorCursorBackground', 'The background color of the editor cursor. Allows customizing the color of a character overlapped by a block cursor.')); +export const editorMultiCursorPrimaryForeground = registerColor('editorMultiCursor.primary.foreground', { dark: editorCursorForeground, light: editorCursorForeground, hcDark: editorCursorForeground, hcLight: editorCursorForeground }, nls.localize('editorMultiCursorPrimaryForeground', 'Color of the primary editor cursor when multiple cursors are present.')); +export const editorMultiCursorPrimaryBackground = registerColor('editorMultiCursor.primary.background', { dark: editorCursorBackground, light: editorCursorBackground, hcDark: editorCursorBackground, hcLight: editorCursorBackground }, nls.localize('editorMultiCursorPrimaryBackground', 'The background color of the primary editor cursor when multiple cursors are present. Allows customizing the color of a character overlapped by a block cursor.')); +export const editorMultiCursorSecondaryForeground = registerColor('editorMultiCursor.secondary.foreground', { dark: editorCursorForeground, light: editorCursorForeground, hcDark: editorCursorForeground, hcLight: editorCursorForeground }, nls.localize('editorMultiCursorSecondaryForeground', 'Color of secondary editor cursors when multiple cursors are present.')); +export const editorMultiCursorSecondaryBackground = registerColor('editorMultiCursor.secondary.background', { dark: editorCursorBackground, light: editorCursorBackground, hcDark: editorCursorBackground, hcLight: editorCursorBackground }, nls.localize('editorMultiCursorSecondaryBackground', 'The background color of secondary editor cursors when multiple cursors are present. Allows customizing the color of a character overlapped by a block cursor.')); export const editorWhitespaces = registerColor('editorWhitespace.foreground', { dark: '#e3e4e229', light: '#33333333', hcDark: '#e3e4e229', hcLight: '#CCCCCC' }, nls.localize('editorWhitespaces', 'Color of whitespace characters in the editor.')); export const editorLineNumbers = registerColor('editorLineNumber.foreground', { dark: '#858585', light: '#237893', hcDark: Color.white, hcLight: '#292929' }, nls.localize('editorLineNumbers', 'Color of editor line numbers.')); diff --git a/src/vs/editor/common/core/lineRange.ts b/src/vs/editor/common/core/lineRange.ts index da150f4795260..603541cbb86d8 100644 --- a/src/vs/editor/common/core/lineRange.ts +++ b/src/vs/editor/common/core/lineRange.ts @@ -7,6 +7,7 @@ import { BugIndicatingError } from 'vs/base/common/errors'; import { OffsetRange } from 'vs/editor/common/core/offsetRange'; import { Range } from 'vs/editor/common/core/range'; import { findFirstIdxMonotonousOrArrLen, findLastIdxMonotonous, findLastMonotonous } from 'vs/base/common/arraysFind'; +import { ITextModel } from 'vs/editor/common/model'; /** * A range of lines (1-based). @@ -52,6 +53,19 @@ export class LineRange { return result.ranges; } + public static join(lineRanges: LineRange[]): LineRange { + if (lineRanges.length === 0) { + throw new BugIndicatingError('lineRanges cannot be empty'); + } + let startLineNumber = lineRanges[0].startLineNumber; + let endLineNumberExclusive = lineRanges[0].endLineNumberExclusive; + for (let i = 1; i < lineRanges.length; i++) { + startLineNumber = Math.min(startLineNumber, lineRanges[i].startLineNumber); + endLineNumberExclusive = Math.max(endLineNumberExclusive, lineRanges[i].endLineNumberExclusive); + } + return new LineRange(startLineNumber, endLineNumberExclusive); + } + public static ofLength(startLineNumber: number, length: number): LineRange { return new LineRange(startLineNumber, startLineNumber + length); } @@ -63,6 +77,32 @@ export class LineRange { return new LineRange(lineRange[0], lineRange[1]); } + /** + * @internal + */ + public static invert(range: LineRange, model: ITextModel): LineRange[] { + if (range.isEmpty) { + return []; + } + const result: LineRange[] = []; + if (range.startLineNumber > 1) { + result.push(new LineRange(1, range.startLineNumber)); + } + if (range.endLineNumberExclusive < model.getLineCount() + 1) { + result.push(new LineRange(range.endLineNumberExclusive, model.getLineCount() + 1)); + } + return result.filter(r => !r.isEmpty); + } + + /** + * @internal + */ + public static asRange(lineRange: LineRange, model: ITextModel): Range { + return lineRange.isEmpty + ? new Range(lineRange.startLineNumber, 1, lineRange.startLineNumber, model.getLineLength(lineRange.startLineNumber)) + : new Range(lineRange.startLineNumber, 1, lineRange.endLineNumberExclusive - 1, model.getLineLength(lineRange.endLineNumberExclusive - 1)); + } + /** * The start line number. */ diff --git a/src/vs/editor/common/core/positionToOffset.ts b/src/vs/editor/common/core/positionToOffset.ts new file mode 100644 index 0000000000000..484c0a3265f2c --- /dev/null +++ b/src/vs/editor/common/core/positionToOffset.ts @@ -0,0 +1,58 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { findLastIdxMonotonous } from 'vs/base/common/arraysFind'; +import { OffsetRange } from 'vs/editor/common/core/offsetRange'; +import { Position } from 'vs/editor/common/core/position'; +import { Range } from 'vs/editor/common/core/range'; +import { TextLength } from 'vs/editor/common/core/textLength'; + +export class PositionOffsetTransformer { + private readonly lineStartOffsetByLineIdx: number[]; + + constructor(public readonly text: string) { + this.lineStartOffsetByLineIdx = []; + this.lineStartOffsetByLineIdx.push(0); + for (let i = 0; i < text.length; i++) { + if (text.charAt(i) === '\n') { + this.lineStartOffsetByLineIdx.push(i + 1); + } + } + } + + getOffset(position: Position): number { + return this.lineStartOffsetByLineIdx[position.lineNumber - 1] + position.column - 1; + } + + getOffsetRange(range: Range): OffsetRange { + return new OffsetRange( + this.getOffset(range.getStartPosition()), + this.getOffset(range.getEndPosition()) + ); + } + + getPosition(offset: number): Position { + const idx = findLastIdxMonotonous(this.lineStartOffsetByLineIdx, i => i <= offset); + const lineNumber = idx + 1; + const column = offset - this.lineStartOffsetByLineIdx[idx] + 1; + return new Position(lineNumber, column); + } + + getRange(offsetRange: OffsetRange): Range { + return Range.fromPositions( + this.getPosition(offsetRange.start), + this.getPosition(offsetRange.endExclusive) + ); + } + + getTextLength(offsetRange: OffsetRange): TextLength { + return TextLength.ofRange(this.getRange(offsetRange)); + } + + get textLength(): TextLength { + const lineIdx = this.lineStartOffsetByLineIdx.length - 1; + return new TextLength(lineIdx, this.text.length - this.lineStartOffsetByLineIdx[lineIdx]); + } +} diff --git a/src/vs/editor/common/core/rangeMapping.ts b/src/vs/editor/common/core/rangeMapping.ts new file mode 100644 index 0000000000000..379e046357d7e --- /dev/null +++ b/src/vs/editor/common/core/rangeMapping.ts @@ -0,0 +1,73 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { findLastMonotonous } from 'vs/base/common/arraysFind'; +import { Position } from 'vs/editor/common/core/position'; +import { Range } from 'vs/editor/common/core/range'; +import { TextLength } from 'vs/editor/common/core/textLength'; + +/** + * Represents a list of mappings of ranges from one document to another. + */ +export class RangeMapping { + constructor(public readonly mappings: readonly SingleRangeMapping[]) { + } + + mapPosition(position: Position): PositionOrRange { + const mapping = findLastMonotonous(this.mappings, m => m.original.getStartPosition().isBeforeOrEqual(position)); + if (!mapping) { + return PositionOrRange.position(position); + } + if (mapping.original.containsPosition(position)) { + return PositionOrRange.range(mapping.modified); + } + const l = TextLength.betweenPositions(mapping.original.getEndPosition(), position); + return PositionOrRange.position(l.addToPosition(mapping.modified.getEndPosition())); + } + + mapRange(range: Range): Range { + const start = this.mapPosition(range.getStartPosition()); + const end = this.mapPosition(range.getEndPosition()); + return Range.fromPositions( + start.range?.getStartPosition() ?? start.position!, + end.range?.getEndPosition() ?? end.position!, + ); + } + + reverse(): RangeMapping { + return new RangeMapping(this.mappings.map(mapping => mapping.reverse())); + } +} + +export class SingleRangeMapping { + constructor( + public readonly original: Range, + public readonly modified: Range, + ) { + } + + reverse(): SingleRangeMapping { + return new SingleRangeMapping(this.modified, this.original); + } + + toString() { + return `${this.original.toString()} -> ${this.modified.toString()}`; + } +} + +export class PositionOrRange { + public static position(position: Position): PositionOrRange { + return new PositionOrRange(position, undefined); + } + + public static range(range: Range): PositionOrRange { + return new PositionOrRange(undefined, range); + } + + private constructor( + public readonly position: Position | undefined, + public readonly range: Range | undefined, + ) { } +} diff --git a/src/vs/editor/common/core/textEdit.ts b/src/vs/editor/common/core/textEdit.ts new file mode 100644 index 0000000000000..e353361d95353 --- /dev/null +++ b/src/vs/editor/common/core/textEdit.ts @@ -0,0 +1,228 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { assert, assertFn, checkAdjacentItems } from 'vs/base/common/assert'; +import { BugIndicatingError } from 'vs/base/common/errors'; +import { Position } from 'vs/editor/common/core/position'; +import { PositionOffsetTransformer } from 'vs/editor/common/core/positionToOffset'; +import { Range } from 'vs/editor/common/core/range'; +import { TextLength } from 'vs/editor/common/core/textLength'; + +export class TextEdit { + constructor(public readonly edits: readonly SingleTextEdit[]) { + assertFn(() => checkAdjacentItems(edits, (a, b) => a.range.getEndPosition().isBeforeOrEqual(b.range.getStartPosition()))); + } + + /** + * Joins touching edits and removes empty edits. + */ + normalize(): TextEdit { + const edits: SingleTextEdit[] = []; + for (const edit of this.edits) { + if (edits.length > 0 && edits[edits.length - 1].range.getEndPosition().equals(edit.range.getStartPosition())) { + const last = edits[edits.length - 1]; + edits[edits.length - 1] = new SingleTextEdit(last.range.plusRange(edit.range), last.text + edit.text); + } else if (!edit.isEmpty) { + edits.push(edit); + } + } + return new TextEdit(edits); + } + + mapPosition(position: Position): Position | Range { + let lineDelta = 0; + let curLine = 0; + let columnDeltaInCurLine = 0; + + for (const edit of this.edits) { + const start = edit.range.getStartPosition(); + const end = edit.range.getEndPosition(); + + if (position.isBeforeOrEqual(start)) { + break; + } + + const len = TextLength.ofText(edit.text); + if (position.isBefore(end)) { + const startPos = new Position(start.lineNumber + lineDelta, start.column + (start.lineNumber + lineDelta === curLine ? columnDeltaInCurLine : 0)); + const endPos = len.addToPosition(startPos); + return rangeFromPositions(startPos, endPos); + } + + lineDelta += len.lineCount - (edit.range.endLineNumber - edit.range.startLineNumber); + + if (len.lineCount === 0) { + if (end.lineNumber !== start.lineNumber) { + columnDeltaInCurLine += len.columnCount - (end.column - 1); + } else { + columnDeltaInCurLine += len.columnCount - (end.column - start.column); + } + } else { + columnDeltaInCurLine = len.columnCount; + } + curLine = end.lineNumber + lineDelta; + } + + return new Position(position.lineNumber + lineDelta, position.column + (position.lineNumber + lineDelta === curLine ? columnDeltaInCurLine : 0)); + } + + mapRange(range: Range): Range { + function getStart(p: Position | Range) { + return p instanceof Position ? p : p.getStartPosition(); + } + + function getEnd(p: Position | Range) { + return p instanceof Position ? p : p.getEndPosition(); + } + + const start = getStart(this.mapPosition(range.getStartPosition())); + const end = getEnd(this.mapPosition(range.getEndPosition())); + + return rangeFromPositions(start, end); + } + + // TODO: `doc` is not needed for this! + inverseMapPosition(positionAfterEdit: Position, doc: AbstractText): Position | Range { + const reversed = this.inverse(doc); + return reversed.mapPosition(positionAfterEdit); + } + + inverseMapRange(range: Range, doc: AbstractText): Range { + const reversed = this.inverse(doc); + return reversed.mapRange(range); + } + + apply(text: AbstractText): string { + let result = ''; + let lastEditEnd = new Position(1, 1); + for (const edit of this.edits) { + const editRange = edit.range; + const editStart = editRange.getStartPosition(); + const editEnd = editRange.getEndPosition(); + + const r = rangeFromPositions(lastEditEnd, editStart); + if (!r.isEmpty()) { + result += text.getValueOfRange(r); + } + result += edit.text; + lastEditEnd = editEnd; + } + const r = rangeFromPositions(lastEditEnd, text.endPositionExclusive); + if (!r.isEmpty()) { + result += text.getValueOfRange(r); + } + return result; + } + + applyToString(str: string): string { + const strText = new StringText(str); + return this.apply(strText); + } + + inverse(doc: AbstractText): TextEdit { + const ranges = this.getNewRanges(); + return new TextEdit(this.edits.map((e, idx) => new SingleTextEdit(ranges[idx], doc.getValueOfRange(e.range)))); + } + + getNewRanges(): Range[] { + const newRanges: Range[] = []; + let previousEditEndLineNumber = 0; + let lineOffset = 0; + let columnOffset = 0; + for (const edit of this.edits) { + const textLength = TextLength.ofText(edit.text); + const newRangeStart = Position.lift({ + lineNumber: edit.range.startLineNumber + lineOffset, + column: edit.range.startColumn + (edit.range.startLineNumber === previousEditEndLineNumber ? columnOffset : 0) + }); + const newRange = textLength.createRange(newRangeStart); + newRanges.push(newRange); + lineOffset = newRange.endLineNumber - edit.range.endLineNumber; + columnOffset = newRange.endColumn - edit.range.endColumn; + previousEditEndLineNumber = edit.range.endLineNumber; + } + return newRanges; + } +} + +export class SingleTextEdit { + constructor( + public readonly range: Range, + public readonly text: string, + ) { + } + + get isEmpty(): boolean { + return this.range.isEmpty() && this.text.length === 0; + } + + static equals(first: SingleTextEdit, second: SingleTextEdit) { + return first.range.equalsRange(second.range) && first.text === second.text; + } +} + +function rangeFromPositions(start: Position, end: Position): Range { + if (!start.isBeforeOrEqual(end)) { + throw new BugIndicatingError('start must be before end'); + } + return new Range(start.lineNumber, start.column, end.lineNumber, end.column); +} + +export abstract class AbstractText { + abstract getValueOfRange(range: Range): string; + abstract readonly length: TextLength; + + get endPositionExclusive(): Position { + return this.length.addToPosition(new Position(1, 1)); + } + + getValue() { + return this.getValueOfRange(this.length.toRange()); + } +} + +export class LineBasedText extends AbstractText { + constructor( + private readonly _getLineContent: (lineNumber: number) => string, + private readonly _lineCount: number, + ) { + assert(_lineCount >= 1); + + super(); + } + + getValueOfRange(range: Range): string { + if (range.startLineNumber === range.endLineNumber) { + return this._getLineContent(range.startLineNumber).substring(range.startColumn - 1, range.endColumn - 1); + } + let result = this._getLineContent(range.startLineNumber).substring(range.startColumn - 1); + for (let i = range.startLineNumber + 1; i < range.endLineNumber; i++) { + result += '\n' + this._getLineContent(i); + } + result += '\n' + this._getLineContent(range.endLineNumber).substring(0, range.endColumn - 1); + return result; + } + + get length(): TextLength { + const lastLine = this._getLineContent(this._lineCount); + return new TextLength(this._lineCount - 1, lastLine.length); + } +} + +export class StringText extends AbstractText { + private readonly _t = new PositionOffsetTransformer(this.value); + + constructor(public readonly value: string) { + super(); + } + + getValueOfRange(range: Range): string { + return this._t.getOffsetRange(range).substring(this.value); + } + + get length(): TextLength { + return this._t.textLength; + } +} diff --git a/src/vs/editor/common/core/textLength.ts b/src/vs/editor/common/core/textLength.ts new file mode 100644 index 0000000000000..bee3897d5fdd0 --- /dev/null +++ b/src/vs/editor/common/core/textLength.ts @@ -0,0 +1,123 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +import { Position } from 'vs/editor/common/core/position'; +import { Range } from 'vs/editor/common/core/range'; + +/** + * Represents a non-negative length of text in terms of line and column count. +*/ +export class TextLength { + public static zero = new TextLength(0, 0); + + public static lengthDiffNonNegative(start: TextLength, end: TextLength): TextLength { + if (end.isLessThan(start)) { + return TextLength.zero; + } + if (start.lineCount === end.lineCount) { + return new TextLength(0, end.columnCount - start.columnCount); + } else { + return new TextLength(end.lineCount - start.lineCount, end.columnCount); + } + } + + public static betweenPositions(position1: Position, position2: Position): TextLength { + if (position1.lineNumber === position2.lineNumber) { + return new TextLength(0, position2.column - position1.column); + } else { + return new TextLength(position2.lineNumber - position1.lineNumber, position2.column - 1); + } + } + + public static ofRange(range: Range) { + return TextLength.betweenPositions(range.getStartPosition(), range.getEndPosition()); + } + + public static ofText(text: string): TextLength { + let line = 0; + let column = 0; + for (const c of text) { + if (c === '\n') { + line++; + column = 0; + } else { + column++; + } + } + return new TextLength(line, column); + } + + constructor( + public readonly lineCount: number, + public readonly columnCount: number + ) { } + + public isZero() { + return this.lineCount === 0 && this.columnCount === 0; + } + + public isLessThan(other: TextLength): boolean { + if (this.lineCount !== other.lineCount) { + return this.lineCount < other.lineCount; + } + return this.columnCount < other.columnCount; + } + + public isGreaterThan(other: TextLength): boolean { + if (this.lineCount !== other.lineCount) { + return this.lineCount > other.lineCount; + } + return this.columnCount > other.columnCount; + } + + public isGreaterThanOrEqualTo(other: TextLength): boolean { + if (this.lineCount !== other.lineCount) { + return this.lineCount > other.lineCount; + } + return this.columnCount >= other.columnCount; + } + + public equals(other: TextLength): boolean { + return this.lineCount === other.lineCount && this.columnCount === other.columnCount; + } + + public compare(other: TextLength): number { + if (this.lineCount !== other.lineCount) { + return this.lineCount - other.lineCount; + } + return this.columnCount - other.columnCount; + } + + public add(other: TextLength): TextLength { + if (other.lineCount === 0) { + return new TextLength(this.lineCount, this.columnCount + other.columnCount); + } else { + return new TextLength(this.lineCount + other.lineCount, other.columnCount); + } + } + + public createRange(startPosition: Position): Range { + if (this.lineCount === 0) { + return new Range(startPosition.lineNumber, startPosition.column, startPosition.lineNumber, startPosition.column + this.columnCount); + } else { + return new Range(startPosition.lineNumber, startPosition.column, startPosition.lineNumber + this.lineCount, this.columnCount + 1); + } + } + + public toRange(): Range { + return new Range(1, 1, this.lineCount + 1, this.columnCount + 1); + } + + public addToPosition(position: Position): Position { + if (this.lineCount === 0) { + return new Position(position.lineNumber, position.column + this.columnCount); + } else { + return new Position(position.lineNumber + this.lineCount, this.columnCount + 1); + } + } + + toString() { + return `${this.lineCount},${this.columnCount}`; + } +} diff --git a/src/vs/editor/common/core/wordCharacterClassifier.ts b/src/vs/editor/common/core/wordCharacterClassifier.ts index 638ff3ac26ac6..b984c2726576b 100644 --- a/src/vs/editor/common/core/wordCharacterClassifier.ts +++ b/src/vs/editor/common/core/wordCharacterClassifier.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { CharCode } from 'vs/base/common/charCode'; +import { LRUCache } from 'vs/base/common/map'; import { CharacterClassifier } from 'vs/editor/common/core/characterClassifier'; export const enum WordCharacterClass { @@ -14,8 +15,19 @@ export const enum WordCharacterClass { export class WordCharacterClassifier extends CharacterClassifier { - constructor(wordSeparators: string) { + public readonly intlSegmenterLocales: Intl.UnicodeBCP47LocaleIdentifier[]; + private readonly _segmenter: Intl.Segmenter | null = null; + private _cachedLine: string | null = null; + private _cachedSegments: IntlWordSegmentData[] = []; + + constructor(wordSeparators: string, intlSegmenterLocales: Intl.UnicodeBCP47LocaleIdentifier[]) { super(WordCharacterClass.Regular); + this.intlSegmenterLocales = intlSegmenterLocales; + if (this.intlSegmenterLocales.length > 0) { + this._segmenter = new Intl.Segmenter(this.intlSegmenterLocales, { granularity: 'word' }); + } else { + this._segmenter = null; + } for (let i = 0, len = wordSeparators.length; i < len; i++) { this.set(wordSeparators.charCodeAt(i), WordCharacterClass.WordSeparator); @@ -25,18 +37,74 @@ export class WordCharacterClassifier extends CharacterClassifier offset) { + break; + } + candidate = segment; + } + return candidate; + } + + public findNextIntlWordAtOrAfterOffset(lineContent: string, offset: number): IntlWordSegmentData | null { + for (const segment of this._getIntlSegmenterWordsOnLine(lineContent)) { + if (segment.index < offset) { + continue; + } + return segment; + } + return null; + } + + private _getIntlSegmenterWordsOnLine(line: string): IntlWordSegmentData[] { + if (!this._segmenter) { + return []; + } + + // Check if the line has changed from the previous call + if (this._cachedLine === line) { + return this._cachedSegments; + } -function once(computeFn: (input: string) => R): (input: string) => R { - const cache: { [key: string]: R } = {}; // TODO@Alex unbounded cache - return (input: string): R => { - if (!cache.hasOwnProperty(input)) { - cache[input] = computeFn(input); + // Update the cache with the new line + this._cachedLine = line; + this._cachedSegments = this._filterWordSegments(this._segmenter.segment(line)); + + return this._cachedSegments; + } + + private _filterWordSegments(segments: Intl.Segments): IntlWordSegmentData[] { + const result: IntlWordSegmentData[] = []; + for (const segment of segments) { + if (this._isWordLike(segment)) { + result.push(segment); + } + } + return result; + } + + private _isWordLike(segment: Intl.SegmentData): segment is IntlWordSegmentData { + if (segment.isWordLike) { + return true; } - return cache[input]; - }; + return false; + } +} + +export interface IntlWordSegmentData extends Intl.SegmentData { + isWordLike: true; } -export const getMapForWordSeparators = once( - (input) => new WordCharacterClassifier(input) -); +const wordClassifierCache = new LRUCache(10); + +export function getMapForWordSeparators(wordSeparators: string, intlSegmenterLocales: Intl.UnicodeBCP47LocaleIdentifier[]): WordCharacterClassifier { + const key = `${wordSeparators}/${intlSegmenterLocales.join(',')}`; + let result = wordClassifierCache.get(key)!; + if (!result) { + result = new WordCharacterClassifier(wordSeparators, intlSegmenterLocales); + wordClassifierCache.set(key, result); + } + return result; +} diff --git a/src/vs/editor/common/cursor/cursor.ts b/src/vs/editor/common/cursor/cursor.ts index a5be0b58c3311..4df16ec8db26a 100644 --- a/src/vs/editor/common/cursor/cursor.ts +++ b/src/vs/editor/common/cursor/cursor.ts @@ -136,7 +136,7 @@ export class CursorsController extends Disposable { this._columnSelectData = columnSelectData; } - public revealPrimary(eventsCollector: ViewModelEventsCollector, source: string | null | undefined, minimalReveal: boolean, verticalType: VerticalRevealType, revealHorizontal: boolean, scrollType: editorCommon.ScrollType): void { + public revealAll(eventsCollector: ViewModelEventsCollector, source: string | null | undefined, minimalReveal: boolean, verticalType: VerticalRevealType, revealHorizontal: boolean, scrollType: editorCommon.ScrollType): void { const viewPositions = this._cursors.getViewPositions(); let revealViewRange: Range | null = null; @@ -150,6 +150,12 @@ export class CursorsController extends Disposable { eventsCollector.emitViewEvent(new ViewRevealRangeRequestEvent(source, minimalReveal, revealViewRange, revealViewSelections, verticalType, revealHorizontal, scrollType)); } + public revealPrimary(eventsCollector: ViewModelEventsCollector, source: string | null | undefined, minimalReveal: boolean, verticalType: VerticalRevealType, revealHorizontal: boolean, scrollType: editorCommon.ScrollType): void { + const primaryCursor = this._cursors.getPrimaryCursor(); + const revealViewSelections = [primaryCursor.viewState.selection]; + eventsCollector.emitViewEvent(new ViewRevealRangeRequestEvent(source, minimalReveal, null, revealViewSelections, verticalType, revealHorizontal, scrollType)); + } + public saveState(): editorCommon.ICursorState[] { const result: editorCommon.ICursorState[] = []; @@ -212,7 +218,7 @@ export class CursorsController extends Disposable { } this.setStates(eventsCollector, 'restoreState', CursorChangeReason.NotSet, CursorState.fromModelSelections(desiredSelections)); - this.revealPrimary(eventsCollector, 'restoreState', false, VerticalRevealType.Simple, true, editorCommon.ScrollType.Immediate); + this.revealAll(eventsCollector, 'restoreState', false, VerticalRevealType.Simple, true, editorCommon.ScrollType.Immediate); } public onModelContentChanged(eventsCollector: ViewModelEventsCollector, event: InternalModelContentChangeEvent | ModelInjectedTextChangedEvent): void { @@ -252,7 +258,7 @@ export class CursorsController extends Disposable { if (this._hasFocus && e.resultingSelection && e.resultingSelection.length > 0) { const cursorState = CursorState.fromModelSelections(e.resultingSelection); if (this.setStates(eventsCollector, 'modelChange', e.isUndoing ? CursorChangeReason.Undo : e.isRedoing ? CursorChangeReason.Redo : CursorChangeReason.RecoverFromMarkers, cursorState)) { - this.revealPrimary(eventsCollector, 'modelChange', false, VerticalRevealType.Simple, true, editorCommon.ScrollType.Smooth); + this.revealAll(eventsCollector, 'modelChange', false, VerticalRevealType.Simple, true, editorCommon.ScrollType.Smooth); } } else { const selectionsFromMarkers = this._cursors.readSelectionFromMarkers(); @@ -519,7 +525,7 @@ export class CursorsController extends Disposable { this._cursors.startTrackingSelections(); this._validateAutoClosedActions(); if (this._emitStateChangedIfNecessary(eventsCollector, source, cursorChangeReason, oldState, false)) { - this.revealPrimary(eventsCollector, source, false, VerticalRevealType.Simple, true, editorCommon.ScrollType.Smooth); + this.revealAll(eventsCollector, source, false, VerticalRevealType.Simple, true, editorCommon.ScrollType.Smooth); } } diff --git a/src/vs/editor/common/cursor/cursorTypeOperations.ts b/src/vs/editor/common/cursor/cursorTypeOperations.ts index e71f02a960e0c..ffa80cbb63ca7 100644 --- a/src/vs/editor/common/cursor/cursorTypeOperations.ts +++ b/src/vs/editor/common/cursor/cursorTypeOperations.ts @@ -648,7 +648,7 @@ export class TypeOperations { // Do not auto-close ' or " after a word character if (pair.open.length === 1 && (ch === '\'' || ch === '"') && autoCloseConfig !== 'always') { - const wordSeparators = getMapForWordSeparators(config.wordSeparators); + const wordSeparators = getMapForWordSeparators(config.wordSeparators, []); if (lineBefore.length > 0) { const characterBefore = lineBefore.charCodeAt(lineBefore.length - 1); if (wordSeparators.get(characterBefore) === WordCharacterClass.Regular) { diff --git a/src/vs/editor/common/cursor/cursorWordOperations.ts b/src/vs/editor/common/cursor/cursorWordOperations.ts index 8a3f98d37c237..b16172cc89a9f 100644 --- a/src/vs/editor/common/cursor/cursorWordOperations.ts +++ b/src/vs/editor/common/cursor/cursorWordOperations.ts @@ -8,7 +8,7 @@ import * as strings from 'vs/base/common/strings'; import { EditorAutoClosingEditStrategy, EditorAutoClosingStrategy } from 'vs/editor/common/config/editorOptions'; import { CursorConfiguration, ICursorSimpleModel, SelectionStartKind, SingleCursorState } from 'vs/editor/common/cursorCommon'; import { DeleteOperations } from 'vs/editor/common/cursor/cursorDeleteOperations'; -import { WordCharacterClass, WordCharacterClassifier, getMapForWordSeparators } from 'vs/editor/common/core/wordCharacterClassifier'; +import { WordCharacterClass, WordCharacterClassifier, IntlWordSegmentData, getMapForWordSeparators } from 'vs/editor/common/core/wordCharacterClassifier'; import { Position } from 'vs/editor/common/core/position'; import { Range } from 'vs/editor/common/core/range'; import { Selection } from 'vs/editor/common/core/selection'; @@ -67,6 +67,11 @@ export class WordOperations { return { start: start, end: end, wordType: wordType, nextCharClass: nextCharClass }; } + private static _createIntlWord(intlWord: IntlWordSegmentData, nextCharClass: WordCharacterClass): IFindWordResult { + // console.log('INTL WORD ==> ' + intlWord.index + ' => ' + intlWord.index + intlWord.segment.length + ':::: <<<' + intlWord.segment + '>>>'); + return { start: intlWord.index, end: intlWord.index + intlWord.segment.length, wordType: WordType.Regular, nextCharClass: nextCharClass }; + } + private static _findPreviousWordOnLine(wordSeparators: WordCharacterClassifier, model: ICursorSimpleModel, position: Position): IFindWordResult | null { const lineContent = model.getLineContent(position.lineNumber); return this._doFindPreviousWordOnLine(lineContent, wordSeparators, position); @@ -74,10 +79,17 @@ export class WordOperations { private static _doFindPreviousWordOnLine(lineContent: string, wordSeparators: WordCharacterClassifier, position: Position): IFindWordResult | null { let wordType = WordType.None; + + const previousIntlWord = wordSeparators.findPrevIntlWordBeforeOrAtOffset(lineContent, position.column - 2); + for (let chIndex = position.column - 2; chIndex >= 0; chIndex--) { const chCode = lineContent.charCodeAt(chIndex); const chClass = wordSeparators.get(chCode); + if (previousIntlWord && chIndex === previousIntlWord.index) { + return this._createIntlWord(previousIntlWord, chClass); + } + if (chClass === WordCharacterClass.Regular) { if (wordType === WordType.Separator) { return this._createWord(lineContent, wordType, chClass, chIndex + 1, this._findEndOfWord(lineContent, wordSeparators, wordType, chIndex + 1)); @@ -103,11 +115,18 @@ export class WordOperations { } private static _findEndOfWord(lineContent: string, wordSeparators: WordCharacterClassifier, wordType: WordType, startIndex: number): number { + + const nextIntlWord = wordSeparators.findNextIntlWordAtOrAfterOffset(lineContent, startIndex); + const len = lineContent.length; for (let chIndex = startIndex; chIndex < len; chIndex++) { const chCode = lineContent.charCodeAt(chIndex); const chClass = wordSeparators.get(chCode); + if (nextIntlWord && chIndex === nextIntlWord.index + nextIntlWord.segment.length) { + return chIndex; + } + if (chClass === WordCharacterClass.Whitespace) { return chIndex; } @@ -130,10 +149,16 @@ export class WordOperations { let wordType = WordType.None; const len = lineContent.length; + const nextIntlWord = wordSeparators.findNextIntlWordAtOrAfterOffset(lineContent, position.column - 1); + for (let chIndex = position.column - 1; chIndex < len; chIndex++) { const chCode = lineContent.charCodeAt(chIndex); const chClass = wordSeparators.get(chCode); + if (nextIntlWord && chIndex === nextIntlWord.index) { + return this._createIntlWord(nextIntlWord, chClass); + } + if (chClass === WordCharacterClass.Regular) { if (wordType === WordType.Separator) { return this._createWord(lineContent, wordType, chClass, this._findStartOfWord(lineContent, wordSeparators, wordType, chIndex - 1), chIndex); @@ -159,10 +184,17 @@ export class WordOperations { } private static _findStartOfWord(lineContent: string, wordSeparators: WordCharacterClassifier, wordType: WordType, startIndex: number): number { + + const previousIntlWord = wordSeparators.findPrevIntlWordBeforeOrAtOffset(lineContent, startIndex); + for (let chIndex = startIndex; chIndex >= 0; chIndex--) { const chCode = lineContent.charCodeAt(chIndex); const chClass = wordSeparators.get(chCode); + if (previousIntlWord && chIndex === previousIntlWord.index) { + return chIndex; + } + if (chClass === WordCharacterClass.Whitespace) { return chIndex + 1; } @@ -689,8 +721,8 @@ export class WordOperations { }; } - public static getWordAtPosition(model: ITextModel, _wordSeparators: string, position: Position): IWordAtPosition | null { - const wordSeparators = getMapForWordSeparators(_wordSeparators); + public static getWordAtPosition(model: ITextModel, _wordSeparators: string, _intlSegmenterLocales: string[], position: Position): IWordAtPosition | null { + const wordSeparators = getMapForWordSeparators(_wordSeparators, _intlSegmenterLocales); const prevWord = WordOperations._findPreviousWordOnLine(wordSeparators, model, position); if (prevWord && prevWord.wordType === WordType.Regular && prevWord.start <= position.column - 1 && position.column - 1 <= prevWord.end) { return WordOperations._createWordAtPosition(model, position.lineNumber, prevWord); @@ -703,7 +735,7 @@ export class WordOperations { } public static word(config: CursorConfiguration, model: ICursorSimpleModel, cursor: SingleCursorState, inSelectionMode: boolean, position: Position): SingleCursorState { - const wordSeparators = getMapForWordSeparators(config.wordSeparators); + const wordSeparators = getMapForWordSeparators(config.wordSeparators, config.wordSegmenterLocales); const prevWord = WordOperations._findPreviousWordOnLine(wordSeparators, model, position); const nextWord = WordOperations._findNextWordOnLine(wordSeparators, model, position); diff --git a/src/vs/editor/common/cursorCommon.ts b/src/vs/editor/common/cursorCommon.ts index 13b95ad129970..c5411aa853987 100644 --- a/src/vs/editor/common/cursorCommon.ts +++ b/src/vs/editor/common/cursorCommon.ts @@ -76,6 +76,7 @@ export class CursorConfiguration { public readonly surroundingPairs: CharacterMap; public readonly blockCommentStartToken: string | null; public readonly shouldAutoCloseBefore: { quote: (ch: string) => boolean; bracket: (ch: string) => boolean; comment: (ch: string) => boolean }; + public readonly wordSegmenterLocales: string[]; private readonly _languageId: string; private _electricChars: { [key: string]: boolean } | null; @@ -97,6 +98,7 @@ export class CursorConfiguration { || e.hasChanged(EditorOption.useTabStops) || e.hasChanged(EditorOption.fontInfo) || e.hasChanged(EditorOption.readOnly) + || e.hasChanged(EditorOption.wordSegmenterLocales) ); } @@ -134,6 +136,7 @@ export class CursorConfiguration { this.autoClosingOvertype = options.get(EditorOption.autoClosingOvertype); this.autoSurround = options.get(EditorOption.autoSurround); this.autoIndent = options.get(EditorOption.autoIndent); + this.wordSegmenterLocales = options.get(EditorOption.wordSegmenterLocales); this.surroundingPairs = {}; this._electricChars = null; diff --git a/src/vs/editor/common/diff/defaultLinesDiffComputer/defaultLinesDiffComputer.ts b/src/vs/editor/common/diff/defaultLinesDiffComputer/defaultLinesDiffComputer.ts index e2de212aa40cc..b7c34e0760486 100644 --- a/src/vs/editor/common/diff/defaultLinesDiffComputer/defaultLinesDiffComputer.ts +++ b/src/vs/editor/common/diff/defaultLinesDiffComputer/defaultLinesDiffComputer.ts @@ -13,11 +13,11 @@ import { DateTimeout, ITimeout, InfiniteTimeout, SequenceDiff } from 'vs/editor/ import { DynamicProgrammingDiffing } from 'vs/editor/common/diff/defaultLinesDiffComputer/algorithms/dynamicProgrammingDiffing'; import { MyersDiffAlgorithm } from 'vs/editor/common/diff/defaultLinesDiffComputer/algorithms/myersDiffAlgorithm'; import { computeMovedLines } from 'vs/editor/common/diff/defaultLinesDiffComputer/computeMovedLines'; -import { extendDiffsToEntireWordIfAppropriate, optimizeSequenceDiffs, removeVeryShortMatchingLinesBetweenDiffs, removeVeryShortMatchingTextBetweenLongDiffs, removeShortMatches } from 'vs/editor/common/diff/defaultLinesDiffComputer/heuristicSequenceOptimizations'; +import { extendDiffsToEntireWordIfAppropriate, optimizeSequenceDiffs, removeShortMatches, removeVeryShortMatchingLinesBetweenDiffs, removeVeryShortMatchingTextBetweenLongDiffs } from 'vs/editor/common/diff/defaultLinesDiffComputer/heuristicSequenceOptimizations'; +import { LineSequence } from 'vs/editor/common/diff/defaultLinesDiffComputer/lineSequence'; +import { LinesSliceCharSequence } from 'vs/editor/common/diff/defaultLinesDiffComputer/linesSliceCharSequence'; import { ILinesDiffComputer, ILinesDiffComputerOptions, LinesDiff, MovedText } from 'vs/editor/common/diff/linesDiffComputer'; import { DetailedLineRangeMapping, RangeMapping } from '../rangeMapping'; -import { LinesSliceCharSequence } from 'vs/editor/common/diff/defaultLinesDiffComputer/linesSliceCharSequence'; -import { LineSequence } from 'vs/editor/common/diff/defaultLinesDiffComputer/lineSequence'; export class DefaultLinesDiffComputer implements ILinesDiffComputer { private readonly dynamicProgrammingDiffing = new DynamicProgrammingDiffing(); @@ -256,8 +256,11 @@ export function lineRangeMappingFromRangeMappings(alignments: RangeMapping[], or } assertFn(() => { - if (!dontAssertStartLine) { - if (changes.length > 0 && changes[0].original.startLineNumber !== changes[0].modified.startLineNumber) { + if (!dontAssertStartLine && changes.length > 0) { + if (changes[0].modified.startLineNumber !== changes[0].original.startLineNumber) { + return false; + } + if (modifiedLines.length - changes[changes.length - 1].modified.endLineNumberExclusive !== originalLines.length - changes[changes.length - 1].original.endLineNumberExclusive) { return false; } } diff --git a/src/vs/editor/common/diff/defaultLinesDiffComputer/heuristicSequenceOptimizations.ts b/src/vs/editor/common/diff/defaultLinesDiffComputer/heuristicSequenceOptimizations.ts index 08efd57813629..fddfb1e0c6186 100644 --- a/src/vs/editor/common/diff/defaultLinesDiffComputer/heuristicSequenceOptimizations.ts +++ b/src/vs/editor/common/diff/defaultLinesDiffComputer/heuristicSequenceOptimizations.ts @@ -247,7 +247,7 @@ export function extendDiffsToEntireWordIfAppropriate(sequence1: LinesSliceCharSe while (equalMappings.length > 0) { const next = equalMappings[0]; - const intersects = next.seq1Range.intersects(w1) || next.seq2Range.intersects(w2); + const intersects = next.seq1Range.intersects(w.seq1Range) || next.seq2Range.intersects(w.seq2Range); if (!intersects) { break; } diff --git a/src/vs/editor/common/diff/rangeMapping.ts b/src/vs/editor/common/diff/rangeMapping.ts index d00f00616985d..810df11032f85 100644 --- a/src/vs/editor/common/diff/rangeMapping.ts +++ b/src/vs/editor/common/diff/rangeMapping.ts @@ -92,6 +92,12 @@ export class LineRangeMapping { * Also contains inner range mappings. */ export class DetailedLineRangeMapping extends LineRangeMapping { + public static fromRangeMappings(rangeMappings: RangeMapping[]): DetailedLineRangeMapping { + const originalRange = LineRange.join(rangeMappings.map(r => LineRange.fromRangeInclusive(r.originalRange))); + const modifiedRange = LineRange.join(rangeMappings.map(r => LineRange.fromRangeInclusive(r.modifiedRange))); + return new DetailedLineRangeMapping(originalRange, modifiedRange, rangeMappings); + } + /** * If inner changes have not been computed, this is set to undefined. * Otherwise, it represents the character-level diff in this line range. @@ -112,6 +118,12 @@ export class DetailedLineRangeMapping extends LineRangeMapping { public override flip(): DetailedLineRangeMapping { return new DetailedLineRangeMapping(this.modified, this.original, this.innerChanges?.map(c => c.flip())); } + + public withInnerChangesFromLineRanges(): DetailedLineRangeMapping { + return new DetailedLineRangeMapping(this.original, this.modified, [ + new RangeMapping(this.original.toExclusiveRange(), this.modified.toExclusiveRange()), + ]); + } } /** diff --git a/src/vs/editor/common/editorCommon.ts b/src/vs/editor/common/editorCommon.ts index 480c1250baed1..57b9cde626841 100644 --- a/src/vs/editor/common/editorCommon.ts +++ b/src/vs/editor/common/editorCommon.ts @@ -771,12 +771,3 @@ export interface CompositionTypePayload { positionDelta: number; } -/** - * @internal - */ -export interface PastePayload { - text: string; - pasteOnNewLine: boolean; - multicursorText: string[] | null; - mode: string | null; -} diff --git a/src/vs/editor/common/editorContextKeys.ts b/src/vs/editor/common/editorContextKeys.ts index 4cc1b78c7e47b..2311fbd3a859c 100644 --- a/src/vs/editor/common/editorContextKeys.ts +++ b/src/vs/editor/common/editorContextKeys.ts @@ -30,10 +30,16 @@ export namespace EditorContextKeys { export const inMultiDiffEditor = new RawContextKey('inMultiDiffEditor', false, nls.localize('inMultiDiffEditor', "Whether the context is a multi diff editor")); export const multiDiffEditorAllCollapsed = new RawContextKey('multiDiffEditorAllCollapsed', undefined, nls.localize('multiDiffEditorAllCollapsed', "Whether all files in multi diff editor are collapsed")); export const hasChanges = new RawContextKey('diffEditorHasChanges', false, nls.localize('diffEditorHasChanges', "Whether the diff editor has changes")); - export const comparingMovedCode = new RawContextKey('comparingMovedCode', false, nls.localize('comparingMovedCode', "Whether a moved code block is selected for comparison")); export const accessibleDiffViewerVisible = new RawContextKey('accessibleDiffViewerVisible', false, nls.localize('accessibleDiffViewerVisible', "Whether the accessible diff viewer is visible")); export const diffEditorRenderSideBySideInlineBreakpointReached = new RawContextKey('diffEditorRenderSideBySideInlineBreakpointReached', false, nls.localize('diffEditorRenderSideBySideInlineBreakpointReached', "Whether the diff editor render side by side inline breakpoint is reached")); + export const diffEditorInlineMode = new RawContextKey('diffEditorInlineMode', false, nls.localize('diffEditorInlineMode', "Whether inline mode is active")); + + export const diffEditorOriginalWritable = new RawContextKey('diffEditorOriginalWritable', false, nls.localize('diffEditorOriginalWritable', "Whether modified is writable in the diff editor")); + export const diffEditorModifiedWritable = new RawContextKey('diffEditorModifiedWritable', false, nls.localize('diffEditorModifiedWritable', "Whether modified is writable in the diff editor")); + export const diffEditorOriginalUri = new RawContextKey('diffEditorOriginalUri', '', nls.localize('diffEditorOriginalUri', "The uri of the original document")); + export const diffEditorModifiedUri = new RawContextKey('diffEditorModifiedUri', '', nls.localize('diffEditorModifiedUri', "The uri of the modified document")); + export const columnSelection = new RawContextKey('editorColumnSelection', false, nls.localize('editorColumnSelection', "Whether `editor.columnSelection` is enabled")); export const writable = readOnly.toNegated(); export const hasNonEmptySelection = new RawContextKey('editorHasSelection', false, nls.localize('editorHasSelection', "Whether the editor has text selected")); @@ -42,7 +48,7 @@ export namespace EditorContextKeys { export const hasSingleSelection = hasMultipleSelections.toNegated(); export const tabMovesFocus = new RawContextKey('editorTabMovesFocus', false, nls.localize('editorTabMovesFocus', "Whether `Tab` will move focus out of the editor")); export const tabDoesNotMoveFocus = tabMovesFocus.toNegated(); - export const isInWalkThroughSnippet = new RawContextKey('isInEmbeddedEditor', false, true); + export const isInEmbeddedEditor = new RawContextKey('isInEmbeddedEditor', false, true); export const canUndo = new RawContextKey('canUndo', false, true); export const canRedo = new RawContextKey('canRedo', false, true); diff --git a/src/vs/editor/common/languageSelector.ts b/src/vs/editor/common/languageSelector.ts index e657a75337638..db32360aa2e79 100644 --- a/src/vs/editor/common/languageSelector.ts +++ b/src/vs/editor/common/languageSelector.ts @@ -131,3 +131,14 @@ export function score(selector: LanguageSelector | undefined, candidateUri: URI, return 0; } } + + +export function targetsNotebooks(selector: LanguageSelector): boolean { + if (typeof selector === 'string') { + return false; + } else if (Array.isArray(selector)) { + return selector.some(targetsNotebooks); + } else { + return !!(selector).notebookType; + } +} diff --git a/src/vs/editor/common/languages.ts b/src/vs/editor/common/languages.ts index 43d17fb060973..2c3925b4d45c7 100644 --- a/src/vs/editor/common/languages.ts +++ b/src/vs/editor/common/languages.ts @@ -25,6 +25,7 @@ import { localize } from 'vs/nls'; import { ExtensionIdentifier } from 'vs/platform/extensions/common/extensions'; import { IMarkerData } from 'vs/platform/markers/common/markers'; import { LanguageFilter } from 'vs/editor/common/languageSelector'; +import { HierarchicalKind } from 'vs/base/common/hierarchicalKind'; /** * @internal @@ -547,6 +548,22 @@ export interface CompletionList { duration?: number; } +/** + * Info provided on partial acceptance. + */ +export interface PartialAcceptInfo { + kind: PartialAcceptTriggerKind; +} + +/** + * How a partial acceptance was triggered. + */ +export const enum PartialAcceptTriggerKind { + Word = 0, + Line = 1, + Suggest = 2, +} + /** * How a suggest provider was triggered. */ @@ -718,7 +735,7 @@ export interface InlineCompletionsProvider; - provideDocumentPasteEdits?(model: model.ITextModel, ranges: readonly IRange[], dataTransfer: IReadonlyVSDataTransfer, context: DocumentPasteContext, token: CancellationToken): Promise; + provideDocumentPasteEdits?(model: model.ITextModel, ranges: readonly IRange[], dataTransfer: IReadonlyVSDataTransfer, context: DocumentPasteContext, token: CancellationToken): Promise; + + resolveDocumentPasteEdit?(edit: DocumentPasteEdit, token: CancellationToken): Promise; } /** @@ -1652,6 +1686,19 @@ export interface RenameProvider { resolveRenameLocation?(model: model.ITextModel, position: Position, token: CancellationToken): ProviderResult; } +export enum NewSymbolNameTag { + AIGenerated = 1 +} + +export interface NewSymbolName { + readonly newSymbolName: string; + readonly tags?: readonly NewSymbolNameTag[]; +} + +export interface NewSymbolNamesProvider { + provideNewSymbolNames(model: model.ITextModel, range: IRange, token: CancellationToken): ProviderResult; +} + export interface Command { id: string; title: string; @@ -1697,6 +1744,14 @@ export interface CommentInfo { commentingRanges: CommentingRanges; } + +/** + * @internal + */ +export interface CommentingRangeResourceHint { + schemes: readonly string[]; +} + /** * @internal */ @@ -1719,6 +1774,14 @@ export enum CommentThreadState { Resolved = 1 } +/** + * @internal + */ +export enum CommentThreadApplicability { + Current = 0, + Outdated = 1 +} + /** * @internal */ @@ -1756,6 +1819,7 @@ export interface CommentThread { initialCollapsibleState?: CommentThreadCollapsibleState; onDidChangeInitialCollapsibleState: Event; state?: CommentThreadState; + applicability?: CommentThreadApplicability; canReply: boolean; input?: CommentInput; onDidChangeInput: Event; @@ -1846,7 +1910,7 @@ export interface PendingCommentThread { body: string; range: IRange | undefined; uri: URI; - owner: string; + uniqueOwner: string; isReply: boolean; } @@ -2077,13 +2141,14 @@ export enum ExternalUriOpenerPriority { /** * @internal */ -export type DropYieldTo = { readonly providerId: string } | { readonly mimeType: string }; +export type DropYieldTo = { readonly kind: HierarchicalKind } | { readonly mimeType: string }; /** * @internal */ export interface DocumentOnDropEdit { - readonly label: string; + readonly title: string; + readonly kind: HierarchicalKind | undefined; readonly handledMimeType?: string; readonly yieldTo?: readonly DropYieldTo[]; insertText: string | { readonly snippet: string }; @@ -2097,7 +2162,7 @@ export interface DocumentOnDropEditProvider { readonly id?: string; readonly dropMimeTypes?: readonly string[]; - provideDocumentOnDropEdits(model: model.ITextModel, position: IPosition, dataTransfer: IReadonlyVSDataTransfer, token: CancellationToken): ProviderResult; + provideDocumentOnDropEdits(model: model.ITextModel, position: IPosition, dataTransfer: IReadonlyVSDataTransfer, token: CancellationToken): ProviderResult; } export interface DocumentContextItem { @@ -2130,3 +2195,24 @@ export interface MappedEditsProvider { token: CancellationToken ): Promise; } + +export interface IInlineEdit { + text: string; + range: IRange; + accepted?: Command; + rejected?: Command; +} + +export interface IInlineEditContext { + triggerKind: InlineEditTriggerKind; +} + +export enum InlineEditTriggerKind { + Invoke = 0, + Automatic = 1, +} + +export interface InlineEditProvider { + provideInlineEdit(model: model.ITextModel, context: IInlineEditContext, token: CancellationToken): ProviderResult; + freeInlineEdit(edit: T): void; +} diff --git a/src/vs/editor/common/model.ts b/src/vs/editor/common/model.ts index ad0d31765ffcf..e21aa7d600c7a 100644 --- a/src/vs/editor/common/model.ts +++ b/src/vs/editor/common/model.ts @@ -70,11 +70,19 @@ export interface IGlyphMarginLanesModel { /** * Position in the minimap to render the decoration. */ -export enum MinimapPosition { +export const enum MinimapPosition { Inline = 1, Gutter = 2 } +/** + * Section header style. + */ +export const enum MinimapSectionHeaderStyle { + Normal = 1, + Underlined = 2 +} + export interface IDecorationOptions { /** * CSS color to render. @@ -119,6 +127,14 @@ export interface IModelDecorationMinimapOptions extends IDecorationOptions { * The position in the minimap. */ position: MinimapPosition; + /** + * If the decoration is for a section header, which header style. + */ + sectionHeaderStyle?: MinimapSectionHeaderStyle | null; + /** + * If the decoration is for a section header, the header text. + */ + sectionHeaderText?: string | null; } /** diff --git a/src/vs/editor/common/model/bracketPairsTextModelPart/bracketPairsTree/beforeEditPositionMapper.ts b/src/vs/editor/common/model/bracketPairsTextModelPart/bracketPairsTree/beforeEditPositionMapper.ts index 501aa07c39b25..1f95f84df48b1 100644 --- a/src/vs/editor/common/model/bracketPairsTextModelPart/bracketPairsTree/beforeEditPositionMapper.ts +++ b/src/vs/editor/common/model/bracketPairsTextModelPart/bracketPairsTree/beforeEditPositionMapper.ts @@ -4,7 +4,8 @@ *--------------------------------------------------------------------------------------------*/ import { Range } from 'vs/editor/common/core/range'; -import { Length, lengthAdd, lengthDiffNonNegative, lengthLessThanEqual, LengthObj, lengthOfString, lengthToObj, positionToLength, toLength } from './length'; +import { Length, lengthAdd, lengthDiffNonNegative, lengthLessThanEqual, lengthOfString, lengthToObj, positionToLength, toLength } from './length'; +import { TextLength } from 'vs/editor/common/core/textLength'; import { IModelContentChange } from 'vs/editor/common/textModelEvents'; export class TextEditInfo { @@ -73,7 +74,7 @@ export class BeforeEditPositionMapper { return lengthDiffNonNegative(offset, nextChangeOffset); } - private translateOldToCur(oldOffsetObj: LengthObj): Length { + private translateOldToCur(oldOffsetObj: TextLength): Length { if (oldOffsetObj.lineCount === this.deltaLineIdxInOld) { return toLength(oldOffsetObj.lineCount + this.deltaOldToNewLineCount, oldOffsetObj.columnCount + this.deltaOldToNewColumnCount); } else { @@ -126,9 +127,9 @@ class TextEditInfoCache { return new TextEditInfoCache(edit.startOffset, edit.endOffset, edit.newLength); } - public readonly endOffsetBeforeObj: LengthObj; - public readonly endOffsetAfterObj: LengthObj; - public readonly offsetObj: LengthObj; + public readonly endOffsetBeforeObj: TextLength; + public readonly endOffsetAfterObj: TextLength; + public readonly offsetObj: TextLength; constructor( startOffset: Length, diff --git a/src/vs/editor/common/model/bracketPairsTextModelPart/bracketPairsTree/length.ts b/src/vs/editor/common/model/bracketPairsTextModelPart/bracketPairsTree/length.ts index 40cb02556882f..d41a62233e59c 100644 --- a/src/vs/editor/common/model/bracketPairsTextModelPart/bracketPairsTree/length.ts +++ b/src/vs/editor/common/model/bracketPairsTextModelPart/bracketPairsTree/length.ts @@ -6,75 +6,7 @@ import { splitLines } from 'vs/base/common/strings'; import { Position } from 'vs/editor/common/core/position'; import { Range } from 'vs/editor/common/core/range'; - -/** - * Represents a non-negative length in terms of line and column count. - * Prefer using {@link Length} for performance reasons. -*/ -export class LengthObj { - public static zero = new LengthObj(0, 0); - - public static lengthDiffNonNegative(start: LengthObj, end: LengthObj): LengthObj { - if (end.isLessThan(start)) { - return LengthObj.zero; - } - if (start.lineCount === end.lineCount) { - return new LengthObj(0, end.columnCount - start.columnCount); - } else { - return new LengthObj(end.lineCount - start.lineCount, end.columnCount); - } - } - - constructor( - public readonly lineCount: number, - public readonly columnCount: number - ) { } - - public isZero() { - return this.lineCount === 0 && this.columnCount === 0; - } - - public toLength(): Length { - return toLength(this.lineCount, this.columnCount); - } - - public isLessThan(other: LengthObj): boolean { - if (this.lineCount !== other.lineCount) { - return this.lineCount < other.lineCount; - } - return this.columnCount < other.columnCount; - } - - public isGreaterThan(other: LengthObj): boolean { - if (this.lineCount !== other.lineCount) { - return this.lineCount > other.lineCount; - } - return this.columnCount > other.columnCount; - } - - public equals(other: LengthObj): boolean { - return this.lineCount === other.lineCount && this.columnCount === other.columnCount; - } - - public compare(other: LengthObj): number { - if (this.lineCount !== other.lineCount) { - return this.lineCount - other.lineCount; - } - return this.columnCount - other.columnCount; - } - - public add(other: LengthObj): LengthObj { - if (other.lineCount === 0) { - return new LengthObj(this.lineCount, this.columnCount + other.columnCount); - } else { - return new LengthObj(this.lineCount + other.lineCount, other.columnCount); - } - } - - toString() { - return `${this.lineCount},${this.columnCount}`; - } -} +import { TextLength } from 'vs/editor/common/core/textLength'; /** * The end must be greater than or equal to the start. @@ -117,11 +49,11 @@ export function toLength(lineCount: number, columnCount: number): Length { return (lineCount * factor + columnCount) as any as Length; } -export function lengthToObj(length: Length): LengthObj { +export function lengthToObj(length: Length): TextLength { const l = length as any as number; const lineCount = Math.floor(l / factor); const columnCount = l - lineCount * factor; - return new LengthObj(lineCount, columnCount); + return new TextLength(lineCount, columnCount); } export function lengthGetLineCount(length: Length): number { @@ -216,11 +148,11 @@ export function lengthsToRange(lengthStart: Length, lengthEnd: Length): Range { return new Range(lineCount + 1, colCount + 1, lineCount2 + 1, colCount2 + 1); } -export function lengthOfRange(range: Range): LengthObj { +export function lengthOfRange(range: Range): TextLength { if (range.startLineNumber === range.endLineNumber) { - return new LengthObj(0, range.endColumn - range.startColumn); + return new TextLength(0, range.endColumn - range.startColumn); } else { - return new LengthObj(range.endLineNumber - range.startLineNumber, range.endColumn - 1); + return new TextLength(range.endLineNumber - range.startLineNumber, range.endColumn - 1); } } @@ -235,9 +167,9 @@ export function lengthOfString(str: string): Length { return toLength(lines.length - 1, lines[lines.length - 1].length); } -export function lengthOfStringObj(str: string): LengthObj { +export function lengthOfStringObj(str: string): TextLength { const lines = splitLines(str); - return new LengthObj(lines.length - 1, lines[lines.length - 1].length); + return new TextLength(lines.length - 1, lines[lines.length - 1].length); } /** diff --git a/src/vs/editor/common/model/textModel.ts b/src/vs/editor/common/model/textModel.ts index 7117b8240af1d..626217e8bffd9 100644 --- a/src/vs/editor/common/model/textModel.ts +++ b/src/vs/editor/common/model/textModel.ts @@ -245,7 +245,7 @@ export class TextModel extends Disposable implements model.ITextModel, IDecorati private _buffer: model.ITextBuffer; private _bufferDisposable: IDisposable; private _options: model.TextModelResolvedOptions; - private _languageSelectionListener = this._register(new MutableDisposable()); + private readonly _languageSelectionListener = this._register(new MutableDisposable()); private _isDisposed: boolean; private __isDisposing: boolean; @@ -1256,7 +1256,7 @@ export class TextModel extends Disposable implements model.ITextModel, IDecorati ); } - private _validateEditOperations(rawOperations: model.IIdentifiedSingleEditOperation[]): model.ValidAnnotatedEditOperation[] { + private _validateEditOperations(rawOperations: readonly model.IIdentifiedSingleEditOperation[]): model.ValidAnnotatedEditOperation[] { const result: model.ValidAnnotatedEditOperation[] = []; for (let i = 0, len = rawOperations.length; i < len; i++) { result[i] = this._validateEditOperation(rawOperations[i]); @@ -1406,10 +1406,10 @@ export class TextModel extends Disposable implements model.ITextModel, IDecorati } } - public applyEdits(operations: model.IIdentifiedSingleEditOperation[]): void; - public applyEdits(operations: model.IIdentifiedSingleEditOperation[], computeUndoEdits: false): void; - public applyEdits(operations: model.IIdentifiedSingleEditOperation[], computeUndoEdits: true): model.IValidEditOperation[]; - public applyEdits(rawOperations: model.IIdentifiedSingleEditOperation[], computeUndoEdits: boolean = false): void | model.IValidEditOperation[] { + public applyEdits(operations: readonly model.IIdentifiedSingleEditOperation[]): void; + public applyEdits(operations: readonly model.IIdentifiedSingleEditOperation[], computeUndoEdits: false): void; + public applyEdits(operations: readonly model.IIdentifiedSingleEditOperation[], computeUndoEdits: true): model.IValidEditOperation[]; + public applyEdits(rawOperations: readonly model.IIdentifiedSingleEditOperation[], computeUndoEdits: boolean = false): void | model.IValidEditOperation[] { try { this._onDidChangeDecorations.beginDeferredEmit(); this._eventEmitter.beginDeferredEmit(); @@ -2219,12 +2219,15 @@ export class ModelDecorationGlyphMarginOptions { export class ModelDecorationMinimapOptions extends DecorationOptions { readonly position: model.MinimapPosition; + readonly sectionHeaderStyle: model.MinimapSectionHeaderStyle | null; + readonly sectionHeaderText: string | null; private _resolvedColor: Color | undefined; - constructor(options: model.IModelDecorationMinimapOptions) { super(options); this.position = options.position; + this.sectionHeaderStyle = options.sectionHeaderStyle ?? null; + this.sectionHeaderText = options.sectionHeaderText ?? null; } public getColor(theme: IColorTheme): Color | undefined { diff --git a/src/vs/editor/common/model/textModelSearch.ts b/src/vs/editor/common/model/textModelSearch.ts index 87c4bd8ecf7ae..81f6cbc5e2032 100644 --- a/src/vs/editor/common/model/textModelSearch.ts +++ b/src/vs/editor/common/model/textModelSearch.ts @@ -62,7 +62,7 @@ export class SearchParams { canUseSimpleSearch = this.matchCase; } - return new SearchData(regex, this.wordSeparators ? getMapForWordSeparators(this.wordSeparators) : null, canUseSimpleSearch ? this.searchString : null); + return new SearchData(regex, this.wordSeparators ? getMapForWordSeparators(this.wordSeparators, []) : null, canUseSimpleSearch ? this.searchString : null); } } diff --git a/src/vs/editor/common/model/textModelText.ts b/src/vs/editor/common/model/textModelText.ts new file mode 100644 index 0000000000000..0a603fa1ed20d --- /dev/null +++ b/src/vs/editor/common/model/textModelText.ts @@ -0,0 +1,25 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Range } from 'vs/editor/common/core/range'; +import { AbstractText } from 'vs/editor/common/core/textEdit'; +import { TextLength } from 'vs/editor/common/core/textLength'; +import { ITextModel } from 'vs/editor/common/model'; + +export class TextModelText extends AbstractText { + constructor(private readonly _textModel: ITextModel) { + super(); + } + + getValueOfRange(range: Range): string { + return this._textModel.getValueInRange(range); + } + + get length(): TextLength { + const lastLineNumber = this._textModel.getLineCount(); + const lastLineLen = this._textModel.getLineLength(lastLineNumber); + return new TextLength(lastLineNumber - 1, lastLineLen); + } +} diff --git a/src/vs/editor/common/model/textModelTokens.ts b/src/vs/editor/common/model/textModelTokens.ts index fdfb6dbe98fbf..fb1b7364d49ba 100644 --- a/src/vs/editor/common/model/textModelTokens.ts +++ b/src/vs/editor/common/model/textModelTokens.ts @@ -128,6 +128,11 @@ export class TokenizerWithStateStoreAndTextModel return lineTokens; } + public hasAccurateTokensForLine(lineNumber: number): boolean { + const firstInvalidLineNumber = this.store.getFirstInvalidEndStateLineNumberOrMax(); + return (lineNumber < firstInvalidLineNumber); + } + public isCheapToTokenize(lineNumber: number): boolean { const firstInvalidLineNumber = this.store.getFirstInvalidEndStateLineNumberOrMax(); if (lineNumber < firstInvalidLineNumber) { diff --git a/src/vs/editor/common/model/tokenizationTextModelPart.ts b/src/vs/editor/common/model/tokenizationTextModelPart.ts index 61490912068fc..804f63c6a2800 100644 --- a/src/vs/editor/common/model/tokenizationTextModelPart.ts +++ b/src/vs/editor/common/model/tokenizationTextModelPart.ts @@ -142,6 +142,11 @@ export class TokenizationTextModelPart extends TextModelPart implements ITokeniz this.grammarTokens.forceTokenization(lineNumber); } + public hasAccurateTokensForLine(lineNumber: number): boolean { + this.validateLineNumber(lineNumber); + return this.grammarTokens.hasAccurateTokensForLine(lineNumber); + } + public isCheapToTokenize(lineNumber: number): boolean { this.validateLineNumber(lineNumber); return this.grammarTokens.isCheapToTokenize(lineNumber); @@ -568,6 +573,13 @@ class GrammarTokens extends Disposable { this._defaultBackgroundTokenizer?.checkFinished(); } + public hasAccurateTokensForLine(lineNumber: number): boolean { + if (!this._tokenizer) { + return true; + } + return this._tokenizer.hasAccurateTokensForLine(lineNumber); + } + public isCheapToTokenize(lineNumber: number): boolean { if (!this._tokenizer) { return true; diff --git a/src/vs/editor/common/services/editorSimpleWorker.ts b/src/vs/editor/common/services/editorSimpleWorker.ts index f03e018cac091..195a870b0af19 100644 --- a/src/vs/editor/common/services/editorSimpleWorker.ts +++ b/src/vs/editor/common/services/editorSimpleWorker.ts @@ -28,6 +28,7 @@ import { createProxyObject, getAllMethodNames } from 'vs/base/common/objects'; import { IDocumentDiffProviderOptions } from 'vs/editor/common/diff/documentDiffProvider'; import { BugIndicatingError } from 'vs/base/common/errors'; import { IDocumentColorComputerTarget, computeDefaultDocumentColors } from 'vs/editor/common/languages/defaultDocumentColorsComputer'; +import { FindSectionHeaderOptions, SectionHeader, findSectionHeaders } from 'vs/editor/common/services/findSectionHeaders'; export interface IMirrorModel extends IMirrorTextModel { readonly uri: URI; @@ -401,6 +402,14 @@ export class EditorSimpleWorker implements IRequestHandler, IDisposable { return UnicodeTextModelHighlighter.computeUnicodeHighlights(model, options, range); } + public async findSectionHeaders(url: string, options: FindSectionHeaderOptions): Promise { + const model = this._getModel(url); + if (!model) { + return []; + } + return findSectionHeaders(model, options); + } + // ---- BEGIN diff -------------------------------------------------------------------------- public async computeDiff(originalUrl: string, modifiedUrl: string, options: IDocumentDiffProviderOptions, algorithm: DiffAlgorithmName): Promise { diff --git a/src/vs/editor/common/services/editorWorker.ts b/src/vs/editor/common/services/editorWorker.ts index 9e1cca8a460de..7e87024cafce8 100644 --- a/src/vs/editor/common/services/editorWorker.ts +++ b/src/vs/editor/common/services/editorWorker.ts @@ -11,6 +11,7 @@ import { IInplaceReplaceSupportResult, TextEdit } from 'vs/editor/common/languag import { UnicodeHighlighterOptions } from 'vs/editor/common/services/unicodeTextModelHighlighter'; import { createDecorator } from 'vs/platform/instantiation/common/instantiation'; import type { EditorSimpleWorker } from 'vs/editor/common/services/editorSimpleWorker'; +import { SectionHeader, FindSectionHeaderOptions } from 'vs/editor/common/services/findSectionHeaders'; export const IEditorWorkerService = createDecorator('editorWorkerService'); @@ -36,6 +37,8 @@ export interface IEditorWorkerService { canNavigateValueSet(resource: URI): boolean; navigateValueSet(resource: URI, range: IRange, up: boolean): Promise; + + findSectionHeaders(uri: URI, options: FindSectionHeaderOptions): Promise; } export interface IDiffComputationResult { diff --git a/src/vs/editor/common/services/findSectionHeaders.ts b/src/vs/editor/common/services/findSectionHeaders.ts new file mode 100644 index 0000000000000..08bd37097412c --- /dev/null +++ b/src/vs/editor/common/services/findSectionHeaders.ts @@ -0,0 +1,120 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IRange } from 'vs/editor/common/core/range'; +import { FoldingRules } from 'vs/editor/common/languages/languageConfiguration'; + +export interface ISectionHeaderFinderTarget { + getLineCount(): number; + getLineContent(lineNumber: number): string; +} + +export interface FindSectionHeaderOptions { + foldingRules?: FoldingRules; + findRegionSectionHeaders: boolean; + findMarkSectionHeaders: boolean; +} + +export interface SectionHeader { + /** + * The location of the header text in the text model. + */ + range: IRange; + /** + * The section header text. + */ + text: string; + /** + * Whether the section header includes a separator line. + */ + hasSeparatorLine: boolean; + /** + * This section should be omitted before rendering if it's not in a comment. + */ + shouldBeInComments: boolean; +} + +const markRegex = /\bMARK:\s*(.*)$/d; +const trimDashesRegex = /^-+|-+$/g; + +/** + * Find section headers in the model. + * + * @param model the text model to search in + * @param options options to search with + * @returns an array of section headers + */ +export function findSectionHeaders(model: ISectionHeaderFinderTarget, options: FindSectionHeaderOptions): SectionHeader[] { + let headers: SectionHeader[] = []; + if (options.findRegionSectionHeaders && options.foldingRules?.markers) { + const regionHeaders = collectRegionHeaders(model, options); + headers = headers.concat(regionHeaders); + } + if (options.findMarkSectionHeaders) { + const markHeaders = collectMarkHeaders(model); + headers = headers.concat(markHeaders); + } + return headers; +} + +function collectRegionHeaders(model: ISectionHeaderFinderTarget, options: FindSectionHeaderOptions): SectionHeader[] { + const regionHeaders: SectionHeader[] = []; + const endLineNumber = model.getLineCount(); + for (let lineNumber = 1; lineNumber <= endLineNumber; lineNumber++) { + const lineContent = model.getLineContent(lineNumber); + const match = lineContent.match(options.foldingRules!.markers!.start); + if (match) { + const range = { startLineNumber: lineNumber, startColumn: match[0].length + 1, endLineNumber: lineNumber, endColumn: lineContent.length + 1 }; + if (range.endColumn > range.startColumn) { + const sectionHeader = { + range, + ...getHeaderText(lineContent.substring(match[0].length)), + shouldBeInComments: false + }; + if (sectionHeader.text || sectionHeader.hasSeparatorLine) { + regionHeaders.push(sectionHeader); + } + } + } + } + return regionHeaders; +} + +function collectMarkHeaders(model: ISectionHeaderFinderTarget): SectionHeader[] { + const markHeaders: SectionHeader[] = []; + const endLineNumber = model.getLineCount(); + for (let lineNumber = 1; lineNumber <= endLineNumber; lineNumber++) { + const lineContent = model.getLineContent(lineNumber); + addMarkHeaderIfFound(lineContent, lineNumber, markHeaders); + } + return markHeaders; +} + +function addMarkHeaderIfFound(lineContent: string, lineNumber: number, sectionHeaders: SectionHeader[]) { + markRegex.lastIndex = 0; + const match = markRegex.exec(lineContent); + if (match) { + const column = match.indices![1][0] + 1; + const endColumn = match.indices![1][1] + 1; + const range = { startLineNumber: lineNumber, startColumn: column, endLineNumber: lineNumber, endColumn: endColumn }; + if (range.endColumn > range.startColumn) { + const sectionHeader = { + range, + ...getHeaderText(match[1]), + shouldBeInComments: true + }; + if (sectionHeader.text || sectionHeader.hasSeparatorLine) { + sectionHeaders.push(sectionHeader); + } + } + } +} + +function getHeaderText(text: string): { text: string; hasSeparatorLine: boolean } { + text = text.trim(); + const hasSeparatorLine = text.startsWith('-'); + text = text.replace(trimDashesRegex, ''); + return { text, hasSeparatorLine }; +} diff --git a/src/vs/editor/common/services/languageFeatureDebounce.ts b/src/vs/editor/common/services/languageFeatureDebounce.ts index e537b05bc8243..5f82d301a612a 100644 --- a/src/vs/editor/common/services/languageFeatureDebounce.ts +++ b/src/vs/editor/common/services/languageFeatureDebounce.ts @@ -134,7 +134,7 @@ export class LanguageFeatureDebounceService implements ILanguageFeatureDebounceS const key = `${IdentityHash.of(feature)},${min}${extra ? ',' + extra : ''}`; let info = this._data.get(key); if (!info) { - if (!this._isDev) { + if (this._isDev) { this._logService.debug(`[DEBOUNCE: ${name}] is disabled in developed mode`); info = new NullDebounceInformation(min * 1.5); } else { diff --git a/src/vs/editor/common/services/languageFeatures.ts b/src/vs/editor/common/services/languageFeatures.ts index 760e6e6e22adf..72889bd0b7e22 100644 --- a/src/vs/editor/common/services/languageFeatures.ts +++ b/src/vs/editor/common/services/languageFeatures.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { LanguageFeatureRegistry, NotebookInfoResolver } from 'vs/editor/common/languageFeatureRegistry'; -import { CodeActionProvider, CodeLensProvider, CompletionItemProvider, DeclarationProvider, DefinitionProvider, DocumentColorProvider, DocumentFormattingEditProvider, DocumentHighlightProvider, DocumentOnDropEditProvider, DocumentPasteEditProvider, DocumentRangeFormattingEditProvider, DocumentRangeSemanticTokensProvider, DocumentSemanticTokensProvider, DocumentSymbolProvider, EvaluatableExpressionProvider, FoldingRangeProvider, HoverProvider, ImplementationProvider, InlayHintsProvider, InlineCompletionsProvider, InlineValuesProvider, LinkedEditingRangeProvider, LinkProvider, MappedEditsProvider, MultiDocumentHighlightProvider, OnTypeFormattingEditProvider, ReferenceProvider, RenameProvider, SelectionRangeProvider, SignatureHelpProvider, TypeDefinitionProvider } from 'vs/editor/common/languages'; +import { CodeActionProvider, CodeLensProvider, CompletionItemProvider, DeclarationProvider, DefinitionProvider, DocumentColorProvider, DocumentFormattingEditProvider, DocumentHighlightProvider, DocumentOnDropEditProvider, DocumentPasteEditProvider, DocumentRangeFormattingEditProvider, DocumentRangeSemanticTokensProvider, DocumentSemanticTokensProvider, DocumentSymbolProvider, EvaluatableExpressionProvider, FoldingRangeProvider, HoverProvider, ImplementationProvider, InlayHintsProvider, InlineCompletionsProvider, InlineValuesProvider, LinkedEditingRangeProvider, LinkProvider, MappedEditsProvider, MultiDocumentHighlightProvider, NewSymbolNamesProvider, OnTypeFormattingEditProvider, ReferenceProvider, RenameProvider, SelectionRangeProvider, SignatureHelpProvider, TypeDefinitionProvider, InlineEditProvider } from 'vs/editor/common/languages'; import { createDecorator } from 'vs/platform/instantiation/common/instantiation'; export const ILanguageFeaturesService = createDecorator('ILanguageFeaturesService'); @@ -29,6 +29,8 @@ export interface ILanguageFeaturesService { readonly renameProvider: LanguageFeatureRegistry; + readonly newSymbolNamesProvider: LanguageFeatureRegistry; + readonly documentFormattingEditProvider: LanguageFeatureRegistry; readonly documentRangeFormattingEditProvider: LanguageFeatureRegistry; @@ -63,6 +65,8 @@ export interface ILanguageFeaturesService { readonly inlineCompletionsProvider: LanguageFeatureRegistry; + readonly inlineEditProvider: LanguageFeatureRegistry; + readonly completionProvider: LanguageFeatureRegistry; readonly linkedEditingRangeProvider: LanguageFeatureRegistry; diff --git a/src/vs/editor/common/services/languageFeaturesService.ts b/src/vs/editor/common/services/languageFeaturesService.ts index c6504614ce396..920a78d7402f2 100644 --- a/src/vs/editor/common/services/languageFeaturesService.ts +++ b/src/vs/editor/common/services/languageFeaturesService.ts @@ -5,7 +5,7 @@ import { URI } from 'vs/base/common/uri'; import { LanguageFeatureRegistry, NotebookInfo, NotebookInfoResolver } from 'vs/editor/common/languageFeatureRegistry'; -import { CodeActionProvider, CodeLensProvider, CompletionItemProvider, DocumentPasteEditProvider, DeclarationProvider, DefinitionProvider, DocumentColorProvider, DocumentFormattingEditProvider, MultiDocumentHighlightProvider, DocumentHighlightProvider, DocumentOnDropEditProvider, DocumentRangeFormattingEditProvider, DocumentRangeSemanticTokensProvider, DocumentSemanticTokensProvider, DocumentSymbolProvider, EvaluatableExpressionProvider, FoldingRangeProvider, HoverProvider, ImplementationProvider, InlayHintsProvider, InlineCompletionsProvider, InlineValuesProvider, LinkedEditingRangeProvider, LinkProvider, OnTypeFormattingEditProvider, ReferenceProvider, RenameProvider, SelectionRangeProvider, SignatureHelpProvider, TypeDefinitionProvider, MappedEditsProvider } from 'vs/editor/common/languages'; +import { CodeActionProvider, CodeLensProvider, CompletionItemProvider, DocumentPasteEditProvider, DeclarationProvider, DefinitionProvider, DocumentColorProvider, DocumentFormattingEditProvider, MultiDocumentHighlightProvider, DocumentHighlightProvider, DocumentOnDropEditProvider, DocumentRangeFormattingEditProvider, DocumentRangeSemanticTokensProvider, DocumentSemanticTokensProvider, DocumentSymbolProvider, EvaluatableExpressionProvider, FoldingRangeProvider, HoverProvider, ImplementationProvider, InlayHintsProvider, InlineCompletionsProvider, InlineValuesProvider, LinkedEditingRangeProvider, LinkProvider, OnTypeFormattingEditProvider, ReferenceProvider, RenameProvider, SelectionRangeProvider, SignatureHelpProvider, TypeDefinitionProvider, MappedEditsProvider, NewSymbolNamesProvider, InlineEditProvider } from 'vs/editor/common/languages'; import { ILanguageFeaturesService } from 'vs/editor/common/services/languageFeatures'; import { InstantiationType, registerSingleton } from 'vs/platform/instantiation/common/extensions'; @@ -15,6 +15,7 @@ export class LanguageFeaturesService implements ILanguageFeaturesService { readonly referenceProvider = new LanguageFeatureRegistry(this._score.bind(this)); readonly renameProvider = new LanguageFeatureRegistry(this._score.bind(this)); + readonly newSymbolNamesProvider = new LanguageFeatureRegistry(this._score.bind(this)); readonly codeActionProvider = new LanguageFeatureRegistry(this._score.bind(this)); readonly definitionProvider = new LanguageFeatureRegistry(this._score.bind(this)); readonly typeDefinitionProvider = new LanguageFeatureRegistry(this._score.bind(this)); @@ -35,6 +36,7 @@ export class LanguageFeaturesService implements ILanguageFeaturesService { readonly foldingRangeProvider = new LanguageFeatureRegistry(this._score.bind(this)); readonly linkProvider = new LanguageFeatureRegistry(this._score.bind(this)); readonly inlineCompletionsProvider = new LanguageFeatureRegistry(this._score.bind(this)); + readonly inlineEditProvider = new LanguageFeatureRegistry(this._score.bind(this)); readonly completionProvider = new LanguageFeatureRegistry(this._score.bind(this)); readonly linkedEditingRangeProvider = new LanguageFeatureRegistry(this._score.bind(this)); readonly inlineValuesProvider = new LanguageFeatureRegistry(this._score.bind(this)); diff --git a/src/vs/editor/common/standalone/standaloneEnums.ts b/src/vs/editor/common/standalone/standaloneEnums.ts index bbdeb0560ff61..d01db6500b346 100644 --- a/src/vs/editor/common/standalone/standaloneEnums.ts +++ b/src/vs/editor/common/standalone/standaloneEnums.ts @@ -236,91 +236,93 @@ export enum EditorOption { hover = 60, inDiffEditor = 61, inlineSuggest = 62, - letterSpacing = 63, - lightbulb = 64, - lineDecorationsWidth = 65, - lineHeight = 66, - lineNumbers = 67, - lineNumbersMinChars = 68, - linkedEditing = 69, - links = 70, - matchBrackets = 71, - minimap = 72, - mouseStyle = 73, - mouseWheelScrollSensitivity = 74, - mouseWheelZoom = 75, - multiCursorMergeOverlapping = 76, - multiCursorModifier = 77, - multiCursorPaste = 78, - multiCursorLimit = 79, - occurrencesHighlight = 80, - overviewRulerBorder = 81, - overviewRulerLanes = 82, - padding = 83, - pasteAs = 84, - parameterHints = 85, - peekWidgetDefaultFocus = 86, - definitionLinkOpensInPeek = 87, - quickSuggestions = 88, - quickSuggestionsDelay = 89, - readOnly = 90, - readOnlyMessage = 91, - renameOnType = 92, - renderControlCharacters = 93, - renderFinalNewline = 94, - renderLineHighlight = 95, - renderLineHighlightOnlyWhenFocus = 96, - renderValidationDecorations = 97, - renderWhitespace = 98, - revealHorizontalRightPadding = 99, - roundedSelection = 100, - rulers = 101, - scrollbar = 102, - scrollBeyondLastColumn = 103, - scrollBeyondLastLine = 104, - scrollPredominantAxis = 105, - selectionClipboard = 106, - selectionHighlight = 107, - selectOnLineNumbers = 108, - showFoldingControls = 109, - showUnused = 110, - snippetSuggestions = 111, - smartSelect = 112, - smoothScrolling = 113, - stickyScroll = 114, - stickyTabStops = 115, - stopRenderingLineAfter = 116, - suggest = 117, - suggestFontSize = 118, - suggestLineHeight = 119, - suggestOnTriggerCharacters = 120, - suggestSelection = 121, - tabCompletion = 122, - tabIndex = 123, - unicodeHighlighting = 124, - unusualLineTerminators = 125, - useShadowDOM = 126, - useTabStops = 127, - wordBreak = 128, - wordSeparators = 129, - wordWrap = 130, - wordWrapBreakAfterCharacters = 131, - wordWrapBreakBeforeCharacters = 132, - wordWrapColumn = 133, - wordWrapOverride1 = 134, - wordWrapOverride2 = 135, - wrappingIndent = 136, - wrappingStrategy = 137, - showDeprecated = 138, - inlayHints = 139, - editorClassName = 140, - pixelRatio = 141, - tabFocusMode = 142, - layoutInfo = 143, - wrappingInfo = 144, - defaultColorDecorators = 145, - colorDecoratorsActivatedOn = 146, - inlineCompletionsAccessibilityVerbose = 147 + inlineEdit = 63, + letterSpacing = 64, + lightbulb = 65, + lineDecorationsWidth = 66, + lineHeight = 67, + lineNumbers = 68, + lineNumbersMinChars = 69, + linkedEditing = 70, + links = 71, + matchBrackets = 72, + minimap = 73, + mouseStyle = 74, + mouseWheelScrollSensitivity = 75, + mouseWheelZoom = 76, + multiCursorMergeOverlapping = 77, + multiCursorModifier = 78, + multiCursorPaste = 79, + multiCursorLimit = 80, + occurrencesHighlight = 81, + overviewRulerBorder = 82, + overviewRulerLanes = 83, + padding = 84, + pasteAs = 85, + parameterHints = 86, + peekWidgetDefaultFocus = 87, + definitionLinkOpensInPeek = 88, + quickSuggestions = 89, + quickSuggestionsDelay = 90, + readOnly = 91, + readOnlyMessage = 92, + renameOnType = 93, + renderControlCharacters = 94, + renderFinalNewline = 95, + renderLineHighlight = 96, + renderLineHighlightOnlyWhenFocus = 97, + renderValidationDecorations = 98, + renderWhitespace = 99, + revealHorizontalRightPadding = 100, + roundedSelection = 101, + rulers = 102, + scrollbar = 103, + scrollBeyondLastColumn = 104, + scrollBeyondLastLine = 105, + scrollPredominantAxis = 106, + selectionClipboard = 107, + selectionHighlight = 108, + selectOnLineNumbers = 109, + showFoldingControls = 110, + showUnused = 111, + snippetSuggestions = 112, + smartSelect = 113, + smoothScrolling = 114, + stickyScroll = 115, + stickyTabStops = 116, + stopRenderingLineAfter = 117, + suggest = 118, + suggestFontSize = 119, + suggestLineHeight = 120, + suggestOnTriggerCharacters = 121, + suggestSelection = 122, + tabCompletion = 123, + tabIndex = 124, + unicodeHighlighting = 125, + unusualLineTerminators = 126, + useShadowDOM = 127, + useTabStops = 128, + wordBreak = 129, + wordSegmenterLocales = 130, + wordSeparators = 131, + wordWrap = 132, + wordWrapBreakAfterCharacters = 133, + wordWrapBreakBeforeCharacters = 134, + wordWrapColumn = 135, + wordWrapOverride1 = 136, + wordWrapOverride2 = 137, + wrappingIndent = 138, + wrappingStrategy = 139, + showDeprecated = 140, + inlayHints = 141, + editorClassName = 142, + pixelRatio = 143, + tabFocusMode = 144, + layoutInfo = 145, + wrappingInfo = 146, + defaultColorDecorators = 147, + colorDecoratorsActivatedOn = 148, + inlineCompletionsAccessibilityVerbose = 149 } /** @@ -415,6 +417,11 @@ export enum InlineCompletionTriggerKind { */ Explicit = 1 } + +export enum InlineEditTriggerKind { + Invoke = 0, + Automatic = 1 +} /** * Virtual Key Codes, the value does not hold any inherent meaning. * Inspired somewhat from https://msdn.microsoft.com/en-us/library/windows/desktop/dd375731(v=vs.85).aspx @@ -640,6 +647,14 @@ export enum MinimapPosition { Gutter = 2 } +/** + * Section header style. + */ +export enum MinimapSectionHeaderStyle { + Normal = 1, + Underlined = 2 +} + /** * Type of hit element with the mouse in the editor. */ @@ -702,6 +717,10 @@ export enum MouseTargetType { OUTSIDE_EDITOR = 13 } +export enum NewSymbolNameTag { + AIGenerated = 1 +} + /** * A positioning preference for rendering overlay widgets. */ @@ -730,6 +749,15 @@ export enum OverviewRulerLane { Full = 7 } +/** + * How a partial acceptance was triggered. + */ +export enum PartialAcceptTriggerKind { + Word = 0, + Line = 1, + Suggest = 2 +} + export enum PositionAffinity { /** * Prefers the left most position. diff --git a/src/vs/editor/common/standaloneStrings.ts b/src/vs/editor/common/standaloneStrings.ts index c6472a818999d..1bcfecfeb9793 100644 --- a/src/vs/editor/common/standaloneStrings.ts +++ b/src/vs/editor/common/standaloneStrings.ts @@ -25,8 +25,8 @@ export namespace AccessibilityHelpNLS { export const tabFocusModeOffMsg = nls.localize("tabFocusModeOffMsg", "Pressing Tab in the current editor will insert the tab character. Toggle this behavior {0}."); export const tabFocusModeOffMsgNoKb = nls.localize("tabFocusModeOffMsgNoKb", "Pressing Tab in the current editor will insert the tab character. The command {0} is currently not triggerable by a keybinding."); export const showAccessibilityHelpAction = nls.localize("showAccessibilityHelpAction", "Show Accessibility Help"); - export const listAudioCues = nls.localize("listAudioCuesCommand", "Run the command: List Audio Cues for an overview of all audio cues and their current status."); - export const listAlerts = nls.localize("listAlertsCommand", "Run the command: List Alerts for an overview of alerts and their current status."); + export const listSignalSounds = nls.localize("listSignalSoundsCommand", "Run the command: List Signal Sounds for an overview of all sounds and their current status."); + export const listAlerts = nls.localize("listAnnouncementsCommand", "Run the command: List Signal Announcements for an overview of announcements and their current status."); export const quickChat = nls.localize("quickChatCommand", "Toggle quick chat ({0}) to open or close a chat session."); export const quickChatNoKb = nls.localize("quickChatCommandNoKb", "Toggle quick chat is not currently triggerable by a keybinding."); export const startInlineChat = nls.localize("startInlineChatCommand", "Start inline chat ({0}) to create an in editor chat session."); diff --git a/src/vs/editor/common/tokenizationTextModelPart.ts b/src/vs/editor/common/tokenizationTextModelPart.ts index 8884b008d988c..07eb06f9fdc14 100644 --- a/src/vs/editor/common/tokenizationTextModelPart.ts +++ b/src/vs/editor/common/tokenizationTextModelPart.ts @@ -56,6 +56,12 @@ export interface ITokenizationTextModelPart { */ tokenizeIfCheap(lineNumber: number): void; + /** + * Check if tokenization information is accurate for `lineNumber`. + * @internal + */ + hasAccurateTokensForLine(lineNumber: number): boolean; + /** * Check if calling `forceTokenization` for this `lineNumber` will be cheap (time-wise). * This is based on a heuristic. diff --git a/src/vs/editor/common/viewLayout/linesLayout.ts b/src/vs/editor/common/viewLayout/linesLayout.ts index 7bb55aeef6eca..71bf9d5b956b8 100644 --- a/src/vs/editor/common/viewLayout/linesLayout.ts +++ b/src/vs/editor/common/viewLayout/linesLayout.ts @@ -727,7 +727,8 @@ export class LinesLayout { relativeVerticalOffset: linesOffsets, centeredLineNumber: centeredLineNumber, completelyVisibleStartLineNumber: completelyVisibleStartLineNumber, - completelyVisibleEndLineNumber: completelyVisibleEndLineNumber + completelyVisibleEndLineNumber: completelyVisibleEndLineNumber, + lineHeight: this._lineHeight, }; } diff --git a/src/vs/editor/common/viewLayout/viewLinesViewportData.ts b/src/vs/editor/common/viewLayout/viewLinesViewportData.ts index 8ddcfddb99d50..6e072c52648d7 100644 --- a/src/vs/editor/common/viewLayout/viewLinesViewportData.ts +++ b/src/vs/editor/common/viewLayout/viewLinesViewportData.ts @@ -46,6 +46,8 @@ export class ViewportData { private readonly _model: IViewModel; + public readonly lineHeight: number; + constructor( selections: Selection[], partialData: IPartialViewLinesViewportData, @@ -57,6 +59,7 @@ export class ViewportData { this.endLineNumber = partialData.endLineNumber | 0; this.relativeVerticalOffset = partialData.relativeVerticalOffset; this.bigNumbersDelta = partialData.bigNumbersDelta | 0; + this.lineHeight = partialData.lineHeight | 0; this.whitespaceViewportData = whitespaceViewportData; this._model = model; diff --git a/src/vs/editor/common/viewModel.ts b/src/vs/editor/common/viewModel.ts index 4f92417e89b8b..9356bb2ab01c7 100644 --- a/src/vs/editor/common/viewModel.ts +++ b/src/vs/editor/common/viewModel.ts @@ -87,6 +87,7 @@ export interface IViewModel extends ICursorSimpleModel { setCursorColumnSelectData(columnSelectData: IColumnSelectData): void; getPrevEditOperationType(): EditOperationType; setPrevEditOperationType(type: EditOperationType): void; + revealAllCursors(source: string | null | undefined, revealHorizontal: boolean, minimalReveal?: boolean): void; revealPrimaryCursor(source: string | null | undefined, revealHorizontal: boolean, minimalReveal?: boolean): void; revealTopMostCursor(source: string | null | undefined): void; revealBottomMostCursor(source: string | null | undefined): void; @@ -181,6 +182,11 @@ export interface IPartialViewLinesViewportData { * The last completely visible line number. */ readonly completelyVisibleEndLineNumber: number; + + /** + * The height of a line. + */ + readonly lineHeight: number; } export interface IViewWhitespaceViewportData { diff --git a/src/vs/editor/common/viewModel/viewModelImpl.ts b/src/vs/editor/common/viewModel/viewModelImpl.ts index bf152209d83bd..ea1f1fba62a24 100644 --- a/src/vs/editor/common/viewModel/viewModelImpl.ts +++ b/src/vs/editor/common/viewModel/viewModelImpl.ts @@ -1069,6 +1069,9 @@ export class ViewModel extends Disposable implements IViewModel { public executeCommands(commands: ICommand[], source?: string | null | undefined): void { this._executeCursorEdit(eventsCollector => this._cursor.executeCommands(eventsCollector, commands, source)); } + public revealAllCursors(source: string | null | undefined, revealHorizontal: boolean, minimalReveal: boolean = false): void { + this._withViewEventsCollector(eventsCollector => this._cursor.revealAll(eventsCollector, source, minimalReveal, viewEvents.VerticalRevealType.Simple, revealHorizontal, ScrollType.Smooth)); + } public revealPrimaryCursor(source: string | null | undefined, revealHorizontal: boolean, minimalReveal: boolean = false): void { this._withViewEventsCollector(eventsCollector => this._cursor.revealPrimary(eventsCollector, source, minimalReveal, viewEvents.VerticalRevealType.Simple, revealHorizontal, ScrollType.Smooth)); } diff --git a/src/vs/editor/contrib/codeAction/browser/codeAction.ts b/src/vs/editor/contrib/codeAction/browser/codeAction.ts index fb6ce190c81c2..c665dd778fe87 100644 --- a/src/vs/editor/contrib/codeAction/browser/codeAction.ts +++ b/src/vs/editor/contrib/codeAction/browser/codeAction.ts @@ -25,6 +25,7 @@ import { INotificationService } from 'vs/platform/notification/common/notificati import { IProgress, Progress } from 'vs/platform/progress/common/progress'; import { ITelemetryService } from 'vs/platform/telemetry/common/telemetry'; import { CodeActionFilter, CodeActionItem, CodeActionKind, CodeActionSet, CodeActionTrigger, CodeActionTriggerSource, filtersAction, mayIncludeActionsOfKind } from '../common/types'; +import { HierarchicalKind } from 'vs/base/common/hierarchicalKind'; export const codeActionCommandId = 'editor.action.codeAction'; export const quickFixCommandId = 'editor.action.quickFix'; @@ -79,7 +80,7 @@ class ManagedCodeActionSet extends Disposable implements CodeActionSet { } public get hasAutoFix() { - return this.validActions.some(({ action: fix }) => !!fix.kind && CodeActionKind.QuickFix.contains(new CodeActionKind(fix.kind)) && !!fix.isPreferred); + return this.validActions.some(({ action: fix }) => !!fix.kind && CodeActionKind.QuickFix.contains(new HierarchicalKind(fix.kind)) && !!fix.isPreferred); } public get hasAIFix() { @@ -178,7 +179,7 @@ function getCodeActionProviders( // We don't know what type of actions this provider will return. return true; } - return provider.providedCodeActionKinds.some(kind => mayIncludeActionsOfKind(filter, new CodeActionKind(kind))); + return provider.providedCodeActionKinds.some(kind => mayIncludeActionsOfKind(filter, new HierarchicalKind(kind))); }); } @@ -200,16 +201,16 @@ function* getAdditionalDocumentationForShowingActions( function getDocumentationFromProvider( provider: languages.CodeActionProvider, providedCodeActions: readonly languages.CodeAction[], - only?: CodeActionKind + only?: HierarchicalKind ): languages.Command | undefined { if (!provider.documentation) { return undefined; } - const documentation = provider.documentation.map(entry => ({ kind: new CodeActionKind(entry.kind), command: entry.command })); + const documentation = provider.documentation.map(entry => ({ kind: new HierarchicalKind(entry.kind), command: entry.command })); if (only) { - let currentBest: { readonly kind: CodeActionKind; readonly command: languages.Command } | undefined; + let currentBest: { readonly kind: HierarchicalKind; readonly command: languages.Command } | undefined; for (const entry of documentation) { if (entry.kind.contains(only)) { if (!currentBest) { @@ -234,7 +235,7 @@ function getDocumentationFromProvider( } for (const entry of documentation) { - if (entry.kind.contains(new CodeActionKind(action.kind))) { + if (entry.kind.contains(new HierarchicalKind(action.kind))) { return entry.command; } } @@ -347,7 +348,7 @@ CommandsRegistry.registerCommand('_executeCodeActionProvider', async function (a throw illegalArgument(); } - const include = typeof kind === 'string' ? new CodeActionKind(kind) : undefined; + const include = typeof kind === 'string' ? new HierarchicalKind(kind) : undefined; const codeActionSet = await getCodeActions( codeActionProvider, model, diff --git a/src/vs/editor/contrib/codeAction/browser/codeActionCommands.ts b/src/vs/editor/contrib/codeAction/browser/codeActionCommands.ts index 923c9181b7c17..ae15cf9bae0ad 100644 --- a/src/vs/editor/contrib/codeAction/browser/codeActionCommands.ts +++ b/src/vs/editor/contrib/codeAction/browser/codeActionCommands.ts @@ -3,6 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { HierarchicalKind } from 'vs/base/common/hierarchicalKind'; import { IJSONSchema } from 'vs/base/common/jsonSchema'; import { KeyCode, KeyMod } from 'vs/base/common/keyCodes'; import { escapeRegExpCharacters } from 'vs/base/common/strings'; @@ -17,7 +18,7 @@ import { CodeActionAutoApply, CodeActionCommandArgs, CodeActionFilter, CodeActio import { CodeActionController } from './codeActionController'; import { SUPPORTED_CODE_ACTIONS } from './codeActionModel'; -function contextKeyForSupportedActions(kind: CodeActionKind) { +function contextKeyForSupportedActions(kind: HierarchicalKind) { return ContextKeyExpr.regex( SUPPORTED_CODE_ACTIONS.keys()[0], new RegExp('(\\s|^)' + escapeRegExpCharacters(kind.value) + '\\b')); @@ -99,7 +100,7 @@ export class CodeActionCommand extends EditorCommand { public runEditorCommand(_accessor: ServicesAccessor, editor: ICodeEditor, userArgs: any) { const args = CodeActionCommandArgs.fromUser(userArgs, { - kind: CodeActionKind.Empty, + kind: HierarchicalKind.Empty, apply: CodeActionAutoApply.IfSingle, }); return triggerCodeActionsForEditorSelection(editor, @@ -164,7 +165,7 @@ export class RefactorAction extends EditorAction { ? nls.localize('editor.action.refactor.noneMessage.preferred', "No preferred refactorings available") : nls.localize('editor.action.refactor.noneMessage', "No refactorings available"), { - include: CodeActionKind.Refactor.contains(args.kind) ? args.kind : CodeActionKind.None, + include: CodeActionKind.Refactor.contains(args.kind) ? args.kind : HierarchicalKind.None, onlyIncludePreferredActions: args.preferred }, args.apply, CodeActionTriggerSource.Refactor); @@ -207,7 +208,7 @@ export class SourceAction extends EditorAction { ? nls.localize('editor.action.source.noneMessage.preferred', "No preferred source actions available") : nls.localize('editor.action.source.noneMessage', "No source actions available"), { - include: CodeActionKind.Source.contains(args.kind) ? args.kind : CodeActionKind.None, + include: CodeActionKind.Source.contains(args.kind) ? args.kind : HierarchicalKind.None, includeSourceActions: true, onlyIncludePreferredActions: args.preferred, }, diff --git a/src/vs/editor/contrib/codeAction/browser/codeActionController.ts b/src/vs/editor/contrib/codeAction/browser/codeActionController.ts index a29f4334e1fdc..784be6bebeef1 100644 --- a/src/vs/editor/contrib/codeAction/browser/codeActionController.ts +++ b/src/vs/editor/contrib/codeAction/browser/codeActionController.ts @@ -36,8 +36,9 @@ import { IEditorProgressService } from 'vs/platform/progress/common/progress'; import { editorFindMatchHighlight, editorFindMatchHighlightBorder } from 'vs/platform/theme/common/colorRegistry'; import { isHighContrast } from 'vs/platform/theme/common/theme'; import { registerThemingParticipant } from 'vs/platform/theme/common/themeService'; -import { CodeActionAutoApply, CodeActionFilter, CodeActionItem, CodeActionSet, CodeActionTrigger, CodeActionTriggerSource } from '../common/types'; -import { CodeActionModel, CodeActionsState } from './codeActionModel'; +import { CodeActionAutoApply, CodeActionFilter, CodeActionItem, CodeActionKind, CodeActionSet, CodeActionTrigger, CodeActionTriggerSource } from 'vs/editor/contrib/codeAction/common/types'; +import { CodeActionModel, CodeActionsState } from 'vs/editor/contrib/codeAction/browser/codeActionModel'; +import { HierarchicalKind } from 'vs/base/common/hierarchicalKind'; interface IActionShowOptions { @@ -112,12 +113,7 @@ export class CodeActionController extends Disposable implements IEditorContribut command.arguments[0] = { ...command.arguments[0], autoSend: false }; } } - try { - this._lightBulbWidget.value?.hide(); - await this._applyCodeAction(actionItem, false, false, ApplyCodeActionReason.FromAILightbulb); - } finally { - actions.dispose(); - } + await this._applyCodeAction(actionItem, false, false, ApplyCodeActionReason.FromAILightbulb); return; } await this.showCodeActionList(actions, at, { includeDisabledActions: false, fromLightbulb: true }); @@ -284,11 +280,7 @@ export class CodeActionController extends Disposable implements IEditorContribut const delegate: IActionListDelegate = { onSelect: async (action: CodeActionItem, preview?: boolean) => { - try { - await this._applyCodeAction(action, /* retrigger */ true, !!preview, ApplyCodeActionReason.FromCodeActions); - } finally { - actions.dispose(); - } + this._applyCodeAction(action, /* retrigger */ true, !!preview, ApplyCodeActionReason.FromCodeActions); this._actionWidgetService.hide(); currentDecorations.clear(); }, @@ -300,7 +292,22 @@ export class CodeActionController extends Disposable implements IEditorContribut if (token.isCancellationRequested) { return; } - return { canPreview: !!action.action.edit?.edits.length }; + + let canPreview = false; + const actionKind = action.action.kind; + + if (actionKind) { + const hierarchicalKind = new HierarchicalKind(actionKind); + const refactorKinds = [ + CodeActionKind.RefactorExtract, + CodeActionKind.RefactorInline, + CodeActionKind.RefactorRewrite + ]; + + canPreview = refactorKinds.some(refactorKind => refactorKind.contains(hierarchicalKind)); + } + + return { canPreview: canPreview || !!action.action.edit?.edits.length }; }, onFocus: (action: CodeActionItem | undefined) => { if (action && action.action) { diff --git a/src/vs/editor/contrib/codeAction/browser/codeActionKeybindingResolver.ts b/src/vs/editor/contrib/codeAction/browser/codeActionKeybindingResolver.ts index 373d3a5a7c68b..088fcfc9558a1 100644 --- a/src/vs/editor/contrib/codeAction/browser/codeActionKeybindingResolver.ts +++ b/src/vs/editor/contrib/codeAction/browser/codeActionKeybindingResolver.ts @@ -3,6 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { HierarchicalKind } from 'vs/base/common/hierarchicalKind'; import { ResolvedKeybinding } from 'vs/base/common/keybindings'; import { Lazy } from 'vs/base/common/lazy'; import { CodeAction } from 'vs/editor/common/languages'; @@ -11,7 +12,7 @@ import { CodeActionAutoApply, CodeActionCommandArgs, CodeActionKind } from 'vs/e import { IKeybindingService } from 'vs/platform/keybinding/common/keybinding'; interface ResolveCodeActionKeybinding { - readonly kind: CodeActionKind; + readonly kind: HierarchicalKind; readonly preferred: boolean; readonly resolvedKeybinding: ResolvedKeybinding; } @@ -46,7 +47,7 @@ export class CodeActionKeybindingResolver { return { resolvedKeybinding: item.resolvedKeybinding!, ...CodeActionCommandArgs.fromUser(commandArgs, { - kind: CodeActionKind.None, + kind: HierarchicalKind.None, apply: CodeActionAutoApply.Never }) }; @@ -68,7 +69,7 @@ export class CodeActionKeybindingResolver { if (!action.kind) { return undefined; } - const kind = new CodeActionKind(action.kind); + const kind = new HierarchicalKind(action.kind); return candidates .filter(candidate => candidate.kind.contains(kind)) diff --git a/src/vs/editor/contrib/codeAction/browser/codeActionMenu.ts b/src/vs/editor/contrib/codeAction/browser/codeActionMenu.ts index b8ebc3074a975..8763487cb1d01 100644 --- a/src/vs/editor/contrib/codeAction/browser/codeActionMenu.ts +++ b/src/vs/editor/contrib/codeAction/browser/codeActionMenu.ts @@ -12,14 +12,15 @@ import { CodeActionItem, CodeActionKind } from 'vs/editor/contrib/codeAction/com import 'vs/editor/contrib/symbolIcons/browser/symbolIcons'; // The codicon symbol colors are defined here and must be loaded to get colors import { localize } from 'vs/nls'; import { ActionListItemKind, IActionListItem } from 'vs/platform/actionWidget/browser/actionList'; +import { HierarchicalKind } from 'vs/base/common/hierarchicalKind'; interface ActionGroup { - readonly kind: CodeActionKind; + readonly kind: HierarchicalKind; readonly title: string; readonly icon?: ThemeIcon; } -const uncategorizedCodeActionGroup = Object.freeze({ kind: CodeActionKind.Empty, title: localize('codeAction.widget.id.more', 'More Actions...') }); +const uncategorizedCodeActionGroup = Object.freeze({ kind: HierarchicalKind.Empty, title: localize('codeAction.widget.id.more', 'More Actions...') }); const codeActionGroups = Object.freeze([ { kind: CodeActionKind.QuickFix, title: localize('codeAction.widget.id.quickfix', 'Quick Fix') }, @@ -54,7 +55,7 @@ export function toMenuItems( const menuEntries = codeActionGroups.map(group => ({ group, actions: [] as CodeActionItem[] })); for (const action of inputCodeActions) { - const kind = action.action.kind ? new CodeActionKind(action.action.kind) : CodeActionKind.None; + const kind = action.action.kind ? new HierarchicalKind(action.action.kind) : HierarchicalKind.None; for (const menuEntry of menuEntries) { if (menuEntry.group.kind.contains(kind)) { menuEntry.actions.push(action); diff --git a/src/vs/editor/contrib/codeAction/browser/codeActionModel.ts b/src/vs/editor/contrib/codeAction/browser/codeActionModel.ts index 547b6c4310848..9c7e0c73b76bd 100644 --- a/src/vs/editor/contrib/codeAction/browser/codeActionModel.ts +++ b/src/vs/editor/contrib/codeAction/browser/codeActionModel.ts @@ -15,12 +15,13 @@ import { Position } from 'vs/editor/common/core/position'; import { Selection } from 'vs/editor/common/core/selection'; import { LanguageFeatureRegistry } from 'vs/editor/common/languageFeatureRegistry'; import { CodeActionProvider, CodeActionTriggerType } from 'vs/editor/common/languages'; +import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; import { IContextKey, IContextKeyService, RawContextKey } from 'vs/platform/contextkey/common/contextkey'; import { IMarkerService } from 'vs/platform/markers/common/markers'; import { IEditorProgressService, Progress } from 'vs/platform/progress/common/progress'; import { CodeActionKind, CodeActionSet, CodeActionTrigger, CodeActionTriggerSource } from '../common/types'; import { getCodeActions } from './codeAction'; -import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; +import { HierarchicalKind } from 'vs/base/common/hierarchicalKind'; export const SUPPORTED_CODE_ACTIONS = new RawContextKey('supportedCodeAction', ''); @@ -235,7 +236,7 @@ export class CodeActionModel extends Disposable { } // Search for quickfixes in the curret code action set. - const foundQuickfix = codeActionSet.validActions?.some(action => action.action.kind ? CodeActionKind.QuickFix.contains(new CodeActionKind(action.action.kind)) : false); + const foundQuickfix = codeActionSet.validActions?.some(action => action.action.kind ? CodeActionKind.QuickFix.contains(new HierarchicalKind(action.action.kind)) : false); const allMarkers = this._markerService.read({ resource: model.uri }); if (foundQuickfix) { for (const action of codeActionSet.validActions) { @@ -320,7 +321,20 @@ export class CodeActionModel extends Disposable { if (trigger.trigger.type === CodeActionTriggerType.Invoke) { this._progressService?.showWhile(actions, 250); } - this.setState(new CodeActionsState.Triggered(trigger.trigger, startPosition, actions)); + const newState = new CodeActionsState.Triggered(trigger.trigger, startPosition, actions); + let isManualToAutoTransition = false; + if (this._state.type === CodeActionsState.Type.Triggered) { + // Check if the current state is manual and the new state is automatic + isManualToAutoTransition = this._state.trigger.type === CodeActionTriggerType.Invoke && + newState.type === CodeActionsState.Type.Triggered && + newState.trigger.type === CodeActionTriggerType.Auto && + this._state.position !== newState.position; + } + + // Do not trigger state if current state is manual and incoming state is automatic + if (!isManualToAutoTransition) { + this.setState(newState); + } }, undefined); this._codeActionOracle.value.trigger({ type: CodeActionTriggerType.Auto, triggerAction: CodeActionTriggerSource.Default }); } else { diff --git a/src/vs/editor/contrib/codeAction/browser/lightBulbWidget.ts b/src/vs/editor/contrib/codeAction/browser/lightBulbWidget.ts index 42564d79f6650..e76f5a8c229ec 100644 --- a/src/vs/editor/contrib/codeAction/browser/lightBulbWidget.ts +++ b/src/vs/editor/contrib/codeAction/browser/lightBulbWidget.ts @@ -68,7 +68,7 @@ export class LightBulbWidget extends Disposable implements IContentWidget { super(); this._domNode = dom.$('div.lightBulbWidget'); - + this._domNode.role = 'menu'; this._register(Gesture.ignoreTarget(this._domNode)); this._editor.addContentWidget(this); diff --git a/src/vs/editor/contrib/codeAction/common/types.ts b/src/vs/editor/contrib/codeAction/common/types.ts index 19a690e23dceb..febdfda4d3990 100644 --- a/src/vs/editor/contrib/codeAction/common/types.ts +++ b/src/vs/editor/contrib/codeAction/common/types.ts @@ -5,47 +5,27 @@ import { CancellationToken } from 'vs/base/common/cancellation'; import { onUnexpectedExternalError } from 'vs/base/common/errors'; +import { HierarchicalKind } from 'vs/base/common/hierarchicalKind'; import { Position } from 'vs/editor/common/core/position'; import * as languages from 'vs/editor/common/languages'; import { ActionSet } from 'vs/platform/actionWidget/common/actionWidget'; -export class CodeActionKind { - private static readonly sep = '.'; - - public static readonly None = new CodeActionKind('@@none@@'); // Special code action that contains nothing - public static readonly Empty = new CodeActionKind(''); - public static readonly QuickFix = new CodeActionKind('quickfix'); - public static readonly Refactor = new CodeActionKind('refactor'); - public static readonly RefactorExtract = CodeActionKind.Refactor.append('extract'); - public static readonly RefactorInline = CodeActionKind.Refactor.append('inline'); - public static readonly RefactorMove = CodeActionKind.Refactor.append('move'); - public static readonly RefactorRewrite = CodeActionKind.Refactor.append('rewrite'); - public static readonly Notebook = new CodeActionKind('notebook'); - public static readonly Source = new CodeActionKind('source'); - public static readonly SourceOrganizeImports = CodeActionKind.Source.append('organizeImports'); - public static readonly SourceFixAll = CodeActionKind.Source.append('fixAll'); - public static readonly SurroundWith = CodeActionKind.Refactor.append('surround'); +export const CodeActionKind = new class { + public readonly QuickFix = new HierarchicalKind('quickfix'); - constructor( - public readonly value: string - ) { } + public readonly Refactor = new HierarchicalKind('refactor'); + public readonly RefactorExtract = this.Refactor.append('extract'); + public readonly RefactorInline = this.Refactor.append('inline'); + public readonly RefactorMove = this.Refactor.append('move'); + public readonly RefactorRewrite = this.Refactor.append('rewrite'); - public equals(other: CodeActionKind): boolean { - return this.value === other.value; - } - - public contains(other: CodeActionKind): boolean { - return this.equals(other) || this.value === '' || other.value.startsWith(this.value + CodeActionKind.sep); - } + public readonly Notebook = new HierarchicalKind('notebook'); - public intersects(other: CodeActionKind): boolean { - return this.contains(other) || other.contains(this); - } - - public append(part: string): CodeActionKind { - return new CodeActionKind(this.value + CodeActionKind.sep + part); - } -} + public readonly Source = new HierarchicalKind('source'); + public readonly SourceOrganizeImports = this.Source.append('organizeImports'); + public readonly SourceFixAll = this.Source.append('fixAll'); + public readonly SurroundWith = this.Refactor.append('surround'); +}; export const enum CodeActionAutoApply { IfSingle = 'ifSingle', @@ -69,13 +49,13 @@ export enum CodeActionTriggerSource { } export interface CodeActionFilter { - readonly include?: CodeActionKind; - readonly excludes?: readonly CodeActionKind[]; + readonly include?: HierarchicalKind; + readonly excludes?: readonly HierarchicalKind[]; readonly includeSourceActions?: boolean; readonly onlyIncludePreferredActions?: boolean; } -export function mayIncludeActionsOfKind(filter: CodeActionFilter, providedKind: CodeActionKind): boolean { +export function mayIncludeActionsOfKind(filter: CodeActionFilter, providedKind: HierarchicalKind): boolean { // A provided kind may be a subset or superset of our filtered kind. if (filter.include && !filter.include.intersects(providedKind)) { return false; @@ -96,7 +76,7 @@ export function mayIncludeActionsOfKind(filter: CodeActionFilter, providedKind: } export function filtersAction(filter: CodeActionFilter, action: languages.CodeAction): boolean { - const actionKind = action.kind ? new CodeActionKind(action.kind) : undefined; + const actionKind = action.kind ? new HierarchicalKind(action.kind) : undefined; // Filter out actions by kind if (filter.include) { @@ -127,7 +107,7 @@ export function filtersAction(filter: CodeActionFilter, action: languages.CodeAc return true; } -function excludesAction(providedKind: CodeActionKind, exclude: CodeActionKind, include: CodeActionKind | undefined): boolean { +function excludesAction(providedKind: HierarchicalKind, exclude: HierarchicalKind, include: HierarchicalKind | undefined): boolean { if (!exclude.contains(providedKind)) { return false; } @@ -150,7 +130,7 @@ export interface CodeActionTrigger { } export class CodeActionCommandArgs { - public static fromUser(arg: any, defaults: { kind: CodeActionKind; apply: CodeActionAutoApply }): CodeActionCommandArgs { + public static fromUser(arg: any, defaults: { kind: HierarchicalKind; apply: CodeActionAutoApply }): CodeActionCommandArgs { if (!arg || typeof arg !== 'object') { return new CodeActionCommandArgs(defaults.kind, defaults.apply, false); } @@ -169,9 +149,9 @@ export class CodeActionCommandArgs { } } - private static getKindFromUser(arg: any, defaultKind: CodeActionKind) { + private static getKindFromUser(arg: any, defaultKind: HierarchicalKind) { return typeof arg.kind === 'string' - ? new CodeActionKind(arg.kind) + ? new HierarchicalKind(arg.kind) : defaultKind; } @@ -182,7 +162,7 @@ export class CodeActionCommandArgs { } private constructor( - public readonly kind: CodeActionKind, + public readonly kind: HierarchicalKind, public readonly apply: CodeActionAutoApply, public readonly preferred: boolean, ) { } diff --git a/src/vs/editor/contrib/codeAction/test/browser/codeAction.test.ts b/src/vs/editor/contrib/codeAction/test/browser/codeAction.test.ts index ac919bb619203..c1783a39f1188 100644 --- a/src/vs/editor/contrib/codeAction/test/browser/codeAction.test.ts +++ b/src/vs/editor/contrib/codeAction/test/browser/codeAction.test.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import * as assert from 'assert'; import { CancellationToken } from 'vs/base/common/cancellation'; +import { HierarchicalKind } from 'vs/base/common/hierarchicalKind'; import { DisposableStore } from 'vs/base/common/lifecycle'; import { URI } from 'vs/base/common/uri'; import { ensureNoDisposablesAreLeakedInTestSuite } from 'vs/base/test/common/utils'; @@ -148,20 +149,20 @@ suite('CodeAction', () => { disposables.add(registry.register('fooLang', provider)); { - const { validActions: actions } = disposables.add(await getCodeActions(registry, model, new Range(1, 1, 2, 1), { type: languages.CodeActionTriggerType.Auto, triggerAction: CodeActionTriggerSource.Default, filter: { include: new CodeActionKind('a') } }, Progress.None, CancellationToken.None)); + const { validActions: actions } = disposables.add(await getCodeActions(registry, model, new Range(1, 1, 2, 1), { type: languages.CodeActionTriggerType.Auto, triggerAction: CodeActionTriggerSource.Default, filter: { include: new HierarchicalKind('a') } }, Progress.None, CancellationToken.None)); assert.strictEqual(actions.length, 2); assert.strictEqual(actions[0].action.title, 'a'); assert.strictEqual(actions[1].action.title, 'a.b'); } { - const { validActions: actions } = disposables.add(await getCodeActions(registry, model, new Range(1, 1, 2, 1), { type: languages.CodeActionTriggerType.Auto, triggerAction: CodeActionTriggerSource.Default, filter: { include: new CodeActionKind('a.b') } }, Progress.None, CancellationToken.None)); + const { validActions: actions } = disposables.add(await getCodeActions(registry, model, new Range(1, 1, 2, 1), { type: languages.CodeActionTriggerType.Auto, triggerAction: CodeActionTriggerSource.Default, filter: { include: new HierarchicalKind('a.b') } }, Progress.None, CancellationToken.None)); assert.strictEqual(actions.length, 1); assert.strictEqual(actions[0].action.title, 'a.b'); } { - const { validActions: actions } = disposables.add(await getCodeActions(registry, model, new Range(1, 1, 2, 1), { type: languages.CodeActionTriggerType.Auto, triggerAction: CodeActionTriggerSource.Default, filter: { include: new CodeActionKind('a.b.c') } }, Progress.None, CancellationToken.None)); + const { validActions: actions } = disposables.add(await getCodeActions(registry, model, new Range(1, 1, 2, 1), { type: languages.CodeActionTriggerType.Auto, triggerAction: CodeActionTriggerSource.Default, filter: { include: new HierarchicalKind('a.b.c') } }, Progress.None, CancellationToken.None)); assert.strictEqual(actions.length, 0); } }); @@ -180,7 +181,7 @@ suite('CodeAction', () => { disposables.add(registry.register('fooLang', provider)); - const { validActions: actions } = disposables.add(await getCodeActions(registry, model, new Range(1, 1, 2, 1), { type: languages.CodeActionTriggerType.Auto, triggerAction: CodeActionTriggerSource.Default, filter: { include: new CodeActionKind('a') } }, Progress.None, CancellationToken.None)); + const { validActions: actions } = disposables.add(await getCodeActions(registry, model, new Range(1, 1, 2, 1), { type: languages.CodeActionTriggerType.Auto, triggerAction: CodeActionTriggerSource.Default, filter: { include: new HierarchicalKind('a') } }, Progress.None, CancellationToken.None)); assert.strictEqual(actions.length, 1); assert.strictEqual(actions[0].action.title, 'a'); }); diff --git a/src/vs/editor/contrib/colorPicker/browser/colorDetector.ts b/src/vs/editor/contrib/colorPicker/browser/colorDetector.ts index 23ff1742e84f7..150f23daa078a 100644 --- a/src/vs/editor/contrib/colorPicker/browser/colorDetector.ts +++ b/src/vs/editor/contrib/colorPicker/browser/colorDetector.ts @@ -195,7 +195,7 @@ export class ColorDetector extends Disposable implements IEditorContribution { }); } - private _colorDecorationClassRefs = this._register(new DisposableStore()); + private readonly _colorDecorationClassRefs = this._register(new DisposableStore()); private updateColorDecorators(colorData: IColorData[]): void { this._colorDecorationClassRefs.clear(); diff --git a/src/vs/editor/contrib/colorPicker/browser/standaloneColorPickerActions.ts b/src/vs/editor/contrib/colorPicker/browser/standaloneColorPickerActions.ts index a399e560ed412..26bf28c0c02cd 100644 --- a/src/vs/editor/contrib/colorPicker/browser/standaloneColorPickerActions.ts +++ b/src/vs/editor/contrib/colorPicker/browser/standaloneColorPickerActions.ts @@ -18,7 +18,7 @@ export class ShowOrFocusStandaloneColorPicker extends EditorAction2 { super({ id: 'editor.action.showOrFocusStandaloneColorPicker', title: { - ...localize2('showOrFocusStandaloneColorPicker', "Show or Focus Standalone Color Picker"), + ...localize2('showOrFocusStandaloneColorPicker', "Show or focus a standalone color picker which uses the default color provider. It displays hex/rgb/hsl colors."), mnemonicTitle: localize({ key: 'mishowOrFocusStandaloneColorPicker', comment: ['&& denotes a mnemonic'] }, "&&Show or Focus Standalone Color Picker"), }, precondition: undefined, @@ -47,6 +47,9 @@ class HideStandaloneColorPicker extends EditorAction { kbOpts: { primary: KeyCode.Escape, weight: KeybindingWeight.EditorContrib + }, + metadata: { + description: localize2('hideColorPickerDescription', "Hide the standalone color picker."), } }); } @@ -70,6 +73,9 @@ class InsertColorWithStandaloneColorPicker extends EditorAction { kbOpts: { primary: KeyCode.Enter, weight: KeybindingWeight.EditorContrib + }, + metadata: { + description: localize2('insertColorWithStandaloneColorPickerDescription', "Insert hex/rgb/hsl colors with the focused standalone color picker."), } }); } diff --git a/src/vs/editor/contrib/comment/browser/comment.ts b/src/vs/editor/contrib/comment/browser/comment.ts index 538d6a3ca6c87..2a2a4c283601f 100644 --- a/src/vs/editor/contrib/comment/browser/comment.ts +++ b/src/vs/editor/contrib/comment/browser/comment.ts @@ -63,7 +63,7 @@ abstract class CommentLineAction extends EditorAction { commands.push(new LineCommentCommand( languageConfigurationService, selection.selection, - modelOptions.tabSize, + modelOptions.indentSize, this._type, commentsOptions.insertSpace, commentsOptions.ignoreEmptyLines, diff --git a/src/vs/editor/contrib/comment/browser/lineCommentCommand.ts b/src/vs/editor/contrib/comment/browser/lineCommentCommand.ts index ac498e8a7e45f..a5e19fd79e1a1 100644 --- a/src/vs/editor/contrib/comment/browser/lineCommentCommand.ts +++ b/src/vs/editor/contrib/comment/browser/lineCommentCommand.ts @@ -50,7 +50,7 @@ export const enum Type { export class LineCommentCommand implements ICommand { private readonly _selection: Selection; - private readonly _tabSize: number; + private readonly _indentSize: number; private readonly _type: Type; private readonly _insertSpace: boolean; private readonly _ignoreEmptyLines: boolean; @@ -62,14 +62,14 @@ export class LineCommentCommand implements ICommand { constructor( private readonly languageConfigurationService: ILanguageConfigurationService, selection: Selection, - tabSize: number, + indentSize: number, type: Type, insertSpace: boolean, ignoreEmptyLines: boolean, ignoreFirstLine?: boolean, ) { this._selection = selection; - this._tabSize = tabSize; + this._indentSize = indentSize; this._type = type; this._insertSpace = insertSpace; this._selectionId = null; @@ -209,7 +209,7 @@ export class LineCommentCommand implements ICommand { if (data.shouldRemoveComments) { ops = LineCommentCommand._createRemoveLineCommentsOperations(data.lines, s.startLineNumber); } else { - LineCommentCommand._normalizeInsertionPoint(model, data.lines, s.startLineNumber, this._tabSize); + LineCommentCommand._normalizeInsertionPoint(model, data.lines, s.startLineNumber, this._indentSize); ops = this._createAddLineCommentsOperations(data.lines, s.startLineNumber); } @@ -420,9 +420,9 @@ export class LineCommentCommand implements ICommand { return res; } - private static nextVisibleColumn(currentVisibleColumn: number, tabSize: number, isTab: boolean, columnSize: number): number { + private static nextVisibleColumn(currentVisibleColumn: number, indentSize: number, isTab: boolean, columnSize: number): number { if (isTab) { - return currentVisibleColumn + (tabSize - (currentVisibleColumn % tabSize)); + return currentVisibleColumn + (indentSize - (currentVisibleColumn % indentSize)); } return currentVisibleColumn + columnSize; } @@ -430,7 +430,7 @@ export class LineCommentCommand implements ICommand { /** * Adjust insertion points to have them vertically aligned in the add line comment case */ - public static _normalizeInsertionPoint(model: ISimpleModel, lines: IInsertionPoint[], startLineNumber: number, tabSize: number): void { + public static _normalizeInsertionPoint(model: ISimpleModel, lines: IInsertionPoint[], startLineNumber: number, indentSize: number): void { let minVisibleColumn = Constants.MAX_SAFE_SMALL_INTEGER; let j: number; let lenJ: number; @@ -444,7 +444,7 @@ export class LineCommentCommand implements ICommand { let currentVisibleColumn = 0; for (let j = 0, lenJ = lines[i].commentStrOffset; currentVisibleColumn < minVisibleColumn && j < lenJ; j++) { - currentVisibleColumn = LineCommentCommand.nextVisibleColumn(currentVisibleColumn, tabSize, lineContent.charCodeAt(j) === CharCode.Tab, 1); + currentVisibleColumn = LineCommentCommand.nextVisibleColumn(currentVisibleColumn, indentSize, lineContent.charCodeAt(j) === CharCode.Tab, 1); } if (currentVisibleColumn < minVisibleColumn) { @@ -452,7 +452,7 @@ export class LineCommentCommand implements ICommand { } } - minVisibleColumn = Math.floor(minVisibleColumn / tabSize) * tabSize; + minVisibleColumn = Math.floor(minVisibleColumn / indentSize) * indentSize; for (let i = 0, len = lines.length; i < len; i++) { if (lines[i].ignore) { @@ -463,7 +463,7 @@ export class LineCommentCommand implements ICommand { let currentVisibleColumn = 0; for (j = 0, lenJ = lines[i].commentStrOffset; currentVisibleColumn < minVisibleColumn && j < lenJ; j++) { - currentVisibleColumn = LineCommentCommand.nextVisibleColumn(currentVisibleColumn, tabSize, lineContent.charCodeAt(j) === CharCode.Tab, 1); + currentVisibleColumn = LineCommentCommand.nextVisibleColumn(currentVisibleColumn, indentSize, lineContent.charCodeAt(j) === CharCode.Tab, 1); } if (currentVisibleColumn > minVisibleColumn) { diff --git a/src/vs/editor/contrib/dnd/browser/dnd.ts b/src/vs/editor/contrib/dnd/browser/dnd.ts index d4576e66c8d9a..9e355410d4842 100644 --- a/src/vs/editor/contrib/dnd/browser/dnd.ts +++ b/src/vs/editor/contrib/dnd/browser/dnd.ts @@ -11,7 +11,7 @@ import { isMacintosh } from 'vs/base/common/platform'; import 'vs/css!./dnd'; import { ICodeEditor, IEditorMouseEvent, IMouseTarget, IPartialEditorMouseEvent, MouseTargetType } from 'vs/editor/browser/editorBrowser'; import { EditorContributionInstantiation, registerEditorContribution } from 'vs/editor/browser/editorExtensions'; -import { CodeEditorWidget } from 'vs/editor/browser/widget/codeEditorWidget'; +import { CodeEditorWidget } from 'vs/editor/browser/widget/codeEditor/codeEditorWidget'; import { EditorOption } from 'vs/editor/common/config/editorOptions'; import { CursorChangeReason } from 'vs/editor/common/cursorEvents'; import { Position } from 'vs/editor/common/core/position'; diff --git a/src/vs/editor/contrib/dropOrPasteInto/browser/copyPasteContribution.ts b/src/vs/editor/contrib/dropOrPasteInto/browser/copyPasteContribution.ts index fbdd84d88d475..20ed163b4b256 100644 --- a/src/vs/editor/contrib/dropOrPasteInto/browser/copyPasteContribution.ts +++ b/src/vs/editor/contrib/dropOrPasteInto/browser/copyPasteContribution.ts @@ -3,18 +3,19 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { HierarchicalKind } from 'vs/base/common/hierarchicalKind'; +import { IJSONSchema, SchemaToType } from 'vs/base/common/jsonSchema'; import { KeyCode, KeyMod } from 'vs/base/common/keyCodes'; import { ICodeEditor } from 'vs/editor/browser/editorBrowser'; import { EditorAction, EditorCommand, EditorContributionInstantiation, ServicesAccessor, registerEditorAction, registerEditorCommand, registerEditorContribution } from 'vs/editor/browser/editorExtensions'; import { EditorContextKeys } from 'vs/editor/common/editorContextKeys'; import { registerEditorFeature } from 'vs/editor/common/editorFeatures'; import { CopyPasteController, changePasteTypeCommandId, pasteWidgetVisibleCtx } from 'vs/editor/contrib/dropOrPasteInto/browser/copyPasteController'; -import { DefaultPasteProvidersFeature } from 'vs/editor/contrib/dropOrPasteInto/browser/defaultProviders'; +import { DefaultPasteProvidersFeature, DefaultTextPasteOrDropEditProvider } from 'vs/editor/contrib/dropOrPasteInto/browser/defaultProviders'; import * as nls from 'vs/nls'; import { KeybindingWeight } from 'vs/platform/keybinding/common/keybindingsRegistry'; registerEditorContribution(CopyPasteController.ID, CopyPasteController, EditorContributionInstantiation.Eager); // eager because it listens to events on the container dom node of the editor - registerEditorFeature(DefaultPasteProvidersFeature); registerEditorCommand(new class extends EditorCommand { @@ -29,12 +30,40 @@ registerEditorCommand(new class extends EditorCommand { }); } - public override runEditorCommand(_accessor: ServicesAccessor | null, editor: ICodeEditor, _args: any) { + public override runEditorCommand(_accessor: ServicesAccessor | null, editor: ICodeEditor) { return CopyPasteController.get(editor)?.changePasteType(); } }); -registerEditorAction(class extends EditorAction { +registerEditorCommand(new class extends EditorCommand { + constructor() { + super({ + id: 'editor.hidePasteWidget', + precondition: pasteWidgetVisibleCtx, + kbOpts: { + weight: KeybindingWeight.EditorContrib, + primary: KeyCode.Escape, + } + }); + } + + public override runEditorCommand(_accessor: ServicesAccessor | null, editor: ICodeEditor) { + CopyPasteController.get(editor)?.clearWidgets(); + } +}); + + +registerEditorAction(class PasteAsAction extends EditorAction { + private static readonly argsSchema = { + type: 'object', + properties: { + kind: { + type: 'string', + description: nls.localize('pasteAs.kind', "The kind of the paste edit to try applying. If not provided or there are multiple edits for this kind, the editor will show a picker."), + } + }, + } as const satisfies IJSONSchema; + constructor() { super({ id: 'editor.action.pasteAs', @@ -45,23 +74,20 @@ registerEditorAction(class extends EditorAction { description: 'Paste as', args: [{ name: 'args', - schema: { - type: 'object', - properties: { - 'id': { - type: 'string', - description: nls.localize('pasteAs.id', "The id of the paste edit to try applying. If not provided, the editor will show a picker."), - } - }, - } + schema: PasteAsAction.argsSchema }] } }); } - public override run(_accessor: ServicesAccessor, editor: ICodeEditor, args: any) { - const id = typeof args?.id === 'string' ? args.id : undefined; - return CopyPasteController.get(editor)?.pasteAs(id); + public override run(_accessor: ServicesAccessor, editor: ICodeEditor, args?: SchemaToType) { + let kind = typeof args?.kind === 'string' ? args.kind : undefined; + if (!kind && args) { + // Support old id property + // TODO: remove this in the future + kind = typeof (args as any).id === 'string' ? (args as any).id : undefined; + } + return CopyPasteController.get(editor)?.pasteAs(kind ? new HierarchicalKind(kind) : undefined); } }); @@ -75,7 +101,7 @@ registerEditorAction(class extends EditorAction { }); } - public override run(_accessor: ServicesAccessor, editor: ICodeEditor, args: any) { - return CopyPasteController.get(editor)?.pasteAs('text'); + public override run(_accessor: ServicesAccessor, editor: ICodeEditor) { + return CopyPasteController.get(editor)?.pasteAs({ providerId: DefaultTextPasteOrDropEditProvider.id }); } }); diff --git a/src/vs/editor/contrib/dropOrPasteInto/browser/copyPasteController.ts b/src/vs/editor/contrib/dropOrPasteInto/browser/copyPasteController.ts index 10654d61a52b6..87cddb00a8d7b 100644 --- a/src/vs/editor/contrib/dropOrPasteInto/browser/copyPasteController.ts +++ b/src/vs/editor/contrib/dropOrPasteInto/browser/copyPasteController.ts @@ -8,24 +8,27 @@ import { coalesce } from 'vs/base/common/arrays'; import { CancelablePromise, createCancelablePromise, raceCancellation } from 'vs/base/common/async'; import { CancellationToken } from 'vs/base/common/cancellation'; import { UriList, VSDataTransfer, createStringDataTransferItem, matchesMimeType } from 'vs/base/common/dataTransfer'; +import { HierarchicalKind } from 'vs/base/common/hierarchicalKind'; import { Disposable } from 'vs/base/common/lifecycle'; import { Mimes } from 'vs/base/common/mime'; import * as platform from 'vs/base/common/platform'; import { generateUuid } from 'vs/base/common/uuid'; import { ClipboardEventUtils } from 'vs/editor/browser/controller/textAreaInput'; import { toExternalVSDataTransfer, toVSDataTransfer } from 'vs/editor/browser/dnd'; -import { ICodeEditor } from 'vs/editor/browser/editorBrowser'; +import { ICodeEditor, PastePayload } from 'vs/editor/browser/editorBrowser'; import { IBulkEditService } from 'vs/editor/browser/services/bulkEditService'; import { EditorOption } from 'vs/editor/common/config/editorOptions'; import { IRange, Range } from 'vs/editor/common/core/range'; import { Selection } from 'vs/editor/common/core/selection'; -import { Handler, IEditorContribution, PastePayload } from 'vs/editor/common/editorCommon'; -import { DocumentPasteContext, DocumentPasteEdit, DocumentPasteEditProvider } from 'vs/editor/common/languages'; +import { Handler, IEditorContribution } from 'vs/editor/common/editorCommon'; +import { DocumentPasteContext, DocumentPasteEdit, DocumentPasteEditProvider, DocumentPasteTriggerKind } from 'vs/editor/common/languages'; import { ITextModel } from 'vs/editor/common/model'; import { ILanguageFeaturesService } from 'vs/editor/common/services/languageFeatures'; +import { DefaultTextPasteOrDropEditProvider } from 'vs/editor/contrib/dropOrPasteInto/browser/defaultProviders'; import { createCombinedWorkspaceEdit, sortEditsByYieldTo } from 'vs/editor/contrib/dropOrPasteInto/browser/edit'; import { CodeEditorStateFlag, EditorStateCancellationTokenSource } from 'vs/editor/contrib/editorState/browser/editorState'; import { InlineProgressManager } from 'vs/editor/contrib/inlineProgress/browser/inlineProgress'; +import { MessageController } from 'vs/editor/contrib/message/browser/messageController'; import { localize } from 'vs/nls'; import { IClipboardService } from 'vs/platform/clipboard/common/clipboardService'; import { RawContextKey } from 'vs/platform/contextkey/common/contextkey'; @@ -33,7 +36,6 @@ import { IInstantiationService } from 'vs/platform/instantiation/common/instanti import { IProgressService, ProgressLocation } from 'vs/platform/progress/common/progress'; import { IQuickInputService, IQuickPickItem } from 'vs/platform/quickinput/common/quickInput'; import { PostEditWidgetManager } from './postEditWidget'; -import { MessageController } from 'vs/editor/contrib/message/browser/messageController'; export const changePasteTypeCommandId = 'editor.changePasteType'; @@ -48,6 +50,14 @@ interface CopyMetadata { readonly defaultPastePayload: Omit; } +type PasteEditWithProvider = DocumentPasteEdit & { + provider: DocumentPasteEditProvider; +}; + +type PastePreference = + | HierarchicalKind + | { providerId: string }; + export class CopyPasteController extends Disposable implements IEditorContribution { public static readonly ID = 'editor.contrib.copyPasteActionController'; @@ -56,18 +66,25 @@ export class CopyPasteController extends Disposable implements IEditorContributi return editor.getContribution(CopyPasteController.ID); } - private readonly _editor: ICodeEditor; - - private _currentCopyOperation?: { + /** + * Global tracking the last copy operation. + * + * This is shared across all editors so that you can copy and paste between groups. + * + * TODO: figure out how to make this work with multiple windows + */ + private static _currentCopyOperation?: { readonly handle: string; readonly dataTransferPromise: CancelablePromise; }; + private readonly _editor: ICodeEditor; + private _currentPasteOperation?: CancelablePromise; - private _pasteAsActionContext?: { readonly preferredId: string | undefined }; + private _pasteAsActionContext?: { readonly preferred?: PastePreference }; private readonly _pasteProgressManager: InlineProgressManager; - private readonly _postPasteWidgetManager: PostEditWidgetManager; + private readonly _postPasteWidgetManager: PostEditWidgetManager; constructor( editor: ICodeEditor, @@ -96,10 +113,10 @@ export class CopyPasteController extends Disposable implements IEditorContributi this._postPasteWidgetManager.tryShowSelector(); } - public pasteAs(preferredId?: string) { + public pasteAs(preferred?: PastePreference) { this._editor.focus(); try { - this._pasteAsActionContext = { preferredId }; + this._pasteAsActionContext = { preferred }; getActiveDocument().execCommand('paste'); } finally { this._pasteAsActionContext = undefined; @@ -204,8 +221,8 @@ export class CopyPasteController extends Disposable implements IEditorContributi return dataTransfer; }); - this._currentCopyOperation?.dataTransferPromise.cancel(); - this._currentCopyOperation = { handle: handle, dataTransferPromise: promise }; + CopyPasteController._currentCopyOperation?.dataTransferPromise.cancel(); + CopyPasteController._currentCopyOperation = { handle: handle, dataTransferPromise: promise }; } private async handlePaste(e: ClipboardEvent) { @@ -246,17 +263,20 @@ export class CopyPasteController extends Disposable implements IEditorContributi const allProviders = this._languageFeaturesService.documentPasteEditProvider .ordered(model) .filter(provider => { - if (this._pasteAsActionContext?.preferredId) { - if (this._pasteAsActionContext.preferredId !== provider.id) { + // Filter out providers that don't match the requested paste types + const preference = this._pasteAsActionContext?.preferred; + if (preference) { + if (provider.providedPasteEditKinds && !this.providerMatchesPreference(provider, preference)) { return false; } } + // And providers that don't handle any of mime types in the clipboard return provider.pasteMimeTypes?.some(type => matchesMimeType(type, allPotentialMimeTypes)); }); if (!allProviders.length) { - if (this._pasteAsActionContext?.preferredId) { - this.showPasteAsNoEditMessage(selections, this._pasteAsActionContext?.preferredId); + if (this._pasteAsActionContext?.preferred) { + this.showPasteAsNoEditMessage(selections, this._pasteAsActionContext.preferred); } return; } @@ -268,17 +288,17 @@ export class CopyPasteController extends Disposable implements IEditorContributi e.stopImmediatePropagation(); if (this._pasteAsActionContext) { - this.showPasteAsPick(this._pasteAsActionContext.preferredId, allProviders, selections, dataTransfer, metadata, { trigger: 'explicit', only: this._pasteAsActionContext.preferredId }); + this.showPasteAsPick(this._pasteAsActionContext.preferred, allProviders, selections, dataTransfer, metadata); } else { - this.doPasteInline(allProviders, selections, dataTransfer, metadata, { trigger: 'implicit' }); + this.doPasteInline(allProviders, selections, dataTransfer, metadata, e); } } - private showPasteAsNoEditMessage(selections: readonly Selection[], editId: string) { - MessageController.get(this._editor)?.showMessage(localize('pasteAsError', "No paste edits for '{0}' found", editId), selections[0].getStartPosition()); + private showPasteAsNoEditMessage(selections: readonly Selection[], preference: PastePreference) { + MessageController.get(this._editor)?.showMessage(localize('pasteAsError', "No paste edits for '{0}' found", preference instanceof HierarchicalKind ? preference.value : preference.providerId), selections[0].getStartPosition()); } - private doPasteInline(allProviders: readonly DocumentPasteEditProvider[], selections: readonly Selection[], dataTransfer: VSDataTransfer, metadata: CopyMetadata | undefined, context: DocumentPasteContext): void { + private doPasteInline(allProviders: readonly DocumentPasteEditProvider[], selections: readonly Selection[], dataTransfer: VSDataTransfer, metadata: CopyMetadata | undefined, clipboardEvent: ClipboardEvent): void { const p = createCancelablePromise(async (token) => { const editor = this._editor; if (!editor.hasModel()) { @@ -293,32 +313,38 @@ export class CopyPasteController extends Disposable implements IEditorContributi return; } - // Filter out any providers the don't match the full data transfer we will send them. - const supportedProviders = allProviders.filter(provider => isSupportedPasteProvider(provider, dataTransfer)); + const supportedProviders = allProviders.filter(provider => this.isSupportedPasteProvider(provider, dataTransfer)); if (!supportedProviders.length - || (supportedProviders.length === 1 && supportedProviders[0].id === 'text') // Only our default text provider is active + || (supportedProviders.length === 1 && supportedProviders[0] instanceof DefaultTextPasteOrDropEditProvider) // Only our default text provider is active ) { - await this.applyDefaultPasteHandler(dataTransfer, metadata, tokenSource.token); - return; + return this.applyDefaultPasteHandler(dataTransfer, metadata, tokenSource.token, clipboardEvent); } + const context: DocumentPasteContext = { + triggerKind: DocumentPasteTriggerKind.Automatic, + }; const providerEdits = await this.getPasteEdits(supportedProviders, dataTransfer, model, selections, context, tokenSource.token); if (tokenSource.token.isCancellationRequested) { return; } - // If the only edit returned is a text edit, use the default paste handler - if (providerEdits.length === 1 && providerEdits[0].providerId === 'text') { - await this.applyDefaultPasteHandler(dataTransfer, metadata, tokenSource.token); - return; + // If the only edit returned is our default text edit, use the default paste handler + if (providerEdits.length === 1 && providerEdits[0].provider instanceof DefaultTextPasteOrDropEditProvider) { + return this.applyDefaultPasteHandler(dataTransfer, metadata, tokenSource.token, clipboardEvent); } if (providerEdits.length) { const canShowWidget = editor.getOption(EditorOption.pasteAs).showPasteSelector === 'afterPaste'; - return this._postPasteWidgetManager.applyEditAndShowIfNeeded(selections, { activeEditIndex: 0, allEdits: providerEdits }, canShowWidget, tokenSource.token); + return this._postPasteWidgetManager.applyEditAndShowIfNeeded(selections, { activeEditIndex: 0, allEdits: providerEdits }, canShowWidget, async (edit, token) => { + const resolved = await edit.provider.resolveDocumentPasteEdit?.(edit, token); + if (resolved) { + edit.additionalEdit = resolved.additionalEdit; + } + return edit; + }, tokenSource.token); } - await this.applyDefaultPasteHandler(dataTransfer, metadata, tokenSource.token); + await this.applyDefaultPasteHandler(dataTransfer, metadata, tokenSource.token, clipboardEvent); } finally { tokenSource.dispose(); if (this._currentPasteOperation === p) { @@ -331,7 +357,7 @@ export class CopyPasteController extends Disposable implements IEditorContributi this._currentPasteOperation = p; } - private showPasteAsPick(preferredId: string | undefined, allProviders: readonly DocumentPasteEditProvider[], selections: readonly Selection[], dataTransfer: VSDataTransfer, metadata: CopyMetadata | undefined, context: DocumentPasteContext): void { + private showPasteAsPick(preference: PastePreference | undefined, allProviders: readonly DocumentPasteEditProvider[], selections: readonly Selection[], dataTransfer: VSDataTransfer, metadata: CopyMetadata | undefined): void { const p = createCancelablePromise(async (token) => { const editor = this._editor; if (!editor.hasModel()) { @@ -347,17 +373,32 @@ export class CopyPasteController extends Disposable implements IEditorContributi } // Filter out any providers the don't match the full data transfer we will send them. - let supportedProviders = allProviders.filter(provider => isSupportedPasteProvider(provider, dataTransfer)); - if (preferredId) { + let supportedProviders = allProviders.filter(provider => this.isSupportedPasteProvider(provider, dataTransfer, preference)); + if (preference) { // We are looking for a specific edit - supportedProviders = supportedProviders.filter(edit => edit.id === preferredId); + supportedProviders = supportedProviders.filter(provider => this.providerMatchesPreference(provider, preference)); } - const providerEdits = await this.getPasteEdits(supportedProviders, dataTransfer, model, selections, context, tokenSource.token); + const context: DocumentPasteContext = { + triggerKind: DocumentPasteTriggerKind.PasteAs, + only: preference && preference instanceof HierarchicalKind ? preference : undefined, + }; + let providerEdits = await this.getPasteEdits(supportedProviders, dataTransfer, model, selections, context, tokenSource.token); if (tokenSource.token.isCancellationRequested) { return; } + // Filter out any edits that don't match the requested kind + if (preference) { + providerEdits = providerEdits.filter(edit => { + if (preference instanceof HierarchicalKind) { + return preference.contains(edit.kind); + } else { + return preference.providerId === edit.provider.id; + } + }); + } + if (!providerEdits.length) { if (context.only) { this.showPasteAsNoEditMessage(selections, context.only); @@ -366,14 +407,13 @@ export class CopyPasteController extends Disposable implements IEditorContributi } let pickedEdit: DocumentPasteEdit | undefined; - if (preferredId) { + if (preference) { pickedEdit = providerEdits.at(0); } else { const selected = await this._quickInputService.pick( providerEdits.map((edit): IQuickPickItem & { edit: DocumentPasteEdit } => ({ - label: edit.label, - description: edit.providerId, - detail: edit.detail, + label: edit.title, + description: edit.kind?.value, edit, })), { placeHolder: localize('pasteAsPickerPlaceholder', "Select Paste Action"), @@ -436,8 +476,8 @@ export class CopyPasteController extends Disposable implements IEditorContributi } private async mergeInDataFromCopy(dataTransfer: VSDataTransfer, metadata: CopyMetadata | undefined, token: CancellationToken): Promise { - if (metadata?.id && this._currentCopyOperation?.handle === metadata.id) { - const toMergeDataTransfer = await this._currentCopyOperation.dataTransferPromise; + if (metadata?.id && CopyPasteController._currentCopyOperation?.handle === metadata.id) { + const toMergeDataTransfer = await CopyPasteController._currentCopyOperation.dataTransferPromise; if (token.isCancellationRequested) { return; } @@ -459,36 +499,34 @@ export class CopyPasteController extends Disposable implements IEditorContributi } } - private async getPasteEdits(providers: readonly DocumentPasteEditProvider[], dataTransfer: VSDataTransfer, model: ITextModel, selections: readonly Selection[], context: DocumentPasteContext, token: CancellationToken): Promise> { + private async getPasteEdits(providers: readonly DocumentPasteEditProvider[], dataTransfer: VSDataTransfer, model: ITextModel, selections: readonly Selection[], context: DocumentPasteContext, token: CancellationToken): Promise { const results = await raceCancellation( Promise.all(providers.map(async provider => { try { - const edit = await provider.provideDocumentPasteEdits?.(model, selections, dataTransfer, context, token); - if (edit) { - return { ...edit, providerId: provider.id }; - } + const edits = await provider.provideDocumentPasteEdits?.(model, selections, dataTransfer, context, token); + // TODO: dispose of edits + return edits?.edits?.map(edit => ({ ...edit, provider })); } catch (err) { console.error(err); } return undefined; })), token); - const edits = coalesce(results ?? []); + const edits = coalesce(results ?? []).flat().filter(edit => { + return !context.only || context.only.contains(edit.kind); + }); return sortEditsByYieldTo(edits); } - private async applyDefaultPasteHandler(dataTransfer: VSDataTransfer, metadata: CopyMetadata | undefined, token: CancellationToken) { + private async applyDefaultPasteHandler(dataTransfer: VSDataTransfer, metadata: CopyMetadata | undefined, token: CancellationToken, clipboardEvent: ClipboardEvent) { const textDataTransfer = dataTransfer.get(Mimes.text) ?? dataTransfer.get('text'); - if (!textDataTransfer) { - return; - } - - const text = await textDataTransfer.asString(); + const text = (await textDataTransfer?.asString()) ?? ''; if (token.isCancellationRequested) { return; } const payload: PastePayload = { + clipboardEvent, text, pasteOnNewLine: metadata?.defaultPastePayload.pasteOnNewLine ?? false, multicursorText: metadata?.defaultPastePayload.multicursorText ?? null, @@ -496,8 +534,28 @@ export class CopyPasteController extends Disposable implements IEditorContributi }; this._editor.trigger('keyboard', Handler.Paste, payload); } -} -function isSupportedPasteProvider(provider: DocumentPasteEditProvider, dataTransfer: VSDataTransfer): boolean { - return Boolean(provider.pasteMimeTypes?.some(type => dataTransfer.matches(type))); + /** + * Filter out providers if they: + * - Don't handle any of the data transfer types we have + * - Don't match the preferred paste kind + */ + private isSupportedPasteProvider(provider: DocumentPasteEditProvider, dataTransfer: VSDataTransfer, preference?: PastePreference): boolean { + if (!provider.pasteMimeTypes?.some(type => dataTransfer.matches(type))) { + return false; + } + + return !preference || this.providerMatchesPreference(provider, preference); + } + + private providerMatchesPreference(provider: DocumentPasteEditProvider, preference: PastePreference): boolean { + if (preference instanceof HierarchicalKind) { + if (!provider.providedPasteEditKinds) { + return true; + } + return provider.providedPasteEditKinds.some(providedKind => preference.contains(providedKind)); + } else { + return provider.id === preference.providerId; + } + } } diff --git a/src/vs/editor/contrib/dropOrPasteInto/browser/defaultProviders.ts b/src/vs/editor/contrib/dropOrPasteInto/browser/defaultProviders.ts index 27812236c509c..88ffc64d4bd64 100644 --- a/src/vs/editor/contrib/dropOrPasteInto/browser/defaultProviders.ts +++ b/src/vs/editor/contrib/dropOrPasteInto/browser/defaultProviders.ts @@ -6,6 +6,7 @@ import { coalesce } from 'vs/base/common/arrays'; import { CancellationToken } from 'vs/base/common/cancellation'; import { IReadonlyVSDataTransfer, UriList } from 'vs/base/common/dataTransfer'; +import { HierarchicalKind } from 'vs/base/common/hierarchicalKind'; import { Disposable } from 'vs/base/common/lifecycle'; import { Mimes } from 'vs/base/common/mime'; import { Schemas } from 'vs/base/common/network'; @@ -13,36 +14,46 @@ import { relativePath } from 'vs/base/common/resources'; import { URI } from 'vs/base/common/uri'; import { IPosition } from 'vs/editor/common/core/position'; import { IRange } from 'vs/editor/common/core/range'; -import { DocumentOnDropEdit, DocumentOnDropEditProvider, DocumentPasteContext, DocumentPasteEdit, DocumentPasteEditProvider } from 'vs/editor/common/languages'; +import { DocumentOnDropEdit, DocumentOnDropEditProvider, DocumentPasteContext, DocumentPasteEdit, DocumentPasteEditProvider, DocumentPasteEditsSession, DocumentPasteTriggerKind } from 'vs/editor/common/languages'; import { ITextModel } from 'vs/editor/common/model'; import { ILanguageFeaturesService } from 'vs/editor/common/services/languageFeatures'; import { localize } from 'vs/nls'; import { IWorkspaceContextService } from 'vs/platform/workspace/common/workspace'; -const builtInLabel = localize('builtIn', 'Built-in'); abstract class SimplePasteAndDropProvider implements DocumentOnDropEditProvider, DocumentPasteEditProvider { - abstract readonly id: string; + abstract readonly kind: HierarchicalKind; abstract readonly dropMimeTypes: readonly string[] | undefined; abstract readonly pasteMimeTypes: readonly string[]; - async provideDocumentPasteEdits(_model: ITextModel, _ranges: readonly IRange[], dataTransfer: IReadonlyVSDataTransfer, context: DocumentPasteContext, token: CancellationToken): Promise { + async provideDocumentPasteEdits(_model: ITextModel, _ranges: readonly IRange[], dataTransfer: IReadonlyVSDataTransfer, context: DocumentPasteContext, token: CancellationToken): Promise { const edit = await this.getEdit(dataTransfer, token); - return edit ? { insertText: edit.insertText, label: edit.label, detail: edit.detail, handledMimeType: edit.handledMimeType, yieldTo: edit.yieldTo } : undefined; + if (!edit) { + return undefined; + } + + return { + dispose() { }, + edits: [{ insertText: edit.insertText, title: edit.title, kind: edit.kind, handledMimeType: edit.handledMimeType, yieldTo: edit.yieldTo }] + }; } - async provideDocumentOnDropEdits(_model: ITextModel, _position: IPosition, dataTransfer: IReadonlyVSDataTransfer, token: CancellationToken): Promise { + async provideDocumentOnDropEdits(_model: ITextModel, _position: IPosition, dataTransfer: IReadonlyVSDataTransfer, token: CancellationToken): Promise { const edit = await this.getEdit(dataTransfer, token); - return edit ? { insertText: edit.insertText, label: edit.label, handledMimeType: edit.handledMimeType, yieldTo: edit.yieldTo } : undefined; + return edit ? [{ insertText: edit.insertText, title: edit.title, kind: edit.kind, handledMimeType: edit.handledMimeType, yieldTo: edit.yieldTo }] : undefined; } protected abstract getEdit(dataTransfer: IReadonlyVSDataTransfer, token: CancellationToken): Promise; } -class DefaultTextProvider extends SimplePasteAndDropProvider { +export class DefaultTextPasteOrDropEditProvider extends SimplePasteAndDropProvider { + + static readonly id = 'text'; + static readonly kind = new HierarchicalKind('text.plain'); - readonly id = 'text'; + readonly id = DefaultTextPasteOrDropEditProvider.id; + readonly kind = DefaultTextPasteOrDropEditProvider.kind; readonly dropMimeTypes = [Mimes.text]; readonly pasteMimeTypes = [Mimes.text]; @@ -61,16 +72,16 @@ class DefaultTextProvider extends SimplePasteAndDropProvider { const insertText = await textEntry.asString(); return { handledMimeType: Mimes.text, - label: localize('text.label', "Insert Plain Text"), - detail: builtInLabel, - insertText + title: localize('text.label', "Insert Plain Text"), + insertText, + kind: this.kind, }; } } class PathProvider extends SimplePasteAndDropProvider { - readonly id = 'uri'; + readonly kind = new HierarchicalKind('uri.absolute'); readonly dropMimeTypes = [Mimes.uriList]; readonly pasteMimeTypes = [Mimes.uriList]; @@ -108,15 +119,15 @@ class PathProvider extends SimplePasteAndDropProvider { return { handledMimeType: Mimes.uriList, insertText, - label, - detail: builtInLabel, + title: label, + kind: this.kind, }; } } class RelativePathProvider extends SimplePasteAndDropProvider { - readonly id = 'relativePath'; + readonly kind = new HierarchicalKind('uri.relative'); readonly dropMimeTypes = [Mimes.uriList]; readonly pasteMimeTypes = [Mimes.uriList]; @@ -144,24 +155,24 @@ class RelativePathProvider extends SimplePasteAndDropProvider { return { handledMimeType: Mimes.uriList, insertText: relativeUris.join(' '), - label: entries.length > 1 + title: entries.length > 1 ? localize('defaultDropProvider.uriList.relativePaths', "Insert Relative Paths") : localize('defaultDropProvider.uriList.relativePath', "Insert Relative Path"), - detail: builtInLabel, + kind: this.kind, }; } } class PasteHtmlProvider implements DocumentPasteEditProvider { - public readonly id = 'html'; + public readonly kind = new HierarchicalKind('html'); public readonly pasteMimeTypes = ['text/html']; private readonly _yieldTo = [{ mimeType: Mimes.text }]; - async provideDocumentPasteEdits(_model: ITextModel, _ranges: readonly IRange[], dataTransfer: IReadonlyVSDataTransfer, context: DocumentPasteContext, token: CancellationToken): Promise { - if (context.trigger !== 'explicit' && context.only !== this.id) { + async provideDocumentPasteEdits(_model: ITextModel, _ranges: readonly IRange[], dataTransfer: IReadonlyVSDataTransfer, context: DocumentPasteContext, token: CancellationToken): Promise { + if (context.triggerKind !== DocumentPasteTriggerKind.PasteAs && !context.only?.contains(this.kind)) { return; } @@ -172,10 +183,13 @@ class PasteHtmlProvider implements DocumentPasteEditProvider { } return { - insertText: htmlText, - yieldTo: this._yieldTo, - label: localize('pasteHtmlLabel', 'Insert HTML'), - detail: builtInLabel, + dispose() { }, + edits: [{ + insertText: htmlText, + yieldTo: this._yieldTo, + title: localize('pasteHtmlLabel', 'Insert HTML'), + kind: this.kind, + }], }; } } @@ -205,7 +219,7 @@ export class DefaultDropProvidersFeature extends Disposable { ) { super(); - this._register(languageFeaturesService.documentOnDropEditProvider.register('*', new DefaultTextProvider())); + this._register(languageFeaturesService.documentOnDropEditProvider.register('*', new DefaultTextPasteOrDropEditProvider())); this._register(languageFeaturesService.documentOnDropEditProvider.register('*', new PathProvider())); this._register(languageFeaturesService.documentOnDropEditProvider.register('*', new RelativePathProvider(workspaceContextService))); } @@ -218,7 +232,7 @@ export class DefaultPasteProvidersFeature extends Disposable { ) { super(); - this._register(languageFeaturesService.documentPasteEditProvider.register('*', new DefaultTextProvider())); + this._register(languageFeaturesService.documentPasteEditProvider.register('*', new DefaultTextPasteOrDropEditProvider())); this._register(languageFeaturesService.documentPasteEditProvider.register('*', new PathProvider())); this._register(languageFeaturesService.documentPasteEditProvider.register('*', new RelativePathProvider(workspaceContextService))); this._register(languageFeaturesService.documentPasteEditProvider.register('*', new PasteHtmlProvider())); diff --git a/src/vs/editor/contrib/dropOrPasteInto/browser/dropIntoEditorContribution.ts b/src/vs/editor/contrib/dropOrPasteInto/browser/dropIntoEditorContribution.ts index 4817431d18934..52dc73b8ce23a 100644 --- a/src/vs/editor/contrib/dropOrPasteInto/browser/dropIntoEditorContribution.ts +++ b/src/vs/editor/contrib/dropOrPasteInto/browser/dropIntoEditorContribution.ts @@ -16,6 +16,7 @@ import { Registry } from 'vs/platform/registry/common/platform'; import { DropIntoEditorController, changeDropTypeCommandId, defaultProviderConfig, dropWidgetVisibleCtx } from './dropIntoEditorController'; registerEditorContribution(DropIntoEditorController.ID, DropIntoEditorController, EditorContributionInstantiation.BeforeFirstInteraction); +registerEditorFeature(DefaultDropProvidersFeature); registerEditorCommand(new class extends EditorCommand { constructor() { @@ -34,7 +35,22 @@ registerEditorCommand(new class extends EditorCommand { } }); -registerEditorFeature(DefaultDropProvidersFeature); +registerEditorCommand(new class extends EditorCommand { + constructor() { + super({ + id: 'editor.hideDropWidget', + precondition: dropWidgetVisibleCtx, + kbOpts: { + weight: KeybindingWeight.EditorContrib, + primary: KeyCode.Escape, + } + }); + } + + public override runEditorCommand(_accessor: ServicesAccessor | null, editor: ICodeEditor, _args: any) { + DropIntoEditorController.get(editor)?.clearWidgets(); + } +}); Registry.as(ConfigurationExtensions.Configuration).registerConfiguration({ ...editorConfigurationBaseNode, diff --git a/src/vs/editor/contrib/dropOrPasteInto/browser/dropIntoEditorController.ts b/src/vs/editor/contrib/dropOrPasteInto/browser/dropIntoEditorController.ts index 48dae75565c4c..32e64cff65046 100644 --- a/src/vs/editor/contrib/dropOrPasteInto/browser/dropIntoEditorController.ts +++ b/src/vs/editor/contrib/dropOrPasteInto/browser/dropIntoEditorController.ts @@ -6,6 +6,7 @@ import { coalesce } from 'vs/base/common/arrays'; import { CancelablePromise, createCancelablePromise, raceCancellation } from 'vs/base/common/async'; import { VSDataTransfer, matchesMimeType } from 'vs/base/common/dataTransfer'; +import { HierarchicalKind } from 'vs/base/common/hierarchicalKind'; import { Disposable } from 'vs/base/common/lifecycle'; import { toExternalVSDataTransfer } from 'vs/editor/browser/dnd'; import { ICodeEditor } from 'vs/editor/browser/editorBrowser'; @@ -45,7 +46,7 @@ export class DropIntoEditorController extends Disposable implements IEditorContr private _currentOperation?: CancelablePromise; private readonly _dropProgressManager: InlineProgressManager; - private readonly _postDropWidgetManager: PostEditWidgetManager; + private readonly _postDropWidgetManager: PostEditWidgetManager; private readonly treeItemsTransfer = LocalSelectionTransfer.getInstance(); @@ -115,7 +116,7 @@ export class DropIntoEditorController extends Disposable implements IEditorContr const activeEditIndex = this.getInitialActiveEditIndex(model, edits); const canShowWidget = editor.getOption(EditorOption.dropIntoEditor).showDropSelector === 'afterDrop'; // Pass in the parent token here as it tracks cancelling the entire drop operation - await this._postDropWidgetManager.applyEditAndShowIfNeeded([Range.fromPositions(position)], { activeEditIndex, allEdits: edits }, canShowWidget, token); + await this._postDropWidgetManager.applyEditAndShowIfNeeded([Range.fromPositions(position)], { activeEditIndex, allEdits: edits }, canShowWidget, async edit => edit, token); } } finally { tokenSource.dispose(); @@ -132,25 +133,24 @@ export class DropIntoEditorController extends Disposable implements IEditorContr private async getDropEdits(providers: readonly DocumentOnDropEditProvider[], model: ITextModel, position: IPosition, dataTransfer: VSDataTransfer, tokenSource: EditorStateCancellationTokenSource) { const results = await raceCancellation(Promise.all(providers.map(async provider => { try { - const edit = await provider.provideDocumentOnDropEdits(model, position, dataTransfer, tokenSource.token); - if (edit) { - return { ...edit, providerId: provider.id }; - } + const edits = await provider.provideDocumentOnDropEdits(model, position, dataTransfer, tokenSource.token); + return edits?.map(edit => ({ ...edit, providerId: provider.id })); } catch (err) { console.error(err); } return undefined; })), tokenSource.token); - const edits = coalesce(results ?? []); + const edits = coalesce(results ?? []).flat(); return sortEditsByYieldTo(edits); } private getInitialActiveEditIndex(model: ITextModel, edits: ReadonlyArray) { const preferredProviders = this._configService.getValue>(defaultProviderConfig, { resource: model.uri }); - for (const [configMime, desiredId] of Object.entries(preferredProviders)) { + for (const [configMime, desiredKindStr] of Object.entries(preferredProviders)) { + const desiredKind = new HierarchicalKind(desiredKindStr); const editIndex = edits.findIndex(edit => - desiredId === edit.providerId + desiredKind.value === edit.providerId && edit.handledMimeType && matchesMimeType(configMime, [edit.handledMimeType])); if (editIndex >= 0) { return editIndex; diff --git a/src/vs/editor/contrib/dropOrPasteInto/browser/edit.ts b/src/vs/editor/contrib/dropOrPasteInto/browser/edit.ts index 55d8dff9e7b3c..81cc89436cf85 100644 --- a/src/vs/editor/contrib/dropOrPasteInto/browser/edit.ts +++ b/src/vs/editor/contrib/dropOrPasteInto/browser/edit.ts @@ -5,21 +5,16 @@ import { URI } from 'vs/base/common/uri'; import { ResourceTextEdit } from 'vs/editor/browser/services/bulkEditService'; -import { DropYieldTo, WorkspaceEdit } from 'vs/editor/common/languages'; +import { DocumentOnDropEdit, DocumentPasteEdit, DropYieldTo, WorkspaceEdit } from 'vs/editor/common/languages'; import { Range } from 'vs/editor/common/core/range'; import { SnippetParser } from 'vs/editor/contrib/snippet/browser/snippetParser'; - -export interface DropOrPasteEdit { - readonly label: string; - readonly insertText: string | { readonly snippet: string }; - readonly additionalEdit?: WorkspaceEdit; -} +import { HierarchicalKind } from 'vs/base/common/hierarchicalKind'; /** * Given a {@link DropOrPasteEdit} and set of ranges, creates a {@link WorkspaceEdit} that applies the insert text from * the {@link DropOrPasteEdit} at each range plus any additional edits. */ -export function createCombinedWorkspaceEdit(uri: URI, ranges: readonly Range[], edit: DropOrPasteEdit): WorkspaceEdit { +export function createCombinedWorkspaceEdit(uri: URI, ranges: readonly Range[], edit: DocumentPasteEdit | DocumentOnDropEdit): WorkspaceEdit { // If the edit insert text is empty, skip applying at each range if (typeof edit.insertText === 'string' ? edit.insertText === '' : edit.insertText.snippet === '') { return { @@ -39,13 +34,15 @@ export function createCombinedWorkspaceEdit(uri: URI, ranges: readonly Range[], } export function sortEditsByYieldTo(edits: readonly T[]): T[] { function yieldsTo(yTo: DropYieldTo, other: T): boolean { - return ('providerId' in yTo && yTo.providerId === other.providerId) - || ('mimeType' in yTo && yTo.mimeType === other.handledMimeType); + if ('mimeType' in yTo) { + return yTo.mimeType === other.handledMimeType; + } + return !!other.kind && yTo.kind.contains(other.kind); } // Build list of nodes each node yields to @@ -84,7 +81,7 @@ export function sortEditsByYieldTo { readonly activeEditIndex: number; - readonly allEdits: ReadonlyArray<{ - readonly label: string; - readonly insertText: string | { readonly snippet: string }; - readonly additionalEdit?: WorkspaceEdit; - }>; + readonly allEdits: ReadonlyArray; } interface ShowCommand { @@ -36,7 +32,7 @@ interface ShowCommand { readonly label: string; } -class PostEditWidget extends Disposable implements IContentWidget { +class PostEditWidget extends Disposable implements IContentWidget { private static readonly baseId = 'editor.widget.postEditWidget'; readonly allowEditorOverflow = true; @@ -53,7 +49,7 @@ class PostEditWidget extends Disposable implements IContentWidget { visibleContext: RawContextKey, private readonly showCommand: ShowCommand, private readonly range: Range, - private readonly edits: EditSet, + private readonly edits: EditSet, private readonly onSelectNewEdit: (editIndex: number) => void, @IContextMenuService private readonly _contextMenuService: IContextMenuService, @IContextKeyService contextKeyService: IContextKeyService, @@ -123,7 +119,7 @@ class PostEditWidget extends Disposable implements IContentWidget { getActions: () => { return this.edits.allEdits.map((edit, i) => toAction({ id: '', - label: edit.label, + label: edit.title, checked: i === this.edits.activeEditIndex, run: () => { if (i !== this.edits.activeEditIndex) { @@ -136,9 +132,9 @@ class PostEditWidget extends Disposable implements IContentWidget { } } -export class PostEditWidgetManager extends Disposable { +export class PostEditWidgetManager extends Disposable { - private readonly _currentWidget = this._register(new MutableDisposable()); + private readonly _currentWidget = this._register(new MutableDisposable>()); constructor( private readonly _id: string, @@ -156,18 +152,23 @@ export class PostEditWidgetManager extends Disposable { )(() => this.clear())); } - public async applyEditAndShowIfNeeded(ranges: readonly Range[], edits: EditSet, canShowWidget: boolean, token: CancellationToken) { + public async applyEditAndShowIfNeeded(ranges: readonly Range[], edits: EditSet, canShowWidget: boolean, resolve: (edit: T, token: CancellationToken) => Promise, token: CancellationToken) { const model = this._editor.getModel(); if (!model || !ranges.length) { return; } - const edit = edits.allEdits[edits.activeEditIndex]; + const edit = edits.allEdits.at(edits.activeEditIndex); if (!edit) { return; } - const combinedWorkspaceEdit = createCombinedWorkspaceEdit(model.uri, ranges, edit); + const resolvedEdit = await resolve(edit, token); + if (token.isCancellationRequested) { + return; + } + + const combinedWorkspaceEdit = createCombinedWorkspaceEdit(model.uri, ranges, resolvedEdit); // Use a decoration to track edits around the trigger range const primaryRange = ranges[0]; @@ -176,6 +177,7 @@ export class PostEditWidgetManager extends Disposable { options: { description: 'paste-line-suffix', stickiness: TrackedRangeStickiness.AlwaysGrowsWhenTypingAtEdges } }]); + this._editor.focus(); let editResult: IBulkEditResult; let editRange: Range | null; try { @@ -185,6 +187,10 @@ export class PostEditWidgetManager extends Disposable { model.deltaDecorations(editTrackingDecoration, []); } + if (token.isCancellationRequested) { + return; + } + if (canShowWidget && editResult.isApplied && edits.allEdits.length > 1) { this.show(editRange ?? primaryRange, edits, async (newEditIndex) => { const model = this._editor.getModel(); @@ -193,16 +199,16 @@ export class PostEditWidgetManager extends Disposable { } await model.undo(); - this.applyEditAndShowIfNeeded(ranges, { activeEditIndex: newEditIndex, allEdits: edits.allEdits }, canShowWidget, token); + this.applyEditAndShowIfNeeded(ranges, { activeEditIndex: newEditIndex, allEdits: edits.allEdits }, canShowWidget, resolve, token); }); } } - public show(range: Range, edits: EditSet, onDidSelectEdit: (newIndex: number) => void) { + public show(range: Range, edits: EditSet, onDidSelectEdit: (newIndex: number) => void) { this.clear(); if (this._editor.hasModel()) { - this._currentWidget.value = this._instantiationService.createInstance(PostEditWidget, this._id, this._editor, this._visibleContext, this._showCommand, range, edits, onDidSelectEdit); + this._currentWidget.value = this._instantiationService.createInstance(PostEditWidget, this._id, this._editor, this._visibleContext, this._showCommand, range, edits, onDidSelectEdit); } } diff --git a/src/vs/editor/contrib/dropOrPasteInto/test/browser/editSort.test.ts b/src/vs/editor/contrib/dropOrPasteInto/test/browser/editSort.test.ts index f41f6866982b7..3013bcde75676 100644 --- a/src/vs/editor/contrib/dropOrPasteInto/test/browser/editSort.test.ts +++ b/src/vs/editor/contrib/dropOrPasteInto/test/browser/editSort.test.ts @@ -3,17 +3,17 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ import * as assert from 'assert'; +import { HierarchicalKind } from 'vs/base/common/hierarchicalKind'; import { ensureNoDisposablesAreLeakedInTestSuite } from 'vs/base/test/common/utils'; import { DocumentOnDropEdit } from 'vs/editor/common/languages'; import { sortEditsByYieldTo } from 'vs/editor/contrib/dropOrPasteInto/browser/edit'; -type DropEdit = DocumentOnDropEdit & { providerId: string | undefined }; -function createTestEdit(providerId: string, args?: Partial): DropEdit { +function createTestEdit(kind: string, args?: Partial): DocumentOnDropEdit { return { - label: '', + title: '', insertText: '', - providerId, + kind: new HierarchicalKind(kind), ...args, }; } @@ -21,48 +21,48 @@ function createTestEdit(providerId: string, args?: Partial): DropEdit suite('sortEditsByYieldTo', () => { test('Should noop for empty edits', () => { - const edits: DropEdit[] = []; + const edits: DocumentOnDropEdit[] = []; assert.deepStrictEqual(sortEditsByYieldTo(edits), []); }); test('Yielded to edit should get sorted after target', () => { - const edits: DropEdit[] = [ - createTestEdit('a', { yieldTo: [{ providerId: 'b' }] }), + const edits: DocumentOnDropEdit[] = [ + createTestEdit('a', { yieldTo: [{ kind: new HierarchicalKind('b') }] }), createTestEdit('b'), ]; - assert.deepStrictEqual(sortEditsByYieldTo(edits).map(x => x.providerId), ['b', 'a']); + assert.deepStrictEqual(sortEditsByYieldTo(edits).map(x => x.kind?.value), ['b', 'a']); }); test('Should handle chain of yield to', () => { { - const edits: DropEdit[] = [ - createTestEdit('c', { yieldTo: [{ providerId: 'a' }] }), - createTestEdit('a', { yieldTo: [{ providerId: 'b' }] }), + const edits: DocumentOnDropEdit[] = [ + createTestEdit('c', { yieldTo: [{ kind: new HierarchicalKind('a') }] }), + createTestEdit('a', { yieldTo: [{ kind: new HierarchicalKind('b') }] }), createTestEdit('b'), ]; - assert.deepStrictEqual(sortEditsByYieldTo(edits).map(x => x.providerId), ['b', 'a', 'c']); + assert.deepStrictEqual(sortEditsByYieldTo(edits).map(x => x.kind?.value), ['b', 'a', 'c']); } { - const edits: DropEdit[] = [ - createTestEdit('a', { yieldTo: [{ providerId: 'b' }] }), - createTestEdit('c', { yieldTo: [{ providerId: 'a' }] }), + const edits: DocumentOnDropEdit[] = [ + createTestEdit('a', { yieldTo: [{ kind: new HierarchicalKind('b') }] }), + createTestEdit('c', { yieldTo: [{ kind: new HierarchicalKind('a') }] }), createTestEdit('b'), ]; - assert.deepStrictEqual(sortEditsByYieldTo(edits).map(x => x.providerId), ['b', 'a', 'c']); + assert.deepStrictEqual(sortEditsByYieldTo(edits).map(x => x.kind?.value), ['b', 'a', 'c']); } }); test(`Should not reorder when yield to isn't used`, () => { - const edits: DropEdit[] = [ - createTestEdit('c', { yieldTo: [{ providerId: 'x' }] }), - createTestEdit('a', { yieldTo: [{ providerId: 'y' }] }), + const edits: DocumentOnDropEdit[] = [ + createTestEdit('c', { yieldTo: [{ kind: new HierarchicalKind('x') }] }), + createTestEdit('a', { yieldTo: [{ kind: new HierarchicalKind('y') }] }), createTestEdit('b'), ]; - assert.deepStrictEqual(sortEditsByYieldTo(edits).map(x => x.providerId), ['c', 'a', 'b']); + assert.deepStrictEqual(sortEditsByYieldTo(edits).map(x => x.kind?.value), ['c', 'a', 'b']); }); ensureNoDisposablesAreLeakedInTestSuite(); diff --git a/src/vs/editor/contrib/find/browser/findController.ts b/src/vs/editor/contrib/find/browser/findController.ts index 5dd9bcc6fb57a..bbcc79d07509c 100644 --- a/src/vs/editor/contrib/find/browser/findController.ts +++ b/src/vs/editor/contrib/find/browser/findController.ts @@ -31,6 +31,7 @@ import { IQuickInputService } from 'vs/platform/quickinput/common/quickInput'; import { IStorageService, StorageScope, StorageTarget } from 'vs/platform/storage/common/storage'; import { IThemeService, themeColorFromId } from 'vs/platform/theme/common/themeService'; import { Selection } from 'vs/editor/common/core/selection'; +import { IHoverService } from 'vs/platform/hover/browser/hover'; const SEARCH_STRING_MAX_LENGTH = 524288; @@ -99,6 +100,7 @@ export class CommonFindController extends Disposable implements IEditorContribut private readonly _clipboardService: IClipboardService; protected readonly _contextKeyService: IContextKeyService; protected readonly _notificationService: INotificationService; + protected readonly _hoverService: IHoverService; get editor() { return this._editor; @@ -113,7 +115,8 @@ export class CommonFindController extends Disposable implements IEditorContribut @IContextKeyService contextKeyService: IContextKeyService, @IStorageService storageService: IStorageService, @IClipboardService clipboardService: IClipboardService, - @INotificationService notificationService: INotificationService + @INotificationService notificationService: INotificationService, + @IHoverService hoverService: IHoverService ) { super(); this._editor = editor; @@ -122,6 +125,7 @@ export class CommonFindController extends Disposable implements IEditorContribut this._storageService = storageService; this._clipboardService = clipboardService; this._notificationService = notificationService; + this._hoverService = hoverService; this._updateHistoryDelayer = new Delayer(500); this._state = this._register(new FindReplaceState()); @@ -448,8 +452,9 @@ export class FindController extends CommonFindController implements IFindControl @INotificationService notificationService: INotificationService, @IStorageService _storageService: IStorageService, @IClipboardService clipboardService: IClipboardService, + @IHoverService hoverService: IHoverService, ) { - super(editor, _contextKeyService, _storageService, clipboardService, notificationService); + super(editor, _contextKeyService, _storageService, clipboardService, notificationService, hoverService); this._widget = null; this._findOptionsWidget = null; } @@ -503,7 +508,7 @@ export class FindController extends CommonFindController implements IFindControl } private _createFindWidget() { - this._widget = this._register(new FindWidget(this._editor, this, this._state, this._contextViewService, this._keybindingService, this._contextKeyService, this._themeService, this._storageService, this._notificationService)); + this._widget = this._register(new FindWidget(this._editor, this, this._state, this._contextViewService, this._keybindingService, this._contextKeyService, this._themeService, this._storageService, this._notificationService, this._hoverService)); this._findOptionsWidget = this._register(new FindOptionsWidget(this._editor, this._state, this._keybindingService)); } diff --git a/src/vs/editor/contrib/find/browser/findOptionsWidget.ts b/src/vs/editor/contrib/find/browser/findOptionsWidget.ts index 9ebf4167820e0..007723f698687 100644 --- a/src/vs/editor/contrib/find/browser/findOptionsWidget.ts +++ b/src/vs/editor/contrib/find/browser/findOptionsWidget.ts @@ -13,6 +13,7 @@ import { FIND_IDS } from 'vs/editor/contrib/find/browser/findModel'; import { FindReplaceState } from 'vs/editor/contrib/find/browser/findState'; import { IKeybindingService } from 'vs/platform/keybinding/common/keybinding'; import { asCssVariable, inputActiveOptionBackground, inputActiveOptionBorder, inputActiveOptionForeground } from 'vs/platform/theme/common/colorRegistry'; +import { createInstantHoverDelegate } from 'vs/base/browser/ui/hover/hoverDelegateFactory'; export class FindOptionsWidget extends Widget implements IOverlayWidget { @@ -52,9 +53,12 @@ export class FindOptionsWidget extends Widget implements IOverlayWidget { inputActiveOptionBackground: asCssVariable(inputActiveOptionBackground), }; + const hoverDelegate = this._register(createInstantHoverDelegate()); + this.caseSensitive = this._register(new CaseSensitiveToggle({ appendTitle: this._keybindingLabelFor(FIND_IDS.ToggleCaseSensitiveCommand), isChecked: this._state.matchCase, + hoverDelegate, ...toggleStyles })); this._domNode.appendChild(this.caseSensitive.domNode); @@ -67,6 +71,7 @@ export class FindOptionsWidget extends Widget implements IOverlayWidget { this.wholeWords = this._register(new WholeWordsToggle({ appendTitle: this._keybindingLabelFor(FIND_IDS.ToggleWholeWordCommand), isChecked: this._state.wholeWord, + hoverDelegate, ...toggleStyles })); this._domNode.appendChild(this.wholeWords.domNode); @@ -79,6 +84,7 @@ export class FindOptionsWidget extends Widget implements IOverlayWidget { this.regex = this._register(new RegexToggle({ appendTitle: this._keybindingLabelFor(FIND_IDS.ToggleRegexCommand), isChecked: this._state.isRegex, + hoverDelegate, ...toggleStyles })); this._domNode.appendChild(this.regex.domNode); diff --git a/src/vs/editor/contrib/find/browser/findWidget.ts b/src/vs/editor/contrib/find/browser/findWidget.ts index f89e3d8183751..d3949c02e726c 100644 --- a/src/vs/editor/contrib/find/browser/findWidget.ts +++ b/src/vs/editor/contrib/find/browser/findWidget.ts @@ -43,6 +43,9 @@ import { isHighContrast } from 'vs/platform/theme/common/theme'; import { assertIsDefined } from 'vs/base/common/types'; import { defaultInputBoxStyles, defaultToggleStyles } from 'vs/platform/theme/browser/defaultStyles'; import { Selection } from 'vs/editor/common/core/selection'; +import { createInstantHoverDelegate, getDefaultHoverDelegate } from 'vs/base/browser/ui/hover/hoverDelegateFactory'; +import { IHoverDelegate } from 'vs/base/browser/ui/hover/hoverDelegate'; +import { IHoverService } from 'vs/platform/hover/browser/hover'; const findSelectionIcon = registerIcon('find-selection', Codicon.selection, nls.localize('findSelectionIcon', 'Icon for \'Find in Selection\' in the editor find widget.')); const findCollapsedIcon = registerIcon('find-collapsed', Codicon.chevronRight, nls.localize('findCollapsedIcon', 'Icon to indicate that the editor find widget is collapsed.')); @@ -169,6 +172,7 @@ export class FindWidget extends Widget implements IOverlayWidget, IVerticalSashL themeService: IThemeService, storageService: IStorageService, notificationService: INotificationService, + private readonly _hoverService: IHoverService, ) { super(); this._codeEditor = codeEditor; @@ -1010,23 +1014,28 @@ export class FindWidget extends Widget implements IOverlayWidget, IVerticalSashL this._matchesCount.className = 'matchesCount'; this._updateMatchesCount(); + // Create a scoped hover delegate for all find related buttons + const hoverDelegate = this._register(createInstantHoverDelegate()); + // Previous button this._prevBtn = this._register(new SimpleButton({ label: NLS_PREVIOUS_MATCH_BTN_LABEL + this._keybindingLabelFor(FIND_IDS.PreviousMatchFindAction), icon: findPreviousMatchIcon, + hoverDelegate, onTrigger: () => { assertIsDefined(this._codeEditor.getAction(FIND_IDS.PreviousMatchFindAction)).run().then(undefined, onUnexpectedError); } - })); + }, this._hoverService)); // Next button this._nextBtn = this._register(new SimpleButton({ label: NLS_NEXT_MATCH_BTN_LABEL + this._keybindingLabelFor(FIND_IDS.NextMatchFindAction), icon: findNextMatchIcon, + hoverDelegate, onTrigger: () => { assertIsDefined(this._codeEditor.getAction(FIND_IDS.NextMatchFindAction)).run().then(undefined, onUnexpectedError); } - })); + }, this._hoverService)); const findPart = document.createElement('div'); findPart.className = 'find-part'; @@ -1043,6 +1052,7 @@ export class FindWidget extends Widget implements IOverlayWidget, IVerticalSashL icon: findSelectionIcon, title: NLS_TOGGLE_SELECTION_FIND_TITLE + this._keybindingLabelFor(FIND_IDS.ToggleSearchScopeCommand), isChecked: false, + hoverDelegate: hoverDelegate, inputActiveOptionBackground: asCssVariable(inputActiveOptionBackground), inputActiveOptionBorder: asCssVariable(inputActiveOptionBorder), inputActiveOptionForeground: asCssVariable(inputActiveOptionForeground), @@ -1077,6 +1087,7 @@ export class FindWidget extends Widget implements IOverlayWidget, IVerticalSashL this._closeBtn = this._register(new SimpleButton({ label: NLS_CLOSE_BTN_LABEL + this._keybindingLabelFor(FIND_IDS.CloseFindWidgetCommand), icon: widgetClose, + hoverDelegate, onTrigger: () => { this._state.change({ isRevealed: false, searchScope: null }, false); }, @@ -1092,7 +1103,7 @@ export class FindWidget extends Widget implements IOverlayWidget, IVerticalSashL } } } - })); + }, this._hoverService)); // Replace input this._replaceInput = this._register(new ContextScopedReplaceInput(null, undefined, { @@ -1138,10 +1149,14 @@ export class FindWidget extends Widget implements IOverlayWidget, IVerticalSashL } })); + // Create scoped hover delegate for replace actions + const replaceHoverDelegate = this._register(createInstantHoverDelegate()); + // Replace one button this._replaceBtn = this._register(new SimpleButton({ label: NLS_REPLACE_BTN_LABEL + this._keybindingLabelFor(FIND_IDS.ReplaceOneAction), icon: findReplaceIcon, + hoverDelegate: replaceHoverDelegate, onTrigger: () => { this._controller.replace(); }, @@ -1151,16 +1166,17 @@ export class FindWidget extends Widget implements IOverlayWidget, IVerticalSashL e.preventDefault(); } } - })); + }, this._hoverService)); // Replace all button this._replaceAllBtn = this._register(new SimpleButton({ label: NLS_REPLACE_ALL_BTN_LABEL + this._keybindingLabelFor(FIND_IDS.ReplaceAllAction), icon: findReplaceAllIcon, + hoverDelegate: replaceHoverDelegate, onTrigger: () => { this._controller.replaceAll(); } - })); + }, this._hoverService)); const replacePart = document.createElement('div'); replacePart.className = 'replace-part'; @@ -1185,7 +1201,7 @@ export class FindWidget extends Widget implements IOverlayWidget, IVerticalSashL } this._showViewZone(); } - })); + }, this._hoverService)); this._toggleReplaceBtn.setExpanded(this._isReplaceVisible); // Widget @@ -1299,6 +1315,7 @@ export interface ISimpleButtonOpts { readonly label: string; readonly className?: string; readonly icon?: ThemeIcon; + readonly hoverDelegate?: IHoverDelegate; readonly onTrigger: () => void; readonly onKeyDown?: (e: IKeyboardEvent) => void; } @@ -1308,7 +1325,10 @@ export class SimpleButton extends Widget { private readonly _opts: ISimpleButtonOpts; private readonly _domNode: HTMLElement; - constructor(opts: ISimpleButtonOpts) { + constructor( + opts: ISimpleButtonOpts, + hoverService: IHoverService + ) { super(); this._opts = opts; @@ -1321,11 +1341,11 @@ export class SimpleButton extends Widget { } this._domNode = document.createElement('div'); - this._domNode.title = this._opts.label; this._domNode.tabIndex = 0; this._domNode.className = className; this._domNode.setAttribute('role', 'button'); this._domNode.setAttribute('aria-label', this._opts.label); + this._register(hoverService.setupUpdatableHover(opts.hoverDelegate ?? getDefaultHoverDelegate('element'), this._domNode, this._opts.label)); this.onclick(this._domNode, (e) => { this._opts.onTrigger(); diff --git a/src/vs/editor/contrib/find/test/browser/findController.test.ts b/src/vs/editor/contrib/find/test/browser/findController.test.ts index 27717765fa398..9bdb1cb77861e 100644 --- a/src/vs/editor/contrib/find/test/browser/findController.test.ts +++ b/src/vs/editor/contrib/find/test/browser/findController.test.ts @@ -18,6 +18,7 @@ import { CONTEXT_FIND_INPUT_FOCUSED } from 'vs/editor/contrib/find/browser/findM import { withAsyncTestCodeEditor } from 'vs/editor/test/browser/testCodeEditor'; import { IClipboardService } from 'vs/platform/clipboard/common/clipboardService'; import { IContextKey, IContextKeyService } from 'vs/platform/contextkey/common/contextkey'; +import { IHoverService } from 'vs/platform/hover/browser/hover'; import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; import { ServiceCollection } from 'vs/platform/instantiation/common/serviceCollection'; import { INotificationService } from 'vs/platform/notification/common/notification'; @@ -35,9 +36,10 @@ class TestFindController extends CommonFindController { @IContextKeyService contextKeyService: IContextKeyService, @IStorageService storageService: IStorageService, @IClipboardService clipboardService: IClipboardService, - @INotificationService notificationService: INotificationService + @INotificationService notificationService: INotificationService, + @IHoverService hoverService: IHoverService ) { - super(editor, contextKeyService, storageService, clipboardService, notificationService); + super(editor, contextKeyService, storageService, clipboardService, notificationService, hoverService); this._findInputFocused = CONTEXT_FIND_INPUT_FOCUSED.bindTo(contextKeyService); this._updateHistoryDelayer = new Delayer(50); this.hasFocus = false; diff --git a/src/vs/editor/contrib/format/browser/format.ts b/src/vs/editor/contrib/format/browser/format.ts index 742518daae3b7..dbf6ede9eae1b 100644 --- a/src/vs/editor/contrib/format/browser/format.ts +++ b/src/vs/editor/contrib/format/browser/format.ts @@ -30,7 +30,7 @@ import { IProgress } from 'vs/platform/progress/common/progress'; import { ILanguageFeaturesService } from 'vs/editor/common/services/languageFeatures'; import { LanguageFeatureRegistry } from 'vs/editor/common/languageFeatureRegistry'; import { ILogService } from 'vs/platform/log/common/log'; -import { AudioCue, IAudioCueService } from 'vs/platform/audioCues/browser/audioCueService'; +import { AccessibilitySignal, IAccessibilitySignalService } from 'vs/platform/accessibilitySignal/browser/accessibilitySignalService'; export function getRealAndSyntheticDocumentFormattersOrdered( documentFormattingEditProvider: LanguageFeatureRegistry, @@ -135,7 +135,7 @@ export async function formatDocumentRangesWithProvider( ): Promise { const workerService = accessor.get(IEditorWorkerService); const logService = accessor.get(ILogService); - const audioCueService = accessor.get(IAudioCueService); + const accessibilitySignalService = accessor.get(IAccessibilitySignalService); let model: ITextModel; let cts: CancellationTokenSource; @@ -279,7 +279,7 @@ export async function formatDocumentRangesWithProvider( return null; }); } - audioCueService.playAudioCue(AudioCue.format, { userGesture }); + accessibilitySignalService.playSignal(AccessibilitySignal.format, { userGesture }); return true; } @@ -312,7 +312,7 @@ export async function formatDocumentWithProvider( userGesture?: boolean ): Promise { const workerService = accessor.get(IEditorWorkerService); - const audioCueService = accessor.get(IAudioCueService); + const accessibilitySignalService = accessor.get(IAccessibilitySignalService); let model: ITextModel; let cts: CancellationTokenSource; @@ -373,7 +373,7 @@ export async function formatDocumentWithProvider( return null; }); } - audioCueService.playAudioCue(AudioCue.format, { userGesture }); + accessibilitySignalService.playSignal(AccessibilitySignal.format, { userGesture }); return true; } diff --git a/src/vs/editor/contrib/format/browser/formatActions.ts b/src/vs/editor/contrib/format/browser/formatActions.ts index a9c4984e4c1d5..1f345ac532961 100644 --- a/src/vs/editor/contrib/format/browser/formatActions.ts +++ b/src/vs/editor/contrib/format/browser/formatActions.ts @@ -21,7 +21,7 @@ import { ILanguageFeaturesService } from 'vs/editor/common/services/languageFeat import { formatDocumentRangesWithSelectedProvider, formatDocumentWithSelectedProvider, FormattingMode, getOnTypeFormattingEdits } from 'vs/editor/contrib/format/browser/format'; import { FormattingEdit } from 'vs/editor/contrib/format/browser/formattingEdit'; import * as nls from 'vs/nls'; -import { AudioCue, IAudioCueService } from 'vs/platform/audioCues/browser/audioCueService'; +import { AccessibilitySignal, IAccessibilitySignalService } from 'vs/platform/accessibilitySignal/browser/accessibilitySignalService'; import { CommandsRegistry, ICommandService } from 'vs/platform/commands/common/commands'; import { ContextKeyExpr } from 'vs/platform/contextkey/common/contextkey'; import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; @@ -40,7 +40,7 @@ export class FormatOnType implements IEditorContribution { private readonly _editor: ICodeEditor, @ILanguageFeaturesService private readonly _languageFeaturesService: ILanguageFeaturesService, @IEditorWorkerService private readonly _workerService: IEditorWorkerService, - @IAudioCueService private readonly _audioCueService: IAudioCueService + @IAccessibilitySignalService private readonly _accessibilitySignalService: IAccessibilitySignalService ) { this._disposables.add(_languageFeaturesService.onTypeFormattingEditProvider.onDidChange(this._update, this)); this._disposables.add(_editor.onDidChangeModel(() => this._update())); @@ -143,7 +143,7 @@ export class FormatOnType implements IEditorContribution { return; } if (isNonEmptyArray(edits)) { - this._audioCueService.playAudioCue(AudioCue.format, { userGesture: false }); + this._accessibilitySignalService.playSignal(AccessibilitySignal.format, { userGesture: false }); FormattingEdit.execute(this._editor, edits, true); } }).finally(() => { diff --git a/src/vs/editor/contrib/gotoSymbol/browser/goToCommands.ts b/src/vs/editor/contrib/gotoSymbol/browser/goToCommands.ts index c579e25acc4b0..aa6b78654392b 100644 --- a/src/vs/editor/contrib/gotoSymbol/browser/goToCommands.ts +++ b/src/vs/editor/contrib/gotoSymbol/browser/goToCommands.ts @@ -13,7 +13,7 @@ import { CodeEditorStateFlag, EditorStateCancellationTokenSource } from 'vs/edit import { IActiveCodeEditor, ICodeEditor, isCodeEditor } from 'vs/editor/browser/editorBrowser'; import { EditorAction2, ServicesAccessor } from 'vs/editor/browser/editorExtensions'; import { ICodeEditorService } from 'vs/editor/browser/services/codeEditorService'; -import { EmbeddedCodeEditorWidget } from 'vs/editor/browser/widget/embeddedCodeEditorWidget'; +import { EmbeddedCodeEditorWidget } from 'vs/editor/browser/widget/codeEditor/embeddedCodeEditorWidget'; import { EditorOption, GoToLocationValues } from 'vs/editor/common/config/editorOptions'; import * as corePosition from 'vs/editor/common/core/position'; import { IRange, Range } from 'vs/editor/common/core/range'; @@ -286,9 +286,7 @@ registerAction2(class GoToDefinitionAction extends DefinitionAction { ...nls.localize2('actions.goToDecl.label', "Go to Definition"), mnemonicTitle: nls.localize({ key: 'miGotoDefinition', comment: ['&& denotes a mnemonic'] }, "Go to &&Definition"), }, - precondition: ContextKeyExpr.and( - EditorContextKeys.hasDefinitionProvider, - EditorContextKeys.isInWalkThroughSnippet.toNegated()), + precondition: EditorContextKeys.hasDefinitionProvider, keybinding: [{ when: EditorContextKeys.editorTextFocus, primary: KeyCode.F12, @@ -327,7 +325,7 @@ registerAction2(class OpenDefinitionToSideAction extends DefinitionAction { title: nls.localize2('actions.goToDeclToSide.label', "Open Definition to the Side"), precondition: ContextKeyExpr.and( EditorContextKeys.hasDefinitionProvider, - EditorContextKeys.isInWalkThroughSnippet.toNegated()), + EditorContextKeys.isInEmbeddedEditor.toNegated()), keybinding: [{ when: EditorContextKeys.editorTextFocus, primary: KeyChord(KeyMod.CtrlCmd | KeyCode.KeyK, KeyCode.F12), @@ -357,7 +355,7 @@ registerAction2(class PeekDefinitionAction extends DefinitionAction { precondition: ContextKeyExpr.and( EditorContextKeys.hasDefinitionProvider, PeekContext.notInPeekEditor, - EditorContextKeys.isInWalkThroughSnippet.toNegated() + EditorContextKeys.isInEmbeddedEditor.toNegated() ), keybinding: { when: EditorContextKeys.editorTextFocus, @@ -417,7 +415,7 @@ registerAction2(class GoToDeclarationAction extends DeclarationAction { }, precondition: ContextKeyExpr.and( EditorContextKeys.hasDeclarationProvider, - EditorContextKeys.isInWalkThroughSnippet.toNegated() + EditorContextKeys.isInEmbeddedEditor.toNegated() ), menu: [{ id: MenuId.EditorContext, @@ -451,7 +449,7 @@ registerAction2(class PeekDeclarationAction extends DeclarationAction { precondition: ContextKeyExpr.and( EditorContextKeys.hasDeclarationProvider, PeekContext.notInPeekEditor, - EditorContextKeys.isInWalkThroughSnippet.toNegated() + EditorContextKeys.isInEmbeddedEditor.toNegated() ), menu: { id: MenuId.EditorContextPeek, @@ -502,9 +500,7 @@ registerAction2(class GoToTypeDefinitionAction extends TypeDefinitionAction { ...nls.localize2('actions.goToTypeDefinition.label', "Go to Type Definition"), mnemonicTitle: nls.localize({ key: 'miGotoTypeDefinition', comment: ['&& denotes a mnemonic'] }, "Go to &&Type Definition"), }, - precondition: ContextKeyExpr.and( - EditorContextKeys.hasTypeDefinitionProvider, - EditorContextKeys.isInWalkThroughSnippet.toNegated()), + precondition: EditorContextKeys.hasTypeDefinitionProvider, keybinding: { when: EditorContextKeys.editorTextFocus, primary: 0, @@ -539,7 +535,7 @@ registerAction2(class PeekTypeDefinitionAction extends TypeDefinitionAction { precondition: ContextKeyExpr.and( EditorContextKeys.hasTypeDefinitionProvider, PeekContext.notInPeekEditor, - EditorContextKeys.isInWalkThroughSnippet.toNegated() + EditorContextKeys.isInEmbeddedEditor.toNegated() ), menu: { id: MenuId.EditorContextPeek, @@ -590,9 +586,7 @@ registerAction2(class GoToImplementationAction extends ImplementationAction { ...nls.localize2('actions.goToImplementation.label', "Go to Implementations"), mnemonicTitle: nls.localize({ key: 'miGotoImplementation', comment: ['&& denotes a mnemonic'] }, "Go to &&Implementations"), }, - precondition: ContextKeyExpr.and( - EditorContextKeys.hasImplementationProvider, - EditorContextKeys.isInWalkThroughSnippet.toNegated()), + precondition: EditorContextKeys.hasImplementationProvider, keybinding: { when: EditorContextKeys.editorTextFocus, primary: KeyMod.CtrlCmd | KeyCode.F12, @@ -627,7 +621,7 @@ registerAction2(class PeekImplementationAction extends ImplementationAction { precondition: ContextKeyExpr.and( EditorContextKeys.hasImplementationProvider, PeekContext.notInPeekEditor, - EditorContextKeys.isInWalkThroughSnippet.toNegated() + EditorContextKeys.isInEmbeddedEditor.toNegated() ), keybinding: { when: EditorContextKeys.editorTextFocus, @@ -680,7 +674,7 @@ registerAction2(class GoToReferencesAction extends ReferencesAction { precondition: ContextKeyExpr.and( EditorContextKeys.hasReferenceProvider, PeekContext.notInPeekEditor, - EditorContextKeys.isInWalkThroughSnippet.toNegated() + EditorContextKeys.isInEmbeddedEditor.toNegated() ), keybinding: { when: EditorContextKeys.editorTextFocus, @@ -718,7 +712,7 @@ registerAction2(class PeekReferencesAction extends ReferencesAction { precondition: ContextKeyExpr.and( EditorContextKeys.hasReferenceProvider, PeekContext.notInPeekEditor, - EditorContextKeys.isInWalkThroughSnippet.toNegated() + EditorContextKeys.isInEmbeddedEditor.toNegated() ), menu: { id: MenuId.EditorContextPeek, @@ -750,7 +744,7 @@ class GenericGoToLocationAction extends SymbolNavigationAction { title: nls.localize2('label.generic', "Go to Any Symbol"), precondition: ContextKeyExpr.and( PeekContext.notInPeekEditor, - EditorContextKeys.isInWalkThroughSnippet.toNegated() + EditorContextKeys.isInEmbeddedEditor.toNegated() ), }); } diff --git a/src/vs/editor/contrib/gotoSymbol/browser/peek/referencesTree.ts b/src/vs/editor/contrib/gotoSymbol/browser/peek/referencesTree.ts index ec127689cb02a..bb76dd67d6cc0 100644 --- a/src/vs/editor/contrib/gotoSymbol/browser/peek/referencesTree.ts +++ b/src/vs/editor/contrib/gotoSymbol/browser/peek/referencesTree.ts @@ -162,12 +162,14 @@ export class FileReferencesRenderer implements ITreeRenderer, index: number, templateData: OneReferenceTemplate): void { templateData.set(node.element, node.filterData); } - disposeTemplate(): void { + disposeTemplate(templateData: OneReferenceTemplate): void { + templateData.dispose(); } } diff --git a/src/vs/editor/contrib/gotoSymbol/browser/peek/referencesWidget.ts b/src/vs/editor/contrib/gotoSymbol/browser/peek/referencesWidget.ts index d23e38b81abd5..b80e75d47eca3 100644 --- a/src/vs/editor/contrib/gotoSymbol/browser/peek/referencesWidget.ts +++ b/src/vs/editor/contrib/gotoSymbol/browser/peek/referencesWidget.ts @@ -16,7 +16,7 @@ import { Schemas } from 'vs/base/common/network'; import { basenameOrAuthority, dirname } from 'vs/base/common/resources'; import 'vs/css!./referencesWidget'; import { ICodeEditor } from 'vs/editor/browser/editorBrowser'; -import { EmbeddedCodeEditorWidget } from 'vs/editor/browser/widget/embeddedCodeEditorWidget'; +import { EmbeddedCodeEditorWidget } from 'vs/editor/browser/widget/codeEditor/embeddedCodeEditorWidget'; import { IEditorOptions } from 'vs/editor/common/config/editorOptions'; import { IRange, Range } from 'vs/editor/common/core/range'; import { ScrollType } from 'vs/editor/common/editorCommon'; diff --git a/src/vs/editor/contrib/hover/browser/hover.ts b/src/vs/editor/contrib/hover/browser/hover.ts index 509eb67e32438..961acfc39c95f 100644 --- a/src/vs/editor/contrib/hover/browser/hover.ts +++ b/src/vs/editor/contrib/hover/browser/hover.ts @@ -470,7 +470,7 @@ class ShowOrFocusHoverAction extends EditorAction { ] }, "Show or Focus Hover"), metadata: { - description: `Show or Focus Hover`, + description: nls.localize2('showOrFocusHoverDescription', 'Show or focus the editor hover which shows documentation, references, and other content for a symbol at the current cursor position.'), args: [{ name: 'args', schema: { @@ -551,7 +551,10 @@ class ShowDefinitionPreviewHoverAction extends EditorAction { ] }, "Show Definition Preview Hover"), alias: 'Show Definition Preview Hover', - precondition: undefined + precondition: undefined, + metadata: { + description: nls.localize2('showDefinitionPreviewHoverDescription', 'Show the definition preview hover in the editor.'), + }, }); } @@ -596,7 +599,10 @@ class ScrollUpHoverAction extends EditorAction { kbExpr: EditorContextKeys.hoverFocused, primary: KeyCode.UpArrow, weight: KeybindingWeight.EditorContrib - } + }, + metadata: { + description: nls.localize2('scrollUpHoverDescription', 'Scroll up the editor hover.') + }, }); } @@ -626,7 +632,10 @@ class ScrollDownHoverAction extends EditorAction { kbExpr: EditorContextKeys.hoverFocused, primary: KeyCode.DownArrow, weight: KeybindingWeight.EditorContrib - } + }, + metadata: { + description: nls.localize2('scrollDownHoverDescription', 'Scroll down the editor hover.'), + }, }); } @@ -656,7 +665,10 @@ class ScrollLeftHoverAction extends EditorAction { kbExpr: EditorContextKeys.hoverFocused, primary: KeyCode.LeftArrow, weight: KeybindingWeight.EditorContrib - } + }, + metadata: { + description: nls.localize2('scrollLeftHoverDescription', 'Scroll left the editor hover.'), + }, }); } @@ -686,7 +698,10 @@ class ScrollRightHoverAction extends EditorAction { kbExpr: EditorContextKeys.hoverFocused, primary: KeyCode.RightArrow, weight: KeybindingWeight.EditorContrib - } + }, + metadata: { + description: nls.localize2('scrollRightHoverDescription', 'Scroll right the editor hover.') + }, }); } @@ -717,7 +732,10 @@ class PageUpHoverAction extends EditorAction { primary: KeyCode.PageUp, secondary: [KeyMod.Alt | KeyCode.UpArrow], weight: KeybindingWeight.EditorContrib - } + }, + metadata: { + description: nls.localize2('pageUpHoverDescription', 'Page up the editor hover.'), + }, }); } @@ -749,7 +767,10 @@ class PageDownHoverAction extends EditorAction { primary: KeyCode.PageDown, secondary: [KeyMod.Alt | KeyCode.DownArrow], weight: KeybindingWeight.EditorContrib - } + }, + metadata: { + description: nls.localize2('pageDownHoverDescription', 'Page down the editor hover.'), + }, }); } @@ -780,7 +801,10 @@ class GoToTopHoverAction extends EditorAction { primary: KeyCode.Home, secondary: [KeyMod.CtrlCmd | KeyCode.UpArrow], weight: KeybindingWeight.EditorContrib - } + }, + metadata: { + description: nls.localize2('goToTopHoverDescription', 'Go to the top of the editor hover.'), + }, }); } @@ -812,7 +836,10 @@ class GoToBottomHoverAction extends EditorAction { primary: KeyCode.End, secondary: [KeyMod.CtrlCmd | KeyCode.DownArrow], weight: KeybindingWeight.EditorContrib - } + }, + metadata: { + description: nls.localize2('goToBottomHoverDescription', 'Go to the bottom of the editor hover.') + }, }); } diff --git a/src/vs/editor/contrib/hover/browser/markerHoverParticipant.ts b/src/vs/editor/contrib/hover/browser/markerHoverParticipant.ts index d56720dcebd68..ffdc5ccf50fb0 100644 --- a/src/vs/editor/contrib/hover/browser/markerHoverParticipant.ts +++ b/src/vs/editor/contrib/hover/browser/markerHoverParticipant.ts @@ -170,15 +170,18 @@ export class MarkerHoverParticipant implements IEditorHoverParticipant { - context.hide(); - MarkerController.get(this._editor)?.showAtMarker(markerHover.marker); - this._editor.focus(); - } - }); + const markerController = MarkerController.get(this._editor); + if (markerController) { + context.statusBar.addAction({ + label: nls.localize('view problem', "View Problem"), + commandId: NextMarkerAction.ID, + run: () => { + context.hide(); + markerController.showAtMarker(markerHover.marker); + this._editor.focus(); + } + }); + } } if (!this._editor.getOption(EditorOption.readOnly)) { diff --git a/src/vs/editor/contrib/indentation/browser/indentation.ts b/src/vs/editor/contrib/indentation/browser/indentation.ts index a14b7d6d5aec9..9860a3b677e50 100644 --- a/src/vs/editor/contrib/indentation/browser/indentation.ts +++ b/src/vs/editor/contrib/indentation/browser/indentation.ts @@ -9,7 +9,7 @@ import { ICodeEditor } from 'vs/editor/browser/editorBrowser'; import { EditorAction, EditorContributionInstantiation, IActionOptions, registerEditorAction, registerEditorContribution, ServicesAccessor } from 'vs/editor/browser/editorExtensions'; import { ShiftCommand } from 'vs/editor/common/commands/shiftCommand'; import { EditorAutoIndentStrategy, EditorOption } from 'vs/editor/common/config/editorOptions'; -import { EditOperation, ISingleEditOperation } from 'vs/editor/common/core/editOperation'; +import { ISingleEditOperation } from 'vs/editor/common/core/editOperation'; import { IRange, Range } from 'vs/editor/common/core/range'; import { Selection } from 'vs/editor/common/core/selection'; import { ICommand, ICursorStateComputerData, IEditOperationBuilder, IEditorContribution } from 'vs/editor/common/editorCommon'; @@ -20,123 +20,11 @@ import { StandardTokenType } from 'vs/editor/common/encodedTokenAttributes'; import { ILanguageConfigurationService } from 'vs/editor/common/languages/languageConfigurationRegistry'; import { IndentConsts } from 'vs/editor/common/languages/supports/indentRules'; import { IModelService } from 'vs/editor/common/services/model'; -import * as indentUtils from 'vs/editor/contrib/indentation/browser/indentUtils'; +import * as indentUtils from 'vs/editor/contrib/indentation/common/indentUtils'; import * as nls from 'vs/nls'; import { IQuickInputService } from 'vs/platform/quickinput/common/quickInput'; -import { normalizeIndentation } from 'vs/editor/common/core/indentation'; import { getGoodIndentForLine, getIndentMetadata } from 'vs/editor/common/languages/autoIndent'; - -export function getReindentEditOperations(model: ITextModel, languageConfigurationService: ILanguageConfigurationService, startLineNumber: number, endLineNumber: number, inheritedIndent?: string): ISingleEditOperation[] { - if (model.getLineCount() === 1 && model.getLineMaxColumn(1) === 1) { - // Model is empty - return []; - } - - const indentationRules = languageConfigurationService.getLanguageConfiguration(model.getLanguageId()).indentationRules; - if (!indentationRules) { - return []; - } - - endLineNumber = Math.min(endLineNumber, model.getLineCount()); - - // Skip `unIndentedLinePattern` lines - while (startLineNumber <= endLineNumber) { - if (!indentationRules.unIndentedLinePattern) { - break; - } - - const text = model.getLineContent(startLineNumber); - if (!indentationRules.unIndentedLinePattern.test(text)) { - break; - } - - startLineNumber++; - } - - if (startLineNumber > endLineNumber - 1) { - return []; - } - - const { tabSize, indentSize, insertSpaces } = model.getOptions(); - const shiftIndent = (indentation: string, count?: number) => { - count = count || 1; - return ShiftCommand.shiftIndent(indentation, indentation.length + count, tabSize, indentSize, insertSpaces); - }; - const unshiftIndent = (indentation: string, count?: number) => { - count = count || 1; - return ShiftCommand.unshiftIndent(indentation, indentation.length + count, tabSize, indentSize, insertSpaces); - }; - const indentEdits: ISingleEditOperation[] = []; - - // indentation being passed to lines below - let globalIndent: string; - - // Calculate indentation for the first line - // If there is no passed-in indentation, we use the indentation of the first line as base. - const currentLineText = model.getLineContent(startLineNumber); - let adjustedLineContent = currentLineText; - if (inheritedIndent !== undefined && inheritedIndent !== null) { - globalIndent = inheritedIndent; - const oldIndentation = strings.getLeadingWhitespace(currentLineText); - - adjustedLineContent = globalIndent + currentLineText.substring(oldIndentation.length); - if (indentationRules.decreaseIndentPattern && indentationRules.decreaseIndentPattern.test(adjustedLineContent)) { - globalIndent = unshiftIndent(globalIndent); - adjustedLineContent = globalIndent + currentLineText.substring(oldIndentation.length); - - } - if (currentLineText !== adjustedLineContent) { - indentEdits.push(EditOperation.replaceMove(new Selection(startLineNumber, 1, startLineNumber, oldIndentation.length + 1), normalizeIndentation(globalIndent, indentSize, insertSpaces))); - } - } else { - globalIndent = strings.getLeadingWhitespace(currentLineText); - } - - // idealIndentForNextLine doesn't equal globalIndent when there is a line matching `indentNextLinePattern`. - let idealIndentForNextLine: string = globalIndent; - - if (indentationRules.increaseIndentPattern && indentationRules.increaseIndentPattern.test(adjustedLineContent)) { - idealIndentForNextLine = shiftIndent(idealIndentForNextLine); - globalIndent = shiftIndent(globalIndent); - } - else if (indentationRules.indentNextLinePattern && indentationRules.indentNextLinePattern.test(adjustedLineContent)) { - idealIndentForNextLine = shiftIndent(idealIndentForNextLine); - } - - startLineNumber++; - - // Calculate indentation adjustment for all following lines - for (let lineNumber = startLineNumber; lineNumber <= endLineNumber; lineNumber++) { - const text = model.getLineContent(lineNumber); - const oldIndentation = strings.getLeadingWhitespace(text); - const adjustedLineContent = idealIndentForNextLine + text.substring(oldIndentation.length); - - if (indentationRules.decreaseIndentPattern && indentationRules.decreaseIndentPattern.test(adjustedLineContent)) { - idealIndentForNextLine = unshiftIndent(idealIndentForNextLine); - globalIndent = unshiftIndent(globalIndent); - } - - if (oldIndentation !== idealIndentForNextLine) { - indentEdits.push(EditOperation.replaceMove(new Selection(lineNumber, 1, lineNumber, oldIndentation.length + 1), normalizeIndentation(idealIndentForNextLine, indentSize, insertSpaces))); - } - - // calculate idealIndentForNextLine - if (indentationRules.unIndentedLinePattern && indentationRules.unIndentedLinePattern.test(text)) { - // In reindent phase, if the line matches `unIndentedLinePattern` we inherit indentation from above lines - // but don't change globalIndent and idealIndentForNextLine. - continue; - } else if (indentationRules.increaseIndentPattern && indentationRules.increaseIndentPattern.test(adjustedLineContent)) { - globalIndent = shiftIndent(globalIndent); - idealIndentForNextLine = globalIndent; - } else if (indentationRules.indentNextLinePattern && indentationRules.indentNextLinePattern.test(adjustedLineContent)) { - idealIndentForNextLine = shiftIndent(idealIndentForNextLine); - } else { - idealIndentForNextLine = globalIndent; - } - } - - return indentEdits; -} +import { getReindentEditOperations } from '../common/indentation'; export class IndentationToSpacesAction extends EditorAction { public static readonly ID = 'editor.action.indentationToSpaces'; @@ -146,7 +34,10 @@ export class IndentationToSpacesAction extends EditorAction { id: IndentationToSpacesAction.ID, label: nls.localize('indentationToSpaces', "Convert Indentation to Spaces"), alias: 'Convert Indentation to Spaces', - precondition: EditorContextKeys.writable + precondition: EditorContextKeys.writable, + metadata: { + description: nls.localize2('indentationToSpacesDescription', "Convert the tab indentation to spaces."), + } }); } @@ -180,7 +71,10 @@ export class IndentationToTabsAction extends EditorAction { id: IndentationToTabsAction.ID, label: nls.localize('indentationToTabs', "Convert Indentation to Tabs"), alias: 'Convert Indentation to Tabs', - precondition: EditorContextKeys.writable + precondition: EditorContextKeys.writable, + metadata: { + description: nls.localize2('indentationToTabsDescription', "Convert the spaces indentation to tabs."), + } }); } @@ -273,7 +167,10 @@ export class IndentUsingTabs extends ChangeIndentationSizeAction { id: IndentUsingTabs.ID, label: nls.localize('indentUsingTabs', "Indent Using Tabs"), alias: 'Indent Using Tabs', - precondition: undefined + precondition: undefined, + metadata: { + description: nls.localize2('indentUsingTabsDescription', "Use indentation with tabs."), + } }); } } @@ -287,7 +184,10 @@ export class IndentUsingSpaces extends ChangeIndentationSizeAction { id: IndentUsingSpaces.ID, label: nls.localize('indentUsingSpaces', "Indent Using Spaces"), alias: 'Indent Using Spaces', - precondition: undefined + precondition: undefined, + metadata: { + description: nls.localize2('indentUsingSpacesDescription', "Use indentation with spaces."), + } }); } } @@ -301,7 +201,10 @@ export class ChangeTabDisplaySize extends ChangeIndentationSizeAction { id: ChangeTabDisplaySize.ID, label: nls.localize('changeTabDisplaySize', "Change Tab Display Size"), alias: 'Change Tab Display Size', - precondition: undefined + precondition: undefined, + metadata: { + description: nls.localize2('changeTabDisplaySizeDescription', "Change the space size equivalent of the tab."), + } }); } } @@ -315,7 +218,10 @@ export class DetectIndentation extends EditorAction { id: DetectIndentation.ID, label: nls.localize('detectIndentation', "Detect Indentation from Content"), alias: 'Detect Indentation from Content', - precondition: undefined + precondition: undefined, + metadata: { + description: nls.localize2('detectIndentationDescription', "Detect the indentation from content."), + } }); } @@ -338,7 +244,10 @@ export class ReindentLinesAction extends EditorAction { id: 'editor.action.reindentlines', label: nls.localize('editor.reindentlines', "Reindent Lines"), alias: 'Reindent Lines', - precondition: EditorContextKeys.writable + precondition: EditorContextKeys.writable, + metadata: { + description: nls.localize2('editor.reindentlinesDescription', "Reindent the lines of the editor."), + } }); } @@ -364,7 +273,10 @@ export class ReindentSelectedLinesAction extends EditorAction { id: 'editor.action.reindentselectedlines', label: nls.localize('editor.reindentselectedlines', "Reindent Selected Lines"), alias: 'Reindent Selected Lines', - precondition: EditorContextKeys.writable + precondition: EditorContextKeys.writable, + metadata: { + description: nls.localize2('editor.reindentselectedlinesDescription', "Reindent the selected lines of the editor."), + } }); } diff --git a/src/vs/editor/contrib/indentation/browser/indentUtils.ts b/src/vs/editor/contrib/indentation/common/indentUtils.ts similarity index 100% rename from src/vs/editor/contrib/indentation/browser/indentUtils.ts rename to src/vs/editor/contrib/indentation/common/indentUtils.ts diff --git a/src/vs/editor/contrib/indentation/common/indentation.ts b/src/vs/editor/contrib/indentation/common/indentation.ts new file mode 100644 index 0000000000000..760a14919fb88 --- /dev/null +++ b/src/vs/editor/contrib/indentation/common/indentation.ts @@ -0,0 +1,124 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as strings from 'vs/base/common/strings'; +import { ShiftCommand } from 'vs/editor/common/commands/shiftCommand'; +import { EditOperation, ISingleEditOperation } from 'vs/editor/common/core/editOperation'; +import { normalizeIndentation } from 'vs/editor/common/core/indentation'; +import { Selection } from 'vs/editor/common/core/selection'; +import { ILanguageConfigurationService } from 'vs/editor/common/languages/languageConfigurationRegistry'; +import { ITextModel } from 'vs/editor/common/model'; + +export function getReindentEditOperations(model: ITextModel, languageConfigurationService: ILanguageConfigurationService, startLineNumber: number, endLineNumber: number, inheritedIndent?: string): ISingleEditOperation[] { + if (model.getLineCount() === 1 && model.getLineMaxColumn(1) === 1) { + // Model is empty + return []; + } + + const indentationRules = languageConfigurationService.getLanguageConfiguration(model.getLanguageId()).indentationRules; + if (!indentationRules) { + return []; + } + + endLineNumber = Math.min(endLineNumber, model.getLineCount()); + + // Skip `unIndentedLinePattern` lines + while (startLineNumber <= endLineNumber) { + if (!indentationRules.unIndentedLinePattern) { + break; + } + + const text = model.getLineContent(startLineNumber); + if (!indentationRules.unIndentedLinePattern.test(text)) { + break; + } + + startLineNumber++; + } + + if (startLineNumber > endLineNumber - 1) { + return []; + } + + const { tabSize, indentSize, insertSpaces } = model.getOptions(); + const shiftIndent = (indentation: string, count?: number) => { + count = count || 1; + return ShiftCommand.shiftIndent(indentation, indentation.length + count, tabSize, indentSize, insertSpaces); + }; + const unshiftIndent = (indentation: string, count?: number) => { + count = count || 1; + return ShiftCommand.unshiftIndent(indentation, indentation.length + count, tabSize, indentSize, insertSpaces); + }; + const indentEdits: ISingleEditOperation[] = []; + + // indentation being passed to lines below + let globalIndent: string; + + // Calculate indentation for the first line + // If there is no passed-in indentation, we use the indentation of the first line as base. + const currentLineText = model.getLineContent(startLineNumber); + let adjustedLineContent = currentLineText; + if (inheritedIndent !== undefined && inheritedIndent !== null) { + globalIndent = inheritedIndent; + const oldIndentation = strings.getLeadingWhitespace(currentLineText); + + adjustedLineContent = globalIndent + currentLineText.substring(oldIndentation.length); + if (indentationRules.decreaseIndentPattern && indentationRules.decreaseIndentPattern.test(adjustedLineContent)) { + globalIndent = unshiftIndent(globalIndent); + adjustedLineContent = globalIndent + currentLineText.substring(oldIndentation.length); + + } + if (currentLineText !== adjustedLineContent) { + indentEdits.push(EditOperation.replaceMove(new Selection(startLineNumber, 1, startLineNumber, oldIndentation.length + 1), normalizeIndentation(globalIndent, indentSize, insertSpaces))); + } + } else { + globalIndent = strings.getLeadingWhitespace(currentLineText); + } + + // idealIndentForNextLine doesn't equal globalIndent when there is a line matching `indentNextLinePattern`. + let idealIndentForNextLine: string = globalIndent; + + if (indentationRules.increaseIndentPattern && indentationRules.increaseIndentPattern.test(adjustedLineContent)) { + idealIndentForNextLine = shiftIndent(idealIndentForNextLine); + globalIndent = shiftIndent(globalIndent); + } + else if (indentationRules.indentNextLinePattern && indentationRules.indentNextLinePattern.test(adjustedLineContent)) { + idealIndentForNextLine = shiftIndent(idealIndentForNextLine); + } + + startLineNumber++; + + // Calculate indentation adjustment for all following lines + for (let lineNumber = startLineNumber; lineNumber <= endLineNumber; lineNumber++) { + const text = model.getLineContent(lineNumber); + const oldIndentation = strings.getLeadingWhitespace(text); + const adjustedLineContent = idealIndentForNextLine + text.substring(oldIndentation.length); + + if (indentationRules.decreaseIndentPattern && indentationRules.decreaseIndentPattern.test(adjustedLineContent)) { + idealIndentForNextLine = unshiftIndent(idealIndentForNextLine); + globalIndent = unshiftIndent(globalIndent); + } + + if (oldIndentation !== idealIndentForNextLine) { + indentEdits.push(EditOperation.replaceMove(new Selection(lineNumber, 1, lineNumber, oldIndentation.length + 1), normalizeIndentation(idealIndentForNextLine, indentSize, insertSpaces))); + } + + // calculate idealIndentForNextLine + if (indentationRules.unIndentedLinePattern && indentationRules.unIndentedLinePattern.test(text)) { + // In reindent phase, if the line matches `unIndentedLinePattern` we inherit indentation from above lines + // but don't change globalIndent and idealIndentForNextLine. + continue; + } else if (indentationRules.increaseIndentPattern && indentationRules.increaseIndentPattern.test(adjustedLineContent)) { + globalIndent = shiftIndent(globalIndent); + idealIndentForNextLine = globalIndent; + } else if (indentationRules.indentNextLinePattern && indentationRules.indentNextLinePattern.test(adjustedLineContent)) { + idealIndentForNextLine = shiftIndent(idealIndentForNextLine); + } else { + idealIndentForNextLine = globalIndent; + } + } + + return indentEdits; +} diff --git a/src/vs/editor/contrib/indentation/test/browser/indentation.test.ts b/src/vs/editor/contrib/indentation/test/browser/indentation.test.ts index 18ee1b9309c9b..b1854502afb36 100644 --- a/src/vs/editor/contrib/indentation/test/browser/indentation.test.ts +++ b/src/vs/editor/contrib/indentation/test/browser/indentation.test.ts @@ -6,19 +6,35 @@ import * as assert from 'assert'; import { DisposableStore } from 'vs/base/common/lifecycle'; import { ensureNoDisposablesAreLeakedInTestSuite } from 'vs/base/test/common/utils'; +import { ILanguageConfigurationService } from 'vs/editor/common/languages/languageConfigurationRegistry'; +import { createTextModel } from 'vs/editor/test/common/testTextModel'; +import { TestInstantiationService } from 'vs/platform/instantiation/test/common/instantiationServiceMock'; import { Range } from 'vs/editor/common/core/range'; import { Selection } from 'vs/editor/common/core/selection'; -import { MetadataConsts, StandardTokenType } from 'vs/editor/common/encodedTokenAttributes'; -import { EncodedTokenizationResult, IState, TokenizationRegistry } from 'vs/editor/common/languages'; +import { MetadataConsts } from 'vs/editor/common/encodedTokenAttributes'; +import { EncodedTokenizationResult, IState, ITokenizationSupport, TokenizationRegistry } from 'vs/editor/common/languages'; import { ILanguageService } from 'vs/editor/common/languages/language'; -import { ILanguageConfigurationService } from 'vs/editor/common/languages/languageConfigurationRegistry'; import { NullState } from 'vs/editor/common/languages/nullTokenize'; import { AutoIndentOnPaste, IndentationToSpacesCommand, IndentationToTabsCommand } from 'vs/editor/contrib/indentation/browser/indentation'; import { withTestCodeEditor } from 'vs/editor/test/browser/testCodeEditor'; import { testCommand } from 'vs/editor/test/browser/testCommand'; -import { javascriptIndentationRules } from 'vs/editor/test/common/modes/supports/javascriptIndentationRules'; -import { javascriptOnEnterRules } from 'vs/editor/test/common/modes/supports/javascriptOnEnterRules'; -import { createTextModel } from 'vs/editor/test/common/testTextModel'; +import { goIndentationRules, htmlIndentationRules, javascriptIndentationRules, latexIndentationRules, luaIndentationRules, phpIndentationRules, rubyIndentationRules } from 'vs/editor/test/common/modes/supports/indentationRules'; +import { cppOnEnterRules, htmlOnEnterRules, javascriptOnEnterRules, phpOnEnterRules } from 'vs/editor/test/common/modes/supports/onEnterRules'; +import { TypeOperations } from 'vs/editor/common/cursor/cursorTypeOperations'; +import { cppBracketRules, goBracketRules, htmlBracketRules, latexBracketRules, luaBracketRules, phpBracketRules, rubyBracketRules, typescriptBracketRules, vbBracketRules } from 'vs/editor/test/common/modes/supports/bracketRules'; +import { latexAutoClosingPairsRules } from 'vs/editor/test/common/modes/supports/autoClosingPairsRules'; + +enum Language { + TypeScript, + Ruby, + PHP, + Go, + CPP, + HTML, + VB, + Latex, + Lua +} function testIndentationToSpacesCommand(lines: string[], selection: Selection, tabSize: number, expectedLines: string[], expectedSelection: Selection): void { testCommand(lines, null, selection, (accessor, sel) => new IndentationToSpacesCommand(sel, tabSize), expectedLines, expectedSelection); @@ -28,7 +44,105 @@ function testIndentationToTabsCommand(lines: string[], selection: Selection, tab testCommand(lines, null, selection, (accessor, sel) => new IndentationToTabsCommand(sel, tabSize), expectedLines, expectedSelection); } -suite('Editor Contrib - Indentation to Spaces', () => { +function registerLanguage(instantiationService: TestInstantiationService, languageId: string, language: Language, disposables: DisposableStore) { + const languageService = instantiationService.get(ILanguageService); + registerLanguageConfiguration(instantiationService, languageId, language, disposables); + disposables.add(languageService.registerLanguage({ id: languageId })); +} + +// TODO@aiday-mar read directly the configuration file +function registerLanguageConfiguration(instantiationService: TestInstantiationService, languageId: string, language: Language, disposables: DisposableStore) { + const languageConfigurationService = instantiationService.get(ILanguageConfigurationService); + switch (language) { + case Language.TypeScript: + disposables.add(languageConfigurationService.register(languageId, { + brackets: typescriptBracketRules, + comments: { + lineComment: '//', + blockComment: ['/*', '*/'] + }, + indentationRules: javascriptIndentationRules, + onEnterRules: javascriptOnEnterRules + })); + break; + case Language.Ruby: + disposables.add(languageConfigurationService.register(languageId, { + brackets: rubyBracketRules, + indentationRules: rubyIndentationRules, + })); + break; + case Language.PHP: + disposables.add(languageConfigurationService.register(languageId, { + brackets: phpBracketRules, + indentationRules: phpIndentationRules, + onEnterRules: phpOnEnterRules + })); + break; + case Language.Go: + disposables.add(languageConfigurationService.register(languageId, { + brackets: goBracketRules, + indentationRules: goIndentationRules + })); + break; + case Language.CPP: + disposables.add(languageConfigurationService.register(languageId, { + brackets: cppBracketRules, + onEnterRules: cppOnEnterRules + })); + break; + case Language.HTML: + disposables.add(languageConfigurationService.register(languageId, { + brackets: htmlBracketRules, + indentationRules: htmlIndentationRules, + onEnterRules: htmlOnEnterRules + })); + break; + case Language.VB: + disposables.add(languageConfigurationService.register(languageId, { + brackets: vbBracketRules, + })); + break; + case Language.Latex: + disposables.add(languageConfigurationService.register(languageId, { + brackets: latexBracketRules, + autoClosingPairs: latexAutoClosingPairsRules, + indentationRules: latexIndentationRules + })); + break; + case Language.Lua: + disposables.add(languageConfigurationService.register(languageId, { + brackets: luaBracketRules, + indentationRules: luaIndentationRules + })); + break; + } +} + +function registerTokens(instantiationService: TestInstantiationService, tokens: { startIndex: number; value: number }[][], languageId: string, disposables: DisposableStore) { + let lineIndex = 0; + const languageService = instantiationService.get(ILanguageService); + const tokenizationSupport: ITokenizationSupport = { + getInitialState: () => NullState, + tokenize: undefined!, + tokenizeEncoded: (line: string, hasEOL: boolean, state: IState): EncodedTokenizationResult => { + const tokensOnLine = tokens[lineIndex++]; + const encodedLanguageId = languageService.languageIdCodec.encodeLanguageId(languageId); + const result = new Uint32Array(2 * tokensOnLine.length); + for (let i = 0; i < tokensOnLine.length; i++) { + result[2 * i] = tokensOnLine[i].startIndex; + result[2 * i + 1] = + ( + (encodedLanguageId << MetadataConsts.LANGUAGEID_OFFSET) + | (tokensOnLine[i].value << MetadataConsts.TOKEN_TYPE_OFFSET) + ); + } + return new EncodedTokenizationResult(result, state); + } + }; + disposables.add(TokenizationRegistry.register(languageId, tokenizationSupport)); +} + +suite('Change Indentation to Spaces - TypeScript/Javascript', () => { ensureNoDisposablesAreLeakedInTestSuite(); @@ -117,7 +231,7 @@ suite('Editor Contrib - Indentation to Spaces', () => { }); }); -suite('Editor Contrib - Indentation to Tabs', () => { +suite('Change Indentation to Tabs - TypeScript/Javascript', () => { ensureNoDisposablesAreLeakedInTestSuite(); @@ -202,7 +316,9 @@ suite('Editor Contrib - Indentation to Tabs', () => { }); }); -suite('Editor Contrib - Auto Indent On Paste', () => { +suite('Indent With Tab - TypeScript/JavaScript', () => { + + const languageId = 'ts-test'; let disposables: DisposableStore; setup(() => { @@ -215,65 +331,66 @@ suite('Editor Contrib - Auto Indent On Paste', () => { ensureNoDisposablesAreLeakedInTestSuite(); - test('issue #119225: Do not add extra leading space when pasting JSDoc', () => { - const languageId = 'leadingSpacePaste'; - const model = createTextModel("", languageId, {}); + test('temp issue because there should be at least one passing test in a suite', () => { + assert.ok(true); + }); + + test.skip('issue #63388: perserve correct indentation on tab 1', () => { + + // https://github.com/microsoft/vscode/issues/63388 + + const model = createTextModel([ + '/*', + ' * Comment', + ' * /', + ].join('\n'), languageId, {}); disposables.add(model); - withTestCodeEditor(model, { autoIndent: 'full' }, (editor, viewModel, instantiationService) => { - const languageService = instantiationService.get(ILanguageService); - const languageConfigurationService = instantiationService.get(ILanguageConfigurationService); - disposables.add(languageService.registerLanguage({ id: languageId })); - disposables.add(TokenizationRegistry.register(languageId, { - getInitialState: (): IState => NullState, - tokenize: () => { - throw new Error('not implemented'); - }, - tokenizeEncoded: (line: string, hasEOL: boolean, state: IState): EncodedTokenizationResult => { - const tokensArr: number[] = []; - if (line.indexOf('*') !== -1) { - tokensArr.push(0); - tokensArr.push(StandardTokenType.Comment << MetadataConsts.TOKEN_TYPE_OFFSET); - } else { - tokensArr.push(0); - tokensArr.push(StandardTokenType.Other << MetadataConsts.TOKEN_TYPE_OFFSET); - } - const tokens = new Uint32Array(tokensArr.length); - for (let i = 0; i < tokens.length; i++) { - tokens[i] = tokensArr[i]; - } - return new EncodedTokenizationResult(tokens, state); - } - })); - disposables.add(languageConfigurationService.register(languageId, { - brackets: [ - ['{', '}'], - ['[', ']'], - ['(', ')'] - ], - comments: { - lineComment: '//', - blockComment: ['/*', '*/'] - }, - indentationRules: javascriptIndentationRules, - onEnterRules: javascriptOnEnterRules - })); - const autoIndentOnPasteController = editor.registerAndInstantiateContribution(AutoIndentOnPaste.ID, AutoIndentOnPaste); - const pasteText = [ - '/**', - ' * JSDoc', - ' */', - 'function a() {}' - ].join('\n'); + withTestCodeEditor(model, { autoIndent: "full" }, (editor, viewModel, instantiationService) => { - viewModel.paste(pasteText, true, undefined, 'keyboard'); - autoIndentOnPasteController.trigger(new Range(1, 1, 4, 16)); - assert.strictEqual(model.getValue(), pasteText); + registerLanguage(instantiationService, languageId, Language.TypeScript, disposables); + editor.setSelection(new Selection(1, 1, 3, 5)); + editor.executeCommands('editor.action.indentLines', TypeOperations.indent(viewModel.cursorConfig, editor.getModel(), editor.getSelections())); + assert.strictEqual(model.getValue(), [ + ' /*', + ' * Comment', + ' * /', + ].join('\n')); + }); + }); + + test.skip('issue #63388: perserve correct indentation on tab 2', () => { + + // https://github.com/microsoft/vscode/issues/63388 + + const model = createTextModel([ + 'switch (something) {', + ' case 1:', + ' whatever();', + ' break;', + '}', + ].join('\n'), languageId, {}); + disposables.add(model); + + withTestCodeEditor(model, { autoIndent: "full" }, (editor, viewModel, instantiationService) => { + + registerLanguage(instantiationService, languageId, Language.TypeScript, disposables); + editor.setSelection(new Selection(1, 1, 5, 2)); + editor.executeCommands('editor.action.indentLines', TypeOperations.indent(viewModel.cursorConfig, editor.getModel(), editor.getSelections())); + assert.strictEqual(model.getValue(), [ + ' switch (something) {', + ' case 1:', + ' whatever();', + ' break;', + ' }', + ].join('\n')); }); }); }); -suite('Editor Contrib - Keep Indent On Paste', () => { +suite('Auto Indent On Paste - TypeScript/JavaScript', () => { + + const languageId = 'ts-test'; let disposables: DisposableStore; setup(() => { @@ -286,25 +403,62 @@ suite('Editor Contrib - Keep Indent On Paste', () => { ensureNoDisposablesAreLeakedInTestSuite(); - test('issue #167299: Blank line removes indent', () => { - const languageId = 'blankLineRemovesIndent'; + test('issue #119225: Do not add extra leading space when pasting JSDoc', () => { + const model = createTextModel("", languageId, {}); disposables.add(model); + withTestCodeEditor(model, { autoIndent: 'full' }, (editor, viewModel, instantiationService) => { - const languageService = instantiationService.get(ILanguageService); - const languageConfigurationService = instantiationService.get(ILanguageConfigurationService); - disposables.add(languageService.registerLanguage({ id: languageId })); - disposables.add(languageConfigurationService.register(languageId, { - brackets: [ - ['{', '}'], - ['[', ']'], - ['(', ')'] + const pasteText = [ + '/**', + ' * JSDoc', + ' */', + 'function a() {}' + ].join('\n'); + const tokens = [ + [ + { startIndex: 0, value: 1 }, + { startIndex: 3, value: 1 }, ], - indentationRules: javascriptIndentationRules, - onEnterRules: javascriptOnEnterRules - })); - + [ + { startIndex: 0, value: 1 }, + { startIndex: 2, value: 1 }, + { startIndex: 8, value: 1 }, + ], + [ + { startIndex: 0, value: 1 }, + { startIndex: 1, value: 1 }, + { startIndex: 3, value: 0 }, + ], + [ + { startIndex: 0, value: 0 }, + { startIndex: 8, value: 0 }, + { startIndex: 9, value: 0 }, + { startIndex: 10, value: 0 }, + { startIndex: 11, value: 0 }, + { startIndex: 12, value: 0 }, + { startIndex: 13, value: 0 }, + { startIndex: 14, value: 0 }, + { startIndex: 15, value: 0 }, + ] + ]; + registerLanguage(instantiationService, languageId, Language.TypeScript, disposables); + registerTokens(instantiationService, tokens, languageId, disposables); const autoIndentOnPasteController = editor.registerAndInstantiateContribution(AutoIndentOnPaste.ID, AutoIndentOnPaste); + viewModel.paste(pasteText, true, undefined, 'keyboard'); + autoIndentOnPasteController.trigger(new Range(1, 1, 4, 16)); + assert.strictEqual(model.getValue(), pasteText); + }); + }); + + test('issue #167299: Blank line removes indent', () => { + + const model = createTextModel("", languageId, {}); + disposables.add(model); + + withTestCodeEditor(model, { autoIndent: 'full' }, (editor, viewModel, instantiationService) => { + + // no need for tokenization because there are no comments const pasteText = [ '', 'export type IncludeReference =', @@ -319,9 +473,1236 @@ suite('Editor Contrib - Keep Indent On Paste', () => { '}' ].join('\n'); + registerLanguage(instantiationService, languageId, Language.TypeScript, disposables); + const autoIndentOnPasteController = editor.registerAndInstantiateContribution(AutoIndentOnPaste.ID, AutoIndentOnPaste); viewModel.paste(pasteText, true, undefined, 'keyboard'); autoIndentOnPasteController.trigger(new Range(1, 1, 11, 2)); assert.strictEqual(model.getValue(), pasteText); }); }); + + test('issue #29803: do not indent when pasting text with only one line', () => { + + // https://github.com/microsoft/vscode/issues/29803 + + const model = createTextModel([ + 'const linkHandler = new Class(a, b, c,', + ' d)' + ].join('\n'), languageId, {}); + disposables.add(model); + + withTestCodeEditor(model, { autoIndent: 'full' }, (editor, viewModel, instantiationService) => { + registerLanguage(instantiationService, languageId, Language.TypeScript, disposables); + editor.setSelection(new Selection(2, 6, 2, 6)); + const text = ', null'; + viewModel.paste(text, true, undefined, 'keyboard'); + const autoIndentOnPasteController = editor.registerAndInstantiateContribution(AutoIndentOnPaste.ID, AutoIndentOnPaste); + autoIndentOnPasteController.trigger(new Range(2, 6, 2, 11)); + assert.strictEqual(model.getValue(), [ + 'const linkHandler = new Class(a, b, c,', + ' d, null)' + ].join('\n')); + }); + }); + + test('issue #29753: incorrect indentation after comment', () => { + + // https://github.com/microsoft/vscode/issues/29753 + + const model = createTextModel([ + 'class A {', + ' /**', + ' * used only for debug purposes.', + ' */', + ' private _codeInfo: KeyMapping[];', + '}', + ].join('\n'), languageId, {}); + disposables.add(model); + + withTestCodeEditor(model, { autoIndent: 'full' }, (editor, viewModel, instantiationService) => { + registerLanguage(instantiationService, languageId, Language.TypeScript, disposables); + editor.setSelection(new Selection(5, 24, 5, 34)); + const text = 'IMacLinuxKeyMapping'; + viewModel.paste(text, true, undefined, 'keyboard'); + const autoIndentOnPasteController = editor.registerAndInstantiateContribution(AutoIndentOnPaste.ID, AutoIndentOnPaste); + autoIndentOnPasteController.trigger(new Range(5, 24, 5, 43)); + assert.strictEqual(model.getValue(), [ + 'class A {', + ' /**', + ' * used only for debug purposes.', + ' */', + ' private _codeInfo: IMacLinuxKeyMapping[];', + '}', + ].join('\n')); + }); + }); + + test('issue #29753: incorrect indentation of header comment', () => { + + // https://github.com/microsoft/vscode/issues/29753 + + const model = createTextModel('', languageId, {}); + disposables.add(model); + + withTestCodeEditor(model, { autoIndent: 'full' }, (editor, viewModel, instantiationService) => { + registerLanguage(instantiationService, languageId, Language.TypeScript, disposables); + const text = [ + '/*----------------', + ' * Copyright (c) ', + ' * Licensed under ...', + ' *-----------------*/', + ].join('\n'); + viewModel.paste(text, true, undefined, 'keyboard'); + const autoIndentOnPasteController = editor.registerAndInstantiateContribution(AutoIndentOnPaste.ID, AutoIndentOnPaste); + autoIndentOnPasteController.trigger(new Range(1, 1, 4, 22)); + assert.strictEqual(model.getValue(), text); + }); + }); + + // Failing tests found in issues... + + test.skip('issue #181065: Incorrect paste of object within comment', () => { + + // https://github.com/microsoft/vscode/issues/181065 + + const model = createTextModel("", languageId, {}); + disposables.add(model); + + withTestCodeEditor(model, { autoIndent: 'full' }, (editor, viewModel, instantiationService) => { + const text = [ + '/**', + ' * @typedef {', + ' * }', + ' */' + ].join('\n'); + const tokens = [ + [ + { startIndex: 0, value: 1 }, + { startIndex: 3, value: 1 }, + ], + [ + { startIndex: 0, value: 1 }, + { startIndex: 2, value: 1 }, + { startIndex: 3, value: 1 }, + { startIndex: 11, value: 1 }, + { startIndex: 12, value: 0 }, + { startIndex: 13, value: 0 }, + ], + [ + { startIndex: 0, value: 1 }, + { startIndex: 2, value: 0 }, + { startIndex: 3, value: 0 }, + { startIndex: 4, value: 0 }, + ], + [ + { startIndex: 0, value: 1 }, + { startIndex: 1, value: 1 }, + { startIndex: 3, value: 0 }, + ] + ]; + registerLanguage(instantiationService, languageId, Language.TypeScript, disposables); + registerTokens(instantiationService, tokens, languageId, disposables); + const autoIndentOnPasteController = editor.registerAndInstantiateContribution(AutoIndentOnPaste.ID, AutoIndentOnPaste); + viewModel.paste(text, true, undefined, 'keyboard'); + autoIndentOnPasteController.trigger(new Range(1, 1, 4, 4)); + assert.strictEqual(model.getValue(), text); + }); + }); + + test.skip('issue #86301: preserve cursor at inserted indentation level', () => { + + // https://github.com/microsoft/vscode/issues/86301 + + const model = createTextModel([ + '() => {', + '', + '}', + ].join('\n'), languageId, {}); + disposables.add(model); + + withTestCodeEditor(model, { autoIndent: 'full' }, (editor, viewModel, instantiationService) => { + editor.setSelection(new Selection(2, 1, 2, 1)); + const text = [ + '() => {', + '', + '}', + '' + ].join('\n'); + registerLanguage(instantiationService, languageId, Language.TypeScript, disposables); + const autoIndentOnPasteController = editor.registerAndInstantiateContribution(AutoIndentOnPaste.ID, AutoIndentOnPaste); + viewModel.paste(text, true, undefined, 'keyboard'); + autoIndentOnPasteController.trigger(new Range(2, 1, 5, 1)); + + // notes: + // why is line 3 not indented to the same level as line 2? + // looks like the indentation is inserted correctly at line 5, but the cursor does not appear at the maximum indentation level? + assert.strictEqual(model.getValue(), [ + '() => {', + ' () => {', + ' ', // <- should also be indented + ' }', + ' ', // <- cursor should be at the end of the indentation + '}', + ].join('\n')); + + const selection = viewModel.getSelection(); + assert.deepStrictEqual(selection, new Selection(5, 5, 5, 5)); + }); + }); + + test.skip('issue #85781: indent line with extra white space', () => { + + // https://github.com/microsoft/vscode/issues/85781 + // note: still to determine whether this is a bug or not + + const model = createTextModel([ + '() => {', + ' console.log("a");', + '}', + ].join('\n'), languageId, {}); + disposables.add(model); + + withTestCodeEditor(model, { autoIndent: 'full' }, (editor, viewModel, instantiationService) => { + editor.setSelection(new Selection(2, 5, 2, 5)); + const text = [ + '() => {', + ' console.log("b")', + '}', + ' ' + ].join('\n'); + registerLanguage(instantiationService, languageId, Language.TypeScript, disposables); + const autoIndentOnPasteController = editor.registerAndInstantiateContribution(AutoIndentOnPaste.ID, AutoIndentOnPaste); + viewModel.paste(text, true, undefined, 'keyboard'); + // todo@aiday-mar, make sure range is correct, and make test work as in real life + autoIndentOnPasteController.trigger(new Range(2, 5, 5, 6)); + assert.strictEqual(model.getValue(), [ + '() => {', + ' () => {', + ' console.log("b")', + ' }', + ' console.log("a");', + '}', + ].join('\n')); + }); + }); + + test.skip('issue #29589: incorrect indentation of closing brace on paste', () => { + + // https://github.com/microsoft/vscode/issues/29589 + + const model = createTextModel('', languageId, {}); + disposables.add(model); + + withTestCodeEditor(model, { autoIndent: 'full' }, (editor, viewModel, instantiationService) => { + editor.setSelection(new Selection(2, 5, 2, 5)); + const text = [ + 'function makeSub(a,b) {', + 'subsent = sent.substring(a,b);', + 'return subsent;', + '}', + ].join('\n'); + registerLanguage(instantiationService, languageId, Language.TypeScript, disposables); + const autoIndentOnPasteController = editor.registerAndInstantiateContribution(AutoIndentOnPaste.ID, AutoIndentOnPaste); + viewModel.paste(text, true, undefined, 'keyboard'); + // todo@aiday-mar, make sure range is correct, and make test work as in real life + autoIndentOnPasteController.trigger(new Range(1, 1, 4, 2)); + assert.strictEqual(model.getValue(), [ + 'function makeSub(a,b) {', + 'subsent = sent.substring(a,b);', + 'return subsent;', + '}', + ].join('\n')); + }); + }); + + test.skip('issue #201420: incorrect indentation when first line is comment', () => { + + // https://github.com/microsoft/vscode/issues/201420 + + const model = createTextModel([ + 'function bar() {', + '', + '}', + ].join('\n'), languageId, {}); + disposables.add(model); + + withTestCodeEditor(model, { autoIndent: 'full' }, (editor, viewModel, instantiationService) => { + const tokens = [ + [{ startIndex: 0, value: 0 }, { startIndex: 8, value: 0 }, { startIndex: 9, value: 0 }, { startIndex: 12, value: 0 }, { startIndex: 13, value: 0 }, { startIndex: 14, value: 0 }, { startIndex: 15, value: 0 }, { startIndex: 16, value: 0 }], + [{ startIndex: 0, value: 1 }, { startIndex: 2, value: 1 }, { startIndex: 3, value: 1 }, { startIndex: 10, value: 1 }], + [{ startIndex: 0, value: 0 }, { startIndex: 5, value: 0 }, { startIndex: 6, value: 0 }, { startIndex: 9, value: 0 }, { startIndex: 10, value: 0 }, { startIndex: 11, value: 0 }, { startIndex: 12, value: 0 }, { startIndex: 14, value: 0 }], + [{ startIndex: 0, value: 0 }, { startIndex: 1, value: 0 }] + ]; + registerLanguage(instantiationService, languageId, Language.TypeScript, disposables); + registerTokens(instantiationService, tokens, languageId, disposables); + + editor.setSelection(new Selection(2, 1, 2, 1)); + const text = [ + '// comment', + 'const foo = 42', + ].join('\n'); + registerLanguage(instantiationService, languageId, Language.TypeScript, disposables); + const autoIndentOnPasteController = editor.registerAndInstantiateContribution(AutoIndentOnPaste.ID, AutoIndentOnPaste); + viewModel.paste(text, true, undefined, 'keyboard'); + autoIndentOnPasteController.trigger(new Range(2, 1, 3, 15)); + assert.strictEqual(model.getValue(), [ + 'function bar() {', + ' // comment', + ' const foo = 42', + '}', + ].join('\n')); + }); + }); }); + +suite('Auto Indent On Type - TypeScript/JavaScript', () => { + + const languageId = "ts-test"; + let disposables: DisposableStore; + + setup(() => { + disposables = new DisposableStore(); + }); + + teardown(() => { + disposables.dispose(); + }); + + ensureNoDisposablesAreLeakedInTestSuite(); + + // Failing tests from issues... + + test('issue #208215: indent after arrow function', () => { + + // https://github.com/microsoft/vscode/issues/208215 + + const model = createTextModel("", languageId, {}); + disposables.add(model); + + withTestCodeEditor(model, { autoIndent: "full" }, (editor, viewModel, instantiationService) => { + registerLanguage(instantiationService, languageId, Language.TypeScript, disposables); + viewModel.type('const add1 = (n) =>'); + viewModel.type("\n", 'keyboard'); + assert.strictEqual(model.getValue(), [ + 'const add1 = (n) =>', + ' ', + ].join('\n')); + }); + }); + + test('issue #208215: indent after arrow function 2', () => { + + // https://github.com/microsoft/vscode/issues/208215 + + const model = createTextModel([ + 'const array = [1, 2, 3, 4, 5];', + 'array.map(', + ' v =>', + ].join('\n'), languageId, {}); + disposables.add(model); + + withTestCodeEditor(model, { autoIndent: "full" }, (editor, viewModel, instantiationService) => { + registerLanguage(instantiationService, languageId, Language.TypeScript, disposables); + editor.setSelection(new Selection(3, 9, 3, 9)); + viewModel.type("\n", 'keyboard'); + assert.strictEqual(model.getValue(), [ + 'const array = [1, 2, 3, 4, 5];', + 'array.map(', + ' v =>', + ' ' + ].join('\n')); + }); + }); + + test('issue #116843: indent after arrow function', () => { + + // https://github.com/microsoft/vscode/issues/116843 + + const model = createTextModel("", languageId, {}); + disposables.add(model); + + withTestCodeEditor(model, { autoIndent: "full" }, (editor, viewModel, instantiationService) => { + + registerLanguage(instantiationService, languageId, Language.TypeScript, disposables); + + viewModel.type([ + 'const add1 = (n) =>', + ' n + 1;', + ].join('\n')); + viewModel.type("\n", 'keyboard'); + assert.strictEqual(model.getValue(), [ + 'const add1 = (n) =>', + ' n + 1;', + '', + ].join('\n')); + }); + }); + + test('issue #29755: do not add indentation on enter if indentation is already valid', () => { + + //https://github.com/microsoft/vscode/issues/29755 + + const model = createTextModel([ + 'function f() {', + ' const one = 1;', + ' const two = 2;', + '}', + ].join('\n'), languageId, {}); + disposables.add(model); + + withTestCodeEditor(model, { autoIndent: "full" }, (editor, viewModel, instantiationService) => { + + registerLanguage(instantiationService, languageId, Language.TypeScript, disposables); + editor.setSelection(new Selection(3, 1, 3, 1)); + viewModel.type('\n', 'keyboard'); + assert.strictEqual(model.getValue(), [ + 'function f() {', + ' const one = 1;', + '', + ' const two = 2;', + '}', + ].join('\n')); + }); + }); + + test('issue #36090', () => { + + // https://github.com/microsoft/vscode/issues/36090 + + const model = createTextModel([ + 'class ItemCtrl {', + ' getPropertiesByItemId(id) {', + ' return this.fetchItem(id)', + ' .then(item => {', + ' return this.getPropertiesOfItem(item);', + ' });', + ' }', + '}', + ].join('\n'), languageId, {}); + disposables.add(model); + + withTestCodeEditor(model, { autoIndent: 'advanced' }, (editor, viewModel, instantiationService) => { + registerLanguage(instantiationService, languageId, Language.TypeScript, disposables); + editor.setSelection(new Selection(7, 6, 7, 6)); + viewModel.type('\n', 'keyboard'); + assert.strictEqual(model.getValue(), + [ + 'class ItemCtrl {', + ' getPropertiesByItemId(id) {', + ' return this.fetchItem(id)', + ' .then(item => {', + ' return this.getPropertiesOfItem(item);', + ' });', + ' }', + ' ', + '}', + ].join('\n') + ); + assert.deepStrictEqual(editor.getSelection(), new Selection(8, 5, 8, 5)); + }); + }); + + test('issue #115304: indent block comment onEnter', () => { + + // https://github.com/microsoft/vscode/issues/115304 + + const model = createTextModel([ + '/** */', + 'function f() {}', + ].join('\n'), languageId, {}); + disposables.add(model); + + withTestCodeEditor(model, { autoIndent: 'advanced' }, (editor, viewModel, instantiationService) => { + registerLanguage(instantiationService, languageId, Language.TypeScript, disposables); + editor.setSelection(new Selection(1, 4, 1, 4)); + viewModel.type('\n', 'keyboard'); + assert.strictEqual(model.getValue(), + [ + '/**', + ' * ', + ' */', + 'function f() {}', + ].join('\n') + ); + assert.deepStrictEqual(editor.getSelection(), new Selection(2, 4, 2, 4)); + }); + }); + + test('issue #43244: indent when lambda arrow function is detected, outdent when end is reached', () => { + + // https://github.com/microsoft/vscode/issues/43244 + + const model = createTextModel([ + 'const array = [1, 2, 3, 4, 5];', + 'array.map(_)' + ].join('\n'), languageId, {}); + disposables.add(model); + + withTestCodeEditor(model, { autoIndent: "full" }, (editor, viewModel, instantiationService) => { + registerLanguage(instantiationService, languageId, Language.TypeScript, disposables); + editor.setSelection(new Selection(2, 12, 2, 12)); + viewModel.type("\n", 'keyboard'); + assert.strictEqual(model.getValue(), [ + 'const array = [1, 2, 3, 4, 5];', + 'array.map(_', + ' ', + ')' + ].join('\n')); + }); + }); + + test('issue #43244: incorrect indentation after if/for/while without braces', () => { + + // https://github.com/microsoft/vscode/issues/43244 + + const model = createTextModel([ + 'function f() {', + ' if (condition)', + '}' + ].join('\n'), languageId, {}); + disposables.add(model); + + withTestCodeEditor(model, { autoIndent: "full" }, (editor, viewModel, instantiationService) => { + + registerLanguage(instantiationService, languageId, Language.TypeScript, disposables); + editor.setSelection(new Selection(2, 19, 2, 19)); + viewModel.type("\n", 'keyboard'); + assert.strictEqual(model.getValue(), [ + 'function f() {', + ' if (condition)', + ' ', + '}', + ].join('\n')); + + viewModel.type("return;"); + viewModel.type("\n", 'keyboard'); + assert.strictEqual(model.getValue(), [ + 'function f() {', + ' if (condition)', + ' return;', + ' ', + '}', + ].join('\n')); + }); + }); + + // Failing tests... + + test.skip('issue #208232: incorrect indentation inside of comments', () => { + + // https://github.com/microsoft/vscode/issues/208232 + + const model = createTextModel([ + '/**', + 'indentation done for {', + '*/' + ].join('\n'), languageId, {}); + disposables.add(model); + + withTestCodeEditor(model, { autoIndent: "full" }, (editor, viewModel, instantiationService) => { + + registerLanguage(instantiationService, languageId, Language.TypeScript, disposables); + editor.setSelection(new Selection(2, 23, 2, 23)); + viewModel.type("\n", 'keyboard'); + assert.strictEqual(model.getValue(), [ + '/**', + 'indentation done for {', + '', + '*/' + ].join('\n')); + }); + }); + + test.skip('issue #43244: indent after equal sign is detected', () => { + + // https://github.com/microsoft/vscode/issues/43244 + // issue: Should indent after an equal sign is detected followed by whitespace characters. + // This should be outdented when a semi-colon is detected indicating the end of the assignment. + + // TODO: requires exploring indent/outdent pairs instead + + const model = createTextModel([ + 'const array =' + ].join('\n'), languageId, {}); + disposables.add(model); + + withTestCodeEditor(model, { autoIndent: "full" }, (editor, viewModel, instantiationService) => { + registerLanguage(instantiationService, languageId, Language.TypeScript, disposables); + editor.setSelection(new Selection(1, 14, 1, 14)); + viewModel.type("\n", 'keyboard'); + assert.strictEqual(model.getValue(), [ + 'const array =', + ' ' + ].join('\n')); + }); + }); + + test.skip('issue #43244: indent after dot detected after object/array signifying a method call', () => { + + // https://github.com/microsoft/vscode/issues/43244 + // issue: When a dot is written, we should detect that this is a method call and indent accordingly + + // TODO: requires exploring indent/outdent pairs instead + + const model = createTextModel([ + 'const array = [1, 2, 3];', + 'array.' + ].join('\n'), languageId, {}); + disposables.add(model); + + withTestCodeEditor(model, { autoIndent: "full" }, (editor, viewModel, instantiationService) => { + registerLanguage(instantiationService, languageId, Language.TypeScript, disposables); + editor.setSelection(new Selection(2, 7, 2, 7)); + viewModel.type("\n", 'keyboard'); + assert.strictEqual(model.getValue(), [ + 'const array = [1, 2, 3];', + 'array.', + ' ' + ].join('\n')); + }); + }); + + test.skip('issue #43244: indent after dot detected on a subsequent line after object/array signifying a method call', () => { + + // https://github.com/microsoft/vscode/issues/43244 + // issue: When a dot is written, we should detect that this is a method call and indent accordingly + + // TODO: requires exploring indent/outdent pairs instead + + const model = createTextModel([ + 'const array = [1, 2, 3]', + ].join('\n'), languageId, {}); + disposables.add(model); + + withTestCodeEditor(model, { autoIndent: "full" }, (editor, viewModel, instantiationService) => { + registerLanguage(instantiationService, languageId, Language.TypeScript, disposables); + editor.setSelection(new Selection(2, 7, 2, 7)); + viewModel.type("\n", 'keyboard'); + viewModel.type("."); + assert.strictEqual(model.getValue(), [ + 'const array = [1, 2, 3]', + ' .' + ].join('\n')); + }); + }); + + test.skip('issue #43244: keep indentation when methods called on object/array', () => { + + // https://github.com/microsoft/vscode/issues/43244 + // Currently passes, but should pass with all the tests above too + + // TODO: requires exploring indent/outdent pairs instead + + const model = createTextModel([ + 'const array = [1, 2, 3]', + ' .filter(() => true)' + ].join('\n'), languageId, {}); + disposables.add(model); + + withTestCodeEditor(model, { autoIndent: "full" }, (editor, viewModel, instantiationService) => { + registerLanguage(instantiationService, languageId, Language.TypeScript, disposables); + editor.setSelection(new Selection(2, 24, 2, 24)); + viewModel.type("\n", 'keyboard'); + assert.strictEqual(model.getValue(), [ + 'const array = [1, 2, 3]', + ' .filter(() => true)', + ' ' + ].join('\n')); + }); + }); + + test.skip('issue #43244: keep indentation when chained methods called on object/array', () => { + + // https://github.com/microsoft/vscode/issues/43244 + // When the call chain is not finished yet, and we type a dot, we do not want to change the indentation + + // TODO: requires exploring indent/outdent pairs instead + + const model = createTextModel([ + 'const array = [1, 2, 3]', + ' .filter(() => true)', + ' ' + ].join('\n'), languageId, {}); + disposables.add(model); + + withTestCodeEditor(model, { autoIndent: "full" }, (editor, viewModel, instantiationService) => { + registerLanguage(instantiationService, languageId, Language.TypeScript, disposables); + editor.setSelection(new Selection(3, 5, 3, 5)); + viewModel.type("."); + assert.strictEqual(model.getValue(), [ + 'const array = [1, 2, 3]', + ' .filter(() => true)', + ' .' // here we don't want to increase the indentation because we have chained methods + ].join('\n')); + }); + }); + + test.skip('issue #43244: outdent when a semi-color is detected indicating the end of the assignment', () => { + + // https://github.com/microsoft/vscode/issues/43244 + + // TODO: requires exploring indent/outdent pairs instead + + const model = createTextModel([ + 'const array = [1, 2, 3]', + ' .filter(() => true);' + ].join('\n'), languageId, {}); + disposables.add(model); + + withTestCodeEditor(model, { autoIndent: "full" }, (editor, viewModel, instantiationService) => { + registerLanguage(instantiationService, languageId, Language.TypeScript, disposables); + editor.setSelection(new Selection(2, 25, 2, 25)); + viewModel.type("\n", 'keyboard'); + assert.strictEqual(model.getValue(), [ + 'const array = [1, 2, 3]', + ' .filter(() => true);', + '' + ].join('\n')); + }); + }); + + + test.skip('issue #40115: keep indentation when added', () => { + + // https://github.com/microsoft/vscode/issues/40115 + + const model = createTextModel('function foo() {}', languageId, {}); + disposables.add(model); + + withTestCodeEditor(model, { autoIndent: "full" }, (editor, viewModel, instantiationService) => { + + registerLanguage(instantiationService, languageId, Language.TypeScript, disposables); + + editor.setSelection(new Selection(1, 17, 1, 17)); + viewModel.type("\n", 'keyboard'); + assert.strictEqual(model.getValue(), [ + 'function foo() {', + ' ', + '}', + ].join('\n')); + editor.setSelection(new Selection(2, 5, 2, 5)); + viewModel.type("\n", 'keyboard'); + assert.strictEqual(model.getValue(), [ + 'function foo() {', + ' ', + ' ', + '}', + ].join('\n')); + }); + }); + + test.skip('issue #193875: incorrect indentation on enter', () => { + + // https://github.com/microsoft/vscode/issues/193875 + + const model = createTextModel([ + '{', + ' for(;;)', + ' for(;;) {}', + '}', + ].join('\n'), languageId, {}); + disposables.add(model); + + withTestCodeEditor(model, { autoIndent: "full" }, (editor, viewModel, instantiationService) => { + + registerLanguage(instantiationService, languageId, Language.TypeScript, disposables); + editor.setSelection(new Selection(3, 14, 3, 14)); + viewModel.type("\n", 'keyboard'); + assert.strictEqual(model.getValue(), [ + '{', + ' for(;;)', + ' for(;;) {', + ' ', + ' }', + '}', + ].join('\n')); + }); + }); + + test.skip('issue #67678: indent on typing curly brace', () => { + + // https://github.com/microsoft/vscode/issues/67678 + + const model = createTextModel([ + 'if (true) {', + 'console.log("a")', + 'console.log("b")', + '', + ].join('\n'), languageId, {}); + disposables.add(model); + + withTestCodeEditor(model, { autoIndent: "full" }, (editor, viewModel, instantiationService) => { + + registerLanguage(instantiationService, languageId, Language.TypeScript, disposables); + editor.setSelection(new Selection(4, 1, 4, 1)); + viewModel.type("}", 'keyboard'); + assert.strictEqual(model.getValue(), [ + 'if (true) {', + ' console.log("a")', + ' console.log("b")', + '}', + ].join('\n')); + }); + }); + + test.skip('issue #46401: outdent when encountering bracket on line - allman style indentation', () => { + + // https://github.com/microsoft/vscode/issues/46401 + + const model = createTextModel([ + 'if (true)', + ' ', + ].join('\n'), languageId, {}); + disposables.add(model); + + withTestCodeEditor(model, { autoIndent: "full" }, (editor, viewModel, instantiationService) => { + + registerLanguage(instantiationService, languageId, Language.TypeScript, disposables); + editor.setSelection(new Selection(2, 5, 2, 5)); + viewModel.type("{}", 'keyboard'); + assert.strictEqual(model.getValue(), [ + 'if (true)', + '{}', + ].join('\n')); + + editor.setSelection(new Selection(2, 2, 2, 2)); + viewModel.type("\n", 'keyboard'); + assert.strictEqual(model.getValue(), [ + 'if (true)', + '{', + ' ', + '}' + ].join('\n')); + }); + }); + + test.skip('issue #125261: typing closing brace does not keep the current indentation', () => { + + // https://github.com/microsoft/vscode/issues/125261 + + const model = createTextModel([ + 'foo {', + ' ', + ].join('\n'), languageId, {}); + disposables.add(model); + + withTestCodeEditor(model, { autoIndent: "keep" }, (editor, viewModel, instantiationService) => { + registerLanguage(instantiationService, languageId, Language.TypeScript, disposables); + editor.setSelection(new Selection(2, 5, 2, 5)); + viewModel.type("}", 'keyboard'); + assert.strictEqual(model.getValue(), [ + 'foo {', + '}', + ].join('\n')); + }); + }); +}); + +suite('Auto Indent On Type - Ruby', () => { + + const languageId = "ruby-test"; + let disposables: DisposableStore; + + setup(() => { + disposables = new DisposableStore(); + }); + + teardown(() => { + disposables.dispose(); + }); + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('issue #198350: in or when incorrectly match non keywords for Ruby', () => { + + // https://github.com/microsoft/vscode/issues/198350 + + const model = createTextModel("", languageId, {}); + disposables.add(model); + + withTestCodeEditor(model, { autoIndent: "full" }, (editor, viewModel, instantiationService) => { + + registerLanguage(instantiationService, languageId, Language.Ruby, disposables); + + viewModel.type("def foo\n i"); + viewModel.type("n", 'keyboard'); + assert.strictEqual(model.getValue(), "def foo\n in"); + viewModel.type(" ", 'keyboard'); + assert.strictEqual(model.getValue(), "def foo\nin "); + + viewModel.model.setValue(""); + viewModel.type(" # in"); + assert.strictEqual(model.getValue(), " # in"); + viewModel.type(" ", 'keyboard'); + assert.strictEqual(model.getValue(), " # in "); + }); + }); + + // Failing tests... + + test.skip('issue #199846: in or when incorrectly match non keywords for Ruby', () => { + + // https://github.com/microsoft/vscode/issues/199846 + // explanation: happening because the # is detected probably as a comment + + const model = createTextModel("", languageId, {}); + disposables.add(model); + + withTestCodeEditor(model, { autoIndent: "full" }, (editor, viewModel, instantiationService) => { + + registerLanguage(instantiationService, languageId, Language.Ruby, disposables); + + viewModel.type("method('#foo') do"); + viewModel.type("\n", 'keyboard'); + assert.strictEqual(model.getValue(), [ + "method('#foo') do", + " " + ].join('\n')); + }); + }); +}); + +suite('Auto Indent On Type - PHP', () => { + + const languageId = "php-test"; + let disposables: DisposableStore; + + setup(() => { + disposables = new DisposableStore(); + }); + + teardown(() => { + disposables.dispose(); + }); + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('temp issue because there should be at least one passing test in a suite', () => { + assert.ok(true); + }); + + test.skip('issue #199050: should not indent after { detected in a string', () => { + + // https://github.com/microsoft/vscode/issues/199050 + + const model = createTextModel("$phrase = preg_replace('#(\{1|%s).*#su', '', $phrase);", languageId, {}); + disposables.add(model); + + withTestCodeEditor(model, { autoIndent: "full" }, (editor, viewModel, instantiationService) => { + + registerLanguage(instantiationService, languageId, Language.PHP, disposables); + editor.setSelection(new Selection(1, 54, 1, 54)); + viewModel.type("\n", 'keyboard'); + assert.strictEqual(model.getValue(), [ + "$phrase = preg_replace('#(\{1|%s).*#su', '', $phrase);", + "" + ].join('\n')); + }); + }); +}); + +suite('Auto Indent On Paste - Go', () => { + + const languageId = "go-test"; + let disposables: DisposableStore; + + setup(() => { + disposables = new DisposableStore(); + }); + + teardown(() => { + disposables.dispose(); + }); + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('temp issue because there should be at least one passing test in a suite', () => { + assert.ok(true); + }); + + test.skip('issue #199050: should not indent after { detected in a string', () => { + + // https://github.com/microsoft/vscode/issues/199050 + + const model = createTextModel([ + 'var s = `', + 'quick brown', + 'fox', + '`', + ].join('\n'), languageId, {}); + disposables.add(model); + + withTestCodeEditor(model, { autoIndent: "full" }, (editor, viewModel, instantiationService) => { + registerLanguage(instantiationService, languageId, Language.Go, disposables); + editor.setSelection(new Selection(3, 1, 3, 1)); + const text = ' '; + const autoIndentOnPasteController = editor.registerAndInstantiateContribution(AutoIndentOnPaste.ID, AutoIndentOnPaste); + viewModel.paste(text, true, undefined, 'keyboard'); + autoIndentOnPasteController.trigger(new Range(3, 1, 3, 3)); + assert.strictEqual(model.getValue(), [ + 'var s = `', + 'quick brown', + ' fox', + '`', + ].join('\n')); + }); + }); +}); + +suite('Auto Indent On Type - CPP', () => { + + const languageId = "cpp-test"; + let disposables: DisposableStore; + + setup(() => { + disposables = new DisposableStore(); + }); + + teardown(() => { + disposables.dispose(); + }); + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('temp issue because there should be at least one passing test in a suite', () => { + assert.ok(true); + }); + + test.skip('issue #178334: incorrect outdent of } when signature spans multiple lines', () => { + + // https://github.com/microsoft/vscode/issues/178334 + + const model = createTextModel([ + 'int WINAPI WinMain(bool instance,', + ' int nshowcmd) {}', + ].join('\n'), languageId, {}); + disposables.add(model); + + withTestCodeEditor(model, { autoIndent: "full" }, (editor, viewModel, instantiationService) => { + registerLanguage(instantiationService, languageId, Language.CPP, disposables); + editor.setSelection(new Selection(2, 20, 2, 20)); + viewModel.type("\n", 'keyboard'); + assert.strictEqual(model.getValue(), [ + 'int WINAPI WinMain(bool instance,', + ' int nshowcmd) {', + ' ', + '}' + ].join('\n')); + }); + }); + + test.skip('issue #118929: incorrect indent when // follows curly brace', () => { + + // https://github.com/microsoft/vscode/issues/118929 + + const model = createTextModel([ + 'if (true) { // jaja', + '}', + ].join('\n'), languageId, {}); + disposables.add(model); + + withTestCodeEditor(model, { autoIndent: "full" }, (editor, viewModel, instantiationService) => { + registerLanguage(instantiationService, languageId, Language.CPP, disposables); + editor.setSelection(new Selection(1, 20, 1, 20)); + viewModel.type("\n", 'keyboard'); + assert.strictEqual(model.getValue(), [ + 'if (true) { // jaja', + ' ', + '}', + ].join('\n')); + }); + }); + + test.skip('issue #111265: auto indentation set to "none" still changes the indentation', () => { + + // https://github.com/microsoft/vscode/issues/111265 + + const model = createTextModel([ + 'int func() {', + ' ', + ].join('\n'), languageId, {}); + disposables.add(model); + + withTestCodeEditor(model, { autoIndent: "none" }, (editor, viewModel, instantiationService) => { + + registerLanguage(instantiationService, languageId, Language.CPP, disposables); + editor.setSelection(new Selection(2, 3, 2, 3)); + viewModel.type("}", 'keyboard'); + assert.strictEqual(model.getValue(), [ + 'int func() {', + ' }', + ].join('\n')); + }); + }); + +}); + +suite('Auto Indent On Type - HTML', () => { + + const languageId = "html-test"; + let disposables: DisposableStore; + + setup(() => { + disposables = new DisposableStore(); + }); + + teardown(() => { + disposables.dispose(); + }); + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('temp issue because there should be at least one passing test in a suite', () => { + assert.ok(true); + }); + + test.skip('issue #61510: incorrect indentation after // in html file', () => { + + // https://github.com/microsoft/vscode/issues/178334 + + const model = createTextModel([ + '
',
+			'  foo //I press  at the end of this line',
+			'
', + ].join('\n'), languageId, {}); + disposables.add(model); + + withTestCodeEditor(model, { autoIndent: "full" }, (editor, viewModel, instantiationService) => { + registerLanguage(instantiationService, languageId, Language.HTML, disposables); + editor.setSelection(new Selection(2, 48, 2, 48)); + viewModel.type("\n", 'keyboard'); + assert.strictEqual(model.getValue(), [ + '
',
+				'  foo //I press  at the end of this line',
+				'  ',
+				'
', + ].join('\n')); + }); + }); +}); + +suite('Auto Indent On Type - Visual Basic', () => { + + const languageId = "vb-test"; + let disposables: DisposableStore; + + setup(() => { + disposables = new DisposableStore(); + }); + + teardown(() => { + disposables.dispose(); + }); + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('temp issue because there should be at least one passing test in a suite', () => { + assert.ok(true); + }); + + test.skip('issue #118932: no indentation in visual basic files', () => { + + // https://github.com/microsoft/vscode/issues/118932 + + const model = createTextModel([ + 'if True then', + ' Some code', + ' end i', + ].join('\n'), languageId, {}); + disposables.add(model); + + withTestCodeEditor(model, { autoIndent: "full" }, (editor, viewModel, instantiationService) => { + registerLanguage(instantiationService, languageId, Language.VB, disposables); + editor.setSelection(new Selection(3, 10, 3, 10)); + viewModel.type("f", 'keyboard'); + assert.strictEqual(model.getValue(), [ + 'if True then', + ' Some code', + 'end if', + ].join('\n')); + }); + }); +}); + + +suite('Auto Indent On Type - Latex', () => { + + const languageId = "latex-test"; + let disposables: DisposableStore; + + setup(() => { + disposables = new DisposableStore(); + }); + + teardown(() => { + disposables.dispose(); + }); + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('temp issue because there should be at least one passing test in a suite', () => { + assert.ok(true); + }); + + test.skip('issue #178075: no auto closing pair when indentation done', () => { + + // https://github.com/microsoft/vscode/issues/178075 + + const model = createTextModel([ + '\\begin{theorem}', + ' \\end', + ].join('\n'), languageId, {}); + disposables.add(model); + + withTestCodeEditor(model, { autoIndent: "full" }, (editor, viewModel, instantiationService) => { + registerLanguage(instantiationService, languageId, Language.Latex, disposables); + editor.setSelection(new Selection(2, 9, 2, 9)); + viewModel.type("{", 'keyboard'); + assert.strictEqual(model.getValue(), [ + '\\begin{theorem}', + '\\end{}', + ].join('\n')); + }); + }); +}); + +suite('Auto Indent On Type - Lua', () => { + + const languageId = "lua-test"; + let disposables: DisposableStore; + + setup(() => { + disposables = new DisposableStore(); + }); + + teardown(() => { + disposables.dispose(); + }); + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('temp issue because there should be at least one passing test in a suite', () => { + assert.ok(true); + }); + + test.skip('issue #178075: no auto closing pair when indentation done', () => { + + // https://github.com/microsoft/vscode/issues/178075 + + const model = createTextModel([ + 'print("asdf function asdf")', + ].join('\n'), languageId, {}); + disposables.add(model); + + withTestCodeEditor(model, { autoIndent: "full" }, (editor, viewModel, instantiationService) => { + registerLanguage(instantiationService, languageId, Language.Lua, disposables); + editor.setSelection(new Selection(1, 28, 1, 28)); + viewModel.type("\n", 'keyboard'); + assert.strictEqual(model.getValue(), [ + 'print("asdf function asdf")', + '' + ].join('\n')); + }); + }); +}); + diff --git a/src/vs/editor/contrib/inlineCompletions/browser/commands.ts b/src/vs/editor/contrib/inlineCompletions/browser/commands.ts index 9727bec235901..ddfde90862d25 100644 --- a/src/vs/editor/contrib/inlineCompletions/browser/commands.ts +++ b/src/vs/editor/contrib/inlineCompletions/browser/commands.ts @@ -76,7 +76,7 @@ export class TriggerInlineSuggestionAction extends EditorAction { await asyncTransaction(async tx => { /** @description triggerExplicitly from command */ await controller?.model.get()?.triggerExplicitly(tx); - controller?.playAudioCue(tx); + controller?.playAccessibilitySignal(tx); }); } } diff --git a/src/vs/editor/contrib/inlineCompletions/browser/ghostText.ts b/src/vs/editor/contrib/inlineCompletions/browser/ghostText.ts index 68f913238a70d..21abd0b709396 100644 --- a/src/vs/editor/contrib/inlineCompletions/browser/ghostText.ts +++ b/src/vs/editor/contrib/inlineCompletions/browser/ghostText.ts @@ -3,9 +3,12 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { equals } from 'vs/base/common/arrays'; import { splitLines } from 'vs/base/common/strings'; +import { Position } from 'vs/editor/common/core/position'; import { Range } from 'vs/editor/common/core/range'; -import { ColumnRange, applyEdits } from 'vs/editor/contrib/inlineCompletions/browser/utils'; +import { SingleTextEdit, TextEdit } from 'vs/editor/common/core/textEdit'; +import { ColumnRange } from 'vs/editor/contrib/inlineCompletions/browser/utils'; export class GhostText { constructor( @@ -24,13 +27,12 @@ export class GhostText { * Only used for testing/debugging. */ render(documentText: string, debug: boolean = false): string { - const l = this.lineNumber; - return applyEdits(documentText, [ - ...this.parts.map(p => ({ - range: { startLineNumber: l, endLineNumber: l, startColumn: p.column, endColumn: p.column }, - text: debug ? `[${p.lines.join('\n')}]` : p.lines.join('\n') - })), - ]); + return new TextEdit([ + ...this.parts.map(p => new SingleTextEdit( + Range.fromPositions(new Position(this.lineNumber, p.column)), + debug ? `[${p.lines.join('\n')}]` : p.lines.join('\n') + )), + ]).applyToString(documentText); } renderForScreenReader(lineText: string): string { @@ -40,12 +42,12 @@ export class GhostText { const lastPart = this.parts[this.parts.length - 1]; const cappedLineText = lineText.substr(0, lastPart.column - 1); - const text = applyEdits(cappedLineText, - this.parts.map(p => ({ - range: { startLineNumber: 1, endLineNumber: 1, startColumn: p.column, endColumn: p.column }, - text: p.lines.join('\n') - })) - ); + const text = new TextEdit([ + ...this.parts.map(p => new SingleTextEdit( + Range.fromPositions(new Position(1, p.column)), + p.lines.join('\n') + )), + ]).applyToString(cappedLineText); return text.substring(this.parts[0].column - 1); } @@ -105,14 +107,14 @@ export class GhostTextReplacement { const replaceRange = this.columnRange.toRange(this.lineNumber); if (debug) { - return applyEdits(documentText, [ - { range: Range.fromPositions(replaceRange.getStartPosition()), text: `(` }, - { range: Range.fromPositions(replaceRange.getEndPosition()), text: `)[${this.newLines.join('\n')}]` } - ]); + return new TextEdit([ + new SingleTextEdit(Range.fromPositions(replaceRange.getStartPosition()), '('), + new SingleTextEdit(Range.fromPositions(replaceRange.getEndPosition()), `)[${this.newLines.join('\n')}]`), + ]).applyToString(documentText); } else { - return applyEdits(documentText, [ - { range: replaceRange, text: this.newLines.join('\n') } - ]); + return new TextEdit([ + new SingleTextEdit(replaceRange, this.newLines.join('\n')), + ]).applyToString(documentText); } } @@ -135,6 +137,10 @@ export class GhostTextReplacement { export type GhostTextOrReplacement = GhostText | GhostTextReplacement; +export function ghostTextsOrReplacementsEqual(a: readonly GhostTextOrReplacement[] | undefined, b: readonly GhostTextOrReplacement[] | undefined): boolean { + return equals(a, b, ghostTextOrReplacementEquals); +} + export function ghostTextOrReplacementEquals(a: GhostTextOrReplacement | undefined, b: GhostTextOrReplacement | undefined): boolean { if (a === b) { return true; diff --git a/src/vs/editor/contrib/inlineCompletions/browser/ghostTextWidget.ts b/src/vs/editor/contrib/inlineCompletions/browser/ghostTextWidget.ts index 0cfd4f3cb4430..0c93d8465ab94 100644 --- a/src/vs/editor/contrib/inlineCompletions/browser/ghostTextWidget.ts +++ b/src/vs/editor/contrib/inlineCompletions/browser/ghostTextWidget.ts @@ -184,7 +184,7 @@ export class GhostTextWidget extends Disposable { } } -class AdditionalLinesWidget extends Disposable { +export class AdditionalLinesWidget extends Disposable { private _viewZoneId: string | undefined = undefined; public get viewZoneId(): string | undefined { return this._viewZoneId; } @@ -263,7 +263,7 @@ class AdditionalLinesWidget extends Disposable { } } -interface LineData { +export interface LineData { content: string; // Must not contain a linebreak! decorations: LineDecoration[]; } @@ -325,4 +325,4 @@ function renderLines(domNode: HTMLElement, tabSize: number, lines: LineData[], o domNode.innerHTML = trustedhtml as string; } -const ttPolicy = createTrustedTypesPolicy('editorGhostText', { createHTML: value => value }); +export const ttPolicy = createTrustedTypesPolicy('editorGhostText', { createHTML: value => value }); diff --git a/src/vs/editor/contrib/inlineCompletions/browser/hoverParticipant.ts b/src/vs/editor/contrib/inlineCompletions/browser/hoverParticipant.ts index 373eece44c2e0..90d4ffcba0c12 100644 --- a/src/vs/editor/contrib/inlineCompletions/browser/hoverParticipant.ts +++ b/src/vs/editor/contrib/inlineCompletions/browser/hoverParticipant.ts @@ -113,7 +113,7 @@ export class InlineCompletionsHoverParticipant implements IEditorHoverParticipan constObservable(null), model.selectedInlineCompletionIndex, model.inlineCompletionsCount, - model.selectedInlineCompletion.map(v => /** @description commands */ v?.inlineCompletion.source.inlineCompletions.commands ?? []), + model.activeCommands, ); context.fragment.appendChild(w.getDomNode()); @@ -142,7 +142,7 @@ export class InlineCompletionsHoverParticipant implements IEditorHoverParticipan disposableStore.add(autorun(reader => { /** @description update hover */ - const ghostText = part.controller.model.read(reader)?.ghostText.read(reader); + const ghostText = part.controller.model.read(reader)?.primaryGhostText.read(reader); if (ghostText) { const lineText = this._editor.getModel()!.getLineContent(ghostText.lineNumber); render(ghostText.renderForScreenReader(lineText)); diff --git a/src/vs/editor/contrib/inlineCompletions/browser/inlineCompletionContextKeys.ts b/src/vs/editor/contrib/inlineCompletions/browser/inlineCompletionContextKeys.ts index 1e9a7fbbbd00f..fcbcd62120fd3 100644 --- a/src/vs/editor/contrib/inlineCompletions/browser/inlineCompletionContextKeys.ts +++ b/src/vs/editor/contrib/inlineCompletions/browser/inlineCompletionContextKeys.ts @@ -33,10 +33,10 @@ export class InlineCompletionContextKeys extends Disposable { const model = this.model.read(reader); const state = model?.state.read(reader); - const isInlineCompletionVisible = !!state?.inlineCompletion && state?.ghostText !== undefined && !state?.ghostText.isEmpty(); + const isInlineCompletionVisible = !!state?.inlineCompletion && state?.primaryGhostText !== undefined && !state?.primaryGhostText.isEmpty(); this.inlineCompletionVisible.set(isInlineCompletionVisible); - if (state?.ghostText && state?.inlineCompletion) { + if (state?.primaryGhostText && state?.inlineCompletion) { this.suppressSuggestions.set(state.inlineCompletion.inlineCompletion.source.inlineCompletions.suppressSuggestions); } })); @@ -48,7 +48,7 @@ export class InlineCompletionContextKeys extends Disposable { let startsWithIndentation = false; let startsWithIndentationLessThanTabSize = true; - const ghostText = model?.ghostText.read(reader); + const ghostText = model?.primaryGhostText.read(reader); if (!!model?.selectedSuggestItem && ghostText && ghostText.parts.length > 0) { const { column, lines } = ghostText.parts[0]; diff --git a/src/vs/editor/contrib/inlineCompletions/browser/inlineCompletionsController.ts b/src/vs/editor/contrib/inlineCompletions/browser/inlineCompletionsController.ts index 4f38487a6ffb8..1ecb464978f3d 100644 --- a/src/vs/editor/contrib/inlineCompletions/browser/inlineCompletionsController.ts +++ b/src/vs/editor/contrib/inlineCompletions/browser/inlineCompletionsController.ts @@ -5,8 +5,8 @@ import { createStyleSheet2 } from 'vs/base/browser/dom'; import { alert } from 'vs/base/browser/ui/aria/aria'; -import { Disposable, toDisposable } from 'vs/base/common/lifecycle'; -import { ITransaction, autorun, autorunHandleChanges, constObservable, derived, disposableObservableValue, observableFromEvent, observableSignal, observableValue, transaction } from 'vs/base/common/observable'; +import { Disposable, DisposableStore, toDisposable } from 'vs/base/common/lifecycle'; +import { IObservable, ITransaction, autorun, autorunHandleChanges, constObservable, derived, disposableObservableValue, observableFromEvent, observableSignal, observableValue, transaction } from 'vs/base/common/observable'; import { CoreEditingCommands } from 'vs/editor/browser/coreCommands'; import { ICodeEditor } from 'vs/editor/browser/editorBrowser'; import { EditorOption } from 'vs/editor/common/config/editorOptions'; @@ -23,12 +23,15 @@ import { InlineCompletionsHintsWidget, InlineSuggestionHintsContentWidget } from import { InlineCompletionsModel, VersionIdChangeReason } from 'vs/editor/contrib/inlineCompletions/browser/inlineCompletionsModel'; import { SuggestWidgetAdaptor } from 'vs/editor/contrib/inlineCompletions/browser/suggestWidgetInlineCompletionProvider'; import { localize } from 'vs/nls'; -import { AudioCue, IAudioCueService } from 'vs/platform/audioCues/browser/audioCueService'; +import { AccessibilitySignal, IAccessibilitySignalService } from 'vs/platform/accessibilitySignal/browser/accessibilitySignalService'; import { ICommandService } from 'vs/platform/commands/common/commands'; import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; import { IContextKeyService } from 'vs/platform/contextkey/common/contextkey'; import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; import { IKeybindingService } from 'vs/platform/keybinding/common/keybinding'; +import { mapObservableArrayCached } from 'vs/base/common/observableInternal/utils'; +import { ISettableObservable, observableValueOpts } from 'vs/base/common/observableInternal/base'; +import { itemsEquals, itemEquals } from 'vs/base/common/equals'; export class InlineCompletionsController extends Disposable { static ID = 'editor.contrib.inlineCompletionsController'; @@ -37,9 +40,9 @@ export class InlineCompletionsController extends Disposable { return editor.getContribution(InlineCompletionsController.ID); } - public readonly model = disposableObservableValue('inlineCompletionModel', undefined); + public readonly model = this._register(disposableObservableValue('inlineCompletionModel', undefined)); private readonly _textModelVersionId = observableValue(this, -1); - private readonly _cursorPosition = observableValue(this, new Position(1, 1)); + private readonly _positions = observableValueOpts({ owner: this, equalsFn: itemsEquals(itemEquals()) }, [new Position(1, 1)]); private readonly _suggestWidgetAdaptor = this._register(new SuggestWidgetAdaptor( this.editor, () => this.model.get()?.selectedInlineCompletion.get()?.toSingleTextEdit(undefined), @@ -55,11 +58,20 @@ export class InlineCompletionsController extends Disposable { private readonly _enabled = observableFromEvent(this.editor.onDidChangeConfiguration, () => this.editor.getOption(EditorOption.inlineSuggest).enabled); private readonly _fontFamily = observableFromEvent(this.editor.onDidChangeConfiguration, () => this.editor.getOption(EditorOption.inlineSuggest).fontFamily); - private _ghostTextWidget = this._register(this._instantiationService.createInstance(GhostTextWidget, this.editor, { - ghostText: this.model.map((v, reader) => /** ghostText */ v?.ghostText.read(reader)), - minReservedLineCount: constObservable(0), - targetTextModel: this.model.map(v => v?.textModel), - })); + private readonly _ghostTexts = derived(this, (reader) => { + const model = this.model.read(reader); + return model?.ghostTexts.read(reader) ?? []; + }); + + private readonly _stablizedGhostTexts = convertItemsToStableObservables(this._ghostTexts, this._store); + + private readonly _ghostTextWidgets = mapObservableArrayCached(this, this._stablizedGhostTexts, (ghostText, store) => { + return store.add(this._instantiationService.createInstance(GhostTextWidget, this.editor, { + ghostText: ghostText, + minReservedLineCount: constObservable(0), + targetTextModel: this.model.map(v => v?.textModel), + })); + }).recomputeInitiallyAndOnChange(this._store); private readonly _debounceValue = this._debounceService.for( this._languageFeaturesService.inlineCompletionsProvider, @@ -67,7 +79,7 @@ export class InlineCompletionsController extends Disposable { { min: 50, max: 50 } ); - private readonly _playAudioCueSignal = observableSignal(this); + private readonly _playAccessibilitySignal = observableSignal(this); private readonly _isReadonly = observableFromEvent(this.editor.onDidChangeConfiguration, () => this.editor.getOption(EditorOption.readOnly)); private readonly _textModel = observableFromEvent(this.editor.onDidChangeModel, () => this.editor.getModel()); @@ -81,7 +93,7 @@ export class InlineCompletionsController extends Disposable { @ICommandService private readonly _commandService: ICommandService, @ILanguageFeatureDebounceService private readonly _debounceService: ILanguageFeatureDebounceService, @ILanguageFeaturesService private readonly _languageFeaturesService: ILanguageFeaturesService, - @IAudioCueService private readonly _audioCueService: IAudioCueService, + @IAccessibilitySignalService private readonly _accessibilitySignalService: IAccessibilitySignalService, @IKeybindingService private readonly _keybindingService: IKeybindingService, ) { super(); @@ -101,8 +113,8 @@ export class InlineCompletionsController extends Disposable { InlineCompletionsModel, textModel, this._suggestWidgetAdaptor.selectedItem, - this._cursorPosition, this._textModelVersionId, + this._positions, this._debounceValue, observableFromEvent(editor.onDidChangeConfiguration, () => editor.getOption(EditorOption.suggest).preview), observableFromEvent(editor.onDidChangeConfiguration, () => editor.getOption(EditorOption.suggest).previewMode), @@ -188,7 +200,7 @@ export class InlineCompletionsController extends Disposable { /** @description InlineCompletionsController.forceRenderingAbove */ const state = this.model.read(reader)?.state.read(reader); if (state?.suggestItem) { - if (state.ghostText.lineCount >= 2) { + if (state.primaryGhostText.lineCount >= 2) { this._suggestWidgetAdaptor.forceRenderingAbove(); } } else { @@ -202,14 +214,14 @@ export class InlineCompletionsController extends Disposable { let lastInlineCompletionId: string | undefined = undefined; this._register(autorunHandleChanges({ handleChange: (context, changeSummary) => { - if (context.didChange(this._playAudioCueSignal)) { + if (context.didChange(this._playAccessibilitySignal)) { lastInlineCompletionId = undefined; } return true; }, }, async reader => { - /** @description InlineCompletionsController.playAudioCueAndReadSuggestion */ - this._playAudioCueSignal.read(reader); + /** @description InlineCompletionsController.playAccessibilitySignalAndReadSuggestion */ + this._playAccessibilitySignal.read(reader); const model = this.model.read(reader); const state = model?.state.read(reader); @@ -220,10 +232,10 @@ export class InlineCompletionsController extends Disposable { if (state.inlineCompletion.semanticId !== lastInlineCompletionId) { lastInlineCompletionId = state.inlineCompletion.semanticId; - const lineText = model.textModel.getLineContent(state.ghostText.lineNumber); - this._audioCueService.playAudioCue(AudioCue.inlineSuggestion).then(() => { + const lineText = model.textModel.getLineContent(state.primaryGhostText.lineNumber); + this._accessibilitySignalService.playSignal(AccessibilitySignal.inlineSuggestion).then(() => { if (this.editor.getOption(EditorOption.screenReaderAnnounceInlineSuggestion)) { - this.provideScreenReaderUpdate(state.ghostText.renderForScreenReader(lineText)); + this.provideScreenReaderUpdate(state.primaryGhostText.renderForScreenReader(lineText)); } }); } @@ -238,8 +250,8 @@ export class InlineCompletionsController extends Disposable { this.editor.updateOptions({ inlineCompletionsAccessibilityVerbose: this._configurationService.getValue('accessibility.verbosity.inlineCompletions') }); } - public playAudioCue(tx: ITransaction) { - this._playAudioCueSignal.trigger(tx); + public playAccessibilitySignal(tx: ITransaction) { + this._playAccessibilitySignal.trigger(tx); } private provideScreenReaderUpdate(content: string): void { @@ -260,11 +272,11 @@ export class InlineCompletionsController extends Disposable { private updateObservables(tx: ITransaction, changeReason: VersionIdChangeReason): void { const newModel = this.editor.getModel(); this._textModelVersionId.set(newModel?.getVersionId() ?? -1, tx, changeReason); - this._cursorPosition.set(this.editor.getPosition() ?? new Position(1, 1), tx); + this._positions.set(this.editor.getSelections()?.map(selection => selection.getPosition()) ?? [new Position(1, 1)], tx); } public shouldShowHoverAt(range: Range) { - const ghostText = this.model.get()?.ghostText.get(); + const ghostText = this.model.get()?.primaryGhostText.get(); if (ghostText) { return ghostText.parts.some(p => range.containsPosition(new Position(ghostText.lineNumber, p.column))); } @@ -272,7 +284,7 @@ export class InlineCompletionsController extends Disposable { } public shouldShowHoverAtViewZone(viewZoneId: string): boolean { - return this._ghostTextWidget.ownsViewZone(viewZoneId); + return this._ghostTextWidgets.get()[0]?.ownsViewZone(viewZoneId) ?? false; } public hide() { @@ -281,3 +293,27 @@ export class InlineCompletionsController extends Disposable { }); } } + +function convertItemsToStableObservables(items: IObservable, store: DisposableStore): IObservable[]> { + const result = observableValue[]>('result', []); + const innerObservables: ISettableObservable[] = []; + + store.add(autorun(reader => { + const itemsValue = items.read(reader); + + transaction(tx => { + if (itemsValue.length !== innerObservables.length) { + innerObservables.length = itemsValue.length; + for (let i = 0; i < innerObservables.length; i++) { + if (!innerObservables[i]) { + innerObservables[i] = observableValue('item', itemsValue[i]); + } + } + result.set([...innerObservables], tx); + } + innerObservables.forEach((o, i) => o.set(itemsValue[i], tx)); + }); + })); + + return result; +} diff --git a/src/vs/editor/contrib/inlineCompletions/browser/inlineCompletionsHintsWidget.ts b/src/vs/editor/contrib/inlineCompletions/browser/inlineCompletionsHintsWidget.ts index 5229bc11df217..0bc511b4f7153 100644 --- a/src/vs/editor/contrib/inlineCompletions/browser/inlineCompletionsHintsWidget.ts +++ b/src/vs/editor/contrib/inlineCompletions/browser/inlineCompletionsHintsWidget.ts @@ -40,7 +40,7 @@ export class InlineCompletionsHintsWidget extends Disposable { private sessionPosition: Position | undefined = undefined; private readonly position = derived(this, reader => { - const ghostText = this.model.read(reader)?.ghostText.read(reader); + const ghostText = this.model.read(reader)?.primaryGhostText.read(reader); if (!this.alwaysShowToolbar.read(reader) || !ghostText || ghostText.parts.length === 0) { this.sessionPosition = undefined; @@ -78,7 +78,7 @@ export class InlineCompletionsHintsWidget extends Disposable { this.position, model.selectedInlineCompletionIndex, model.inlineCompletionsCount, - model.selectedInlineCompletion.map(v => /** @description commands */ v?.inlineCompletion.source.inlineCompletions.commands ?? []), + model.activeCommands, )); editor.addContentWidget(contentWidget); store.add(toDisposable(() => editor.removeContentWidget(contentWidget))); @@ -151,8 +151,6 @@ export class InlineSuggestionHintsContentWidget extends Disposable implements IC this.previousAction.enabled = this.nextAction.enabled = false; }, 100)); - private lastCommands: Command[] = []; - constructor( private readonly editor: ICodeEditor, private readonly withBorder: boolean, @@ -225,13 +223,6 @@ export class InlineSuggestionHintsContentWidget extends Disposable implements IC this._register(autorun(reader => { /** @description extra commands */ const extraCommands = this._extraCommands.read(reader); - if (equals(this.lastCommands, extraCommands)) { - // nothing to update - return; - } - - this.lastCommands = extraCommands; - const extraActions = extraCommands.map(c => ({ class: undefined, id: c.id, @@ -302,7 +293,7 @@ class StatusBarViewItem extends MenuEntryActionViewItem { if (this.label) { const div = h('div.keybinding').root; - const k = new KeybindingLabel(div, OS, { disableTitle: true, ...unthemedKeybindingLabelOptions }); + const k = this._register(new KeybindingLabel(div, OS, { disableTitle: true, ...unthemedKeybindingLabelOptions })); k.set(kb); this.label.textContent = this._action.label; this.label.appendChild(div); diff --git a/src/vs/editor/contrib/inlineCompletions/browser/inlineCompletionsModel.ts b/src/vs/editor/contrib/inlineCompletions/browser/inlineCompletionsModel.ts index c031bdf51fc05..7cb849da9db45 100644 --- a/src/vs/editor/contrib/inlineCompletions/browser/inlineCompletionsModel.ts +++ b/src/vs/editor/contrib/inlineCompletions/browser/inlineCompletionsModel.ts @@ -3,26 +3,30 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { Permutation } from 'vs/base/common/arrays'; import { mapFindFirst } from 'vs/base/common/arraysFind'; -import { BugIndicatingError, onUnexpectedExternalError } from 'vs/base/common/errors'; +import { itemsEquals } from 'vs/base/common/equals'; +import { BugIndicatingError, onUnexpectedError, onUnexpectedExternalError } from 'vs/base/common/errors'; import { Disposable } from 'vs/base/common/lifecycle'; -import { IObservable, IReader, ITransaction, autorun, derived, derivedHandleChanges, derivedOpts, recomputeInitiallyAndOnChange, observableSignal, observableValue, subtransaction, transaction } from 'vs/base/common/observable'; -import { commonPrefixLength } from 'vs/base/common/strings'; +import { IObservable, IReader, ITransaction, autorun, derived, derivedHandleChanges, derivedOpts, observableSignal, observableValue, recomputeInitiallyAndOnChange, subtransaction, transaction } from 'vs/base/common/observable'; +import { commonPrefixLength, splitLinesIncludeSeparators } from 'vs/base/common/strings'; import { isDefined } from 'vs/base/common/types'; import { ICodeEditor } from 'vs/editor/browser/editorBrowser'; import { EditOperation } from 'vs/editor/common/core/editOperation'; import { Position } from 'vs/editor/common/core/position'; import { Range } from 'vs/editor/common/core/range'; import { Selection } from 'vs/editor/common/core/selection'; -import { InlineCompletionContext, InlineCompletionTriggerKind } from 'vs/editor/common/languages'; +import { SingleTextEdit, TextEdit } from 'vs/editor/common/core/textEdit'; +import { TextLength } from 'vs/editor/common/core/textLength'; +import { Command, InlineCompletionContext, InlineCompletionTriggerKind, PartialAcceptTriggerKind } from 'vs/editor/common/languages'; import { ILanguageConfigurationService } from 'vs/editor/common/languages/languageConfigurationRegistry'; import { EndOfLinePreference, ITextModel } from 'vs/editor/common/model'; import { IFeatureDebounceInformation } from 'vs/editor/common/services/languageFeatureDebounce'; -import { GhostText, GhostTextOrReplacement, ghostTextOrReplacementEquals } from 'vs/editor/contrib/inlineCompletions/browser/ghostText'; +import { GhostText, GhostTextOrReplacement, ghostTextOrReplacementEquals, ghostTextsOrReplacementsEqual } from 'vs/editor/contrib/inlineCompletions/browser/ghostText'; import { InlineCompletionWithUpdatedRange, InlineCompletionsSource } from 'vs/editor/contrib/inlineCompletions/browser/inlineCompletionsSource'; -import { SingleTextEdit } from 'vs/editor/contrib/inlineCompletions/browser/singleTextEdit'; +import { computeGhostText, singleTextEditAugments, singleTextRemoveCommonPrefix } from 'vs/editor/contrib/inlineCompletions/browser/singleTextEdit'; import { SuggestItemInfo } from 'vs/editor/contrib/inlineCompletions/browser/suggestWidgetInlineCompletionProvider'; -import { Permutation, addPositions, getNewRanges, lengthOfText } from 'vs/editor/contrib/inlineCompletions/browser/utils'; +import { addPositions, subtractPositions } from 'vs/editor/contrib/inlineCompletions/browser/utils'; import { SnippetController2 } from 'vs/editor/contrib/snippet/browser/snippetController2'; import { ICommandService } from 'vs/platform/commands/common/commands'; import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; @@ -37,10 +41,11 @@ export enum VersionIdChangeReason { export class InlineCompletionsModel extends Disposable { private readonly _source = this._register(this._instantiationService.createInstance(InlineCompletionsSource, this.textModel, this.textModelVersionId, this._debounceValue)); private readonly _isActive = observableValue(this, false); - readonly _forceUpdateSignal = observableSignal('forceUpdate'); + readonly _forceUpdateExplicitlySignal = observableSignal(this); // We use a semantic id to keep the same inline completion selected even if the provider reorders the completions. private readonly _selectedInlineCompletionId = observableValue(this, undefined); + private readonly _primaryPosition = derived(this, reader => this._positions.read(reader)[0] ?? new Position(1, 1)); private _isAcceptingPartially = false; public get isAcceptingPartially() { return this._isAcceptingPartially; } @@ -48,8 +53,8 @@ export class InlineCompletionsModel extends Disposable { constructor( public readonly textModel: ITextModel, public readonly selectedSuggestItem: IObservable, - public readonly cursorPosition: IObservable, public readonly textModelVersionId: IObservable, + private readonly _positions: IObservable, private readonly _debounceValue: IFeatureDebounceInformation, private readonly _suggestPreviewEnabled: IObservable, private readonly _suggestPreviewMode: IObservable<'prefix' | 'subword' | 'subwordSmart'>, @@ -61,7 +66,7 @@ export class InlineCompletionsModel extends Disposable { ) { super(); - this._register(recomputeInitiallyAndOnChange(this._fetchInlineCompletions)); + this._register(recomputeInitiallyAndOnChange(this._fetchInlineCompletionsPromise)); let lastItem: InlineCompletionWithUpdatedRange | undefined = undefined; this._register(autorun(reader => { @@ -84,7 +89,8 @@ export class InlineCompletionsModel extends Disposable { VersionIdChangeReason.Undo, VersionIdChangeReason.AcceptWord, ]); - private readonly _fetchInlineCompletions = derivedHandleChanges({ + + private readonly _fetchInlineCompletionsPromise = derivedHandleChanges({ owner: this, createEmptyChangeSummary: () => ({ preserveCurrentCompletion: false, @@ -94,13 +100,13 @@ export class InlineCompletionsModel extends Disposable { /** @description fetch inline completions */ if (ctx.didChange(this.textModelVersionId) && this._preserveCurrentCompletionReasons.has(ctx.change)) { changeSummary.preserveCurrentCompletion = true; - } else if (ctx.didChange(this._forceUpdateSignal)) { - changeSummary.inlineCompletionTriggerKind = ctx.change; + } else if (ctx.didChange(this._forceUpdateExplicitlySignal)) { + changeSummary.inlineCompletionTriggerKind = InlineCompletionTriggerKind.Explicit; } return true; }, }, (reader, changeSummary) => { - this._forceUpdateSignal.read(reader); + this._forceUpdateExplicitlySignal.read(reader); const shouldUpdate = (this._enabled.read(reader) && this.selectedSuggestItem.read(reader)) || this._isActive.read(reader); if (!shouldUpdate) { this._source.cancelUpdate(); @@ -109,10 +115,6 @@ export class InlineCompletionsModel extends Disposable { this.textModelVersionId.read(reader); // Refetch on text change - const itemToPreserveCandidate = this.selectedInlineCompletion.get(); - const itemToPreserve = changeSummary.preserveCurrentCompletion || itemToPreserveCandidate?.forwardStable - ? itemToPreserveCandidate : undefined; - const suggestWidgetInlineCompletions = this._source.suggestWidgetInlineCompletions.get(); const suggestItem = this.selectedSuggestItem.read(reader); if (suggestWidgetInlineCompletions && !suggestItem) { @@ -126,25 +128,28 @@ export class InlineCompletionsModel extends Disposable { }); } - const cursorPosition = this.cursorPosition.read(reader); + const cursorPosition = this._primaryPosition.read(reader); const context: InlineCompletionContext = { triggerKind: changeSummary.inlineCompletionTriggerKind, selectedSuggestionInfo: suggestItem?.toSelectedSuggestionInfo(), }; + const itemToPreserveCandidate = this.selectedInlineCompletion.get(); + const itemToPreserve = changeSummary.preserveCurrentCompletion || itemToPreserveCandidate?.forwardStable + ? itemToPreserveCandidate : undefined; return this._source.fetch(cursorPosition, context, itemToPreserve); }); public async trigger(tx?: ITransaction): Promise { this._isActive.set(true, tx); - await this._fetchInlineCompletions.get(); + await this._fetchInlineCompletionsPromise.get(); } public async triggerExplicitly(tx?: ITransaction): Promise { subtransaction(tx, tx => { this._isActive.set(true, tx); - this._forceUpdateSignal.trigger(tx, InlineCompletionTriggerKind.Explicit); + this._forceUpdateExplicitlySignal.trigger(tx); }); - await this._fetchInlineCompletions.get(); + await this._fetchInlineCompletionsPromise.get(); } public stop(tx?: ITransaction): void { @@ -154,10 +159,10 @@ export class InlineCompletionsModel extends Disposable { }); } - private readonly _filteredInlineCompletionItems = derived(this, reader => { + private readonly _filteredInlineCompletionItems = derivedOpts({ owner: this, equalsFn: itemsEquals() }, reader => { const c = this._source.inlineCompletions.read(reader); if (!c) { return []; } - const cursorPosition = this.cursorPosition.read(reader); + const cursorPosition = this._primaryPosition.read(reader); const filteredCompletions = c.inlineCompletions.filter(c => c.isVisible(this.textModel, cursorPosition, reader)); return filteredCompletions; }); @@ -181,6 +186,10 @@ export class InlineCompletionsModel extends Disposable { return filteredCompletions[idx]; }); + public readonly activeCommands = derivedOpts({ owner: this, equalsFn: itemsEquals() }, + r => this.selectedInlineCompletion.read(r)?.inlineCompletion.source.inlineCompletions.commands ?? [] + ); + public readonly lastTriggerKind: IObservable = this._source.inlineCompletions.map(this, v => v?.request.context.triggerKind); @@ -193,14 +202,16 @@ export class InlineCompletionsModel extends Disposable { }); public readonly state = derivedOpts<{ + edits: readonly SingleTextEdit[]; + primaryGhostText: GhostTextOrReplacement; + ghostTexts: readonly GhostTextOrReplacement[]; suggestItem: SuggestItemInfo | undefined; inlineCompletion: InlineCompletionWithUpdatedRange | undefined; - ghostText: GhostTextOrReplacement; } | undefined>({ owner: this, - equalityComparer: (a, b) => { + equalsFn: (a, b) => { if (!a || !b) { return a === b; } - return ghostTextOrReplacementEquals(a.ghostText, b.ghostText) + return ghostTextsOrReplacementsEqual(a.ghostTexts, b.ghostTexts) && a.inlineCompletion === b.inlineCompletion && a.suggestItem === b.suggestItem; } @@ -209,36 +220,41 @@ export class InlineCompletionsModel extends Disposable { const suggestItem = this.selectedSuggestItem.read(reader); if (suggestItem) { - const suggestCompletion = suggestItem.toSingleTextEdit().removeCommonPrefix(model); - const augmentedCompletion = this._computeAugmentedCompletion(suggestCompletion, reader); + const suggestCompletionEdit = singleTextRemoveCommonPrefix(suggestItem.toSingleTextEdit(), model); + const augmentation = this._computeAugmentation(suggestCompletionEdit, reader); const isSuggestionPreviewEnabled = this._suggestPreviewEnabled.read(reader); - if (!isSuggestionPreviewEnabled && !augmentedCompletion) { return undefined; } + if (!isSuggestionPreviewEnabled && !augmentation) { return undefined; } - const edit = augmentedCompletion?.edit ?? suggestCompletion; - const editPreviewLength = augmentedCompletion ? augmentedCompletion.edit.text.length - suggestCompletion.text.length : 0; + const fullEdit = augmentation?.edit ?? suggestCompletionEdit; + const fullEditPreviewLength = augmentation ? augmentation.edit.text.length - suggestCompletionEdit.text.length : 0; const mode = this._suggestPreviewMode.read(reader); - const cursor = this.cursorPosition.read(reader); - const newGhostText = edit.computeGhostText(model, mode, cursor, editPreviewLength); - - // Show an invisible ghost text to reserve space - const ghostText = newGhostText ?? new GhostText(edit.range.endLineNumber, []); - return { ghostText, inlineCompletion: augmentedCompletion?.completion, suggestItem }; + const positions = this._positions.read(reader); + const edits = [fullEdit, ...getSecondaryEdits(this.textModel, positions, fullEdit)]; + const ghostTexts = edits + .map((edit, idx) => computeGhostText(edit, model, mode, positions[idx], fullEditPreviewLength)) + .filter(isDefined); + const primaryGhostText = ghostTexts[0] ?? new GhostText(fullEdit.range.endLineNumber, []); + return { edits, primaryGhostText, ghostTexts, inlineCompletion: augmentation?.completion, suggestItem }; } else { if (!this._isActive.read(reader)) { return undefined; } - const item = this.selectedInlineCompletion.read(reader); - if (!item) { return undefined; } + const inlineCompletion = this.selectedInlineCompletion.read(reader); + if (!inlineCompletion) { return undefined; } - const replacement = item.toSingleTextEdit(reader); + const replacement = inlineCompletion.toSingleTextEdit(reader); const mode = this._inlineSuggestMode.read(reader); - const cursor = this.cursorPosition.read(reader); - const ghostText = replacement.computeGhostText(model, mode, cursor); - return ghostText ? { ghostText, inlineCompletion: item, suggestItem: undefined } : undefined; + const positions = this._positions.read(reader); + const edits = [replacement, ...getSecondaryEdits(this.textModel, positions, replacement)]; + const ghostTexts = edits + .map((edit, idx) => computeGhostText(edit, model, mode, positions[idx], 0)) + .filter(isDefined); + if (!ghostTexts[0]) { return undefined; } + return { edits, primaryGhostText: ghostTexts[0], ghostTexts, inlineCompletion, suggestItem: undefined }; } }); - private _computeAugmentedCompletion(suggestCompletion: SingleTextEdit, reader: IReader | undefined) { + private _computeAugmentation(suggestCompletion: SingleTextEdit, reader: IReader | undefined) { const model = this.textModel; const suggestWidgetInlineCompletions = this._source.suggestWidgetInlineCompletions.read(reader); const candidateInlineCompletions = suggestWidgetInlineCompletions @@ -247,20 +263,29 @@ export class InlineCompletionsModel extends Disposable { const augmentedCompletion = mapFindFirst(candidateInlineCompletions, completion => { let r = completion.toSingleTextEdit(reader); - r = r.removeCommonPrefix(model, Range.fromPositions(r.range.getStartPosition(), suggestCompletion.range.getEndPosition())); - return r.augments(suggestCompletion) ? { edit: r, completion } : undefined; + r = singleTextRemoveCommonPrefix(r, model, Range.fromPositions(r.range.getStartPosition(), suggestCompletion.range.getEndPosition())); + return singleTextEditAugments(r, suggestCompletion) ? { completion, edit: r } : undefined; }); return augmentedCompletion; } - public readonly ghostText = derivedOpts({ + public readonly ghostTexts = derivedOpts({ + owner: this, + equalsFn: ghostTextsOrReplacementsEqual + }, reader => { + const v = this.state.read(reader); + if (!v) { return undefined; } + return v.ghostTexts; + }); + + public readonly primaryGhostText = derivedOpts({ owner: this, - equalityComparer: ghostTextOrReplacementEquals + equalsFn: ghostTextOrReplacementEquals }, reader => { const v = this.state.read(reader); if (!v) { return undefined; } - return v.ghostText; + return v?.primaryGhostText; }); private async _deltaSelectedInlineCompletionIndex(delta: 1 | -1): Promise { @@ -289,7 +314,7 @@ export class InlineCompletionsModel extends Disposable { } const state = this.state.get(); - if (!state || state.ghostText.isEmpty() || !state.inlineCompletion) { + if (!state || state.primaryGhostText.isEmpty() || !state.inlineCompletion) { return; } const completion = state.inlineCompletion.toInlineCompletion(undefined); @@ -299,19 +324,20 @@ export class InlineCompletionsModel extends Disposable { editor.executeEdits( 'inlineSuggestion.accept', [ - EditOperation.replaceMove(completion.range, ''), + EditOperation.replace(completion.range, ''), ...completion.additionalTextEdits ] ); editor.setPosition(completion.snippetInfo.range.getStartPosition(), 'inlineCompletionAccept'); SnippetController2.get(editor)?.insert(completion.snippetInfo.snippet, { undoStopBefore: false }); } else { - const edits = this._getEdits(editor, completion.toSingleTextEdit()); + const edits = state.edits; + const selections = getEndPositionsAfterApplying(edits).map(p => Selection.fromPositions(p)); editor.executeEdits('inlineSuggestion.accept', [ - ...edits.edits.map(edit => EditOperation.replaceMove(edit.range, edit.text)), + ...edits.map(edit => EditOperation.replace(edit.range, edit.text)), ...completion.additionalTextEdits ]); - editor.setSelections(edits.editorSelections, 'inlineCompletionAccept'); + editor.setSelections(selections, 'inlineCompletionAccept'); } if (completion.command) { @@ -361,7 +387,7 @@ export class InlineCompletionsModel extends Disposable { } } return acceptUntilIndexExclusive; - }); + }, PartialAcceptTriggerKind.Word); } public async acceptNextLine(editor: ICodeEditor): Promise { @@ -371,19 +397,19 @@ export class InlineCompletionsModel extends Disposable { return m.index + 1; } return text.length; - }); + }, PartialAcceptTriggerKind.Line); } - private async _acceptNext(editor: ICodeEditor, getAcceptUntilIndex: (position: Position, text: string) => number): Promise { + private async _acceptNext(editor: ICodeEditor, getAcceptUntilIndex: (position: Position, text: string) => number, kind: PartialAcceptTriggerKind): Promise { if (editor.getModel() !== this.textModel) { throw new BugIndicatingError(); } const state = this.state.get(); - if (!state || state.ghostText.isEmpty() || !state.inlineCompletion) { + if (!state || state.primaryGhostText.isEmpty() || !state.inlineCompletion) { return; } - const ghostText = state.ghostText; + const ghostText = state.primaryGhostText; const completion = state.inlineCompletion.toInlineCompletion(undefined); if (completion.snippetInfo || completion.filterText !== completion.insertText) { @@ -393,16 +419,17 @@ export class InlineCompletionsModel extends Disposable { } const firstPart = ghostText.parts[0]; - const position = new Position(ghostText.lineNumber, firstPart.column); - const text = firstPart.text; - const acceptUntilIndexExclusive = getAcceptUntilIndex(position, text); - - if (acceptUntilIndexExclusive === text.length && ghostText.parts.length === 1) { + const ghostTextPos = new Position(ghostText.lineNumber, firstPart.column); + const ghostTextVal = firstPart.text; + const acceptUntilIndexExclusive = getAcceptUntilIndex(ghostTextPos, ghostTextVal); + if (acceptUntilIndexExclusive === ghostTextVal.length && ghostText.parts.length === 1) { this.accept(editor); return; } + const partialGhostTextVal = ghostTextVal.substring(0, acceptUntilIndexExclusive); - const partialText = text.substring(0, acceptUntilIndexExclusive); + const positions = this._positions.get(); + const cursorPosition = positions[0]; // Executing the edit might free the completion, so we have to hold a reference on it. completion.source.addRef(); @@ -410,26 +437,28 @@ export class InlineCompletionsModel extends Disposable { this._isAcceptingPartially = true; try { editor.pushUndoStop(); - const replaceRange = Range.fromPositions(completion.range.getStartPosition(), position); - const newText = completion.insertText.substring( - 0, - firstPart.column - completion.range.startColumn + acceptUntilIndexExclusive); - const singleTextEdit = new SingleTextEdit(replaceRange, newText); - const edits = this._getEdits(editor, singleTextEdit); - editor.executeEdits('inlineSuggestion.accept', edits.edits.map(edit => EditOperation.replaceMove(edit.range, edit.text))); - editor.setSelections(edits.editorSelections, 'inlineCompletionPartialAccept'); + const replaceRange = Range.fromPositions(cursorPosition, ghostTextPos); + const newText = editor.getModel()!.getValueInRange(replaceRange) + partialGhostTextVal; + const primaryEdit = new SingleTextEdit(replaceRange, newText); + const edits = [primaryEdit, ...getSecondaryEdits(this.textModel, positions, primaryEdit)]; + const selections = getEndPositionsAfterApplying(edits).map(p => Selection.fromPositions(p)); + editor.executeEdits('inlineSuggestion.accept', edits.map(edit => EditOperation.replace(edit.range, edit.text))); + editor.setSelections(selections, 'inlineCompletionPartialAccept'); } finally { this._isAcceptingPartially = false; } if (completion.source.provider.handlePartialAccept) { - const acceptedRange = Range.fromPositions(completion.range.getStartPosition(), addPositions(position, lengthOfText(partialText))); + const acceptedRange = Range.fromPositions(completion.range.getStartPosition(), TextLength.ofText(partialGhostTextVal).addToPosition(ghostTextPos)); // This assumes that the inline completion and the model use the same EOL style. const text = editor.getModel()!.getValueInRange(acceptedRange, EndOfLinePreference.LF); completion.source.provider.handlePartialAccept( completion.source.inlineCompletions, completion.sourceInlineCompletion, text.length, + { + kind, + } ); } } finally { @@ -437,41 +466,9 @@ export class InlineCompletionsModel extends Disposable { } } - private _getEdits(editor: ICodeEditor, completion: SingleTextEdit): { edits: SingleTextEdit[]; editorSelections: Selection[] } { - - const selections = editor.getSelections() ?? []; - const secondaryPositions = selections.slice(1).map(selection => selection.getPosition()); - const primaryPosition = selections[0].getPosition(); - const textModel = editor.getModel()!; - const replacedTextAfterPrimaryCursor = textModel - .getLineContent(primaryPosition.lineNumber) - .substring(primaryPosition.column - 1, completion.range.endColumn - 1); - const secondaryEditText = completion.text.substring(primaryPosition.column - completion.range.startColumn); - const edits = [ - new SingleTextEdit(completion.range, completion.text), - ...secondaryPositions.map(pos => { - const textAfterSecondaryCursor = this.textModel - .getLineContent(pos.lineNumber) - .substring(pos.column - 1); - const l = commonPrefixLength(replacedTextAfterPrimaryCursor, textAfterSecondaryCursor); - const range = Range.fromPositions(pos, pos.delta(0, l)); - return new SingleTextEdit(range, secondaryEditText); - }) - ]; - const sortPerm = Permutation.createSortPermutation(edits, (edit1, edit2) => Range.compareRangesUsingStarts(edit1.range, edit2.range)); - const sortedNewRanges = getNewRanges(sortPerm.apply(edits)); - const newRanges = sortPerm.inverse().apply(sortedNewRanges); - const editorSelections = newRanges.map(range => Selection.fromPositions(range.getEndPosition())); - - return { - edits, - editorSelections - }; - } - public handleSuggestAccepted(item: SuggestItemInfo) { - const itemEdit = item.toSingleTextEdit().removeCommonPrefix(this.textModel); - const augmentedCompletion = this._computeAugmentedCompletion(itemEdit, undefined); + const itemEdit = singleTextRemoveCommonPrefix(item.toSingleTextEdit(), this.textModel); + const augmentedCompletion = this._computeAugmentation(itemEdit, undefined); if (!augmentedCompletion) { return; } const inlineCompletion = augmentedCompletion.completion.inlineCompletion; @@ -479,6 +476,58 @@ export class InlineCompletionsModel extends Disposable { inlineCompletion.source.inlineCompletions, inlineCompletion.sourceInlineCompletion, itemEdit.text.length, + { + kind: PartialAcceptTriggerKind.Suggest, + } ); } } + +export function getSecondaryEdits(textModel: ITextModel, positions: readonly Position[], primaryEdit: SingleTextEdit): SingleTextEdit[] { + if (positions.length === 1) { + // No secondary cursor positions + return []; + } + const primaryPosition = positions[0]; + const secondaryPositions = positions.slice(1); + const primaryEditStartPosition = primaryEdit.range.getStartPosition(); + const primaryEditEndPosition = primaryEdit.range.getEndPosition(); + const replacedTextAfterPrimaryCursor = textModel.getValueInRange( + Range.fromPositions(primaryPosition, primaryEditEndPosition) + ); + const positionWithinTextEdit = subtractPositions(primaryPosition, primaryEditStartPosition); + if (positionWithinTextEdit.lineNumber < 1) { + onUnexpectedError(new BugIndicatingError( + `positionWithinTextEdit line number should be bigger than 0. + Invalid subtraction between ${primaryPosition.toString()} and ${primaryEditStartPosition.toString()}` + )); + return []; + } + const secondaryEditText = substringPos(primaryEdit.text, positionWithinTextEdit); + return secondaryPositions.map(pos => { + const posEnd = addPositions(subtractPositions(pos, primaryEditStartPosition), primaryEditEndPosition); + const textAfterSecondaryCursor = textModel.getValueInRange( + Range.fromPositions(pos, posEnd) + ); + const l = commonPrefixLength(replacedTextAfterPrimaryCursor, textAfterSecondaryCursor); + const range = Range.fromPositions(pos, pos.delta(0, l)); + return new SingleTextEdit(range, secondaryEditText); + }); +} + +function substringPos(text: string, pos: Position): string { + let subtext = ''; + const lines = splitLinesIncludeSeparators(text); + for (let i = pos.lineNumber - 1; i < lines.length; i++) { + subtext += lines[i].substring(i === pos.lineNumber - 1 ? pos.column - 1 : 0); + } + return subtext; +} + +function getEndPositionsAfterApplying(edits: readonly SingleTextEdit[]): Position[] { + const sortPerm = Permutation.createSortPermutation(edits, (edit1, edit2) => Range.compareRangesUsingStarts(edit1.range, edit2.range)); + const edit = new TextEdit(sortPerm.apply(edits)); + const sortedNewRanges = edit.getNewRanges(); + const newRanges = sortPerm.inverse().apply(sortedNewRanges); + return newRanges.map(range => range.getEndPosition()); +} diff --git a/src/vs/editor/contrib/inlineCompletions/browser/inlineCompletionsSource.ts b/src/vs/editor/contrib/inlineCompletions/browser/inlineCompletionsSource.ts index 3e38e9e8161fb..32b77dd23fe5e 100644 --- a/src/vs/editor/contrib/inlineCompletions/browser/inlineCompletionsSource.ts +++ b/src/vs/editor/contrib/inlineCompletions/browser/inlineCompletionsSource.ts @@ -4,18 +4,21 @@ *--------------------------------------------------------------------------------------------*/ import { CancellationToken, CancellationTokenSource } from 'vs/base/common/cancellation'; +import { equalsIfDefined, itemEquals } from 'vs/base/common/equals'; import { matchesSubString } from 'vs/base/common/filters'; import { Disposable, IDisposable, MutableDisposable } from 'vs/base/common/lifecycle'; -import { IObservable, IReader, ITransaction, derived, disposableObservableValue, transaction } from 'vs/base/common/observable'; +import { IObservable, IReader, ITransaction, derivedOpts, disposableObservableValue, transaction } from 'vs/base/common/observable'; import { Position } from 'vs/editor/common/core/position'; import { Range } from 'vs/editor/common/core/range'; +import { SingleTextEdit } from 'vs/editor/common/core/textEdit'; +import { TextLength } from 'vs/editor/common/core/textLength'; import { InlineCompletionContext, InlineCompletionTriggerKind } from 'vs/editor/common/languages'; import { ILanguageConfigurationService } from 'vs/editor/common/languages/languageConfigurationRegistry'; import { EndOfLinePreference, ITextModel } from 'vs/editor/common/model'; import { IFeatureDebounceInformation } from 'vs/editor/common/services/languageFeatureDebounce'; import { ILanguageFeaturesService } from 'vs/editor/common/services/languageFeatures'; import { InlineCompletionItem, InlineCompletionProviderResult, provideInlineCompletions } from 'vs/editor/contrib/inlineCompletions/browser/provideInlineCompletions'; -import { SingleTextEdit } from 'vs/editor/contrib/inlineCompletions/browser/singleTextEdit'; +import { singleTextRemoveCommonPrefix } from 'vs/editor/contrib/inlineCompletions/browser/singleTextEdit'; export class InlineCompletionsSource extends Disposable { private readonly _updateOperation = this._register(new MutableDisposable()); @@ -148,20 +151,13 @@ class UpdateRequest { public satisfies(other: UpdateRequest): boolean { return this.position.equals(other.position) - && equals(this.context.selectedSuggestionInfo, other.context.selectedSuggestionInfo, (v1, v2) => v1.equals(v2)) + && equalsIfDefined(this.context.selectedSuggestionInfo, other.context.selectedSuggestionInfo, itemEquals()) && (other.context.triggerKind === InlineCompletionTriggerKind.Automatic || this.context.triggerKind === InlineCompletionTriggerKind.Explicit) && this.versionId === other.versionId; } } -function equals(v1: T | undefined, v2: T | undefined, equals: (v1: T, v2: T) => boolean): boolean { - if (!v1 || !v2) { - return v1 === v2; - } - return equals(v1, v2); -} - class UpdateOperation implements IDisposable { constructor( public readonly request: UpdateRequest, @@ -182,26 +178,13 @@ export class UpToDateInlineCompletions implements IDisposable { private _refCount = 1; private readonly _prependedInlineCompletionItems: InlineCompletionItem[] = []; - private _rangeVersionIdValue = 0; - private readonly _rangeVersionId = derived(this, reader => { - this.versionId.read(reader); - let changed = false; - for (const i of this._inlineCompletions) { - changed = changed || i._updateRange(this.textModel); - } - if (changed) { - this._rangeVersionIdValue++; - } - return this._rangeVersionIdValue; - }); - constructor( private readonly inlineCompletionProviderResult: InlineCompletionProviderResult, public readonly request: UpdateRequest, - private readonly textModel: ITextModel, - private readonly versionId: IObservable, + private readonly _textModel: ITextModel, + private readonly _versionId: IObservable, ) { - const ids = textModel.deltaDecorations([], inlineCompletionProviderResult.completions.map(i => ({ + const ids = _textModel.deltaDecorations([], inlineCompletionProviderResult.completions.map(i => ({ range: i.range, options: { description: 'inline-completion-tracking-range' @@ -209,7 +192,7 @@ export class UpToDateInlineCompletions implements IDisposable { }))); this._inlineCompletions = inlineCompletionProviderResult.completions.map( - (i, index) => new InlineCompletionWithUpdatedRange(i, ids[index], this._rangeVersionId) + (i, index) => new InlineCompletionWithUpdatedRange(i, ids[index], this._textModel, this._versionId) ); } @@ -223,9 +206,9 @@ export class UpToDateInlineCompletions implements IDisposable { if (this._refCount === 0) { setTimeout(() => { // To fix https://github.com/microsoft/vscode/issues/188348 - if (!this.textModel.isDisposed()) { + if (!this._textModel.isDisposed()) { // This is just cleanup. It's ok if it happens with a delay. - this.textModel.deltaDecorations(this._inlineCompletions.map(i => i.decorationId), []); + this._textModel.deltaDecorations(this._inlineCompletions.map(i => i.decorationId), []); } }, 0); this.inlineCompletionProviderResult.dispose(); @@ -240,13 +223,13 @@ export class UpToDateInlineCompletions implements IDisposable { inlineCompletion.source.addRef(); } - const id = this.textModel.deltaDecorations([], [{ + const id = this._textModel.deltaDecorations([], [{ range, options: { description: 'inline-completion-tracking-range' }, }])[0]; - this._inlineCompletions.unshift(new InlineCompletionWithUpdatedRange(inlineCompletion, id, this._rangeVersionId, range)); + this._inlineCompletions.unshift(new InlineCompletionWithUpdatedRange(inlineCompletion, id, this._textModel, this._versionId)); this._prependedInlineCompletionItems.push(inlineCompletion); } } @@ -257,36 +240,38 @@ export class InlineCompletionWithUpdatedRange { this.inlineCompletion.insertText, this.inlineCompletion.range.getStartPosition().toString() ]); - private _updatedRange: Range; - private _isValid = true; public get forwardStable() { return this.inlineCompletion.source.inlineCompletions.enableForwardStability ?? false; } + private readonly _updatedRange = derivedOpts({ owner: this, equalsFn: Range.equalsRange }, reader => { + this._modelVersion.read(reader); + return this._textModel.getDecorationRange(this.decorationId); + }); + constructor( public readonly inlineCompletion: InlineCompletionItem, public readonly decorationId: string, - private readonly rangeVersion: IObservable, - initialRange?: Range, + private readonly _textModel: ITextModel, + private readonly _modelVersion: IObservable, ) { - this._updatedRange = initialRange ?? inlineCompletion.range; } public toInlineCompletion(reader: IReader | undefined): InlineCompletionItem { - return this.inlineCompletion.withRange(this._getUpdatedRange(reader)); + return this.inlineCompletion.withRange(this._updatedRange.read(reader) ?? emptyRange); } public toSingleTextEdit(reader: IReader | undefined): SingleTextEdit { - return new SingleTextEdit(this._getUpdatedRange(reader), this.inlineCompletion.insertText); + return new SingleTextEdit(this._updatedRange.read(reader) ?? emptyRange, this.inlineCompletion.insertText); } public isVisible(model: ITextModel, cursorPosition: Position, reader: IReader | undefined): boolean { - const minimizedReplacement = this._toFilterTextReplacement(reader).removeCommonPrefix(model); - + const minimizedReplacement = singleTextRemoveCommonPrefix(this._toFilterTextReplacement(reader), model); + const updatedRange = this._updatedRange.read(reader); if ( - !this._isValid - || !this.inlineCompletion.range.getStartPosition().equals(this._getUpdatedRange(reader).getStartPosition()) + !updatedRange + || !this.inlineCompletion.range.getStartPosition().equals(updatedRange.getStartPosition()) || cursorPosition.lineNumber !== minimizedReplacement.range.startLineNumber ) { return false; @@ -322,45 +307,17 @@ export class InlineCompletionWithUpdatedRange { } public canBeReused(model: ITextModel, position: Position): boolean { - const result = this._isValid - && this._getUpdatedRange(undefined).containsPosition(position) + const updatedRange = this._updatedRange.read(undefined); + const result = !!updatedRange + && updatedRange.containsPosition(position) && this.isVisible(model, position, undefined) - && !this._isSmallerThanOriginal(undefined); + && TextLength.ofRange(updatedRange).isGreaterThanOrEqualTo(TextLength.ofRange(this.inlineCompletion.range)); return result; } private _toFilterTextReplacement(reader: IReader | undefined): SingleTextEdit { - return new SingleTextEdit(this._getUpdatedRange(reader), this.inlineCompletion.filterText); - } - - private _isSmallerThanOriginal(reader: IReader | undefined): boolean { - return length(this._getUpdatedRange(reader)).isBefore(length(this.inlineCompletion.range)); - } - - private _getUpdatedRange(reader: IReader | undefined): Range { - this.rangeVersion.read(reader); // This makes sure all the ranges are updated. - return this._updatedRange; - } - - public _updateRange(textModel: ITextModel): boolean { - const range = textModel.getDecorationRange(this.decorationId); - if (!range) { - // A setValue call might flush all decorations. - this._isValid = false; - return true; - } - if (!this._updatedRange.equalsRange(range)) { - this._updatedRange = range; - return true; - } - return false; + return new SingleTextEdit(this._updatedRange.read(reader) ?? emptyRange, this.inlineCompletion.filterText); } } -function length(range: Range): Position { - if (range.startLineNumber === range.endLineNumber) { - return new Position(1, 1 + range.endColumn - range.startColumn); - } else { - return new Position(1 + range.endLineNumber - range.startLineNumber, range.endColumn); - } -} +const emptyRange = new Range(1, 1, 1, 1); diff --git a/src/vs/editor/contrib/inlineCompletions/browser/provideInlineCompletions.ts b/src/vs/editor/contrib/inlineCompletions/browser/provideInlineCompletions.ts index 9d91e0ade1ed7..28052040c3296 100644 --- a/src/vs/editor/contrib/inlineCompletions/browser/provideInlineCompletions.ts +++ b/src/vs/editor/contrib/inlineCompletions/browser/provideInlineCompletions.ts @@ -17,7 +17,7 @@ import { Command, InlineCompletion, InlineCompletionContext, InlineCompletionPro import { ILanguageConfigurationService } from 'vs/editor/common/languages/languageConfigurationRegistry'; import { ITextModel } from 'vs/editor/common/model'; import { fixBracketsInLine } from 'vs/editor/common/model/bracketPairsTextModelPart/fixBrackets'; -import { SingleTextEdit } from 'vs/editor/contrib/inlineCompletions/browser/singleTextEdit'; +import { SingleTextEdit } from 'vs/editor/common/core/textEdit'; import { getReadonlyEmptyArray } from 'vs/editor/contrib/inlineCompletions/browser/utils'; import { SnippetParser, Text } from 'vs/editor/contrib/snippet/browser/snippetParser'; diff --git a/src/vs/editor/contrib/inlineCompletions/browser/singleTextEdit.ts b/src/vs/editor/contrib/inlineCompletions/browser/singleTextEdit.ts index c958fcdb6053d..750eb459829e7 100644 --- a/src/vs/editor/contrib/inlineCompletions/browser/singleTextEdit.ts +++ b/src/vs/editor/contrib/inlineCompletions/browser/singleTextEdit.ts @@ -7,142 +7,136 @@ import { IDiffChange, LcsDiff } from 'vs/base/common/diff/diff'; import { commonPrefixLength, getLeadingWhitespace } from 'vs/base/common/strings'; import { Position } from 'vs/editor/common/core/position'; import { Range } from 'vs/editor/common/core/range'; +import { TextLength } from 'vs/editor/common/core/textLength'; +import { SingleTextEdit } from 'vs/editor/common/core/textEdit'; import { EndOfLinePreference, ITextModel } from 'vs/editor/common/model'; import { GhostText, GhostTextPart } from 'vs/editor/contrib/inlineCompletions/browser/ghostText'; -import { addPositions, lengthOfText } from 'vs/editor/contrib/inlineCompletions/browser/utils'; -export class SingleTextEdit { - constructor( - public readonly range: Range, - public readonly text: string - ) { +export function singleTextRemoveCommonPrefix(edit: SingleTextEdit, model: ITextModel, validModelRange?: Range): SingleTextEdit { + const modelRange = validModelRange ? edit.range.intersectRanges(validModelRange) : edit.range; + if (!modelRange) { + return edit; } + const valueToReplace = model.getValueInRange(modelRange, EndOfLinePreference.LF); + const commonPrefixLen = commonPrefixLength(valueToReplace, edit.text); + const start = TextLength.ofText(valueToReplace.substring(0, commonPrefixLen)).addToPosition(edit.range.getStartPosition()); + const text = edit.text.substring(commonPrefixLen); + const range = Range.fromPositions(start, edit.range.getEndPosition()); + return new SingleTextEdit(range, text); +} - removeCommonPrefix(model: ITextModel, validModelRange?: Range): SingleTextEdit { - const modelRange = validModelRange ? this.range.intersectRanges(validModelRange) : this.range; - if (!modelRange) { - return this; - } - const valueToReplace = model.getValueInRange(modelRange, EndOfLinePreference.LF); - const commonPrefixLen = commonPrefixLength(valueToReplace, this.text); - const start = addPositions(this.range.getStartPosition(), lengthOfText(valueToReplace.substring(0, commonPrefixLen))); - const text = this.text.substring(commonPrefixLen); - const range = Range.fromPositions(start, this.range.getEndPosition()); - return new SingleTextEdit(range, text); - } +export function singleTextEditAugments(edit: SingleTextEdit, base: SingleTextEdit): boolean { + // The augmented completion must replace the base range, but can replace even more + return edit.text.startsWith(base.text) && rangeExtends(edit.range, base.range); +} - augments(base: SingleTextEdit): boolean { - // The augmented completion must replace the base range, but can replace even more - return this.text.startsWith(base.text) && rangeExtends(this.range, base.range); +/** + * @param previewSuffixLength Sets where to split `inlineCompletion.text`. + * If the text is `hello` and the suffix length is 2, the non-preview part is `hel` and the preview-part is `lo`. +*/ +export function computeGhostText( + edit: SingleTextEdit, + model: ITextModel, + mode: 'prefix' | 'subword' | 'subwordSmart', + cursorPosition?: Position, + previewSuffixLength = 0 +): GhostText | undefined { + let e = singleTextRemoveCommonPrefix(edit, model); + + if (e.range.endLineNumber !== e.range.startLineNumber) { + // This edit might span multiple lines, but the first lines must be a common prefix. + return undefined; } - /** - * @param previewSuffixLength Sets where to split `inlineCompletion.text`. - * If the text is `hello` and the suffix length is 2, the non-preview part is `hel` and the preview-part is `lo`. - */ - computeGhostText( - model: ITextModel, - mode: 'prefix' | 'subword' | 'subwordSmart', - cursorPosition?: Position, - previewSuffixLength = 0 - ): GhostText | undefined { - let edit = this.removeCommonPrefix(model); - - if (edit.range.endLineNumber !== edit.range.startLineNumber) { - // This edit might span multiple lines, but the first lines must be a common prefix. - return undefined; - } - - const sourceLine = model.getLineContent(edit.range.startLineNumber); - const sourceIndentationLength = getLeadingWhitespace(sourceLine).length; + const sourceLine = model.getLineContent(e.range.startLineNumber); + const sourceIndentationLength = getLeadingWhitespace(sourceLine).length; - const suggestionTouchesIndentation = edit.range.startColumn - 1 <= sourceIndentationLength; - if (suggestionTouchesIndentation) { - // source: ··········[······abc] - // ^^^^^^^^^ inlineCompletion.range - // ^^^^^^^^^^ ^^^^^^ sourceIndentationLength - // ^^^^^^ replacedIndentation.length - // ^^^ rangeThatDoesNotReplaceIndentation + const suggestionTouchesIndentation = e.range.startColumn - 1 <= sourceIndentationLength; + if (suggestionTouchesIndentation) { + // source: ··········[······abc] + // ^^^^^^^^^ inlineCompletion.range + // ^^^^^^^^^^ ^^^^^^ sourceIndentationLength + // ^^^^^^ replacedIndentation.length + // ^^^ rangeThatDoesNotReplaceIndentation - // inlineCompletion.text: '··foo' - // ^^ suggestionAddedIndentationLength + // inlineCompletion.text: '··foo' + // ^^ suggestionAddedIndentationLength - const suggestionAddedIndentationLength = getLeadingWhitespace(edit.text).length; + const suggestionAddedIndentationLength = getLeadingWhitespace(e.text).length; - const replacedIndentation = sourceLine.substring(edit.range.startColumn - 1, sourceIndentationLength); + const replacedIndentation = sourceLine.substring(e.range.startColumn - 1, sourceIndentationLength); - const [startPosition, endPosition] = [edit.range.getStartPosition(), edit.range.getEndPosition()]; - const newStartPosition = - startPosition.column + replacedIndentation.length <= endPosition.column - ? startPosition.delta(0, replacedIndentation.length) - : endPosition; - const rangeThatDoesNotReplaceIndentation = Range.fromPositions(newStartPosition, endPosition); + const [startPosition, endPosition] = [e.range.getStartPosition(), e.range.getEndPosition()]; + const newStartPosition = + startPosition.column + replacedIndentation.length <= endPosition.column + ? startPosition.delta(0, replacedIndentation.length) + : endPosition; + const rangeThatDoesNotReplaceIndentation = Range.fromPositions(newStartPosition, endPosition); - const suggestionWithoutIndentationChange = - edit.text.startsWith(replacedIndentation) - // Adds more indentation without changing existing indentation: We can add ghost text for this - ? edit.text.substring(replacedIndentation.length) - // Changes or removes existing indentation. Only add ghost text for the non-indentation part. - : edit.text.substring(suggestionAddedIndentationLength); + const suggestionWithoutIndentationChange = + e.text.startsWith(replacedIndentation) + // Adds more indentation without changing existing indentation: We can add ghost text for this + ? e.text.substring(replacedIndentation.length) + // Changes or removes existing indentation. Only add ghost text for the non-indentation part. + : e.text.substring(suggestionAddedIndentationLength); - edit = new SingleTextEdit(rangeThatDoesNotReplaceIndentation, suggestionWithoutIndentationChange); - } + e = new SingleTextEdit(rangeThatDoesNotReplaceIndentation, suggestionWithoutIndentationChange); + } - // This is a single line string - const valueToBeReplaced = model.getValueInRange(edit.range); + // This is a single line string + const valueToBeReplaced = model.getValueInRange(e.range); - const changes = cachingDiff(valueToBeReplaced, edit.text); + const changes = cachingDiff(valueToBeReplaced, e.text); - if (!changes) { - // No ghost text in case the diff would be too slow to compute - return undefined; - } + if (!changes) { + // No ghost text in case the diff would be too slow to compute + return undefined; + } - const lineNumber = edit.range.startLineNumber; + const lineNumber = e.range.startLineNumber; - const parts = new Array(); + const parts = new Array(); - if (mode === 'prefix') { - const filteredChanges = changes.filter(c => c.originalLength === 0); - if (filteredChanges.length > 1 || filteredChanges.length === 1 && filteredChanges[0].originalStart !== valueToBeReplaced.length) { - // Prefixes only have a single change. - return undefined; - } + if (mode === 'prefix') { + const filteredChanges = changes.filter(c => c.originalLength === 0); + if (filteredChanges.length > 1 || filteredChanges.length === 1 && filteredChanges[0].originalStart !== valueToBeReplaced.length) { + // Prefixes only have a single change. + return undefined; } + } - const previewStartInCompletionText = edit.text.length - previewSuffixLength; + const previewStartInCompletionText = e.text.length - previewSuffixLength; - for (const c of changes) { - const insertColumn = edit.range.startColumn + c.originalStart + c.originalLength; + for (const c of changes) { + const insertColumn = e.range.startColumn + c.originalStart + c.originalLength; - if (mode === 'subwordSmart' && cursorPosition && cursorPosition.lineNumber === edit.range.startLineNumber && insertColumn < cursorPosition.column) { - // No ghost text before cursor - return undefined; - } + if (mode === 'subwordSmart' && cursorPosition && cursorPosition.lineNumber === e.range.startLineNumber && insertColumn < cursorPosition.column) { + // No ghost text before cursor + return undefined; + } - if (c.originalLength > 0) { - return undefined; - } + if (c.originalLength > 0) { + return undefined; + } - if (c.modifiedLength === 0) { - continue; - } + if (c.modifiedLength === 0) { + continue; + } - const modifiedEnd = c.modifiedStart + c.modifiedLength; - const nonPreviewTextEnd = Math.max(c.modifiedStart, Math.min(modifiedEnd, previewStartInCompletionText)); - const nonPreviewText = edit.text.substring(c.modifiedStart, nonPreviewTextEnd); - const italicText = edit.text.substring(nonPreviewTextEnd, Math.max(c.modifiedStart, modifiedEnd)); + const modifiedEnd = c.modifiedStart + c.modifiedLength; + const nonPreviewTextEnd = Math.max(c.modifiedStart, Math.min(modifiedEnd, previewStartInCompletionText)); + const nonPreviewText = e.text.substring(c.modifiedStart, nonPreviewTextEnd); + const italicText = e.text.substring(nonPreviewTextEnd, Math.max(c.modifiedStart, modifiedEnd)); - if (nonPreviewText.length > 0) { - parts.push(new GhostTextPart(insertColumn, nonPreviewText, false)); - } - if (italicText.length > 0) { - parts.push(new GhostTextPart(insertColumn, italicText, true)); - } + if (nonPreviewText.length > 0) { + parts.push(new GhostTextPart(insertColumn, nonPreviewText, false)); + } + if (italicText.length > 0) { + parts.push(new GhostTextPart(insertColumn, italicText, true)); } - - return new GhostText(lineNumber, parts); } + + return new GhostText(lineNumber, parts); } function rangeExtends(extendingRange: Range, rangeToExtend: Range): boolean { diff --git a/src/vs/editor/contrib/inlineCompletions/browser/suggestWidgetInlineCompletionProvider.ts b/src/vs/editor/contrib/inlineCompletions/browser/suggestWidgetInlineCompletionProvider.ts index 90d53b26c8f2d..5f98b45033a1f 100644 --- a/src/vs/editor/contrib/inlineCompletions/browser/suggestWidgetInlineCompletionProvider.ts +++ b/src/vs/editor/contrib/inlineCompletions/browser/suggestWidgetInlineCompletionProvider.ts @@ -14,10 +14,11 @@ import { SnippetSession } from 'vs/editor/contrib/snippet/browser/snippetSession import { CompletionItem } from 'vs/editor/contrib/suggest/browser/suggest'; import { SuggestController } from 'vs/editor/contrib/suggest/browser/suggestController'; import { IObservable, ITransaction, observableValue, transaction } from 'vs/base/common/observable'; -import { SingleTextEdit } from 'vs/editor/contrib/inlineCompletions/browser/singleTextEdit'; +import { SingleTextEdit } from 'vs/editor/common/core/textEdit'; import { ITextModel } from 'vs/editor/common/model'; import { compareBy, numberComparator } from 'vs/base/common/arrays'; import { findFirstMaxBy } from 'vs/base/common/arraysFind'; +import { singleTextEditAugments, singleTextRemoveCommonPrefix } from 'vs/editor/contrib/inlineCompletions/browser/singleTextEdit'; export class SuggestWidgetAdaptor extends Disposable { private isSuggestWidgetVisible: boolean = false; @@ -66,7 +67,8 @@ export class SuggestWidgetAdaptor extends Disposable { return -1; } - const itemToPreselect = this.suggestControllerPreselector()?.removeCommonPrefix(textModel); + const i = this.suggestControllerPreselector(); + const itemToPreselect = i ? singleTextRemoveCommonPrefix(i, textModel) : undefined; if (!itemToPreselect) { return -1; } @@ -75,8 +77,8 @@ export class SuggestWidgetAdaptor extends Disposable { const candidates = suggestItems .map((suggestItem, index) => { const suggestItemInfo = SuggestItemInfo.fromSuggestion(suggestController, textModel, position, suggestItem, this.isShiftKeyPressed); - const suggestItemTextEdit = suggestItemInfo.toSingleTextEdit().removeCommonPrefix(textModel); - const valid = itemToPreselect.augments(suggestItemTextEdit); + const suggestItemTextEdit = singleTextRemoveCommonPrefix(suggestItemInfo.toSingleTextEdit(), textModel); + const valid = singleTextEditAugments(itemToPreselect, suggestItemTextEdit); return { index, valid, prefixLength: suggestItemTextEdit.text.length, suggestItem }; }) .filter(item => item && item.valid && item.prefixLength > 0); diff --git a/src/vs/editor/contrib/inlineCompletions/browser/utils.ts b/src/vs/editor/contrib/inlineCompletions/browser/utils.ts index cc24afd0eff6d..20236aade3b9a 100644 --- a/src/vs/editor/contrib/inlineCompletions/browser/utils.ts +++ b/src/vs/editor/contrib/inlineCompletions/browser/utils.ts @@ -3,54 +3,13 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { compareBy } from 'vs/base/common/arrays'; import { BugIndicatingError } from 'vs/base/common/errors'; import { DisposableStore, IDisposable } from 'vs/base/common/lifecycle'; import { IObservable, autorunOpts } from 'vs/base/common/observable'; import { ICodeEditor } from 'vs/editor/browser/editorBrowser'; -import { ISingleEditOperation } from 'vs/editor/common/core/editOperation'; import { Position } from 'vs/editor/common/core/position'; -import { IRange, Range } from 'vs/editor/common/core/range'; +import { Range } from 'vs/editor/common/core/range'; import { IModelDeltaDecoration } from 'vs/editor/common/model'; -import { TextModel } from 'vs/editor/common/model/textModel'; - -export function applyEdits(text: string, edits: { range: IRange; text: string }[]): string { - const transformer = new PositionOffsetTransformer(text); - const offsetEdits = edits.map(e => { - const range = Range.lift(e.range); - return ({ - startOffset: transformer.getOffset(range.getStartPosition()), - endOffset: transformer.getOffset(range.getEndPosition()), - text: e.text - }); - }); - - offsetEdits.sort((a, b) => b.startOffset - a.startOffset); - - for (const edit of offsetEdits) { - text = text.substring(0, edit.startOffset) + edit.text + text.substring(edit.endOffset); - } - - return text; -} - -class PositionOffsetTransformer { - private readonly lineStartOffsetByLineIdx: number[]; - - constructor(text: string) { - this.lineStartOffsetByLineIdx = []; - this.lineStartOffsetByLineIdx.push(0); - for (let i = 0; i < text.length; i++) { - if (text.charAt(i) === '\n') { - this.lineStartOffsetByLineIdx.push(i + 1); - } - } - } - - getOffset(position: Position): number { - return this.lineStartOffsetByLineIdx[position.lineNumber - 1] + position.column - 1; - } -} const array: ReadonlyArray = []; export function getReadonlyEmptyArray(): readonly T[] { @@ -96,85 +55,6 @@ export function addPositions(pos1: Position, pos2: Position): Position { return new Position(pos1.lineNumber + pos2.lineNumber - 1, pos2.lineNumber === 1 ? pos1.column + pos2.column - 1 : pos2.column); } -export function lengthOfText(text: string): Position { - let line = 1; - let column = 1; - for (const c of text) { - if (c === '\n') { - line++; - column = 1; - } else { - column++; - } - } - return new Position(line, column); -} - -/** - * Given some text edits, this function finds the new ranges of the editted text post application of all edits. - * Assumes that the edit ranges are disjoint and they are sorted in the order of the ranges - * @param edits edits applied - * @returns new ranges post edits for every edit - */ -export function getNewRanges(edits: ISingleEditOperation[]): Range[] { - const newRanges: Range[] = []; - let previousEditEndLineNumber = 0; - let lineOffset = 0; - let columnOffset = 0; - - for (const edit of edits) { - const text = edit.text ?? ''; - const textLength = lengthOfText(text); - const newRangeStart = Position.lift({ - lineNumber: edit.range.startLineNumber + lineOffset, - column: edit.range.startColumn + (edit.range.startLineNumber === previousEditEndLineNumber ? columnOffset : 0) - }); - const newRangeEnd = addPositions( - newRangeStart, - textLength - ); - newRanges.push(Range.fromPositions(newRangeStart, newRangeEnd)); - lineOffset += textLength.lineNumber - edit.range.endLineNumber + edit.range.startLineNumber - 1; - columnOffset = newRangeEnd.column - edit.range.endColumn; - previousEditEndLineNumber = edit.range.endLineNumber; - } - return newRanges; -} - -/** - * Given a text model and edits, this function finds the inverse text edits - * @param model model on which to apply the edits - * @param edits edits applied - * @returns inverse edits - */ -export function inverseEdits(model: TextModel, edits: ISingleEditOperation[]): ISingleEditOperation[] { - const sortPerm = Permutation.createSortPermutation(edits, compareBy(e => e.range, Range.compareRangesUsingStarts)); - const sortedRanges = getNewRanges(sortPerm.apply(edits)); - const newRanges = sortPerm.inverse().apply(sortedRanges); - const inverseEdits: ISingleEditOperation[] = []; - for (let i = 0; i < edits.length; i++) { - inverseEdits.push({ range: newRanges[i], text: model.getValueInRange(edits[i].range) }); - } - return inverseEdits; -} - -export class Permutation { - constructor(private readonly _indexMap: number[]) { } - - public static createSortPermutation(arr: readonly T[], compareFn: (a: T, b: T) => number): Permutation { - const sortIndices = Array.from(arr.keys()).sort((index1, index2) => compareFn(arr[index1], arr[index2])); - return new Permutation(sortIndices); - } - - apply(arr: T[]): T[] { - return arr.map((_, index) => arr[this._indexMap[index]]); - } - - inverse(): Permutation { - const inverseIndexMap = this._indexMap.slice(); - for (let i = 0; i < this._indexMap.length; i++) { - inverseIndexMap[this._indexMap[i]] = i; - } - return new Permutation(inverseIndexMap); - } +export function subtractPositions(pos1: Position, pos2: Position): Position { + return new Position(pos1.lineNumber - pos2.lineNumber + 1, pos1.lineNumber - pos2.lineNumber === 0 ? pos1.column - pos2.column + 1 : pos1.column); } diff --git a/src/vs/editor/contrib/inlineCompletions/test/browser/inlineCompletionsModel.test.ts b/src/vs/editor/contrib/inlineCompletions/test/browser/inlineCompletionsModel.test.ts new file mode 100644 index 0000000000000..648f594059677 --- /dev/null +++ b/src/vs/editor/contrib/inlineCompletions/test/browser/inlineCompletionsModel.test.ts @@ -0,0 +1,92 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +import * as assert from 'assert'; +import { Position } from 'vs/editor/common/core/position'; +import { getSecondaryEdits } from 'vs/editor/contrib/inlineCompletions/browser/inlineCompletionsModel'; +import { SingleTextEdit } from 'vs/editor/common/core/textEdit'; +import { createTextModel } from 'vs/editor/test/common/testTextModel'; +import { Range } from 'vs/editor/common/core/range'; +import { ensureNoDisposablesAreLeakedInTestSuite } from 'vs/base/test/common/utils'; + +suite('inlineCompletionModel', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('getSecondaryEdits - basic', async function () { + + const textModel = createTextModel([ + 'function fib(', + 'function fib(' + ].join('\n')); + const positions = [ + new Position(1, 14), + new Position(2, 14) + ]; + const primaryEdit = new SingleTextEdit(new Range(1, 1, 1, 14), 'function fib() {'); + const secondaryEdits = getSecondaryEdits(textModel, positions, primaryEdit); + assert.deepStrictEqual(secondaryEdits, [new SingleTextEdit( + new Range(2, 14, 2, 14), + ') {' + )]); + textModel.dispose(); + }); + + test('getSecondaryEdits - cursor not on same line as primary edit 1', async function () { + + const textModel = createTextModel([ + 'function fib(', + '', + 'function fib(', + '' + ].join('\n')); + const positions = [ + new Position(2, 1), + new Position(4, 1) + ]; + const primaryEdit = new SingleTextEdit(new Range(1, 1, 2, 1), [ + 'function fib() {', + ' return 0;', + '}' + ].join('\n')); + const secondaryEdits = getSecondaryEdits(textModel, positions, primaryEdit); + assert.deepStrictEqual(secondaryEdits, [new SingleTextEdit( + new Range(4, 1, 4, 1), [ + ' return 0;', + '}' + ].join('\n') + )]); + textModel.dispose(); + }); + + test('getSecondaryEdits - cursor not on same line as primary edit 2', async function () { + + const textModel = createTextModel([ + 'class A {', + '', + 'class B {', + '', + 'function f() {}' + ].join('\n')); + const positions = [ + new Position(2, 1), + new Position(4, 1) + ]; + const primaryEdit = new SingleTextEdit(new Range(1, 1, 2, 1), [ + 'class A {', + ' public x: number = 0;', + ' public y: number = 0;', + '}' + ].join('\n')); + const secondaryEdits = getSecondaryEdits(textModel, positions, primaryEdit); + assert.deepStrictEqual(secondaryEdits, [new SingleTextEdit( + new Range(4, 1, 4, 1), [ + ' public x: number = 0;', + ' public y: number = 0;', + '}' + ].join('\n') + )]); + textModel.dispose(); + }); +}); diff --git a/src/vs/editor/contrib/inlineCompletions/test/browser/inlineCompletionsProvider.test.ts b/src/vs/editor/contrib/inlineCompletions/test/browser/inlineCompletionsProvider.test.ts index 20dd10e2d6709..e160c3daa015c 100644 --- a/src/vs/editor/contrib/inlineCompletions/test/browser/inlineCompletionsProvider.test.ts +++ b/src/vs/editor/contrib/inlineCompletions/test/browser/inlineCompletionsProvider.test.ts @@ -15,13 +15,14 @@ import { LanguageFeaturesService } from 'vs/editor/common/services/languageFeatu import { ViewModel } from 'vs/editor/common/viewModel/viewModelImpl'; import { InlineCompletionsController } from 'vs/editor/contrib/inlineCompletions/browser/inlineCompletionsController'; import { InlineCompletionsModel } from 'vs/editor/contrib/inlineCompletions/browser/inlineCompletionsModel'; -import { SingleTextEdit } from 'vs/editor/contrib/inlineCompletions/browser/singleTextEdit'; +import { SingleTextEdit } from 'vs/editor/common/core/textEdit'; import { GhostTextContext, MockInlineCompletionsProvider } from 'vs/editor/contrib/inlineCompletions/test/browser/utils'; import { ITestCodeEditor, TestCodeEditorInstantiationOptions, withAsyncTestCodeEditor } from 'vs/editor/test/browser/testCodeEditor'; import { createTextModel } from 'vs/editor/test/common/testTextModel'; -import { IAudioCueService } from 'vs/platform/audioCues/browser/audioCueService'; +import { IAccessibilitySignalService } from 'vs/platform/accessibilitySignal/browser/accessibilitySignalService'; import { ServiceCollection } from 'vs/platform/instantiation/common/serviceCollection'; import { Selection } from 'vs/editor/common/core/selection'; +import { computeGhostText } from 'vs/editor/contrib/inlineCompletions/browser/singleTextEdit'; suite('Inline Completions', () => { ensureNoDisposablesAreLeakedInTestSuite(); @@ -37,7 +38,7 @@ suite('Inline Completions', () => { const options = ['prefix', 'subword'] as const; const result = {} as any; for (const option of options) { - result[option] = new SingleTextEdit(range, suggestion).computeGhostText(tempModel, option)?.render(cleanedText, true); + result[option] = computeGhostText(new SingleTextEdit(range, suggestion), tempModel, option)?.render(cleanedText, true); } tempModel.dispose(); @@ -775,9 +776,9 @@ async function withAsyncTestCodeEditorAndInlineCompletionsModel( options.serviceCollection = new ServiceCollection(); } options.serviceCollection.set(ILanguageFeaturesService, languageFeaturesService); - options.serviceCollection.set(IAudioCueService, { - playAudioCue: async () => { }, - isEnabled(cue: unknown) { return false; }, + options.serviceCollection.set(IAccessibilitySignalService, { + playSignal: async () => { }, + isSoundEnabled(signal: unknown) { return false; }, } as any); const d = languageFeaturesService.inlineCompletionsProvider.register({ pattern: '**' }, options.provider); disposableStore.add(d); diff --git a/src/vs/editor/contrib/inlineCompletions/test/browser/suggestWidgetModel.test.ts b/src/vs/editor/contrib/inlineCompletions/test/browser/suggestWidgetModel.test.ts index 06cc564f3d926..354fa36b5af81 100644 --- a/src/vs/editor/contrib/inlineCompletions/test/browser/suggestWidgetModel.test.ts +++ b/src/vs/editor/contrib/inlineCompletions/test/browser/suggestWidgetModel.test.ts @@ -34,7 +34,7 @@ import { InlineCompletionsModel } from 'vs/editor/contrib/inlineCompletions/brow import { InlineCompletionsController } from 'vs/editor/contrib/inlineCompletions/browser/inlineCompletionsController'; import { autorun } from 'vs/base/common/observable'; import { setUnexpectedErrorHandler } from 'vs/base/common/errors'; -import { IAudioCueService } from 'vs/platform/audioCues/browser/audioCueService'; +import { IAccessibilitySignalService } from 'vs/platform/accessibilitySignal/browser/accessibilitySignalService'; import { ensureNoDisposablesAreLeakedInTestSuite } from 'vs/base/test/common/utils'; suite('Suggest Widget Model', () => { @@ -160,9 +160,9 @@ async function withAsyncTestCodeEditorAndInlineCompletionsModel( }], [ILabelService, new class extends mock() { }], [IWorkspaceContextService, new class extends mock() { }], - [IAudioCueService, { - playAudioCue: async () => { }, - isEnabled(cue: unknown) { return false; }, + [IAccessibilitySignalService, { + playSignal: async () => { }, + isSoundEnabled(signal: unknown) { return false; }, } as any] ); diff --git a/src/vs/editor/contrib/inlineCompletions/test/browser/utils.test.ts b/src/vs/editor/contrib/inlineCompletions/test/browser/utils.test.ts deleted file mode 100644 index 16c918fbe1876..0000000000000 --- a/src/vs/editor/contrib/inlineCompletions/test/browser/utils.test.ts +++ /dev/null @@ -1,33 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import * as assert from 'assert'; -import { ensureNoDisposablesAreLeakedInTestSuite } from 'vs/base/test/common/utils'; -import { createTextModel } from 'vs/editor/test/common/testTextModel'; -import { MersenneTwister, getRandomEditInfos, toEdit, } from 'vs/editor/test/common/model/bracketPairColorizer/combineTextEditInfos.test'; -import { inverseEdits } from 'vs/editor/contrib/inlineCompletions/browser/utils'; -import { generateRandomMultilineString } from 'vs/editor/contrib/inlineCompletions/test/browser/utils'; - -suite('getNewRanges', () => { - ensureNoDisposablesAreLeakedInTestSuite(); - - for (let seed = 0; seed < 20; seed++) { - test(`test ${seed}`, () => { - const rng = new MersenneTwister(seed); - const randomText = generateRandomMultilineString(rng, 10); - const model = createTextModel(randomText); - - const edits = getRandomEditInfos(model, rng.nextIntRange(1, 4), rng, true).map(e => toEdit(e)); - const invEdits = inverseEdits(model, edits); - - model.applyEdits(edits); - model.applyEdits(invEdits); - - assert.deepStrictEqual(model.getValue(), randomText); - model.dispose(); - }); - } - -}); diff --git a/src/vs/editor/contrib/inlineCompletions/test/browser/utils.ts b/src/vs/editor/contrib/inlineCompletions/test/browser/utils.ts index 2770ea2aa33bf..11c24b0b0e613 100644 --- a/src/vs/editor/contrib/inlineCompletions/test/browser/utils.ts +++ b/src/vs/editor/contrib/inlineCompletions/test/browser/utils.ts @@ -13,7 +13,6 @@ import { InlineCompletion, InlineCompletionContext, InlineCompletionsProvider } import { ITestCodeEditor } from 'vs/editor/test/browser/testCodeEditor'; import { InlineCompletionsModel } from 'vs/editor/contrib/inlineCompletions/browser/inlineCompletionsModel'; import { autorun } from 'vs/base/common/observable'; -import { MersenneTwister } from 'vs/editor/test/common/model/bracketPairColorizer/combineTextEditInfos.test'; export class MockInlineCompletionsProvider implements InlineCompletionsProvider { private returnValue: InlineCompletion[] = []; @@ -83,7 +82,7 @@ export class GhostTextContext extends Disposable { this._register(autorun(reader => { /** @description update */ - const ghostText = model.ghostText.read(reader); + const ghostText = model.primaryGhostText.read(reader); let view: string | undefined; if (ghostText) { view = ghostText.render(this.editor.getValue(), true); @@ -133,23 +132,3 @@ export class GhostTextContext extends Disposable { } } -export function generateRandomMultilineString(rng: MersenneTwister, numberOfLines: number, maximumLengthOfLines: number = 20): string { - let randomText: string = ''; - for (let i = 0; i < numberOfLines; i++) { - const lengthOfLine = rng.nextIntRange(0, maximumLengthOfLines + 1); - randomText += generateRandomSimpleString(rng, lengthOfLine) + '\n'; - } - return randomText; -} - -function generateRandomSimpleString(rng: MersenneTwister, stringLength: number): string { - const possibleCharacters: string = ' abcdefghijklmnopqrstuvwxyz0123456789'; - let randomText: string = ''; - for (let i = 0; i < stringLength; i++) { - const characterIndex = rng.nextIntRange(0, possibleCharacters.length); - randomText += possibleCharacters.charAt(characterIndex); - - } - return randomText; -} - diff --git a/src/vs/editor/contrib/inlineEdit/browser/commandIds.ts b/src/vs/editor/contrib/inlineEdit/browser/commandIds.ts new file mode 100644 index 0000000000000..ccd1b40df5a7d --- /dev/null +++ b/src/vs/editor/contrib/inlineEdit/browser/commandIds.ts @@ -0,0 +1,10 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +export const inlineEditAcceptId = 'editor.action.inlineEdit.accept'; +export const inlineEditTriggerId = 'editor.action.inlineEdit.trigger'; +export const inlineEditRejectId = 'editor.action.inlineEdit.reject'; +export const inlineEditJumpToId = 'editor.action.inlineEdit.jumpTo'; +export const inlineEditJumpBackId = 'editor.action.inlineEdit.jumpBack'; diff --git a/src/vs/editor/contrib/inlineEdit/browser/commands.ts b/src/vs/editor/contrib/inlineEdit/browser/commands.ts new file mode 100644 index 0000000000000..d7e1f4fe8cb67 --- /dev/null +++ b/src/vs/editor/contrib/inlineEdit/browser/commands.ts @@ -0,0 +1,153 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { KeyCode, KeyMod } from 'vs/base/common/keyCodes'; +import { ICodeEditor } from 'vs/editor/browser/editorBrowser'; +import { EditorAction, ServicesAccessor } from 'vs/editor/browser/editorExtensions'; +import { EditorContextKeys } from 'vs/editor/common/editorContextKeys'; +import { inlineEditAcceptId, inlineEditJumpBackId, inlineEditJumpToId, inlineEditRejectId } from 'vs/editor/contrib/inlineEdit/browser/commandIds'; +import { InlineEditController } from 'vs/editor/contrib/inlineEdit/browser/inlineEditController'; +import { MenuId } from 'vs/platform/actions/common/actions'; +import { ContextKeyExpr } from 'vs/platform/contextkey/common/contextkey'; +import { KeybindingWeight } from 'vs/platform/keybinding/common/keybindingsRegistry'; + +export class AcceptInlineEdit extends EditorAction { + constructor() { + super({ + id: inlineEditAcceptId, + label: 'Accept Inline Edit', + alias: 'Accept Inline Edit', + precondition: ContextKeyExpr.and(EditorContextKeys.writable, InlineEditController.inlineEditVisibleContext), + kbOpts: [ + { + weight: KeybindingWeight.EditorContrib + 1, + primary: KeyCode.Tab, + kbExpr: ContextKeyExpr.and(EditorContextKeys.writable, InlineEditController.inlineEditVisibleContext, InlineEditController.cursorAtInlineEditContext) + }], + menuOpts: [{ + menuId: MenuId.InlineEditToolbar, + title: 'Accept', + group: 'primary', + order: 1, + }], + }); + } + + public async run(accessor: ServicesAccessor | undefined, editor: ICodeEditor): Promise { + const controller = InlineEditController.get(editor); + await controller?.accept(); + } +} + +export class TriggerInlineEdit extends EditorAction { + constructor() { + const activeExpr = ContextKeyExpr.and(EditorContextKeys.writable, ContextKeyExpr.not(InlineEditController.inlineEditVisibleKey)); + super({ + id: 'editor.action.inlineEdit.trigger', + label: 'Trigger Inline Edit', + alias: 'Trigger Inline Edit', + precondition: activeExpr, + kbOpts: { + weight: KeybindingWeight.EditorContrib + 1, + primary: KeyMod.CtrlCmd | KeyMod.Alt | KeyCode.Equal, + kbExpr: activeExpr + }, + }); + } + + public async run(accessor: ServicesAccessor | undefined, editor: ICodeEditor): Promise { + const controller = InlineEditController.get(editor); + controller?.trigger(); + } +} + +export class JumpToInlineEdit extends EditorAction { + constructor() { + const activeExpr = ContextKeyExpr.and(EditorContextKeys.writable, InlineEditController.inlineEditVisibleContext, ContextKeyExpr.not(InlineEditController.cursorAtInlineEditKey)); + + super({ + id: inlineEditJumpToId, + label: 'Jump to Inline Edit', + alias: 'Jump to Inline Edit', + precondition: activeExpr, + kbOpts: { + weight: KeybindingWeight.EditorContrib + 1, + primary: KeyMod.CtrlCmd | KeyMod.Alt | KeyCode.Equal, + kbExpr: activeExpr + }, + menuOpts: [{ + menuId: MenuId.InlineEditToolbar, + title: 'Jump To Edit', + group: 'primary', + order: 3, + when: activeExpr + }], + }); + } + + public async run(accessor: ServicesAccessor | undefined, editor: ICodeEditor): Promise { + const controller = InlineEditController.get(editor); + controller?.jumpToCurrent(); + } +} + +export class JumpBackInlineEdit extends EditorAction { + constructor() { + const activeExpr = ContextKeyExpr.and(EditorContextKeys.writable, InlineEditController.cursorAtInlineEditContext); + + super({ + id: inlineEditJumpBackId, + label: 'Jump Back from Inline Edit', + alias: 'Jump Back from Inline Edit', + precondition: activeExpr, + kbOpts: { + weight: KeybindingWeight.EditorContrib + 10, + primary: KeyMod.CtrlCmd | KeyMod.Alt | KeyCode.Equal, + kbExpr: activeExpr + }, + menuOpts: [{ + menuId: MenuId.InlineEditToolbar, + title: 'Jump Back', + group: 'primary', + order: 3, + when: activeExpr + }], + }); + } + + public async run(accessor: ServicesAccessor | undefined, editor: ICodeEditor): Promise { + const controller = InlineEditController.get(editor); + controller?.jumpBack(); + } +} + +export class RejectInlineEdit extends EditorAction { + constructor() { + const activeExpr = ContextKeyExpr.and(EditorContextKeys.writable, InlineEditController.inlineEditVisibleContext); + super({ + id: inlineEditRejectId, + label: 'Reject Inline Edit', + alias: 'Reject Inline Edit', + precondition: activeExpr, + kbOpts: { + weight: KeybindingWeight.EditorContrib, + primary: KeyCode.Escape, + kbExpr: activeExpr + }, + menuOpts: [{ + menuId: MenuId.InlineEditToolbar, + title: 'Reject', + group: 'secondary', + order: 2, + }], + }); + } + + public async run(accessor: ServicesAccessor | undefined, editor: ICodeEditor): Promise { + const controller = InlineEditController.get(editor); + await controller?.clear(); + } +} + diff --git a/src/vs/editor/contrib/inlineEdit/browser/ghostTextWidget.ts b/src/vs/editor/contrib/inlineEdit/browser/ghostTextWidget.ts new file mode 100644 index 0000000000000..298fcd4452e1f --- /dev/null +++ b/src/vs/editor/contrib/inlineEdit/browser/ghostTextWidget.ts @@ -0,0 +1,231 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable, toDisposable } from 'vs/base/common/lifecycle'; +import { IObservable, derived, observableFromEvent, observableValue } from 'vs/base/common/observable'; +import 'vs/css!./inlineEdit'; +import { ICodeEditor } from 'vs/editor/browser/editorBrowser'; +import { Position } from 'vs/editor/common/core/position'; +import { IRange, Range } from 'vs/editor/common/core/range'; +import { ILanguageService } from 'vs/editor/common/languages/language'; +import { IModelDeltaDecoration, ITextModel, InjectedTextCursorStops } from 'vs/editor/common/model'; +import { LineDecoration } from 'vs/editor/common/viewLayout/lineDecorations'; +import { InlineDecorationType } from 'vs/editor/common/viewModel'; +import { AdditionalLinesWidget, LineData } from 'vs/editor/contrib/inlineCompletions/browser/ghostTextWidget'; +import { GhostText } from 'vs/editor/contrib/inlineCompletions/browser/ghostText'; +import { ColumnRange, applyObservableDecorations } from 'vs/editor/contrib/inlineCompletions/browser/utils'; + +export const INLINE_EDIT_DESCRIPTION = 'inline-edit'; +export interface IGhostTextWidgetModel { + readonly targetTextModel: IObservable; + readonly ghostText: IObservable; + readonly minReservedLineCount: IObservable; + readonly range: IObservable; + readonly backgroundColoring: IObservable; +} + +export class GhostTextWidget extends Disposable { + private readonly isDisposed = observableValue(this, false); + private readonly currentTextModel = observableFromEvent(this.editor.onDidChangeModel, () => /** @description editor.model */ this.editor.getModel()); + + constructor( + private readonly editor: ICodeEditor, + readonly model: IGhostTextWidgetModel, + @ILanguageService private readonly languageService: ILanguageService, + ) { + super(); + + this._register(toDisposable(() => { this.isDisposed.set(true, undefined); })); + this._register(applyObservableDecorations(this.editor, this.decorations)); + } + + private readonly uiState = derived(this, reader => { + if (this.isDisposed.read(reader)) { + return undefined; + } + const textModel = this.currentTextModel.read(reader); + if (textModel !== this.model.targetTextModel.read(reader)) { + return undefined; + } + const ghostText = this.model.ghostText.read(reader); + if (!ghostText) { + return undefined; + } + + + let range = this.model.range?.read(reader); + //if range is empty, we want to remove it + if (range && range.startLineNumber === range.endLineNumber && range.startColumn === range.endColumn) { + range = undefined; + } + //check if both range and text are single line - in this case we want to do inline replacement + //rather than replacing whole lines + const isSingleLine = (range ? range.startLineNumber === range.endLineNumber : true) && ghostText.parts.length === 1 && ghostText.parts[0].lines.length === 1; + + //check if we're just removing code + const isPureRemove = ghostText.parts.length === 1 && ghostText.parts[0].lines.every(l => l.length === 0); + + const inlineTexts: { column: number; text: string; preview: boolean }[] = []; + const additionalLines: LineData[] = []; + + function addToAdditionalLines(lines: readonly string[], className: string | undefined) { + if (additionalLines.length > 0) { + const lastLine = additionalLines[additionalLines.length - 1]; + if (className) { + lastLine.decorations.push(new LineDecoration(lastLine.content.length + 1, lastLine.content.length + 1 + lines[0].length, className, InlineDecorationType.Regular)); + } + lastLine.content += lines[0]; + + lines = lines.slice(1); + } + for (const line of lines) { + additionalLines.push({ + content: line, + decorations: className ? [new LineDecoration(1, line.length + 1, className, InlineDecorationType.Regular)] : [] + }); + } + } + + const textBufferLine = textModel.getLineContent(ghostText.lineNumber); + + let hiddenTextStartColumn: number | undefined = undefined; + let lastIdx = 0; + if (!isPureRemove) { + for (const part of ghostText.parts) { + let lines = part.lines; + //If remove range is set, we want to push all new liens to virtual area + if (range && !isSingleLine) { + addToAdditionalLines(lines, INLINE_EDIT_DESCRIPTION); + lines = []; + } + if (hiddenTextStartColumn === undefined) { + inlineTexts.push({ + column: part.column, + text: lines[0], + preview: part.preview, + }); + lines = lines.slice(1); + } else { + addToAdditionalLines([textBufferLine.substring(lastIdx, part.column - 1)], undefined); + } + + if (lines.length > 0) { + addToAdditionalLines(lines, INLINE_EDIT_DESCRIPTION); + if (hiddenTextStartColumn === undefined && part.column <= textBufferLine.length) { + hiddenTextStartColumn = part.column; + } + } + + lastIdx = part.column - 1; + } + if (hiddenTextStartColumn !== undefined) { + addToAdditionalLines([textBufferLine.substring(lastIdx)], undefined); + } + } + + const hiddenRange = hiddenTextStartColumn !== undefined ? new ColumnRange(hiddenTextStartColumn, textBufferLine.length + 1) : undefined; + + const lineNumber = + (isSingleLine || !range) ? ghostText.lineNumber : range.endLineNumber - 1; + + return { + inlineTexts, + additionalLines, + hiddenRange, + lineNumber, + additionalReservedLineCount: this.model.minReservedLineCount.read(reader), + targetTextModel: textModel, + range, + isSingleLine, + isPureRemove, + backgroundColoring: this.model.backgroundColoring.read(reader) + }; + }); + + private readonly decorations = derived(this, reader => { + const uiState = this.uiState.read(reader); + if (!uiState) { + return []; + } + + const decorations: IModelDeltaDecoration[] = []; + + if (uiState.hiddenRange) { + decorations.push({ + range: uiState.hiddenRange.toRange(uiState.lineNumber), + options: { inlineClassName: 'inline-edit-hidden', description: 'inline-edit-hidden', } + }); + } + + if (uiState.range) { + const ranges = []; + if (uiState.isSingleLine) { + ranges.push(uiState.range); + } + else if (uiState.isPureRemove) { + const lines = uiState.range.endLineNumber - uiState.range.startLineNumber; + for (let i = 0; i < lines; i++) { + const line = uiState.range.startLineNumber + i; + const firstNonWhitespace = uiState.targetTextModel.getLineFirstNonWhitespaceColumn(line); + const lastNonWhitespace = uiState.targetTextModel.getLineLastNonWhitespaceColumn(line); + const range = new Range(line, firstNonWhitespace, line, lastNonWhitespace); + ranges.push(range); + } + } + else { + const lines = uiState.range.endLineNumber - uiState.range.startLineNumber; + for (let i = 0; i < lines; i++) { + const line = uiState.range.startLineNumber + i; + const firstNonWhitespace = uiState.targetTextModel.getLineFirstNonWhitespaceColumn(line); + const lastNonWhitespace = uiState.targetTextModel.getLineLastNonWhitespaceColumn(line); + const range = new Range(line, firstNonWhitespace, line, lastNonWhitespace); + ranges.push(range); + } + } + const className = uiState.backgroundColoring ? 'inline-edit-remove backgroundColoring' : 'inline-edit-remove'; + for (const range of ranges) { + decorations.push({ + range, + options: { inlineClassName: className, description: 'inline-edit-remove', } + }); + } + } + + for (const p of uiState.inlineTexts) { + + decorations.push({ + range: Range.fromPositions(new Position(uiState.lineNumber, p.column)), + options: { + description: INLINE_EDIT_DESCRIPTION, + after: { content: p.text, inlineClassName: p.preview ? 'inline-edit-decoration-preview' : 'inline-edit-decoration', cursorStops: InjectedTextCursorStops.Left }, + showIfCollapsed: true, + } + }); + } + + return decorations; + }); + + private readonly additionalLinesWidget = this._register( + new AdditionalLinesWidget( + this.editor, + this.languageService.languageIdCodec, + derived(reader => { + /** @description lines */ + const uiState = this.uiState.read(reader); + return uiState && !uiState.isPureRemove ? { + lineNumber: uiState.lineNumber, + additionalLines: uiState.additionalLines, + minReservedLineCount: uiState.additionalReservedLineCount, + targetTextModel: uiState.targetTextModel, + } : undefined; + }) + ) + ); + + public ownsViewZone(viewZoneId: string): boolean { + return this.additionalLinesWidget.viewZoneId === viewZoneId; + } +} diff --git a/src/vs/editor/contrib/inlineEdit/browser/hoverParticipant.ts b/src/vs/editor/contrib/inlineEdit/browser/hoverParticipant.ts new file mode 100644 index 0000000000000..6c1c7337f7fc5 --- /dev/null +++ b/src/vs/editor/contrib/inlineEdit/browser/hoverParticipant.ts @@ -0,0 +1,105 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { DisposableStore, IDisposable } from 'vs/base/common/lifecycle'; +import { constObservable } from 'vs/base/common/observable'; +import { ICodeEditor, IEditorMouseEvent, MouseTargetType } from 'vs/editor/browser/editorBrowser'; +import { EditorOption } from 'vs/editor/common/config/editorOptions'; +import { Range } from 'vs/editor/common/core/range'; +import { IModelDecoration } from 'vs/editor/common/model'; +import { HoverAnchor, HoverAnchorType, HoverForeignElementAnchor, IEditorHoverParticipant, IEditorHoverRenderContext, IHoverPart } from 'vs/editor/contrib/hover/browser/hoverTypes'; +import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; +import { ITelemetryService } from 'vs/platform/telemetry/common/telemetry'; +import { InlineEditController } from 'vs/editor/contrib/inlineEdit/browser/inlineEditController'; +import { InlineEditHintsContentWidget } from 'vs/editor/contrib/inlineEdit/browser/inlineEditHintsWidget'; + +export class InlineEditHover implements IHoverPart { + constructor( + public readonly owner: IEditorHoverParticipant, + public readonly range: Range, + public readonly controller: InlineEditController + ) { } + + public isValidForHoverAnchor(anchor: HoverAnchor): boolean { + return ( + anchor.type === HoverAnchorType.Range + && this.range.startColumn <= anchor.range.startColumn + && this.range.endColumn >= anchor.range.endColumn + ); + } +} + +export class InlineEditHoverParticipant implements IEditorHoverParticipant { + + public readonly hoverOrdinal: number = 5; + + constructor( + private readonly _editor: ICodeEditor, + @IInstantiationService private readonly _instantiationService: IInstantiationService, + @ITelemetryService private readonly _telemetryService: ITelemetryService, + ) { + } + + suggestHoverAnchor(mouseEvent: IEditorMouseEvent): HoverAnchor | null { + const controller = InlineEditController.get(this._editor); + if (!controller) { + return null; + } + + const target = mouseEvent.target; + if (target.type === MouseTargetType.CONTENT_VIEW_ZONE) { + // handle the case where the mouse is over the view zone + const viewZoneData = target.detail; + if (controller.shouldShowHoverAtViewZone(viewZoneData.viewZoneId)) { + // const range = Range.fromPositions(this._editor.getModel()!.validatePosition(viewZoneData.positionBefore || viewZoneData.position)); + const range = target.range; + return new HoverForeignElementAnchor(1000, this, range, mouseEvent.event.posx, mouseEvent.event.posy, false); + } + } + if (target.type === MouseTargetType.CONTENT_EMPTY) { + // handle the case where the mouse is over the empty portion of a line following ghost text + if (controller.shouldShowHoverAt(target.range)) { + return new HoverForeignElementAnchor(1000, this, target.range, mouseEvent.event.posx, mouseEvent.event.posy, false); + } + } + if (target.type === MouseTargetType.CONTENT_TEXT) { + // handle the case where the mouse is directly over ghost text + const mightBeForeignElement = target.detail.mightBeForeignElement; + if (mightBeForeignElement && controller.shouldShowHoverAt(target.range)) { + return new HoverForeignElementAnchor(1000, this, target.range, mouseEvent.event.posx, mouseEvent.event.posy, false); + } + } + return null; + } + + computeSync(anchor: HoverAnchor, lineDecorations: IModelDecoration[]): InlineEditHover[] { + if (this._editor.getOption(EditorOption.inlineEdit).showToolbar !== 'onHover') { + return []; + } + + const controller = InlineEditController.get(this._editor); + if (controller && controller.shouldShowHoverAt(anchor.range)) { + return [new InlineEditHover(this, anchor.range, controller)]; + } + return []; + } + + renderHoverParts(context: IEditorHoverRenderContext, hoverParts: InlineEditHover[]): IDisposable { + const disposableStore = new DisposableStore(); + + this._telemetryService.publicLog2<{}, { + owner: 'hediet'; + comment: 'This event tracks whenever an inline edit hover is shown.'; + }>('inlineEditHover.shown'); + + const w = this._instantiationService.createInstance(InlineEditHintsContentWidget, this._editor, false, + constObservable(null), + ); + context.fragment.appendChild(w.getDomNode()); + disposableStore.add(w); + + return disposableStore; + } +} diff --git a/src/vs/editor/contrib/inlineEdit/browser/inlineEdit.contribution.ts b/src/vs/editor/contrib/inlineEdit/browser/inlineEdit.contribution.ts new file mode 100644 index 0000000000000..7196773a7cfc2 --- /dev/null +++ b/src/vs/editor/contrib/inlineEdit/browser/inlineEdit.contribution.ts @@ -0,0 +1,20 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { EditorContributionInstantiation, registerEditorAction, registerEditorContribution } from 'vs/editor/browser/editorExtensions'; +import { HoverParticipantRegistry } from 'vs/editor/contrib/hover/browser/hoverTypes'; +import { AcceptInlineEdit, JumpBackInlineEdit, JumpToInlineEdit, RejectInlineEdit, TriggerInlineEdit } from 'vs/editor/contrib/inlineEdit/browser/commands'; +import { InlineEditHoverParticipant } from 'vs/editor/contrib/inlineEdit/browser/hoverParticipant'; +import { InlineEditController } from 'vs/editor/contrib/inlineEdit/browser/inlineEditController'; + +registerEditorAction(AcceptInlineEdit); +registerEditorAction(RejectInlineEdit); +registerEditorAction(JumpToInlineEdit); +registerEditorAction(JumpBackInlineEdit); +registerEditorAction(TriggerInlineEdit); +registerEditorContribution(InlineEditController.ID, InlineEditController, EditorContributionInstantiation.Eventually); + + +HoverParticipantRegistry.register(InlineEditHoverParticipant); diff --git a/src/vs/editor/contrib/inlineEdit/browser/inlineEdit.css b/src/vs/editor/contrib/inlineEdit/browser/inlineEdit.css new file mode 100644 index 0000000000000..d6d156544e00e --- /dev/null +++ b/src/vs/editor/contrib/inlineEdit/browser/inlineEdit.css @@ -0,0 +1,38 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +.monaco-editor .inline-edit-remove { + background-color: var(--vscode-editorGhostText-background); + font-style: italic; + text-decoration: line-through; +} + +.monaco-editor .inline-edit-remove.backgroundColoring { + background-color: var(--vscode-diffEditor-removedLineBackground); +} + +.monaco-editor .inline-edit-hidden { + opacity: 0; + font-size: 0; +} + +.monaco-editor .inline-edit-decoration, .monaco-editor .suggest-preview-text .inline-edit { + font-style: italic; +} + +.monaco-editor .inline-completion-text-to-replace { + text-decoration: underline; + text-underline-position: under; +} + +.monaco-editor .inline-edit-decoration, +.monaco-editor .inline-edit-decoration-preview, +.monaco-editor .suggest-preview-text .inline-edit { + color: var(--vscode-editorGhostText-foreground) !important; + background-color: var(--vscode-editorGhostText-background); + border: 1px solid var(--vscode-editorGhostText-border); +} + + diff --git a/src/vs/editor/contrib/inlineEdit/browser/inlineEditController.ts b/src/vs/editor/contrib/inlineEdit/browser/inlineEditController.ts new file mode 100644 index 0000000000000..4e0c10eb3352e --- /dev/null +++ b/src/vs/editor/contrib/inlineEdit/browser/inlineEditController.ts @@ -0,0 +1,363 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable, IDisposable } from 'vs/base/common/lifecycle'; +import { ISettableObservable, autorun, constObservable, disposableObservableValue, observableFromEvent, observableSignalFromEvent, observableValue, transaction } from 'vs/base/common/observable'; +import { ICodeEditor } from 'vs/editor/browser/editorBrowser'; +import { EditOperation } from 'vs/editor/common/core/editOperation'; +import { Position } from 'vs/editor/common/core/position'; +import { Range } from 'vs/editor/common/core/range'; +import { GhostTextWidget } from 'vs/editor/contrib/inlineEdit/browser/ghostTextWidget'; +import { IContextKeyService, RawContextKey } from 'vs/platform/contextkey/common/contextkey'; +import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; +import { IInlineEdit, InlineEditTriggerKind } from 'vs/editor/common/languages'; +import { ILanguageFeaturesService } from 'vs/editor/common/services/languageFeatures'; +import { CancellationToken, CancellationTokenSource } from 'vs/base/common/cancellation'; +import { GhostText, GhostTextPart } from 'vs/editor/contrib/inlineCompletions/browser/ghostText'; +import { ICommandService } from 'vs/platform/commands/common/commands'; +import { InlineEditHintsWidget } from 'vs/editor/contrib/inlineEdit/browser/inlineEditHintsWidget'; +import { EditorOption } from 'vs/editor/common/config/editorOptions'; +import { createStyleSheet2 } from 'vs/base/browser/dom'; +import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; +import { onUnexpectedExternalError } from 'vs/base/common/errors'; + +export class InlineEditWidget implements IDisposable { + constructor(public readonly widget: GhostTextWidget, public readonly edit: IInlineEdit) { } + + dispose(): void { + this.widget.dispose(); + } +} + +export class InlineEditController extends Disposable { + static ID = 'editor.contrib.inlineEditController'; + + public static readonly inlineEditVisibleKey = 'inlineEditVisible'; + public static readonly inlineEditVisibleContext = new RawContextKey(InlineEditController.inlineEditVisibleKey, false); + private _isVisibleContext = InlineEditController.inlineEditVisibleContext.bindTo(this.contextKeyService); + + public static readonly cursorAtInlineEditKey = 'cursorAtInlineEdit'; + public static readonly cursorAtInlineEditContext = new RawContextKey(InlineEditController.cursorAtInlineEditKey, false); + private _isCursorAtInlineEditContext = InlineEditController.cursorAtInlineEditContext.bindTo(this.contextKeyService); + + public static get(editor: ICodeEditor): InlineEditController | null { + return editor.getContribution(InlineEditController.ID); + } + + private _currentEdit: ISettableObservable = this._register(disposableObservableValue(this, undefined)); + private _currentRequestCts: CancellationTokenSource | undefined; + + private _jumpBackPosition: Position | undefined; + private _isAccepting: ISettableObservable = observableValue(this, false); + + private readonly _enabled = observableFromEvent(this.editor.onDidChangeConfiguration, () => this.editor.getOption(EditorOption.inlineEdit).enabled); + private readonly _fontFamily = observableFromEvent(this.editor.onDidChangeConfiguration, () => this.editor.getOption(EditorOption.inlineEdit).fontFamily); + private readonly _backgroundColoring = observableFromEvent(this.editor.onDidChangeConfiguration, () => this.editor.getOption(EditorOption.inlineEdit).backgroundColoring); + + + constructor( + public readonly editor: ICodeEditor, + @IInstantiationService private readonly instantiationService: IInstantiationService, + @IContextKeyService private readonly contextKeyService: IContextKeyService, + @ILanguageFeaturesService private readonly languageFeaturesService: ILanguageFeaturesService, + @ICommandService private readonly _commandService: ICommandService, + @IConfigurationService private readonly _configurationService: IConfigurationService, + ) { + super(); + + //Automatically request inline edit when the content was changed + //Cancel the previous request if there is one + //Remove the previous ghost text + const modelChangedSignal = observableSignalFromEvent('InlineEditController.modelContentChangedSignal', editor.onDidChangeModelContent); + this._register(autorun(reader => { + /** @description InlineEditController.modelContentChanged model */ + if (!this._enabled.read(reader)) { + return; + } + modelChangedSignal.read(reader); + if (this._isAccepting.read(reader)) { + return; + } + this.getInlineEdit(editor, true); + })); + + //Check if the cursor is at the ghost text + const cursorPosition = observableFromEvent(editor.onDidChangeCursorPosition, () => editor.getPosition()); + this._register(autorun(reader => { + /** @description InlineEditController.cursorPositionChanged model */ + if (!this._enabled.read(reader)) { + return; + } + + const pos = cursorPosition.read(reader); + if (pos) { + this.checkCursorPosition(pos); + } + })); + + //Perform stuff when the current edit has changed + this._register(autorun((reader) => { + /** @description InlineEditController.update model */ + const currentEdit = this._currentEdit.read(reader); + this._isCursorAtInlineEditContext.set(false); + if (!currentEdit) { + this._isVisibleContext.set(false); + return; + } + this._isVisibleContext.set(true); + const pos = editor.getPosition(); + if (pos) { + this.checkCursorPosition(pos); + } + })); + + //Clear suggestions on lost focus + const editorBlurSingal = observableSignalFromEvent('InlineEditController.editorBlurSignal', editor.onDidBlurEditorWidget); + this._register(autorun(async reader => { + /** @description InlineEditController.editorBlur */ + if (!this._enabled.read(reader)) { + return; + } + editorBlurSingal.read(reader); + // This is a hidden setting very useful for debugging + if (this._configurationService.getValue('editor.experimentalInlineEdit.keepOnBlur') || editor.getOption(EditorOption.inlineEdit).keepOnBlur) { + return; + } + this._currentRequestCts?.dispose(true); + this._currentRequestCts = undefined; + await this.clear(false); + })); + + //Invoke provider on focus + const editorFocusSignal = observableSignalFromEvent('InlineEditController.editorFocusSignal', editor.onDidFocusEditorText); + this._register(autorun(reader => { + /** @description InlineEditController.editorFocus */ + if (!this._enabled.read(reader)) { + return; + } + editorFocusSignal.read(reader); + this.getInlineEdit(editor, true); + })); + + + //handle changes of font setting + const styleElement = this._register(createStyleSheet2()); + this._register(autorun(reader => { + const fontFamily = this._fontFamily.read(reader); + styleElement.setStyle(fontFamily === '' || fontFamily === 'default' ? `` : ` +.monaco-editor .inline-edit-decoration, +.monaco-editor .inline-edit-decoration-preview, +.monaco-editor .inline-edit { + font-family: ${fontFamily}; +}`); + })); + + this._register(new InlineEditHintsWidget(this.editor, this._currentEdit, this.instantiationService)); + } + + private checkCursorPosition(position: Position) { + if (!this._currentEdit) { + this._isCursorAtInlineEditContext.set(false); + return; + } + const gt = this._currentEdit.get()?.edit; + if (!gt) { + this._isCursorAtInlineEditContext.set(false); + return; + } + this._isCursorAtInlineEditContext.set(Range.containsPosition(gt.range, position)); + } + + private validateInlineEdit(editor: ICodeEditor, edit: IInlineEdit): boolean { + //Multiline inline replacing edit must replace whole lines + if (edit.text.includes('\n') && edit.range.startLineNumber !== edit.range.endLineNumber && edit.range.startColumn !== edit.range.endColumn) { + const firstColumn = edit.range.startColumn; + if (firstColumn !== 1) { + return false; + } + const lastLine = edit.range.endLineNumber; + const lastColumn = edit.range.endColumn; + const lineLength = editor.getModel()?.getLineLength(lastLine) ?? 0; + if (lastColumn !== lineLength + 1) { + return false; + } + } + return true; + } + + private async fetchInlineEdit(editor: ICodeEditor, auto: boolean): Promise { + if (this._currentRequestCts) { + this._currentRequestCts.dispose(true); + } + const model = editor.getModel(); + if (!model) { + return; + } + const modelVersion = model.getVersionId(); + const providers = this.languageFeaturesService.inlineEditProvider.all(model); + if (providers.length === 0) { + return; + } + const provider = providers[0]; + this._currentRequestCts = new CancellationTokenSource(); + const token = this._currentRequestCts.token; + const triggerKind = auto ? InlineEditTriggerKind.Automatic : InlineEditTriggerKind.Invoke; + const shouldDebounce = auto; + if (shouldDebounce) { + await wait(50, token); + } + if (token.isCancellationRequested || model.isDisposed() || model.getVersionId() !== modelVersion) { + return; + } + const edit = await provider.provideInlineEdit(model, { triggerKind }, token); + if (!edit) { + return; + } + if (token.isCancellationRequested || model.isDisposed() || model.getVersionId() !== modelVersion) { + return; + } + if (!this.validateInlineEdit(editor, edit)) { + return; + } + return edit; + } + + private async getInlineEdit(editor: ICodeEditor, auto: boolean) { + this._isCursorAtInlineEditContext.set(false); + await this.clear(); + const edit = await this.fetchInlineEdit(editor, auto); + if (!edit) { + return; + } + const line = edit.range.endLineNumber; + const column = edit.range.endColumn; + const ghostText = new GhostText(line, [new GhostTextPart(column, edit.text, false)]); + const instance = this.instantiationService.createInstance(GhostTextWidget, this.editor, { + ghostText: constObservable(ghostText), + minReservedLineCount: constObservable(0), + targetTextModel: constObservable(this.editor.getModel() ?? undefined), + range: constObservable(edit.range), + backgroundColoring: this._backgroundColoring + }); + this._currentEdit.set(new InlineEditWidget(instance, edit), undefined); + } + + public async trigger() { + await this.getInlineEdit(this.editor, false); + } + + public async jumpBack() { + if (!this._jumpBackPosition) { + return; + } + this.editor.setPosition(this._jumpBackPosition); + //if position is outside viewports, scroll to it + this.editor.revealPositionInCenterIfOutsideViewport(this._jumpBackPosition); + } + + public async accept() { + this._isAccepting.set(true, undefined); + const data = this._currentEdit.get()?.edit; + if (!data) { + return; + } + + //It should only happen in case of last line suggestion + let text = data.text; + if (data.text.startsWith('\n')) { + text = data.text.substring(1); + } + this.editor.pushUndoStop(); + this.editor.executeEdits('acceptCurrent', [EditOperation.replace(Range.lift(data.range), text)]); + if (data.accepted) { + await this._commandService + .executeCommand(data.accepted.id, ...(data.accepted.arguments || [])) + .then(undefined, onUnexpectedExternalError); + } + this.freeEdit(data); + transaction((tx) => { + this._currentEdit.set(undefined, tx); + this._isAccepting.set(false, tx); + }); + } + + public jumpToCurrent(): void { + this._jumpBackPosition = this.editor.getSelection()?.getStartPosition(); + + const data = this._currentEdit.get()?.edit; + if (!data) { + return; + } + const position = Position.lift({ lineNumber: data.range.startLineNumber, column: data.range.startColumn }); + this.editor.setPosition(position); + //if position is outside viewports, scroll to it + this.editor.revealPositionInCenterIfOutsideViewport(position); + } + + public async clear(sendRejection: boolean = true) { + const edit = this._currentEdit.get()?.edit; + if (edit && edit?.rejected && sendRejection) { + await this._commandService + .executeCommand(edit.rejected.id, ...(edit.rejected.arguments || [])) + .then(undefined, onUnexpectedExternalError); + } + if (edit) { + this.freeEdit(edit); + } + this._currentEdit.set(undefined, undefined); + } + + private freeEdit(edit: IInlineEdit) { + const model = this.editor.getModel(); + if (!model) { + return; + } + const providers = this.languageFeaturesService.inlineEditProvider.all(model); + if (providers.length === 0) { + return; + } + providers[0].freeInlineEdit(edit); + } + + public shouldShowHoverAt(range: Range) { + const currentEdit = this._currentEdit.get(); + if (!currentEdit) { + return false; + } + const edit = currentEdit.edit; + const model = currentEdit.widget.model; + const overReplaceRange = Range.containsPosition(edit.range, range.getStartPosition()) || Range.containsPosition(edit.range, range.getEndPosition()); + if (overReplaceRange) { + return true; + } + const ghostText = model.ghostText.get(); + if (ghostText) { + return ghostText.parts.some(p => range.containsPosition(new Position(ghostText.lineNumber, p.column))); + } + return false; + } + + public shouldShowHoverAtViewZone(viewZoneId: string): boolean { + return this._currentEdit.get()?.widget.ownsViewZone(viewZoneId) ?? false; + } + +} + +function wait(ms: number, cancellationToken?: CancellationToken): Promise { + return new Promise(resolve => { + let d: IDisposable | undefined = undefined; + const handle = setTimeout(() => { + if (d) { d.dispose(); } + resolve(); + }, ms); + if (cancellationToken) { + d = cancellationToken.onCancellationRequested(() => { + clearTimeout(handle); + if (d) { d.dispose(); } + resolve(); + }); + } + }); +} diff --git a/src/vs/editor/contrib/inlineEdit/browser/inlineEditHintsWidget.css b/src/vs/editor/contrib/inlineEdit/browser/inlineEditHintsWidget.css new file mode 100644 index 0000000000000..8f369a27c3da0 --- /dev/null +++ b/src/vs/editor/contrib/inlineEdit/browser/inlineEditHintsWidget.css @@ -0,0 +1,34 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +.monaco-editor .inlineEditHints.withBorder { + z-index: 39; + color: var(--vscode-editorHoverWidget-foreground); + background-color: var(--vscode-editorHoverWidget-background); + border: 1px solid var(--vscode-editorHoverWidget-border); +} + +.monaco-editor .inlineEditHints a { + color: var(--vscode-foreground); +} + +.monaco-editor .inlineEditHints a:hover { + color: var(--vscode-foreground); +} + +.monaco-editor .inlineEditHints .keybinding { + display: flex; + margin-left: 4px; + opacity: 0.6; +} + +.monaco-editor .inlineEditHints .keybinding .monaco-keybinding-key { + font-size: 8px; + padding: 2px 3px; +} + +.monaco-editor .inlineEditStatusBarItemLabel { + margin-right: 2px; +} diff --git a/src/vs/editor/contrib/inlineEdit/browser/inlineEditHintsWidget.ts b/src/vs/editor/contrib/inlineEdit/browser/inlineEditHintsWidget.ts new file mode 100644 index 0000000000000..59553805863bc --- /dev/null +++ b/src/vs/editor/contrib/inlineEdit/browser/inlineEditHintsWidget.ts @@ -0,0 +1,246 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { h } from 'vs/base/browser/dom'; +import { KeybindingLabel, unthemedKeybindingLabelOptions } from 'vs/base/browser/ui/keybindingLabel/keybindingLabel'; +import { IAction, Separator } from 'vs/base/common/actions'; +import { equals } from 'vs/base/common/arrays'; +import { Disposable, toDisposable } from 'vs/base/common/lifecycle'; +import { IObservable, autorun, autorunWithStore, derived, observableFromEvent } from 'vs/base/common/observable'; +import { OS } from 'vs/base/common/platform'; +import 'vs/css!./inlineEditHintsWidget'; +import { ContentWidgetPositionPreference, ICodeEditor, IContentWidget, IContentWidgetPosition } from 'vs/editor/browser/editorBrowser'; +import { EditorOption } from 'vs/editor/common/config/editorOptions'; +import { Position } from 'vs/editor/common/core/position'; +import { PositionAffinity } from 'vs/editor/common/model'; +import { InlineEditWidget } from 'vs/editor/contrib/inlineEdit/browser/inlineEditController'; +import { MenuEntryActionViewItem, createAndFillInActionBarActions } from 'vs/platform/actions/browser/menuEntryActionViewItem'; +import { IMenuWorkbenchToolBarOptions, WorkbenchToolBar } from 'vs/platform/actions/browser/toolbar'; +import { IMenuService, MenuId, MenuItemAction } from 'vs/platform/actions/common/actions'; +import { IContextKeyService } from 'vs/platform/contextkey/common/contextkey'; +import { IContextMenuService } from 'vs/platform/contextview/browser/contextView'; +import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; +import { IKeybindingService } from 'vs/platform/keybinding/common/keybinding'; +import { ITelemetryService } from 'vs/platform/telemetry/common/telemetry'; + +export class InlineEditHintsWidget extends Disposable { + private readonly alwaysShowToolbar = observableFromEvent(this.editor.onDidChangeConfiguration, () => this.editor.getOption(EditorOption.inlineEdit).showToolbar === 'always'); + + private sessionPosition: Position | undefined = undefined; + + private readonly position = derived(this, reader => { + const ghostText = this.model.read(reader)?.widget.model.ghostText.read(reader); + + if (!this.alwaysShowToolbar.read(reader) || !ghostText || ghostText.parts.length === 0) { + this.sessionPosition = undefined; + return null; + } + + const firstColumn = ghostText.parts[0].column; + if (this.sessionPosition && this.sessionPosition.lineNumber !== ghostText.lineNumber) { + this.sessionPosition = undefined; + } + + const position = new Position(ghostText.lineNumber, Math.min(firstColumn, this.sessionPosition?.column ?? Number.MAX_SAFE_INTEGER)); + this.sessionPosition = position; + return position; + }); + + constructor( + private readonly editor: ICodeEditor, + private readonly model: IObservable, + @IInstantiationService private readonly instantiationService: IInstantiationService, + ) { + super(); + + this._register(autorunWithStore((reader, store) => { + /** @description setup content widget */ + const model = this.model.read(reader); + if (!model || !this.alwaysShowToolbar.read(reader)) { + return; + } + + const contentWidget = store.add(this.instantiationService.createInstance( + InlineEditHintsContentWidget, + this.editor, + true, + this.position, + )); + editor.addContentWidget(contentWidget); + store.add(toDisposable(() => editor.removeContentWidget(contentWidget))); + })); + } +} + +export class InlineEditHintsContentWidget extends Disposable implements IContentWidget { + private static _dropDownVisible = false; + public static get dropDownVisible() { return this._dropDownVisible; } + + private static id = 0; + + private readonly id = `InlineEditHintsContentWidget${InlineEditHintsContentWidget.id++}`; + public readonly allowEditorOverflow = true; + public readonly suppressMouseDown = false; + + private readonly nodes = h('div.inlineEditHints', { className: this.withBorder ? '.withBorder' : '' }, [ + h('div@toolBar'), + ]); + + private readonly toolBar: CustomizedMenuWorkbenchToolBar; + + private readonly inlineCompletionsActionsMenus = this._register(this._menuService.createMenu( + MenuId.InlineEditActions, + this._contextKeyService + )); + + constructor( + private readonly editor: ICodeEditor, + private readonly withBorder: boolean, + private readonly _position: IObservable, + + @IInstantiationService instantiationService: IInstantiationService, + @IContextKeyService private readonly _contextKeyService: IContextKeyService, + @IMenuService private readonly _menuService: IMenuService, + ) { + super(); + + this.toolBar = this._register(instantiationService.createInstance(CustomizedMenuWorkbenchToolBar, this.nodes.toolBar, this.editor, MenuId.InlineEditToolbar, { + menuOptions: { renderShortTitle: true }, + toolbarOptions: { primaryGroup: g => g.startsWith('primary') }, + actionViewItemProvider: (action, options) => { + if (action instanceof MenuItemAction) { + return instantiationService.createInstance(StatusBarViewItem, action, undefined); + } + return undefined; + }, + telemetrySource: 'InlineEditToolbar', + })); + + this._register(this.toolBar.onDidChangeDropdownVisibility(e => { + InlineEditHintsContentWidget._dropDownVisible = e; + })); + + this._register(autorun(reader => { + /** @description update position */ + this._position.read(reader); + this.editor.layoutContentWidget(this); + })); + + this._register(autorun(reader => { + /** @description actions menu */ + + const extraActions = []; + + for (const [_, group] of this.inlineCompletionsActionsMenus.getActions()) { + for (const action of group) { + if (action instanceof MenuItemAction) { + extraActions.push(action); + } + } + } + + if (extraActions.length > 0) { + extraActions.unshift(new Separator()); + } + + this.toolBar.setAdditionalSecondaryActions(extraActions); + })); + + + } + + getId(): string { return this.id; } + + getDomNode(): HTMLElement { + return this.nodes.root; + } + + getPosition(): IContentWidgetPosition | null { + return { + position: this._position.get(), + preference: [ContentWidgetPositionPreference.ABOVE, ContentWidgetPositionPreference.BELOW], + positionAffinity: PositionAffinity.LeftOfInjectedText, + }; + } +} + +class StatusBarViewItem extends MenuEntryActionViewItem { + protected override updateLabel() { + const kb = this._keybindingService.lookupKeybinding(this._action.id, this._contextKeyService); + if (!kb) { + return super.updateLabel(); + } + if (this.label) { + const div = h('div.keybinding').root; + + const k = this._register(new KeybindingLabel(div, OS, { disableTitle: true, ...unthemedKeybindingLabelOptions })); + k.set(kb); + this.label.textContent = this._action.label; + this.label.appendChild(div); + this.label.classList.add('inlineEditStatusBarItemLabel'); + } + } + + protected override updateTooltip(): void { + // NOOP, disable tooltip + } +} + +export class CustomizedMenuWorkbenchToolBar extends WorkbenchToolBar { + private readonly menu = this._store.add(this.menuService.createMenu(this.menuId, this.contextKeyService, { emitEventsForSubmenuChanges: true })); + private additionalActions: IAction[] = []; + private prependedPrimaryActions: IAction[] = []; + + constructor( + container: HTMLElement, + private readonly editor: ICodeEditor, + private readonly menuId: MenuId, + private readonly options2: IMenuWorkbenchToolBarOptions | undefined, + @IMenuService private readonly menuService: IMenuService, + @IContextKeyService private readonly contextKeyService: IContextKeyService, + @IContextMenuService contextMenuService: IContextMenuService, + @IKeybindingService keybindingService: IKeybindingService, + @ITelemetryService telemetryService: ITelemetryService, + ) { + super(container, { resetMenu: menuId, ...options2 }, menuService, contextKeyService, contextMenuService, keybindingService, telemetryService); + + this._store.add(this.menu.onDidChange(() => this.updateToolbar())); + this._store.add(this.editor.onDidChangeCursorPosition(() => this.updateToolbar())); + this.updateToolbar(); + } + + private updateToolbar(): void { + const primary: IAction[] = []; + const secondary: IAction[] = []; + createAndFillInActionBarActions( + this.menu, + this.options2?.menuOptions, + { primary, secondary }, + this.options2?.toolbarOptions?.primaryGroup, this.options2?.toolbarOptions?.shouldInlineSubmenu, this.options2?.toolbarOptions?.useSeparatorsInPrimaryActions + ); + + secondary.push(...this.additionalActions); + primary.unshift(...this.prependedPrimaryActions); + this.setActions(primary, secondary); + } + + setPrependedPrimaryActions(actions: IAction[]): void { + if (equals(this.prependedPrimaryActions, actions, (a, b) => a === b)) { + return; + } + + this.prependedPrimaryActions = actions; + this.updateToolbar(); + } + + setAdditionalSecondaryActions(actions: IAction[]): void { + if (equals(this.additionalActions, actions, (a, b) => a === b)) { + return; + } + + this.additionalActions = actions; + this.updateToolbar(); + } +} diff --git a/src/vs/editor/contrib/lineSelection/browser/lineSelection.ts b/src/vs/editor/contrib/lineSelection/browser/lineSelection.ts index 0e773d818099a..f2b2a2df9b56b 100644 --- a/src/vs/editor/contrib/lineSelection/browser/lineSelection.ts +++ b/src/vs/editor/contrib/lineSelection/browser/lineSelection.ts @@ -39,7 +39,7 @@ export class ExpandLineSelectionAction extends EditorAction { CursorChangeReason.Explicit, CursorMoveCommands.expandLineSelection(viewModel, viewModel.getCursorStates()) ); - viewModel.revealPrimaryCursor(args.source, true); + viewModel.revealAllCursors(args.source, true); } } diff --git a/src/vs/editor/contrib/linesOperations/browser/linesOperations.ts b/src/vs/editor/contrib/linesOperations/browser/linesOperations.ts index 74d7849587ef4..45b3fafba7146 100644 --- a/src/vs/editor/contrib/linesOperations/browser/linesOperations.ts +++ b/src/vs/editor/contrib/linesOperations/browser/linesOperations.ts @@ -25,6 +25,7 @@ import * as nls from 'vs/nls'; import { MenuId } from 'vs/platform/actions/common/actions'; import { KeybindingWeight } from 'vs/platform/keybinding/common/keybindingsRegistry'; import { ILanguageConfigurationService } from 'vs/editor/common/languages/languageConfigurationRegistry'; +import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; // copy lines @@ -243,7 +244,16 @@ export abstract class AbstractSortLinesAction extends EditorAction { } public run(_accessor: ServicesAccessor, editor: ICodeEditor): void { - const selections = editor.getSelections() || []; + if (!editor.hasModel()) { + return; + } + + const model = editor.getModel(); + let selections = editor.getSelections(); + if (selections.length === 1 && selections[0].isEmpty()) { + // Apply to whole document. + selections = [new Selection(1, 1, model.getLineCount(), model.getLineMaxColumn(model.getLineCount()))]; + } for (const selection of selections) { if (!SortLinesCommand.canRun(editor.getModel(), selection, this.descending)) { @@ -308,8 +318,16 @@ export class DeleteDuplicateLinesAction extends EditorAction { const endCursorState: Selection[] = []; let linesDeleted = 0; + let updateSelection = true; + + let selections = editor.getSelections(); + if (selections.length === 1 && selections[0].isEmpty()) { + // Apply to whole document. + selections = [new Selection(1, 1, model.getLineCount(), model.getLineMaxColumn(model.getLineCount()))]; + updateSelection = false; + } - for (const selection of editor.getSelections()) { + for (const selection of selections) { const uniqueLines = new Set(); const lines = []; @@ -347,7 +365,7 @@ export class DeleteDuplicateLinesAction extends EditorAction { } editor.pushUndoStop(); - editor.executeEdits(this.id, edits, endCursorState); + editor.executeEdits(this.id, edits, updateSelection ? endCursorState : undefined); editor.pushUndoStop(); } } @@ -385,7 +403,11 @@ export class TrimTrailingWhitespaceAction extends EditorAction { return; } - const command = new TrimTrailingWhitespaceCommand(selection, cursors); + const config = _accessor.get(IConfigurationService); + const model = editor.getModel(); + const trimInRegexAndStrings = config.getValue('files.trimTrailingWhitespaceInRegexAndStrings', { overrideIdentifier: model?.getLanguageId(), resource: model?.uri }); + + const command = new TrimTrailingWhitespaceCommand(selection, cursors, trimInRegexAndStrings); editor.pushUndoStop(); editor.executeCommands(this.id, [command]); @@ -1187,6 +1209,35 @@ export class CamelCaseAction extends AbstractCaseAction { } } +export class PascalCaseAction extends AbstractCaseAction { + public static wordBoundary = new BackwardsCompatibleRegExp('[_\\s-]', 'gm'); + public static wordBoundaryToMaintain = new BackwardsCompatibleRegExp('(?<=\\.)', 'gm'); + + constructor() { + super({ + id: 'editor.action.transformToPascalcase', + label: nls.localize('editor.transformToPascalcase', "Transform to Pascal Case"), + alias: 'Transform to Pascal Case', + precondition: EditorContextKeys.writable + }); + } + + protected _modifyText(text: string, wordSeparators: string): string { + const wordBoundary = PascalCaseAction.wordBoundary.get(); + const wordBoundaryToMaintain = PascalCaseAction.wordBoundaryToMaintain.get(); + + if (!wordBoundary || !wordBoundaryToMaintain) { + // cannot support this + return text; + } + + const wordsWithMaintainBoundaries = text.split(wordBoundaryToMaintain); + const words = wordsWithMaintainBoundaries.map((word: string) => word.split(wordBoundary)).flat(); + return words.map((word: string) => word.substring(0, 1).toLocaleUpperCase() + word.substring(1)) + .join(''); + } +} + export class KebabCaseAction extends AbstractCaseAction { public static isSupported(): boolean { @@ -1257,6 +1308,9 @@ if (SnakeCaseAction.caseBoundary.isSupported() && SnakeCaseAction.singleLetters. if (CamelCaseAction.wordBoundary.isSupported()) { registerEditorAction(CamelCaseAction); } +if (PascalCaseAction.wordBoundary.isSupported()) { + registerEditorAction(PascalCaseAction); +} if (TitleCaseAction.titleBoundary.isSupported()) { registerEditorAction(TitleCaseAction); } diff --git a/src/vs/editor/contrib/linesOperations/browser/moveLinesCommand.ts b/src/vs/editor/contrib/linesOperations/browser/moveLinesCommand.ts index 8db880a15adc5..68614a2f432f9 100644 --- a/src/vs/editor/contrib/linesOperations/browser/moveLinesCommand.ts +++ b/src/vs/editor/contrib/linesOperations/browser/moveLinesCommand.ts @@ -13,7 +13,7 @@ import { ITextModel } from 'vs/editor/common/model'; import { CompleteEnterAction, IndentAction } from 'vs/editor/common/languages/languageConfiguration'; import { ILanguageConfigurationService } from 'vs/editor/common/languages/languageConfigurationRegistry'; import { IndentConsts } from 'vs/editor/common/languages/supports/indentRules'; -import * as indentUtils from 'vs/editor/contrib/indentation/browser/indentUtils'; +import * as indentUtils from 'vs/editor/contrib/indentation/common/indentUtils'; import { getGoodIndentForLine, getIndentMetadata, IIndentConverter, IVirtualModel } from 'vs/editor/common/languages/autoIndent'; import { getEnterAction } from 'vs/editor/common/languages/enterAction'; diff --git a/src/vs/editor/contrib/linesOperations/test/browser/linesOperations.test.ts b/src/vs/editor/contrib/linesOperations/test/browser/linesOperations.test.ts index 3df2a1f682c1b..5425697a2e4b6 100644 --- a/src/vs/editor/contrib/linesOperations/test/browser/linesOperations.test.ts +++ b/src/vs/editor/contrib/linesOperations/test/browser/linesOperations.test.ts @@ -12,7 +12,7 @@ import { Selection } from 'vs/editor/common/core/selection'; import { Handler } from 'vs/editor/common/editorCommon'; import { ITextModel } from 'vs/editor/common/model'; import { ViewModel } from 'vs/editor/common/viewModel/viewModelImpl'; -import { CamelCaseAction, DeleteAllLeftAction, DeleteAllRightAction, DeleteDuplicateLinesAction, DeleteLinesAction, IndentLinesAction, InsertLineAfterAction, InsertLineBeforeAction, JoinLinesAction, KebabCaseAction, LowerCaseAction, SnakeCaseAction, SortLinesAscendingAction, SortLinesDescendingAction, TitleCaseAction, TransposeAction, UpperCaseAction } from 'vs/editor/contrib/linesOperations/browser/linesOperations'; +import { CamelCaseAction, PascalCaseAction, DeleteAllLeftAction, DeleteAllRightAction, DeleteDuplicateLinesAction, DeleteLinesAction, IndentLinesAction, InsertLineAfterAction, InsertLineBeforeAction, JoinLinesAction, KebabCaseAction, LowerCaseAction, SnakeCaseAction, SortLinesAscendingAction, SortLinesDescendingAction, TitleCaseAction, TransposeAction, UpperCaseAction } from 'vs/editor/contrib/linesOperations/browser/linesOperations'; import { withTestCodeEditor } from 'vs/editor/test/browser/testCodeEditor'; import { createTextModel } from 'vs/editor/test/common/testTextModel'; @@ -53,6 +53,25 @@ suite('Editor Contrib - Line Operations', () => { }); }); + test('should sort lines in ascending order', function () { + withTestCodeEditor( + [ + 'omicron', + 'beta', + 'alpha' + ], {}, (editor) => { + const model = editor.getModel()!; + const sortLinesAscendingAction = new SortLinesAscendingAction(); + + executeAction(sortLinesAscendingAction, editor); + assert.deepStrictEqual(model.getLinesContent(), [ + 'alpha', + 'beta', + 'omicron' + ]); + }); + }); + test('should sort multiple selections in ascending order', function () { withTestCodeEditor( [ @@ -148,7 +167,7 @@ suite('Editor Contrib - Line Operations', () => { }); suite('DeleteDuplicateLinesAction', () => { - test('should remove duplicate lines', function () { + test('should remove duplicate lines within selection', function () { withTestCodeEditor( [ 'alpha', @@ -172,6 +191,29 @@ suite('Editor Contrib - Line Operations', () => { }); }); + test('should remove duplicate lines', function () { + withTestCodeEditor( + [ + 'alpha', + 'beta', + 'beta', + 'beta', + 'alpha', + 'omicron', + ], {}, (editor) => { + const model = editor.getModel()!; + const deleteDuplicateLinesAction = new DeleteDuplicateLinesAction(); + + executeAction(deleteDuplicateLinesAction, editor); + assert.deepStrictEqual(model.getLinesContent(), [ + 'alpha', + 'beta', + 'omicron', + ]); + assert.ok(editor.getSelection().isEmpty()); + }); + }); + test('should remove duplicate lines in multiple selections', function () { withTestCodeEditor( [ @@ -935,6 +977,74 @@ suite('Editor Contrib - Line Operations', () => { assertSelection(editor, new Selection(11, 1, 11, 11)); } ); + + withTestCodeEditor( + [ + 'hello world', + 'öçşğü', + 'parseHTMLString', + 'getElementById', + 'PascalCase', + 'öçşÖÇŞğüĞÜ', + 'audioConverter.convertM4AToMP3();', + 'Capital_Snake_Case', + 'parseHTML4String', + 'Kebab-Case', + ], {}, (editor) => { + const model = editor.getModel()!; + const pascalCaseAction = new PascalCaseAction(); + + editor.setSelection(new Selection(1, 1, 1, 12)); + executeAction(pascalCaseAction, editor); + assert.strictEqual(model.getLineContent(1), 'HelloWorld'); + assertSelection(editor, new Selection(1, 1, 1, 11)); + + editor.setSelection(new Selection(2, 1, 2, 6)); + executeAction(pascalCaseAction, editor); + assert.strictEqual(model.getLineContent(2), 'Öçşğü'); + assertSelection(editor, new Selection(2, 1, 2, 6)); + + editor.setSelection(new Selection(3, 1, 3, 16)); + executeAction(pascalCaseAction, editor); + assert.strictEqual(model.getLineContent(3), 'ParseHTMLString'); + assertSelection(editor, new Selection(3, 1, 3, 16)); + + editor.setSelection(new Selection(4, 1, 4, 15)); + executeAction(pascalCaseAction, editor); + assert.strictEqual(model.getLineContent(4), 'GetElementById'); + assertSelection(editor, new Selection(4, 1, 4, 15)); + + editor.setSelection(new Selection(5, 1, 5, 11)); + executeAction(pascalCaseAction, editor); + assert.strictEqual(model.getLineContent(5), 'PascalCase'); + assertSelection(editor, new Selection(5, 1, 5, 11)); + + editor.setSelection(new Selection(6, 1, 6, 11)); + executeAction(pascalCaseAction, editor); + assert.strictEqual(model.getLineContent(6), 'ÖçşÖÇŞğüĞÜ'); + assertSelection(editor, new Selection(6, 1, 6, 11)); + + editor.setSelection(new Selection(7, 1, 7, 34)); + executeAction(pascalCaseAction, editor); + assert.strictEqual(model.getLineContent(7), 'AudioConverter.ConvertM4AToMP3();'); + assertSelection(editor, new Selection(7, 1, 7, 34)); + + editor.setSelection(new Selection(8, 1, 8, 19)); + executeAction(pascalCaseAction, editor); + assert.strictEqual(model.getLineContent(8), 'CapitalSnakeCase'); + assertSelection(editor, new Selection(8, 1, 8, 17)); + + editor.setSelection(new Selection(9, 1, 9, 17)); + executeAction(pascalCaseAction, editor); + assert.strictEqual(model.getLineContent(9), 'ParseHTML4String'); + assertSelection(editor, new Selection(9, 1, 9, 17)); + + editor.setSelection(new Selection(10, 1, 10, 11)); + executeAction(pascalCaseAction, editor); + assert.strictEqual(model.getLineContent(10), 'KebabCase'); + assertSelection(editor, new Selection(10, 1, 10, 10)); + } + ); }); suite('DeleteAllRightAction', () => { diff --git a/src/vs/editor/contrib/linesOperations/test/browser/moveLinesCommand.test.ts b/src/vs/editor/contrib/linesOperations/test/browser/moveLinesCommand.test.ts index 750eb192c431e..65941bd977bf7 100644 --- a/src/vs/editor/contrib/linesOperations/test/browser/moveLinesCommand.test.ts +++ b/src/vs/editor/contrib/linesOperations/test/browser/moveLinesCommand.test.ts @@ -14,39 +14,42 @@ import { MoveLinesCommand } from 'vs/editor/contrib/linesOperations/browser/move import { testCommand } from 'vs/editor/test/browser/testCommand'; import { TestLanguageConfigurationService } from 'vs/editor/test/common/modes/testLanguageConfigurationService'; +const enum MoveLinesDirection { + Up, + Down +} + function testMoveLinesDownCommand(lines: string[], selection: Selection, expectedLines: string[], expectedSelection: Selection, languageConfigurationService?: ILanguageConfigurationService): void { - const disposables = new DisposableStore(); - if (!languageConfigurationService) { - languageConfigurationService = disposables.add(new TestLanguageConfigurationService()); - } - testCommand(lines, null, selection, (accessor, sel) => new MoveLinesCommand(sel, true, EditorAutoIndentStrategy.Advanced, languageConfigurationService), expectedLines, expectedSelection); - disposables.dispose(); + testMoveLinesUpOrDownCommand(MoveLinesDirection.Down, lines, selection, expectedLines, expectedSelection, languageConfigurationService); } function testMoveLinesUpCommand(lines: string[], selection: Selection, expectedLines: string[], expectedSelection: Selection, languageConfigurationService?: ILanguageConfigurationService): void { - const disposables = new DisposableStore(); - if (!languageConfigurationService) { - languageConfigurationService = disposables.add(new TestLanguageConfigurationService()); - } - testCommand(lines, null, selection, (accessor, sel) => new MoveLinesCommand(sel, false, EditorAutoIndentStrategy.Advanced, languageConfigurationService), expectedLines, expectedSelection); - disposables.dispose(); + testMoveLinesUpOrDownCommand(MoveLinesDirection.Up, lines, selection, expectedLines, expectedSelection, languageConfigurationService); } function testMoveLinesDownWithIndentCommand(languageId: string, lines: string[], selection: Selection, expectedLines: string[], expectedSelection: Selection, languageConfigurationService?: ILanguageConfigurationService): void { + testMoveLinesUpOrDownWithIndentCommand(MoveLinesDirection.Down, languageId, lines, selection, expectedLines, expectedSelection, languageConfigurationService); +} + +function testMoveLinesUpWithIndentCommand(languageId: string, lines: string[], selection: Selection, expectedLines: string[], expectedSelection: Selection, languageConfigurationService?: ILanguageConfigurationService): void { + testMoveLinesUpOrDownWithIndentCommand(MoveLinesDirection.Up, languageId, lines, selection, expectedLines, expectedSelection, languageConfigurationService); +} + +function testMoveLinesUpOrDownCommand(direction: MoveLinesDirection, lines: string[], selection: Selection, expectedLines: string[], expectedSelection: Selection, languageConfigurationService?: ILanguageConfigurationService) { const disposables = new DisposableStore(); if (!languageConfigurationService) { languageConfigurationService = disposables.add(new TestLanguageConfigurationService()); } - testCommand(lines, languageId, selection, (accessor, sel) => new MoveLinesCommand(sel, true, EditorAutoIndentStrategy.Full, languageConfigurationService), expectedLines, expectedSelection); + testCommand(lines, null, selection, (accessor, sel) => new MoveLinesCommand(sel, direction === MoveLinesDirection.Up ? false : true, EditorAutoIndentStrategy.Advanced, languageConfigurationService), expectedLines, expectedSelection); disposables.dispose(); } -function testMoveLinesUpWithIndentCommand(languageId: string, lines: string[], selection: Selection, expectedLines: string[], expectedSelection: Selection, languageConfigurationService?: ILanguageConfigurationService): void { +function testMoveLinesUpOrDownWithIndentCommand(direction: MoveLinesDirection, languageId: string, lines: string[], selection: Selection, expectedLines: string[], expectedSelection: Selection, languageConfigurationService?: ILanguageConfigurationService) { const disposables = new DisposableStore(); if (!languageConfigurationService) { languageConfigurationService = disposables.add(new TestLanguageConfigurationService()); } - testCommand(lines, languageId, selection, (accessor, sel) => new MoveLinesCommand(sel, false, EditorAutoIndentStrategy.Full, languageConfigurationService), expectedLines, expectedSelection); + testCommand(lines, languageId, selection, (accessor, sel) => new MoveLinesCommand(sel, direction === MoveLinesDirection.Up ? false : true, EditorAutoIndentStrategy.Full, languageConfigurationService), expectedLines, expectedSelection); disposables.dispose(); } diff --git a/src/vs/editor/contrib/links/browser/links.ts b/src/vs/editor/contrib/links/browser/links.ts index 9527453bf3297..073c85c85ea25 100644 --- a/src/vs/editor/contrib/links/browser/links.ts +++ b/src/vs/editor/contrib/links/browser/links.ts @@ -237,9 +237,9 @@ export class LinkDetector extends Disposable implements IEditorContribution { const fsPath = resources.originalFSPath(parsedUri); let relativePath: string | null = null; - if (fsPath.startsWith('/./')) { + if (fsPath.startsWith('/./') || fsPath.startsWith('\\.\\')) { relativePath = `.${fsPath.substr(1)}`; - } else if (fsPath.startsWith('//./')) { + } else if (fsPath.startsWith('//./') || fsPath.startsWith('\\\\.\\')) { relativePath = `.${fsPath.substr(2)}`; } diff --git a/src/vs/editor/contrib/peekView/browser/peekView.ts b/src/vs/editor/contrib/peekView/browser/peekView.ts index 5c0882b8db4b4..a0f2dfd914ed5 100644 --- a/src/vs/editor/contrib/peekView/browser/peekView.ts +++ b/src/vs/editor/contrib/peekView/browser/peekView.ts @@ -17,7 +17,7 @@ import 'vs/css!./media/peekViewWidget'; import { ICodeEditor } from 'vs/editor/browser/editorBrowser'; import { EditorContributionInstantiation, registerEditorContribution } from 'vs/editor/browser/editorExtensions'; import { ICodeEditorService } from 'vs/editor/browser/services/codeEditorService'; -import { EmbeddedCodeEditorWidget } from 'vs/editor/browser/widget/embeddedCodeEditorWidget'; +import { EmbeddedCodeEditorWidget } from 'vs/editor/browser/widget/codeEditor/embeddedCodeEditorWidget'; import { EditorOption } from 'vs/editor/common/config/editorOptions'; import { IEditorContribution } from 'vs/editor/common/editorCommon'; import { IOptions, IStyles, ZoneWidget } from 'vs/editor/contrib/zoneWidget/browser/zoneWidget'; diff --git a/src/vs/editor/contrib/quickAccess/browser/commandsQuickAccess.ts b/src/vs/editor/contrib/quickAccess/browser/commandsQuickAccess.ts index 9b91b082ab640..9334d7fcde124 100644 --- a/src/vs/editor/contrib/quickAccess/browser/commandsQuickAccess.ts +++ b/src/vs/editor/contrib/quickAccess/browser/commandsQuickAccess.ts @@ -5,6 +5,8 @@ import { stripIcons } from 'vs/base/common/iconLabels'; import { IEditor } from 'vs/editor/common/editorCommon'; +import { ILocalizedString } from 'vs/nls'; +import { isLocalizedString } from 'vs/platform/action/common/action'; import { ICommandService } from 'vs/platform/commands/common/commands'; import { IDialogService } from 'vs/platform/dialogs/common/dialogs'; import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; @@ -38,9 +40,18 @@ export abstract class AbstractEditorCommandsQuickAccessProvider extends Abstract const editorCommandPicks: ICommandQuickPick[] = []; for (const editorAction of activeTextEditorControl.getSupportedActions()) { + let commandDescription: undefined | ILocalizedString; + if (editorAction.metadata?.description) { + if (isLocalizedString(editorAction.metadata.description)) { + commandDescription = editorAction.metadata.description; + } else { + commandDescription = { original: editorAction.metadata.description, value: editorAction.metadata.description }; + } + } editorCommandPicks.push({ commandId: editorAction.id, commandAlias: editorAction.alias, + commandDescription, label: stripIcons(editorAction.label) || editorAction.id, }); } diff --git a/src/vs/editor/contrib/quickAccess/browser/editorNavigationQuickAccess.ts b/src/vs/editor/contrib/quickAccess/browser/editorNavigationQuickAccess.ts index 20ba9ff079b83..de4198e7446c4 100644 --- a/src/vs/editor/contrib/quickAccess/browser/editorNavigationQuickAccess.ts +++ b/src/vs/editor/contrib/quickAccess/browser/editorNavigationQuickAccess.ts @@ -16,6 +16,7 @@ import { IQuickAccessProvider } from 'vs/platform/quickinput/common/quickAccess' import { IKeyMods, IQuickPick, IQuickPickItem } from 'vs/platform/quickinput/common/quickInput'; import { themeColorFromId } from 'vs/platform/theme/common/themeService'; import { status } from 'vs/base/browser/ui/aria/aria'; +import { TextEditorSelectionSource } from 'vs/platform/editor/common/editor'; interface IEditorLineDecoration { readonly rangeHighlightId: string; @@ -141,7 +142,7 @@ export abstract class AbstractEditorNavigationQuickAccessProvider implements IQu protected abstract provideWithoutTextEditor(picker: IQuickPick, token: CancellationToken): IDisposable; protected gotoLocation({ editor }: IQuickAccessTextEditorContext, options: { range: IRange; keyMods: IKeyMods; forceSideBySide?: boolean; preserveFocus?: boolean }): void { - editor.setSelection(options.range); + editor.setSelection(options.range, TextEditorSelectionSource.JUMP); editor.revealRangeInCenter(options.range, ScrollType.Smooth); if (!options.preserveFocus) { editor.focus(); diff --git a/src/vs/editor/contrib/rename/browser/rename.ts b/src/vs/editor/contrib/rename/browser/rename.ts index cd88b21da7ea2..4b8846679d2ba 100644 --- a/src/vs/editor/contrib/rename/browser/rename.ts +++ b/src/vs/editor/contrib/rename/browser/rename.ts @@ -6,25 +6,29 @@ import { alert } from 'vs/base/browser/ui/aria/aria'; import { raceCancellation } from 'vs/base/common/async'; import { CancellationToken, CancellationTokenSource } from 'vs/base/common/cancellation'; -import { onUnexpectedError } from 'vs/base/common/errors'; +import { CancellationError, onUnexpectedError } from 'vs/base/common/errors'; +import { isMarkdownString } from 'vs/base/common/htmlContent'; import { KeyCode, KeyMod } from 'vs/base/common/keyCodes'; import { DisposableStore } from 'vs/base/common/lifecycle'; import { assertType } from 'vs/base/common/types'; import { URI } from 'vs/base/common/uri'; -import { CodeEditorStateFlag, EditorStateCancellationTokenSource } from 'vs/editor/contrib/editorState/browser/editorState'; import { ICodeEditor } from 'vs/editor/browser/editorBrowser'; -import { EditorAction, EditorCommand, EditorContributionInstantiation, registerEditorAction, registerEditorCommand, registerEditorContribution, registerModelAndPositionCommand, ServicesAccessor } from 'vs/editor/browser/editorExtensions'; +import { EditorAction, EditorCommand, EditorContributionInstantiation, ServicesAccessor, registerEditorAction, registerEditorCommand, registerEditorContribution, registerModelAndPositionCommand } from 'vs/editor/browser/editorExtensions'; import { IBulkEditService } from 'vs/editor/browser/services/bulkEditService'; import { ICodeEditorService } from 'vs/editor/browser/services/codeEditorService'; import { IPosition, Position } from 'vs/editor/common/core/position'; import { Range } from 'vs/editor/common/core/range'; import { IEditorContribution } from 'vs/editor/common/editorCommon'; import { EditorContextKeys } from 'vs/editor/common/editorContextKeys'; -import { ITextModel } from 'vs/editor/common/model'; +import { LanguageFeatureRegistry } from 'vs/editor/common/languageFeatureRegistry'; import { Rejection, RenameLocation, RenameProvider, WorkspaceEdit } from 'vs/editor/common/languages'; +import { ITextModel } from 'vs/editor/common/model'; +import { ILanguageFeaturesService } from 'vs/editor/common/services/languageFeatures'; import { ITextResourceConfigurationService } from 'vs/editor/common/services/textResourceConfiguration'; +import { CodeEditorStateFlag, EditorStateCancellationTokenSource } from 'vs/editor/contrib/editorState/browser/editorState'; import { MessageController } from 'vs/editor/contrib/message/browser/messageController'; import * as nls from 'vs/nls'; +import { Action2, registerAction2 } from 'vs/platform/actions/common/actions'; import { ConfigurationScope, Extensions, IConfigurationRegistry } from 'vs/platform/configuration/common/configurationRegistry'; import { ContextKeyExpr } from 'vs/platform/contextkey/common/contextkey'; import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; @@ -33,9 +37,8 @@ import { ILogService } from 'vs/platform/log/common/log'; import { INotificationService } from 'vs/platform/notification/common/notification'; import { IEditorProgressService } from 'vs/platform/progress/common/progress'; import { Registry } from 'vs/platform/registry/common/platform'; -import { CONTEXT_RENAME_INPUT_VISIBLE, RenameInputField } from './renameInputField'; -import { LanguageFeatureRegistry } from 'vs/editor/common/languageFeatureRegistry'; -import { ILanguageFeaturesService } from 'vs/editor/common/services/languageFeatures'; +import { ITelemetryService } from 'vs/platform/telemetry/common/telemetry'; +import { CONTEXT_RENAME_INPUT_VISIBLE, NewNameSource, RenameWidget, RenameWidgetResult } from './renameWidget'; class RenameSkeleton { @@ -135,7 +138,7 @@ class RenameController implements IEditorContribution { return editor.getContribution(RenameController.ID); } - private readonly _renameInputField: RenameInputField; + private readonly _renameWidget: RenameWidget; private readonly _disposableStore = new DisposableStore(); private _cts: CancellationTokenSource = new CancellationTokenSource(); @@ -148,8 +151,9 @@ class RenameController implements IEditorContribution { @ILogService private readonly _logService: ILogService, @ITextResourceConfigurationService private readonly _configService: ITextResourceConfigurationService, @ILanguageFeaturesService private readonly _languageFeaturesService: ILanguageFeaturesService, + @ITelemetryService private readonly _telemetryService: ITelemetryService, ) { - this._renameInputField = this._disposableStore.add(this._instaService.createInstance(RenameInputField, this.editor, ['acceptRenameInput', 'acceptRenameInputWithPreview'])); + this._renameWidget = this._disposableStore.add(this._instaService.createInstance(RenameWidget, this.editor, ['acceptRenameInput', 'acceptRenameInputWithPreview'])); } dispose(): void { @@ -159,12 +163,15 @@ class RenameController implements IEditorContribution { async run(): Promise { + const trace = this._logService.trace.bind(this._logService, '[rename]'); + // set up cancellation token to prevent reentrant rename, this // is the parent to the resolve- and rename-tokens this._cts.dispose(true); this._cts = new CancellationTokenSource(); if (!this.editor.hasModel()) { + trace('editor has no model'); return undefined; } @@ -172,6 +179,7 @@ class RenameController implements IEditorContribution { const skeleton = new RenameSkeleton(this.editor.getModel(), position, this._languageFeaturesService.renameProvider); if (!skeleton.hasProvider()) { + trace('skeleton has no provider'); return undefined; } @@ -180,12 +188,20 @@ class RenameController implements IEditorContribution { let loc: RenameLocation & Rejection | undefined; try { + trace('resolving rename location'); const resolveLocationOperation = skeleton.resolveRenameLocation(cts1.token); this._progressService.showWhile(resolveLocationOperation, 250); loc = await resolveLocationOperation; - - } catch (e) { - MessageController.get(this.editor)?.showMessage(e || nls.localize('resolveRenameLocationFailed', "An unknown error occurred while resolving rename location"), position); + trace('resolved rename location'); + } catch (e: unknown) { + if (e instanceof CancellationError) { + trace('resolve rename location cancelled', JSON.stringify(e, null, '\t')); + } else { + trace('resolve rename location failed', e instanceof Error ? e : JSON.stringify(e, null, '\t')); + if (typeof e === 'string' || isMarkdownString(e)) { + MessageController.get(this.editor)?.showMessage(e || nls.localize('resolveRenameLocationFailed', "An unknown error occurred while resolving rename location"), position); + } + } return undefined; } finally { @@ -193,35 +209,48 @@ class RenameController implements IEditorContribution { } if (!loc) { + trace('returning early - no loc'); return undefined; } if (loc.rejectReason) { + trace(`returning early - rejected with reason: ${loc.rejectReason}`, loc.rejectReason); MessageController.get(this.editor)?.showMessage(loc.rejectReason, position); return undefined; } if (cts1.token.isCancellationRequested) { + trace('returning early - cts1 cancelled'); return undefined; } // part 2 - do rename at location const cts2 = new EditorStateCancellationTokenSource(this.editor, CodeEditorStateFlag.Position | CodeEditorStateFlag.Value, loc.range, this._cts.token); - const selection = this.editor.getSelection(); - let selectionStart = 0; - let selectionEnd = loc.text.length; + const model = this.editor.getModel(); // @ulugbekna: assumes editor still has a model, otherwise, cts1 should've been cancelled - if (!Range.isEmpty(selection) && !Range.spansMultipleLines(selection) && Range.containsRange(loc.range, selection)) { - selectionStart = Math.max(0, selection.startColumn - loc.range.startColumn); - selectionEnd = Math.min(loc.range.endColumn, selection.endColumn) - loc.range.startColumn; - } + const newSymbolNamesProviders = this._languageFeaturesService.newSymbolNamesProvider.all(model); + + const requestRenameSuggestions = (cts: CancellationToken) => newSymbolNamesProviders.map(p => p.provideNewSymbolNames(model, loc.range, cts)); + trace('creating rename input field and awaiting its result'); const supportPreview = this._bulkEditService.hasPreviewHandler() && this._configService.getValue(this.editor.getModel().uri, 'editor.rename.enablePreview'); - const inputFieldResult = await this._renameInputField.getInput(loc.range, loc.text, selectionStart, selectionEnd, supportPreview, cts2.token); + const inputFieldResult = await this._renameWidget.getInput( + loc.range, + loc.text, + supportPreview, + requestRenameSuggestions, + cts2 + ); + trace('received response from rename input field'); + + if (newSymbolNamesProviders.length > 0) { // @ulugbekna: we're interested only in telemetry for rename suggestions currently + this._reportTelemetry(newSymbolNamesProviders.length, model.getLanguageId(), inputFieldResult); + } // no result, only hint to focus the editor or not if (typeof inputFieldResult === 'boolean') { + trace(`returning early - rename input field response - ${inputFieldResult}`); if (inputFieldResult) { this.editor.focus(); } @@ -231,13 +260,20 @@ class RenameController implements IEditorContribution { this.editor.focus(); + trace('requesting rename edits'); const renameOperation = raceCancellation(skeleton.provideRenameEdits(inputFieldResult.newName, cts2.token), cts2.token).then(async renameResult => { - if (!renameResult || !this.editor.hasModel()) { + if (!renameResult) { + trace('returning early - no rename edits result'); + return; + } + if (!this.editor.hasModel()) { + trace('returning early - no model after rename edits are provided'); return; } if (renameResult.rejectReason) { + trace(`returning early - rejected with reason: ${renameResult.rejectReason}`); this._notificationService.info(renameResult.rejectReason); return; } @@ -245,6 +281,8 @@ class RenameController implements IEditorContribution { // collapse selection to active end this.editor.setSelection(Range.fromPositions(this.editor.getSelection().getPosition())); + trace('applying edits'); + this._bulkEditService.apply(renameResult, { editor: this.editor, showPreview: inputFieldResult.wantsPreview, @@ -253,15 +291,19 @@ class RenameController implements IEditorContribution { quotableLabel: nls.localize('quotableLabel', "Renaming {0} to {1}", loc?.text, inputFieldResult.newName), respectAutoSaveConfig: true }).then(result => { + trace('edits applied'); if (result.ariaSummary) { alert(nls.localize('aria', "Successfully renamed '{0}' to '{1}'. Summary: {2}", loc.text, inputFieldResult.newName, result.ariaSummary)); } }).catch(err => { + trace(`error when applying edits ${JSON.stringify(err, null, '\t')}`); this._notificationService.error(nls.localize('rename.failedApply', "Rename failed to apply edits")); this._logService.error(err); }); }, err => { + trace('error when providing rename edits', JSON.stringify(err, null, '\t')); + this._notificationService.error(nls.localize('rename.failed', "Rename failed to compute edits")); this._logService.error(err); @@ -269,17 +311,79 @@ class RenameController implements IEditorContribution { cts2.dispose(); }); + trace('returning rename operation'); + this._progressService.showWhile(renameOperation, 250); return renameOperation; } acceptRenameInput(wantsPreview: boolean): void { - this._renameInputField.acceptInput(wantsPreview); + this._renameWidget.acceptInput(wantsPreview); } cancelRenameInput(): void { - this._renameInputField.cancelInput(true); + this._renameWidget.cancelInput(true, 'cancelRenameInput command'); + } + + focusNextRenameSuggestion(): void { + this._renameWidget.focusNextRenameSuggestion(); + } + + focusPreviousRenameSuggestion(): void { + this._renameWidget.focusPreviousRenameSuggestion(); + } + + private _reportTelemetry(nRenameSuggestionProviders: number, languageId: string, inputFieldResult: boolean | RenameWidgetResult) { + type RenameInvokedEvent = + { + kind: 'accepted' | 'cancelled'; + languageId: string; + nRenameSuggestionProviders: number; + + /** provided only if kind = 'accepted' */ + source?: NewNameSource['k']; + /** provided only if kind = 'accepted' */ + nRenameSuggestions?: number; + /** provided only if kind = 'accepted' */ + timeBeforeFirstInputFieldEdit?: number; + /** provided only if kind = 'accepted' */ + wantsPreview?: boolean; + }; + + type RenameInvokedClassification = { + owner: 'ulugbekna'; + comment: 'A rename operation was invoked.'; + + kind: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether the rename operation was cancelled or accepted.' }; + languageId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Document language ID.' }; + nRenameSuggestionProviders: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Number of rename providers for this document.' }; + + source?: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether the new name came from the input field or rename suggestions.' }; + nRenameSuggestions?: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Number of rename suggestions user has got' }; + timeBeforeFirstInputFieldEdit?: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Milliseconds before user edits the input field for the first time' }; + wantsPreview?: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'If user wanted preview.' }; + }; + + const value: RenameInvokedEvent = + typeof inputFieldResult === 'boolean' + ? { + kind: 'cancelled', + languageId, + nRenameSuggestionProviders, + } + : { + kind: 'accepted', + languageId, + nRenameSuggestionProviders, + + source: inputFieldResult.stats.source.k, + nRenameSuggestions: inputFieldResult.stats.nRenameSuggestions, + timeBeforeFirstInputFieldEdit: inputFieldResult.stats.timeBeforeFirstInputFieldEdit, + wantsPreview: inputFieldResult.wantsPreview, + }; + + this._telemetryService.publicLog2('renameInvokedEvent', value); } } @@ -326,10 +430,15 @@ export class RenameAction extends EditorAction { } run(accessor: ServicesAccessor, editor: ICodeEditor): Promise { + const logService = accessor.get(ILogService); + const controller = RenameController.get(editor); + if (controller) { + logService.trace('[RenameAction] got controller, running...'); return controller.run(); } + logService.trace('[RenameAction] returning early - controller missing'); return Promise.resolve(); } } @@ -357,7 +466,7 @@ registerEditorCommand(new RenameCommand({ kbOpts: { weight: KeybindingWeight.EditorContrib + 99, kbExpr: ContextKeyExpr.and(EditorContextKeys.focus, ContextKeyExpr.not('isComposing')), - primary: KeyMod.Shift + KeyCode.Enter + primary: KeyMod.CtrlCmd + KeyCode.Enter } })); @@ -373,6 +482,64 @@ registerEditorCommand(new RenameCommand({ } })); +registerAction2(class FocusNextRenameSuggestion extends Action2 { + constructor() { + super({ + id: 'focusNextRenameSuggestion', + title: { + ...nls.localize2('focusNextRenameSuggestion', "Focus Next Rename Suggestion"), + }, + precondition: CONTEXT_RENAME_INPUT_VISIBLE, + keybinding: [ + { + primary: KeyCode.Tab, + secondary: [KeyCode.DownArrow], + weight: KeybindingWeight.EditorContrib + 99, + } + ] + }); + } + + override run(accessor: ServicesAccessor): void { + const currentEditor = accessor.get(ICodeEditorService).getFocusedCodeEditor(); + if (!currentEditor) { return; } + + const controller = RenameController.get(currentEditor); + if (!controller) { return; } + + controller.focusNextRenameSuggestion(); + } +}); + +registerAction2(class FocusPreviousRenameSuggestion extends Action2 { + constructor() { + super({ + id: 'focusPreviousRenameSuggestion', + title: { + ...nls.localize2('focusPreviousRenameSuggestion', "Focus Previous Rename Suggestion"), + }, + precondition: CONTEXT_RENAME_INPUT_VISIBLE, + keybinding: [ + { + primary: KeyMod.Shift | KeyCode.Tab, + secondary: [KeyCode.UpArrow], + weight: KeybindingWeight.EditorContrib + 99, + } + ] + }); + } + + override run(accessor: ServicesAccessor): void { + const currentEditor = accessor.get(ICodeEditorService).getFocusedCodeEditor(); + if (!currentEditor) { return; } + + const controller = RenameController.get(currentEditor); + if (!controller) { return; } + + controller.focusPreviousRenameSuggestion(); + } +}); + // ---- api bridge command registerModelAndPositionCommand('_executeDocumentRenameProvider', function (accessor, model, position, ...args) { diff --git a/src/vs/editor/contrib/rename/browser/renameInputField.ts b/src/vs/editor/contrib/rename/browser/renameInputField.ts deleted file mode 100644 index e00d68cd368b8..0000000000000 --- a/src/vs/editor/contrib/rename/browser/renameInputField.ts +++ /dev/null @@ -1,224 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { CancellationToken } from 'vs/base/common/cancellation'; -import { DisposableStore } from 'vs/base/common/lifecycle'; -import 'vs/css!./renameInputField'; -import { ContentWidgetPositionPreference, ICodeEditor, IContentWidget, IContentWidgetPosition } from 'vs/editor/browser/editorBrowser'; -import { EditorOption } from 'vs/editor/common/config/editorOptions'; -import { IDimension } from 'vs/editor/common/core/dimension'; -import { Position } from 'vs/editor/common/core/position'; -import { IRange } from 'vs/editor/common/core/range'; -import { ScrollType } from 'vs/editor/common/editorCommon'; -import { localize } from 'vs/nls'; -import { IContextKey, IContextKeyService, RawContextKey } from 'vs/platform/contextkey/common/contextkey'; -import { IKeybindingService } from 'vs/platform/keybinding/common/keybinding'; -import { editorWidgetBackground, inputBackground, inputBorder, inputForeground, widgetBorder, widgetShadow } from 'vs/platform/theme/common/colorRegistry'; -import { IColorTheme, IThemeService } from 'vs/platform/theme/common/themeService'; - -export const CONTEXT_RENAME_INPUT_VISIBLE = new RawContextKey('renameInputVisible', false, localize('renameInputVisible', "Whether the rename input widget is visible")); - -export interface RenameInputFieldResult { - newName: string; - wantsPreview?: boolean; -} - -export class RenameInputField implements IContentWidget { - - private _position?: Position; - private _domNode?: HTMLElement; - private _input?: HTMLInputElement; - private _label?: HTMLDivElement; - private _visible?: boolean; - private readonly _visibleContextKey: IContextKey; - private readonly _disposables = new DisposableStore(); - - readonly allowEditorOverflow: boolean = true; - - constructor( - private readonly _editor: ICodeEditor, - private readonly _acceptKeybindings: [string, string], - @IThemeService private readonly _themeService: IThemeService, - @IKeybindingService private readonly _keybindingService: IKeybindingService, - @IContextKeyService contextKeyService: IContextKeyService, - ) { - this._visibleContextKey = CONTEXT_RENAME_INPUT_VISIBLE.bindTo(contextKeyService); - - this._editor.addContentWidget(this); - - this._disposables.add(this._editor.onDidChangeConfiguration(e => { - if (e.hasChanged(EditorOption.fontInfo)) { - this._updateFont(); - } - })); - - this._disposables.add(_themeService.onDidColorThemeChange(this._updateStyles, this)); - } - - dispose(): void { - this._disposables.dispose(); - this._editor.removeContentWidget(this); - } - - getId(): string { - return '__renameInputWidget'; - } - - getDomNode(): HTMLElement { - if (!this._domNode) { - this._domNode = document.createElement('div'); - this._domNode.className = 'monaco-editor rename-box'; - - this._input = document.createElement('input'); - this._input.className = 'rename-input'; - this._input.type = 'text'; - this._input.setAttribute('aria-label', localize('renameAriaLabel', "Rename input. Type new name and press Enter to commit.")); - this._domNode.appendChild(this._input); - - this._label = document.createElement('div'); - this._label.className = 'rename-label'; - this._domNode.appendChild(this._label); - - this._updateFont(); - this._updateStyles(this._themeService.getColorTheme()); - } - return this._domNode; - } - - private _updateStyles(theme: IColorTheme): void { - if (!this._input || !this._domNode) { - return; - } - - const widgetShadowColor = theme.getColor(widgetShadow); - const widgetBorderColor = theme.getColor(widgetBorder); - this._domNode.style.backgroundColor = String(theme.getColor(editorWidgetBackground) ?? ''); - this._domNode.style.boxShadow = widgetShadowColor ? ` 0 0 8px 2px ${widgetShadowColor}` : ''; - this._domNode.style.border = widgetBorderColor ? `1px solid ${widgetBorderColor}` : ''; - this._domNode.style.color = String(theme.getColor(inputForeground) ?? ''); - - this._input.style.backgroundColor = String(theme.getColor(inputBackground) ?? ''); - // this._input.style.color = String(theme.getColor(inputForeground) ?? ''); - const border = theme.getColor(inputBorder); - this._input.style.borderWidth = border ? '1px' : '0px'; - this._input.style.borderStyle = border ? 'solid' : 'none'; - this._input.style.borderColor = border?.toString() ?? 'none'; - } - - private _updateFont(): void { - if (!this._input || !this._label) { - return; - } - - const fontInfo = this._editor.getOption(EditorOption.fontInfo); - this._input.style.fontFamily = fontInfo.fontFamily; - this._input.style.fontWeight = fontInfo.fontWeight; - this._input.style.fontSize = `${fontInfo.fontSize}px`; - - this._label.style.fontSize = `${fontInfo.fontSize * 0.8}px`; - } - - getPosition(): IContentWidgetPosition | null { - if (!this._visible) { - return null; - } - return { - position: this._position!, - preference: [ContentWidgetPositionPreference.BELOW, ContentWidgetPositionPreference.ABOVE] - }; - } - - beforeRender(): IDimension | null { - const [accept, preview] = this._acceptKeybindings; - this._label!.innerText = localize({ key: 'label', comment: ['placeholders are keybindings, e.g "F2 to Rename, Shift+F2 to Preview"'] }, "{0} to Rename, {1} to Preview", this._keybindingService.lookupKeybinding(accept)?.getLabel(), this._keybindingService.lookupKeybinding(preview)?.getLabel()); - return null; - } - - afterRender(position: ContentWidgetPositionPreference | null): void { - if (!position) { - // cancel rename when input widget isn't rendered anymore - this.cancelInput(true); - } - } - - - private _currentAcceptInput?: (wantsPreview: boolean) => void; - private _currentCancelInput?: (focusEditor: boolean) => void; - - acceptInput(wantsPreview: boolean): void { - this._currentAcceptInput?.(wantsPreview); - } - - cancelInput(focusEditor: boolean): void { - this._currentCancelInput?.(focusEditor); - } - - getInput(where: IRange, value: string, selectionStart: number, selectionEnd: number, supportPreview: boolean, token: CancellationToken): Promise { - - this._domNode!.classList.toggle('preview', supportPreview); - - this._position = new Position(where.startLineNumber, where.startColumn); - this._input!.value = value; - this._input!.setAttribute('selectionStart', selectionStart.toString()); - this._input!.setAttribute('selectionEnd', selectionEnd.toString()); - this._input!.size = Math.max((where.endColumn - where.startColumn) * 1.1, 20); - - const disposeOnDone = new DisposableStore(); - - return new Promise(resolve => { - - this._currentCancelInput = (focusEditor) => { - this._currentAcceptInput = undefined; - this._currentCancelInput = undefined; - resolve(focusEditor); - return true; - }; - - this._currentAcceptInput = (wantsPreview) => { - if (this._input!.value.trim().length === 0 || this._input!.value === value) { - // empty or whitespace only or not changed - this.cancelInput(true); - return; - } - - this._currentAcceptInput = undefined; - this._currentCancelInput = undefined; - resolve({ - newName: this._input!.value, - wantsPreview: supportPreview && wantsPreview - }); - }; - - disposeOnDone.add(token.onCancellationRequested(() => this.cancelInput(true))); - disposeOnDone.add(this._editor.onDidBlurEditorWidget(() => this.cancelInput(!this._domNode?.ownerDocument.hasFocus()))); - - this._show(); - - }).finally(() => { - disposeOnDone.dispose(); - this._hide(); - }); - } - - private _show(): void { - this._editor.revealLineInCenterIfOutsideViewport(this._position!.lineNumber, ScrollType.Smooth); - this._visible = true; - this._visibleContextKey.set(true); - this._editor.layoutContentWidget(this); - - setTimeout(() => { - this._input!.focus(); - this._input!.setSelectionRange( - parseInt(this._input!.getAttribute('selectionStart')!), - parseInt(this._input!.getAttribute('selectionEnd')!)); - }, 100); - } - - private _hide(): void { - this._visible = false; - this._visibleContextKey.reset(); - this._editor.layoutContentWidget(this); - } -} diff --git a/src/vs/editor/contrib/rename/browser/renameInputField.css b/src/vs/editor/contrib/rename/browser/renameWidget.css similarity index 92% rename from src/vs/editor/contrib/rename/browser/renameInputField.css rename to src/vs/editor/contrib/rename/browser/renameWidget.css index 1b5de07fab00f..9fa6ac1d3f968 100644 --- a/src/vs/editor/contrib/rename/browser/renameInputField.css +++ b/src/vs/editor/contrib/rename/browser/renameWidget.css @@ -16,6 +16,7 @@ .monaco-editor .rename-box .rename-input { padding: 3px; border-radius: 2px; + width: calc(100% - 8px); /* 4px padding on each side */ } .monaco-editor .rename-box .rename-label { diff --git a/src/vs/editor/contrib/rename/browser/renameWidget.ts b/src/vs/editor/contrib/rename/browser/renameWidget.ts new file mode 100644 index 0000000000000..4c828f7a8dbe3 --- /dev/null +++ b/src/vs/editor/contrib/rename/browser/renameWidget.ts @@ -0,0 +1,866 @@ +/*--------------------------------------------------------------------------------------------- + * 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 'vs/base/browser/dom'; +import * as aria from 'vs/base/browser/ui/aria/aria'; +import { renderIcon } from 'vs/base/browser/ui/iconLabel/iconLabels'; +import { IListRenderer, IListVirtualDelegate } from 'vs/base/browser/ui/list/list'; +import { List } from 'vs/base/browser/ui/list/listWidget'; +import * as arrays from 'vs/base/common/arrays'; +import { DeferredPromise, raceCancellation } from 'vs/base/common/async'; +import { CancellationToken, CancellationTokenSource } from 'vs/base/common/cancellation'; +import { Codicon } from 'vs/base/common/codicons'; +import { Emitter } from 'vs/base/common/event'; +import { DisposableStore, IDisposable, toDisposable } from 'vs/base/common/lifecycle'; +import { StopWatch } from 'vs/base/common/stopwatch'; +import { assertType, isDefined } from 'vs/base/common/types'; +import 'vs/css!./renameWidget'; +import { applyFontInfo } from 'vs/editor/browser/config/domFontInfo'; +import { ContentWidgetPositionPreference, ICodeEditor, IContentWidget, IContentWidgetPosition } from 'vs/editor/browser/editorBrowser'; +import { EditorOption } from 'vs/editor/common/config/editorOptions'; +import { FontInfo } from 'vs/editor/common/config/fontInfo'; +import { IDimension } from 'vs/editor/common/core/dimension'; +import { Position } from 'vs/editor/common/core/position'; +import { IRange, Range } from 'vs/editor/common/core/range'; +import { ScrollType } from 'vs/editor/common/editorCommon'; +import { NewSymbolName, NewSymbolNameTag, ProviderResult } from 'vs/editor/common/languages'; +import { localize } from 'vs/nls'; +import { IContextKey, IContextKeyService, RawContextKey } from 'vs/platform/contextkey/common/contextkey'; +import { IKeybindingService } from 'vs/platform/keybinding/common/keybinding'; +import { ILogService } from 'vs/platform/log/common/log'; +import { getListStyles } from 'vs/platform/theme/browser/defaultStyles'; +import { + editorWidgetBackground, + inputBackground, + inputBorder, + inputForeground, + quickInputListFocusBackground, + quickInputListFocusForeground, + widgetBorder, + widgetShadow +} from 'vs/platform/theme/common/colorRegistry'; +import { IColorTheme, IThemeService } from 'vs/platform/theme/common/themeService'; + +/** for debugging */ +const _sticky = false + // || Boolean("true") // done "weirdly" so that a lint warning prevents you from pushing this + ; + + +export const CONTEXT_RENAME_INPUT_VISIBLE = new RawContextKey('renameInputVisible', false, localize('renameInputVisible', "Whether the rename input widget is visible")); +export const CONTEXT_RENAME_INPUT_FOCUSED = new RawContextKey('renameInputFocused', false, localize('renameInputFocused', "Whether the rename input widget is focused")); + +/** + * "Source" of the new name: + * - 'inputField' - user entered the new name + * - 'renameSuggestion' - user picked from rename suggestions + * - 'userEditedRenameSuggestion' - user _likely_ edited a rename suggestion ("likely" because when input started being edited, a rename suggestion had focus) + */ +export type NewNameSource = + | { k: 'inputField' } + | { k: 'renameSuggestion' } + | { k: 'userEditedRenameSuggestion' }; + +/** + * Various statistics regarding rename input field + */ +export type RenameWidgetStats = { + nRenameSuggestions: number; + source: NewNameSource; + timeBeforeFirstInputFieldEdit: number | undefined; +}; + +export type RenameWidgetResult = { + /** + * The new name to be used + */ + newName: string; + wantsPreview?: boolean; + stats: RenameWidgetStats; +}; + +interface IRenameWidget { + /** + * @returns a `boolean` standing for `shouldFocusEditor`, if user didn't pick a new name, or a {@link RenameWidgetResult} + */ + getInput( + where: IRange, + currentName: string, + supportPreview: boolean, + requestRenameSuggestions: (cts: CancellationToken) => ProviderResult[], + cts: CancellationTokenSource + ): Promise; + + acceptInput(wantsPreview: boolean): void; + cancelInput(focusEditor: boolean, caller: string): void; + + focusNextRenameSuggestion(): void; + focusPreviousRenameSuggestion(): void; +} + +export class RenameWidget implements IRenameWidget, IContentWidget, IDisposable { + + // implement IContentWidget + readonly allowEditorOverflow: boolean = true; + + // UI state + + private _domNode?: HTMLElement; + private _input: RenameInput; + private _renameCandidateListView?: RenameCandidateListView; + private _label?: HTMLDivElement; + + private _nPxAvailableAbove?: number; + private _nPxAvailableBelow?: number; + + // Model state + + private _position?: Position; + private _currentName?: string; + /** Is true if input field got changes when a rename candidate was focused; otherwise, false */ + private _isEditingRenameCandidate: boolean; + + private _visible?: boolean; + + /** must be reset at session start */ + private _beforeFirstInputFieldEditSW: StopWatch; + + /** + * Milliseconds before user edits the input field for the first time + * @remarks must be set once per session + */ + private _timeBeforeFirstInputFieldEdit: number | undefined; + + private _renameCandidateProvidersCts: CancellationTokenSource | undefined; + + private readonly _visibleContextKey: IContextKey; + private readonly _disposables = new DisposableStore(); + + constructor( + private readonly _editor: ICodeEditor, + private readonly _acceptKeybindings: [string, string], + @IThemeService private readonly _themeService: IThemeService, + @IKeybindingService private readonly _keybindingService: IKeybindingService, + @IContextKeyService contextKeyService: IContextKeyService, + @ILogService private readonly _logService: ILogService, + ) { + this._visibleContextKey = CONTEXT_RENAME_INPUT_VISIBLE.bindTo(contextKeyService); + + this._isEditingRenameCandidate = false; + + this._beforeFirstInputFieldEditSW = new StopWatch(); + + this._input = new RenameInput(); + this._disposables.add(this._input); + + this._editor.addContentWidget(this); + + this._disposables.add(this._editor.onDidChangeConfiguration(e => { + if (e.hasChanged(EditorOption.fontInfo)) { + this._updateFont(); + } + })); + + this._disposables.add(_themeService.onDidColorThemeChange(this._updateStyles, this)); + } + + dispose(): void { + this._disposables.dispose(); + this._editor.removeContentWidget(this); + } + + getId(): string { + return '__renameInputWidget'; + } + + getDomNode(): HTMLElement { + if (!this._domNode) { + this._domNode = document.createElement('div'); + this._domNode.className = 'monaco-editor rename-box'; + + this._domNode.appendChild(this._input.domNode); + + this._renameCandidateListView = this._disposables.add( + new RenameCandidateListView(this._domNode, { + fontInfo: this._editor.getOption(EditorOption.fontInfo), + onFocusChange: (newSymbolName: string) => { + this._input.domNode.value = newSymbolName; + this._isEditingRenameCandidate = false; // @ulugbekna: reset + }, + onSelectionChange: () => { + this._isEditingRenameCandidate = false; // @ulugbekna: because user picked a rename suggestion + this.acceptInput(false); // we don't allow preview with mouse click for now + } + }) + ); + + this._disposables.add( + this._input.onDidChange(() => { + if (this._renameCandidateListView?.focusedCandidate !== undefined) { + this._isEditingRenameCandidate = true; + } + this._timeBeforeFirstInputFieldEdit ??= this._beforeFirstInputFieldEditSW.elapsed(); + if (this._renameCandidateProvidersCts?.token.isCancellationRequested === false) { + this._renameCandidateProvidersCts.cancel(); + } + this._renameCandidateListView?.clearFocus(); + }) + ); + + this._label = document.createElement('div'); + this._label.className = 'rename-label'; + this._domNode.appendChild(this._label); + + this._updateFont(); + this._updateStyles(this._themeService.getColorTheme()); + } + return this._domNode; + } + + private _updateStyles(theme: IColorTheme): void { + if (!this._domNode) { + return; + } + + const widgetShadowColor = theme.getColor(widgetShadow); + const widgetBorderColor = theme.getColor(widgetBorder); + this._domNode.style.backgroundColor = String(theme.getColor(editorWidgetBackground) ?? ''); + this._domNode.style.boxShadow = widgetShadowColor ? ` 0 0 8px 2px ${widgetShadowColor}` : ''; + this._domNode.style.border = widgetBorderColor ? `1px solid ${widgetBorderColor}` : ''; + this._domNode.style.color = String(theme.getColor(inputForeground) ?? ''); + + this._input.domNode.style.backgroundColor = String(theme.getColor(inputBackground) ?? ''); + // this._input.style.color = String(theme.getColor(inputForeground) ?? ''); + const border = theme.getColor(inputBorder); + this._input.domNode.style.borderWidth = border ? '1px' : '0px'; + this._input.domNode.style.borderStyle = border ? 'solid' : 'none'; + this._input.domNode.style.borderColor = border?.toString() ?? 'none'; + } + + private _updateFont(): void { + if (this._domNode === undefined) { + return; + } + assertType(this._label !== undefined, 'RenameWidget#_updateFont: _label must not be undefined given _domNode is defined'); + + this._editor.applyFontInfo(this._input.domNode); + + const fontInfo = this._editor.getOption(EditorOption.fontInfo); + this._label.style.fontSize = `${this._computeLabelFontSize(fontInfo.fontSize)}px`; + } + + private _computeLabelFontSize(editorFontSize: number) { + return editorFontSize * 0.8; + } + + getPosition(): IContentWidgetPosition | null { + if (!this._visible) { + return null; + } + + if (!this._editor.hasModel() || // @ulugbekna: shouldn't happen + !this._editor.getDomNode() // @ulugbekna: can happen during tests based on suggestWidget's similar predicate check + ) { + return null; + } + + const bodyBox = dom.getClientArea(this.getDomNode().ownerDocument.body); + const editorBox = dom.getDomNodePagePosition(this._editor.getDomNode()); + + const cursorBoxTop = this._getTopForPosition(); + + this._nPxAvailableAbove = cursorBoxTop + editorBox.top; + this._nPxAvailableBelow = bodyBox.height - this._nPxAvailableAbove; + + const lineHeight = this._editor.getOption(EditorOption.lineHeight); + const { totalHeight: candidateViewHeight } = RenameCandidateView.getLayoutInfo({ lineHeight }); + + const positionPreference = this._nPxAvailableBelow > candidateViewHeight * 6 /* approximate # of candidates to fit in (inclusive of rename input box & rename label) */ + ? [ContentWidgetPositionPreference.BELOW, ContentWidgetPositionPreference.ABOVE] + : [ContentWidgetPositionPreference.ABOVE, ContentWidgetPositionPreference.BELOW]; + + return { + position: this._position!, + preference: positionPreference, + }; + } + + beforeRender(): IDimension | null { + const [accept, preview] = this._acceptKeybindings; + this._label!.innerText = localize({ key: 'label', comment: ['placeholders are keybindings, e.g "F2 to Rename, Shift+F2 to Preview"'] }, "{0} to Rename, {1} to Preview", this._keybindingService.lookupKeybinding(accept)?.getLabel(), this._keybindingService.lookupKeybinding(preview)?.getLabel()); + + this._domNode!.style.minWidth = `200px`; // to prevent from widening when candidates come in + + return null; + } + + afterRender(position: ContentWidgetPositionPreference | null): void { + this._trace('invoking afterRender, position: ', position ? 'not null' : 'null'); + if (position === null) { + // cancel rename when input widget isn't rendered anymore + this.cancelInput(true, 'afterRender (because position is null)'); + return; + } + + if (!this._editor.hasModel() || // shouldn't happen + !this._editor.getDomNode() // can happen during tests based on suggestWidget's similar predicate check + ) { + return; + } + + assertType(this._renameCandidateListView); + assertType(this._nPxAvailableAbove !== undefined); + assertType(this._nPxAvailableBelow !== undefined); + + const inputBoxHeight = dom.getTotalHeight(this._input.domNode); + + const labelHeight = dom.getTotalHeight(this._label!); + + let totalHeightAvailable: number; + if (position === ContentWidgetPositionPreference.BELOW) { + totalHeightAvailable = this._nPxAvailableBelow; + } else { + totalHeightAvailable = this._nPxAvailableAbove; + } + + this._renameCandidateListView!.layout({ + height: totalHeightAvailable - labelHeight - inputBoxHeight, + width: dom.getTotalWidth(this._input.domNode), + }); + } + + + private _currentAcceptInput?: (wantsPreview: boolean) => void; + private _currentCancelInput?: (focusEditor: boolean) => void; + + acceptInput(wantsPreview: boolean): void { + this._trace(`invoking acceptInput`); + this._currentAcceptInput?.(wantsPreview); + } + + cancelInput(focusEditor: boolean, caller: string): void { + this._trace(`invoking cancelInput, caller: ${caller}, _currentCancelInput: ${this._currentAcceptInput ? 'not undefined' : 'undefined'}`); + this._currentCancelInput?.(focusEditor); + } + + focusNextRenameSuggestion() { + if (!this._renameCandidateListView?.focusNext()) { + this._input.domNode.value = this._currentName!; + } + } + + focusPreviousRenameSuggestion() { // TODO@ulugbekna: this and focusNext should set the original name if no candidate is focused + if (!this._renameCandidateListView?.focusPrevious()) { + this._input.domNode.value = this._currentName!; + } + } + + getInput( + where: IRange, + currentName: string, + supportPreview: boolean, + requestRenameCandidates: (cts: CancellationToken) => ProviderResult[], + cts: CancellationTokenSource + ): Promise { + + const { start: selectionStart, end: selectionEnd } = this._getSelection(where, currentName); + + this._renameCandidateProvidersCts = new CancellationTokenSource(); + const candidates = requestRenameCandidates(this._renameCandidateProvidersCts.token); + this._updateRenameCandidates(candidates, currentName, cts.token); + + this._isEditingRenameCandidate = false; + + this._domNode!.classList.toggle('preview', supportPreview); + + this._position = new Position(where.startLineNumber, where.startColumn); + this._currentName = currentName; + + this._input.domNode.value = currentName; + this._input.domNode.setAttribute('selectionStart', selectionStart.toString()); + this._input.domNode.setAttribute('selectionEnd', selectionEnd.toString()); + this._input.domNode.size = Math.max((where.endColumn - where.startColumn) * 1.1, 20); // determines width + + this._beforeFirstInputFieldEditSW.reset(); + + const disposeOnDone = new DisposableStore(); + + disposeOnDone.add(toDisposable(() => cts.dispose(true))); // @ulugbekna: this may result in `this.cancelInput` being called twice, but it should be safe since we set it to undefined after 1st call + disposeOnDone.add(toDisposable(() => { + if (this._renameCandidateProvidersCts !== undefined) { + this._renameCandidateProvidersCts.dispose(true); + this._renameCandidateProvidersCts = undefined; + } + })); + + const inputResult = new DeferredPromise(); + + inputResult.p.finally(() => { + disposeOnDone.dispose(); + this._hide(); + }); + + this._currentCancelInput = (focusEditor) => { + this._trace('invoking _currentCancelInput'); + this._currentAcceptInput = undefined; + this._currentCancelInput = undefined; + this._renameCandidateListView?.clearCandidates(); + inputResult.complete(focusEditor); + return true; + }; + + this._currentAcceptInput = (wantsPreview) => { + this._trace('invoking _currentAcceptInput'); + assertType(this._renameCandidateListView !== undefined); + + const nRenameSuggestions = this._renameCandidateListView.nCandidates; + + let newName: string; + let source: NewNameSource; + const focusedCandidate = this._renameCandidateListView.focusedCandidate; + if (focusedCandidate !== undefined) { + this._trace('using new name from renameSuggestion'); + newName = focusedCandidate; + source = { k: 'renameSuggestion' }; + } else { + this._trace('using new name from inputField'); + newName = this._input.domNode.value; + source = this._isEditingRenameCandidate ? { k: 'userEditedRenameSuggestion' } : { k: 'inputField' }; + } + + if (newName === currentName || newName.trim().length === 0 /* is just whitespace */) { + this.cancelInput(true, '_currentAcceptInput (because newName === value || newName.trim().length === 0)'); + return; + } + + this._currentAcceptInput = undefined; + this._currentCancelInput = undefined; + this._renameCandidateListView.clearCandidates(); + + inputResult.complete({ + newName, + wantsPreview: supportPreview && wantsPreview, + stats: { + source, + nRenameSuggestions, + timeBeforeFirstInputFieldEdit: this._timeBeforeFirstInputFieldEdit, + } + }); + }; + + disposeOnDone.add(cts.token.onCancellationRequested(() => this.cancelInput(true, 'cts.token.onCancellationRequested'))); + if (!_sticky) { + disposeOnDone.add(this._editor.onDidBlurEditorWidget(() => this.cancelInput(!this._domNode?.ownerDocument.hasFocus(), 'editor.onDidBlurEditorWidget'))); + } + + this._show(); + + return inputResult.p; + } + + /** + * This allows selecting only part of the symbol name in the input field based on the selection in the editor + */ + private _getSelection(where: IRange, currentName: string): { start: number; end: number } { + assertType(this._editor.hasModel()); + + const selection = this._editor.getSelection(); + let start = 0; + let end = currentName.length; + + if (!Range.isEmpty(selection) && !Range.spansMultipleLines(selection) && Range.containsRange(where, selection)) { + start = Math.max(0, selection.startColumn - where.startColumn); + end = Math.min(where.endColumn, selection.endColumn) - where.startColumn; + } + + return { start, end }; + } + + private _show(): void { + this._trace('invoking _show'); + this._editor.revealLineInCenterIfOutsideViewport(this._position!.lineNumber, ScrollType.Smooth); + this._visible = true; + this._visibleContextKey.set(true); + this._editor.layoutContentWidget(this); + + // TODO@ulugbekna: could this be simply run in `afterRender`? + setTimeout(() => { + this._input.domNode.focus(); + this._input.domNode.setSelectionRange( + parseInt(this._input!.domNode.getAttribute('selectionStart')!), + parseInt(this._input!.domNode.getAttribute('selectionEnd')!) + ); + }, 100); + } + + private async _updateRenameCandidates(candidates: ProviderResult[], currentName: string, token: CancellationToken) { + const trace = (...args: any[]) => this._trace('_updateRenameCandidates', ...args); + + trace('start'); + const namesListResults = await raceCancellation(Promise.allSettled(candidates), token); + + if (namesListResults === undefined) { + trace('returning early - received updateRenameCandidates results - undefined'); + return; + } + + const newNames = namesListResults.flatMap(namesListResult => + namesListResult.status === 'fulfilled' && isDefined(namesListResult.value) + ? namesListResult.value + : [] + ); + trace(`received updateRenameCandidates results - total (unfiltered) ${newNames.length} candidates.`); + + // deduplicate and filter out the current value + const distinctNames = arrays.distinct(newNames, v => v.newSymbolName); + trace(`distinct candidates - ${distinctNames.length} candidates.`); + + const validDistinctNames = distinctNames.filter(({ newSymbolName }) => newSymbolName.trim().length > 0 && newSymbolName !== this._input.domNode.value && newSymbolName !== currentName); + trace(`valid distinct candidates - ${newNames.length} candidates.`); + + if (validDistinctNames.length < 1) { + trace('returning early - no valid distinct candidates'); + return; + } + + // show the candidates + trace('setting candidates'); + this._renameCandidateListView!.setCandidates(validDistinctNames); + + // ask editor to re-layout given that the widget is now of a different size after rendering rename candidates + trace('asking editor to re-layout'); + this._editor.layoutContentWidget(this); + } + + private _hide(): void { + this._trace('invoked _hide'); + this._visible = false; + this._visibleContextKey.reset(); + this._editor.layoutContentWidget(this); + } + + private _getTopForPosition(): number { + const visibleRanges = this._editor.getVisibleRanges(); + let firstLineInViewport: number; + if (visibleRanges.length > 0) { + firstLineInViewport = visibleRanges[0].startLineNumber; + } else { + this._logService.warn('RenameWidget#_getTopForPosition: this should not happen - visibleRanges is empty'); + firstLineInViewport = Math.max(1, this._position!.lineNumber - 5); // @ulugbekna: fallback to current line minus 5 + } + return this._editor.getTopForLineNumber(this._position!.lineNumber) - this._editor.getTopForLineNumber(firstLineInViewport); + } + + private _trace(...args: unknown[]) { + this._logService.trace('RenameWidget', ...args); + } +} + +class RenameCandidateListView { + + /** Parent node of the list widget; needed to control # of list elements visible */ + private readonly _listContainer: HTMLDivElement; + private readonly _listWidget: List; + + private _lineHeight: number; + private _availableHeight: number; + private _minimumWidth: number; + private _typicalHalfwidthCharacterWidth: number; + + private readonly _disposables: DisposableStore; + + // FIXME@ulugbekna: rewrite using event emitters + constructor(parent: HTMLElement, opts: { fontInfo: FontInfo; onFocusChange: (newSymbolName: string) => void; onSelectionChange: () => void }) { + + this._disposables = new DisposableStore(); + + this._availableHeight = 0; + this._minimumWidth = 0; + + this._lineHeight = opts.fontInfo.lineHeight; + this._typicalHalfwidthCharacterWidth = opts.fontInfo.typicalHalfwidthCharacterWidth; + + this._listContainer = document.createElement('div'); + parent.appendChild(this._listContainer); + + this._listWidget = RenameCandidateListView._createListWidget(this._listContainer, this._candidateViewHeight, opts.fontInfo); + + this._listWidget.onDidChangeFocus( + e => { + if (e.elements.length === 1) { + opts.onFocusChange(e.elements[0].newSymbolName); + } + }, + this._disposables + ); + + this._listWidget.onDidChangeSelection( + e => { + if (e.elements.length === 1) { + opts.onSelectionChange(); + } + }, + this._disposables + ); + + this._disposables.add( + this._listWidget.onDidBlur(e => { // @ulugbekna: because list widget otherwise remembers last focused element and returns it as focused element + this._listWidget.setFocus([]); + }) + ); + + this._listWidget.style(getListStyles({ + listInactiveFocusForeground: quickInputListFocusForeground, + listInactiveFocusBackground: quickInputListFocusBackground, + })); + } + + dispose() { + this._listWidget.dispose(); + this._disposables.dispose(); + } + + // height - max height allowed by parent element + public layout({ height, width }: { height: number; width: number }): void { + this._availableHeight = height; + this._minimumWidth = width; + } + + public setCandidates(candidates: NewSymbolName[]): void { + + // insert candidates into list widget + this._listWidget.splice(0, 0, candidates); + + // adjust list widget layout + const height = this._pickListHeight(candidates.length); + const width = this._pickListWidth(candidates); + + this._listWidget.layout(height, width); + + // adjust list container layout + this._listContainer.style.height = `${height}px`; + this._listContainer.style.width = `${width}px`; + + aria.status(localize('renameSuggestionsReceivedAria', "Received {0} rename suggestions", candidates.length)); + } + + public clearCandidates(): void { + this._listContainer.style.height = '0px'; + this._listContainer.style.width = '0px'; + this._listWidget.splice(0, this._listWidget.length, []); + } + + public get nCandidates() { + return this._listWidget.length; + } + + public get focusedCandidate(): string | undefined { + if (this._listWidget.length === 0) { + return; + } + const selectedElement = this._listWidget.getSelectedElements()[0]; + if (selectedElement !== undefined) { + return selectedElement.newSymbolName; + } + const focusedElement = this._listWidget.getFocusedElements()[0]; + if (focusedElement !== undefined) { + return focusedElement.newSymbolName; + } + return; + } + + public focusNext(): boolean { + if (this._listWidget.length === 0) { + return false; + } + const focusedIxs = this._listWidget.getFocus(); + if (focusedIxs.length === 0) { + this._listWidget.focusFirst(); + return true; + } else { + if (focusedIxs[0] === this._listWidget.length - 1) { + this._listWidget.setFocus([]); + return false; + } else { + this._listWidget.focusNext(); + return true; + } + } + } + + /** + * @returns true if focus is moved to previous element + */ + public focusPrevious(): boolean { + if (this._listWidget.length === 0) { + return false; + } + const focusedIxs = this._listWidget.getFocus(); + if (focusedIxs.length === 0) { + this._listWidget.focusLast(); + return true; + } else { + if (focusedIxs[0] === 0) { + this._listWidget.setFocus([]); + return false; + } else { + this._listWidget.focusPrevious(); + return true; + } + } + } + + public clearFocus(): void { + this._listWidget.setFocus([]); + } + + private get _candidateViewHeight(): number { + const { totalHeight } = RenameCandidateView.getLayoutInfo({ lineHeight: this._lineHeight }); + return totalHeight; + } + + private _pickListHeight(nCandidates: number) { + const heightToFitAllCandidates = this._candidateViewHeight * nCandidates; + const MAX_N_CANDIDATES = 7; // @ulugbekna: max # of candidates we want to show at once + const height = Math.min(heightToFitAllCandidates, this._availableHeight, this._candidateViewHeight * MAX_N_CANDIDATES); + return height; + } + + private _pickListWidth(candidates: NewSymbolName[]): number { + const longestCandidateWidth = Math.ceil(Math.max(...candidates.map(c => c.newSymbolName.length)) * this._typicalHalfwidthCharacterWidth); + const width = Math.max( + this._minimumWidth, + 4 /* padding */ + 16 /* sparkle icon */ + 5 /* margin-left */ + longestCandidateWidth + 10 /* (possibly visible) scrollbar width */ // TODO@ulugbekna: approximate calc - clean this up + ); + return width; + } + + private static _createListWidget(container: HTMLElement, candidateViewHeight: number, fontInfo: FontInfo) { + const virtualDelegate = new class implements IListVirtualDelegate { + getTemplateId(element: NewSymbolName): string { + return 'candidate'; + } + + getHeight(element: NewSymbolName): number { + return candidateViewHeight; + } + }; + + const renderer = new class implements IListRenderer { + readonly templateId = 'candidate'; + + renderTemplate(container: HTMLElement): RenameCandidateView { + return new RenameCandidateView(container, fontInfo); + } + + renderElement(candidate: NewSymbolName, index: number, templateData: RenameCandidateView): void { + templateData.populate(candidate); + } + + disposeTemplate(templateData: RenameCandidateView): void { + templateData.dispose(); + } + }; + + return new List( + 'NewSymbolNameCandidates', + container, + virtualDelegate, + [renderer], + { + keyboardSupport: false, // @ulugbekna: because we handle keyboard events through proper commands & keybinding service, see `rename.ts` + mouseSupport: true, + multipleSelectionSupport: false, + } + ); + } +} + +/** + * @remarks lazily creates the DOM node + */ +class RenameInput implements IDisposable { + + private _domNode: HTMLInputElement | undefined; + + private readonly _onDidChange = new Emitter(); + public readonly onDidChange = this._onDidChange.event; + + private readonly _disposables = new DisposableStore(); + + get domNode() { + if (!this._domNode) { + this._domNode = document.createElement('input'); + this._domNode.className = 'rename-input'; + this._domNode.type = 'text'; + this._domNode.setAttribute('aria-label', localize('renameAriaLabel', "Rename input. Type new name and press Enter to commit.")); + this._disposables.add(dom.addDisposableListener(this._domNode, 'input', () => this._onDidChange.fire())); + } + return this._domNode; + } + + dispose(): void { + this._onDidChange.dispose(); + this._disposables.dispose(); + } +} + +class RenameCandidateView { + + private static _PADDING: number = 2; + + private readonly _domNode: HTMLElement; + private readonly _icon: HTMLElement; + private readonly _label: HTMLElement; + + constructor(parent: HTMLElement, fontInfo: FontInfo) { + + this._domNode = document.createElement('div'); + this._domNode.style.display = `flex`; + this._domNode.style.columnGap = `5px`; + this._domNode.style.alignItems = `center`; + this._domNode.style.height = `${fontInfo.lineHeight}px`; + this._domNode.style.padding = `${RenameCandidateView._PADDING}px`; + + // @ulugbekna: needed to keep space when the `icon.style.display` is set to `none` + const iconContainer = document.createElement('div'); + iconContainer.style.display = `flex`; + iconContainer.style.alignItems = `center`; + iconContainer.style.width = iconContainer.style.height = `${fontInfo.lineHeight * 0.8}px`; + this._domNode.appendChild(iconContainer); + + this._icon = renderIcon(Codicon.sparkle); + this._icon.style.display = `none`; + iconContainer.appendChild(this._icon); + + this._label = document.createElement('div'); + applyFontInfo(this._label, fontInfo); + this._domNode.appendChild(this._label); + + parent.appendChild(this._domNode); + } + + public populate(value: NewSymbolName) { + this._updateIcon(value); + this._updateLabel(value); + } + + private _updateIcon(value: NewSymbolName) { + const isAIGenerated = !!value.tags?.includes(NewSymbolNameTag.AIGenerated); + this._icon.style.display = isAIGenerated ? 'inherit' : 'none'; + } + + private _updateLabel(value: NewSymbolName) { + this._label.innerText = value.newSymbolName; + } + + public static getLayoutInfo({ lineHeight }: { lineHeight: number }): { totalHeight: number } { + const totalHeight = lineHeight + RenameCandidateView._PADDING * 2 /* top & bottom padding */; + return { totalHeight }; + } + + public dispose() { + } +} diff --git a/src/vs/editor/contrib/sectionHeaders/browser/sectionHeaders.ts b/src/vs/editor/contrib/sectionHeaders/browser/sectionHeaders.ts new file mode 100644 index 0000000000000..f3296062d8146 --- /dev/null +++ b/src/vs/editor/contrib/sectionHeaders/browser/sectionHeaders.ts @@ -0,0 +1,208 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { CancelablePromise, RunOnceScheduler } from 'vs/base/common/async'; +import { Disposable } from 'vs/base/common/lifecycle'; +import { ICodeEditor } from 'vs/editor/browser/editorBrowser'; +import { EditorContributionInstantiation, registerEditorContribution } from 'vs/editor/browser/editorExtensions'; +import { EditorOption, IEditorMinimapOptions } from 'vs/editor/common/config/editorOptions'; +import { IEditorContribution } from 'vs/editor/common/editorCommon'; +import { StandardTokenType } from 'vs/editor/common/encodedTokenAttributes'; +import { ILanguageConfigurationService } from 'vs/editor/common/languages/languageConfigurationRegistry'; +import { IModelDeltaDecoration, MinimapPosition, MinimapSectionHeaderStyle, TrackedRangeStickiness } from 'vs/editor/common/model'; +import { ModelDecorationOptions } from 'vs/editor/common/model/textModel'; +import { IEditorWorkerService } from 'vs/editor/common/services/editorWorker'; +import { FindSectionHeaderOptions, SectionHeader } from 'vs/editor/common/services/findSectionHeaders'; + +export class SectionHeaderDetector extends Disposable implements IEditorContribution { + + public static readonly ID: string = 'editor.sectionHeaderDetector'; + + private options: FindSectionHeaderOptions | undefined; + private decorations = this.editor.createDecorationsCollection(); + private computeSectionHeaders: RunOnceScheduler; + private computePromise: CancelablePromise | null; + private currentOccurrences: { [decorationId: string]: SectionHeaderOccurrence }; + + constructor( + private readonly editor: ICodeEditor, + @ILanguageConfigurationService private readonly languageConfigurationService: ILanguageConfigurationService, + @IEditorWorkerService private readonly editorWorkerService: IEditorWorkerService, + ) { + super(); + + this.options = this.createOptions(editor.getOption(EditorOption.minimap)); + this.computePromise = null; + this.currentOccurrences = {}; + + this._register(editor.onDidChangeModel((e) => { + this.currentOccurrences = {}; + this.options = this.createOptions(editor.getOption(EditorOption.minimap)); + this.stop(); + this.computeSectionHeaders.schedule(0); + })); + + this._register(editor.onDidChangeModelLanguage((e) => { + this.currentOccurrences = {}; + this.options = this.createOptions(editor.getOption(EditorOption.minimap)); + this.stop(); + this.computeSectionHeaders.schedule(0); + })); + + this._register(languageConfigurationService.onDidChange((e) => { + const editorLanguageId = this.editor.getModel()?.getLanguageId(); + if (editorLanguageId && e.affects(editorLanguageId)) { + this.currentOccurrences = {}; + this.options = this.createOptions(editor.getOption(EditorOption.minimap)); + this.stop(); + this.computeSectionHeaders.schedule(0); + } + })); + + this._register(editor.onDidChangeConfiguration(e => { + if (this.options && !e.hasChanged(EditorOption.minimap)) { + return; + } + + this.options = this.createOptions(editor.getOption(EditorOption.minimap)); + + // Remove any links (for the getting disabled case) + this.updateDecorations([]); + + // Stop any computation (for the getting disabled case) + this.stop(); + + // Start computing (for the getting enabled case) + this.computeSectionHeaders.schedule(0); + })); + + this._register(this.editor.onDidChangeModelContent(e => { + this.computeSectionHeaders.schedule(); + })); + + this.computeSectionHeaders = this._register(new RunOnceScheduler(() => { + this.findSectionHeaders(); + }, 250)); + + this.computeSectionHeaders.schedule(0); + } + + private createOptions(minimap: Readonly>): FindSectionHeaderOptions | undefined { + if (!minimap || !this.editor.hasModel()) { + return undefined; + } + + const languageId = this.editor.getModel().getLanguageId(); + if (!languageId) { + return undefined; + } + + const commentsConfiguration = this.languageConfigurationService.getLanguageConfiguration(languageId).comments; + const foldingRules = this.languageConfigurationService.getLanguageConfiguration(languageId).foldingRules; + + if (!commentsConfiguration && !foldingRules?.markers) { + return undefined; + } + + return { + foldingRules, + findMarkSectionHeaders: minimap.showMarkSectionHeaders, + findRegionSectionHeaders: minimap.showRegionSectionHeaders, + }; + } + + private findSectionHeaders() { + if (!this.editor.hasModel() + || (!this.options?.findMarkSectionHeaders && !this.options?.findRegionSectionHeaders)) { + return; + } + + const model = this.editor.getModel(); + if (model.isDisposed() || model.isTooLargeForSyncing()) { + return; + } + + const modelVersionId = model.getVersionId(); + this.editorWorkerService.findSectionHeaders(model.uri, this.options) + .then((sectionHeaders) => { + if (model.isDisposed() || model.getVersionId() !== modelVersionId) { + // model changed in the meantime + return; + } + this.updateDecorations(sectionHeaders); + }); + } + + private updateDecorations(sectionHeaders: SectionHeader[]): void { + + const model = this.editor.getModel(); + if (model) { + // Remove all section headers that should be in comments and are not in comments + sectionHeaders = sectionHeaders.filter((sectionHeader) => { + if (!sectionHeader.shouldBeInComments) { + return true; + } + const validRange = model.validateRange(sectionHeader.range); + const tokens = model.tokenization.getLineTokens(validRange.startLineNumber); + const idx = tokens.findTokenIndexAtOffset(validRange.startColumn - 1); + const tokenType = tokens.getStandardTokenType(idx); + const languageId = tokens.getLanguageId(idx); + return (languageId === model.getLanguageId() && tokenType === StandardTokenType.Comment); + }); + } + + const oldDecorations = Object.values(this.currentOccurrences).map(occurrence => occurrence.decorationId); + const newDecorations = sectionHeaders.map(sectionHeader => decoration(sectionHeader)); + + this.editor.changeDecorations((changeAccessor) => { + const decorations = changeAccessor.deltaDecorations(oldDecorations, newDecorations); + + this.currentOccurrences = {}; + for (let i = 0, len = decorations.length; i < len; i++) { + const occurrence = { sectionHeader: sectionHeaders[i], decorationId: decorations[i] }; + this.currentOccurrences[occurrence.decorationId] = occurrence; + } + }); + } + + private stop(): void { + this.computeSectionHeaders.cancel(); + if (this.computePromise) { + this.computePromise.cancel(); + this.computePromise = null; + } + } + + public override dispose(): void { + super.dispose(); + this.stop(); + this.decorations.clear(); + } + +} + +interface SectionHeaderOccurrence { + readonly sectionHeader: SectionHeader; + readonly decorationId: string; +} + +function decoration(sectionHeader: SectionHeader): IModelDeltaDecoration { + return { + range: sectionHeader.range, + options: ModelDecorationOptions.createDynamic({ + description: 'section-header', + stickiness: TrackedRangeStickiness.GrowsOnlyWhenTypingAfter, + collapseOnReplaceEdit: true, + minimap: { + color: undefined, + position: MinimapPosition.Inline, + sectionHeaderStyle: sectionHeader.hasSeparatorLine ? MinimapSectionHeaderStyle.Underlined : MinimapSectionHeaderStyle.Normal, + sectionHeaderText: sectionHeader.text, + }, + }) + }; +} + +registerEditorContribution(SectionHeaderDetector.ID, SectionHeaderDetector, EditorContributionInstantiation.AfterFirstRender); diff --git a/src/vs/editor/contrib/smartSelect/test/browser/smartSelect.test.ts b/src/vs/editor/contrib/smartSelect/test/browser/smartSelect.test.ts index c4a17ea7e76f1..dc9e9767d8183 100644 --- a/src/vs/editor/contrib/smartSelect/test/browser/smartSelect.test.ts +++ b/src/vs/editor/contrib/smartSelect/test/browser/smartSelect.test.ts @@ -16,7 +16,7 @@ import { BracketSelectionRangeProvider } from 'vs/editor/contrib/smartSelect/bro import { provideSelectionRanges } from 'vs/editor/contrib/smartSelect/browser/smartSelect'; import { WordSelectionRangeProvider } from 'vs/editor/contrib/smartSelect/browser/wordSelections'; import { createModelServices } from 'vs/editor/test/common/testTextModel'; -import { javascriptOnEnterRules } from 'vs/editor/test/common/modes/supports/javascriptOnEnterRules'; +import { javascriptOnEnterRules } from 'vs/editor/test/common/modes/supports/onEnterRules'; import { LanguageFeatureRegistry } from 'vs/editor/common/languageFeatureRegistry'; import { ILanguageSelection, ILanguageService } from 'vs/editor/common/languages/language'; import { ensureNoDisposablesAreLeakedInTestSuite } from 'vs/base/test/common/utils'; diff --git a/src/vs/editor/contrib/snippet/browser/snippetController2.ts b/src/vs/editor/contrib/snippet/browser/snippetController2.ts index 0290c45011377..e066ef8ee56d9 100644 --- a/src/vs/editor/contrib/snippet/browser/snippetController2.ts +++ b/src/vs/editor/contrib/snippet/browser/snippetController2.ts @@ -331,7 +331,7 @@ registerEditorCommand(new CommandCtor({ handler: ctrl => ctrl.next(), kbOpts: { weight: KeybindingWeight.EditorContrib + 30, - kbExpr: EditorContextKeys.editorTextFocus, + kbExpr: EditorContextKeys.textInputFocus, primary: KeyCode.Tab } })); @@ -341,7 +341,7 @@ registerEditorCommand(new CommandCtor({ handler: ctrl => ctrl.prev(), kbOpts: { weight: KeybindingWeight.EditorContrib + 30, - kbExpr: EditorContextKeys.editorTextFocus, + kbExpr: EditorContextKeys.textInputFocus, primary: KeyMod.Shift | KeyCode.Tab } })); @@ -351,7 +351,7 @@ registerEditorCommand(new CommandCtor({ handler: ctrl => ctrl.cancel(true), kbOpts: { weight: KeybindingWeight.EditorContrib + 30, - kbExpr: EditorContextKeys.editorTextFocus, + kbExpr: EditorContextKeys.textInputFocus, primary: KeyCode.Escape, secondary: [KeyMod.Shift | KeyCode.Escape] } diff --git a/src/vs/editor/contrib/stickyScroll/browser/stickyScrollActions.ts b/src/vs/editor/contrib/stickyScroll/browser/stickyScrollActions.ts index 1e49f122979b6..6858f47273a4f 100644 --- a/src/vs/editor/contrib/stickyScroll/browser/stickyScrollActions.ts +++ b/src/vs/editor/contrib/stickyScroll/browser/stickyScrollActions.ts @@ -21,7 +21,7 @@ export class ToggleStickyScroll extends Action2 { super({ id: 'editor.action.toggleStickyScroll', title: { - ...localize2('toggleEditorStickyScroll', "Toggle Editor Sticky Scroll"), + ...localize2('toggleEditorStickyScroll', "Toggle/enable the editor sticky scroll which shows the nested scopes at the top of the viewport."), mnemonicTitle: localize({ key: 'mitoggleStickyScroll', comment: ['&& denotes a mnemonic'] }, "&&Toggle Editor Sticky Scroll"), }, category: Categories.View, @@ -53,7 +53,7 @@ export class FocusStickyScroll extends EditorAction2 { super({ id: 'editor.action.focusStickyScroll', title: { - ...localize2('focusStickyScroll', "Focus Sticky Scroll"), + ...localize2('focusStickyScroll', "Focus on the editor sticky scroll"), mnemonicTitle: localize({ key: 'mifocusStickyScroll', comment: ['&& denotes a mnemonic'] }, "&&Focus Sticky Scroll"), }, precondition: ContextKeyExpr.and(ContextKeyExpr.has('config.editor.stickyScroll.enabled'), EditorContextKeys.stickyScrollVisible), @@ -72,7 +72,7 @@ export class SelectNextStickyScrollLine extends EditorAction2 { constructor() { super({ id: 'editor.action.selectNextStickyScrollLine', - title: localize2('selectNextStickyScrollLine.title', "Select next sticky scroll line"), + title: localize2('selectNextStickyScrollLine.title', "Select the next editor sticky scroll line"), precondition: EditorContextKeys.stickyScrollFocused.isEqualTo(true), keybinding: { weight, @@ -90,7 +90,7 @@ export class SelectPreviousStickyScrollLine extends EditorAction2 { constructor() { super({ id: 'editor.action.selectPreviousStickyScrollLine', - title: localize2('selectPreviousStickyScrollLine.title', "Select previous sticky scroll line"), + title: localize2('selectPreviousStickyScrollLine.title', "Select the previous sticky scroll line"), precondition: EditorContextKeys.stickyScrollFocused.isEqualTo(true), keybinding: { weight, @@ -108,7 +108,7 @@ export class GoToStickyScrollLine extends EditorAction2 { constructor() { super({ id: 'editor.action.goToFocusedStickyScrollLine', - title: localize2('goToFocusedStickyScrollLine.title', "Go to focused sticky scroll line"), + title: localize2('goToFocusedStickyScrollLine.title', "Go to the focused sticky scroll line"), precondition: EditorContextKeys.stickyScrollFocused.isEqualTo(true), keybinding: { weight, diff --git a/src/vs/editor/contrib/stickyScroll/browser/stickyScrollController.ts b/src/vs/editor/contrib/stickyScroll/browser/stickyScrollController.ts index fac1f9d3d5189..492524e64b750 100644 --- a/src/vs/editor/contrib/stickyScroll/browser/stickyScrollController.ts +++ b/src/vs/editor/contrib/stickyScroll/browser/stickyScrollController.ts @@ -85,6 +85,7 @@ export class StickyScrollController extends Disposable implements IEditorContrib this._register(this._stickyLineCandidateProvider); this._widgetState = new StickyScrollWidgetState([], [], 0); + this._onDidResize(); this._readConfiguration(); const stickyScrollDomNode = this._stickyScrollWidget.getDomNode(); this._register(this._editor.onDidChangeConfiguration(e => { diff --git a/src/vs/editor/contrib/stickyScroll/browser/stickyScrollModelProvider.ts b/src/vs/editor/contrib/stickyScroll/browser/stickyScrollModelProvider.ts index f1f50f9ced7da..48b0bf1d76b99 100644 --- a/src/vs/editor/contrib/stickyScroll/browser/stickyScrollModelProvider.ts +++ b/src/vs/editor/contrib/stickyScroll/browser/stickyScrollModelProvider.ts @@ -3,23 +3,22 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { Disposable, DisposableStore } from 'vs/base/common/lifecycle'; -import { ICodeEditor } from 'vs/editor/browser/editorBrowser'; +import { Disposable, DisposableStore, IDisposable } from 'vs/base/common/lifecycle'; +import { IActiveCodeEditor } from 'vs/editor/browser/editorBrowser'; import { ILanguageFeaturesService } from 'vs/editor/common/services/languageFeatures'; import { OutlineElement, OutlineGroup, OutlineModel } from 'vs/editor/contrib/documentSymbols/browser/outlineModel'; import { CancellationToken } from 'vs/base/common/cancellation'; import { CancelablePromise, createCancelablePromise, Delayer } from 'vs/base/common/async'; import { FoldingController, RangesLimitReporter } from 'vs/editor/contrib/folding/browser/folding'; -import { ITextModel } from 'vs/editor/common/model'; import { SyntaxRangeProvider } from 'vs/editor/contrib/folding/browser/syntaxRangeProvider'; import { IndentRangeProvider } from 'vs/editor/contrib/folding/browser/indentRangeProvider'; import { ILanguageConfigurationService } from 'vs/editor/common/languages/languageConfigurationRegistry'; import { FoldingRegions } from 'vs/editor/contrib/folding/browser/foldingRanges'; import { onUnexpectedError } from 'vs/base/common/errors'; -import { TextModel } from 'vs/editor/common/model/textModel'; import { StickyElement, StickyModel, StickyRange } from 'vs/editor/contrib/stickyScroll/browser/stickyScrollElement'; import { Iterable } from 'vs/base/common/iterator'; -import { LanguageFeatureRegistry } from 'vs/editor/common/languageFeatureRegistry'; +import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; +import { EditorOption } from 'vs/editor/common/config/editorOptions'; enum ModelProvider { OUTLINE_MODEL = 'outlineModel', @@ -33,16 +32,14 @@ enum Status { CANCELED } -export interface IStickyModelProvider { +export interface IStickyModelProvider extends IDisposable { /** * Method which updates the sticky model - * @param textModel text-model of the editor - * @param textModelVersionId text-model version ID * @param token cancellation token * @returns the sticky model */ - update(textModel: ITextModel, textModelVersionId: number, token: CancellationToken): Promise; + update(token: CancellationToken): Promise; } export class StickyModelProvider extends Disposable implements IStickyModelProvider { @@ -53,33 +50,33 @@ export class StickyModelProvider extends Disposable implements IStickyModelProvi private readonly _updateOperation: DisposableStore = this._register(new DisposableStore()); constructor( - private readonly _editor: ICodeEditor, - @ILanguageConfigurationService readonly _languageConfigurationService: ILanguageConfigurationService, - @ILanguageFeaturesService readonly _languageFeaturesService: ILanguageFeaturesService, - defaultModel: string + private readonly _editor: IActiveCodeEditor, + onProviderUpdate: () => void, + @IInstantiationService _languageConfigurationService: ILanguageConfigurationService, + @ILanguageFeaturesService _languageFeaturesService: ILanguageFeaturesService, ) { super(); - const stickyModelFromCandidateOutlineProvider = new StickyModelFromCandidateOutlineProvider(_languageFeaturesService); - const stickyModelFromSyntaxFoldingProvider = new StickyModelFromCandidateSyntaxFoldingProvider(this._editor, _languageFeaturesService); - const stickyModelFromIndentationFoldingProvider = new StickyModelFromCandidateIndentationFoldingProvider(this._editor, _languageConfigurationService); - - switch (defaultModel) { + switch (this._editor.getOption(EditorOption.stickyScroll).defaultModel) { case ModelProvider.OUTLINE_MODEL: - this._modelProviders.push(stickyModelFromCandidateOutlineProvider); - this._modelProviders.push(stickyModelFromSyntaxFoldingProvider); - this._modelProviders.push(stickyModelFromIndentationFoldingProvider); - break; + this._modelProviders.push(new StickyModelFromCandidateOutlineProvider(this._editor, _languageFeaturesService)); + // fall through case ModelProvider.FOLDING_PROVIDER_MODEL: - this._modelProviders.push(stickyModelFromSyntaxFoldingProvider); - this._modelProviders.push(stickyModelFromIndentationFoldingProvider); - break; + this._modelProviders.push(new StickyModelFromCandidateSyntaxFoldingProvider(this._editor, onProviderUpdate, _languageFeaturesService)); + // fall through case ModelProvider.INDENTATION_MODEL: - this._modelProviders.push(stickyModelFromIndentationFoldingProvider); + this._modelProviders.push(new StickyModelFromCandidateIndentationFoldingProvider(this._editor, _languageConfigurationService)); break; } } + public override dispose(): void { + this._modelProviders.forEach(provider => provider.dispose()); + this._updateOperation.clear(); + this._cancelModelPromise(); + super.dispose(); + } + private _cancelModelPromise(): void { if (this._modelPromise) { this._modelPromise.cancel(); @@ -87,7 +84,7 @@ export class StickyModelProvider extends Disposable implements IStickyModelProvi } } - public async update(textModel: ITextModel, textModelVersionId: number, token: CancellationToken): Promise { + public async update(token: CancellationToken): Promise { this._updateOperation.clear(); this._updateOperation.add({ @@ -101,11 +98,7 @@ export class StickyModelProvider extends Disposable implements IStickyModelProvi return await this._updateScheduler.trigger(async () => { for (const modelProvider of this._modelProviders) { - const { statusPromise, modelPromise } = modelProvider.computeStickyModel( - textModel, - textModelVersionId, - token - ); + const { statusPromise, modelPromise } = modelProvider.computeStickyModel(token); this._modelPromise = modelPromise; const status = await statusPromise; if (this._modelPromise !== modelPromise) { @@ -127,26 +120,24 @@ export class StickyModelProvider extends Disposable implements IStickyModelProvi } } -interface IStickyModelCandidateProvider { +interface IStickyModelCandidateProvider extends IDisposable { get stickyModel(): StickyModel | null; - get provider(): LanguageFeatureRegistry | null; - /** * Method which computes the sticky model and returns a status to signal whether the sticky model has been successfully found - * @param textmodel text-model of the editor - * @param modelVersionId version ID of the text-model * @param token cancellation token * @returns a promise of a status indicating whether the sticky model has been successfully found as well as the model promise */ - computeStickyModel(textmodel: ITextModel, modelVersionId: number, token: CancellationToken): { statusPromise: Promise | Status; modelPromise: CancelablePromise | null }; + computeStickyModel(token: CancellationToken): { statusPromise: Promise | Status; modelPromise: CancelablePromise | null }; } -abstract class StickyModelCandidateProvider implements IStickyModelCandidateProvider { +abstract class StickyModelCandidateProvider extends Disposable implements IStickyModelCandidateProvider { protected _stickyModel: StickyModel | null = null; - constructor() { } + constructor(protected readonly _editor: IActiveCodeEditor) { + super(); + } get stickyModel(): StickyModel | null { return this._stickyModel; @@ -157,13 +148,11 @@ abstract class StickyModelCandidateProvider implements IStickyModelCandidateP return Status.INVALID; } - public abstract get provider(): LanguageFeatureRegistry | null; - - public computeStickyModel(textModel: ITextModel, modelVersionId: number, token: CancellationToken): { statusPromise: Promise | Status; modelPromise: CancelablePromise | null } { - if (token.isCancellationRequested || !this.isProviderValid(textModel)) { + public computeStickyModel(token: CancellationToken): { statusPromise: Promise | Status; modelPromise: CancelablePromise | null } { + if (token.isCancellationRequested || !this.isProviderValid()) { return { statusPromise: this._invalid(), modelPromise: null }; } - const providerModelPromise = createCancelablePromise(token => this.createModelFromProvider(textModel, modelVersionId, token)); + const providerModelPromise = createCancelablePromise(token => this.createModelFromProvider(token)); return { statusPromise: providerModelPromise.then(providerModel => { @@ -174,7 +163,7 @@ abstract class StickyModelCandidateProvider implements IStickyModelCandidateP if (token.isCancellationRequested) { return Status.CANCELED; } - this._stickyModel = this.createStickyModel(textModel, modelVersionId, token, providerModel); + this._stickyModel = this.createStickyModel(token, providerModel); return Status.VALID; }).then(undefined, (err) => { onUnexpectedError(err); @@ -190,57 +179,49 @@ abstract class StickyModelCandidateProvider implements IStickyModelCandidateP * @param model model returned by the provider * @returns boolean indicating whether the model is valid */ - protected isModelValid(model: any): boolean { + protected isModelValid(model: T): boolean { return true; } /** * Method which checks whether the provider is valid before applying it to find the provider model. * This method by default returns true. - * @param textModel text-model of the editor * @returns boolean indicating whether the provider is valid */ - protected isProviderValid(textModel: ITextModel): boolean { + protected isProviderValid(): boolean { return true; } /** * Abstract method which creates the model from the provider and returns the provider model - * @param textModel text-model of the editor - * @param textModelVersionId text-model version ID * @param token cancellation token * @returns the model returned by the provider */ - protected abstract createModelFromProvider(textModel: ITextModel, textModelVersionId: number, token: CancellationToken): Promise; + protected abstract createModelFromProvider(token: CancellationToken): Promise; /** * Abstract method which computes the sticky model from the model returned by the provider and returns the sticky model - * @param textModel text-model of the editor - * @param textModelVersionId text-model version ID * @param token cancellation token * @param model model returned by the provider * @returns the sticky model */ - protected abstract createStickyModel(textModel: ITextModel, textModelVersionId: number, token: CancellationToken, model: T): StickyModel; + protected abstract createStickyModel(token: CancellationToken, model: T): StickyModel; } class StickyModelFromCandidateOutlineProvider extends StickyModelCandidateProvider { - constructor(@ILanguageFeaturesService private readonly _languageFeaturesService: ILanguageFeaturesService) { - super(); + constructor(_editor: IActiveCodeEditor, @ILanguageFeaturesService private readonly _languageFeaturesService: ILanguageFeaturesService) { + super(_editor); } - public get provider(): LanguageFeatureRegistry | null { - return this._languageFeaturesService.documentSymbolProvider; + protected createModelFromProvider(token: CancellationToken): Promise { + return OutlineModel.create(this._languageFeaturesService.documentSymbolProvider, this._editor.getModel(), token); } - protected createModelFromProvider(textModel: ITextModel, modelVersionId: number, token: CancellationToken): Promise { - return OutlineModel.create(this._languageFeaturesService.documentSymbolProvider, textModel, token); - } - - protected createStickyModel(textModel: TextModel, modelVersionId: number, token: CancellationToken, model: OutlineModel): StickyModel { + protected createStickyModel(token: CancellationToken, model: OutlineModel): StickyModel { const { stickyOutlineElement, providerID } = this._stickyModelFromOutlineModel(model, this._stickyModel?.outlineProviderId); - return new StickyModel(textModel.uri, modelVersionId, stickyOutlineElement, providerID); + const textModel = this._editor.getModel(); + return new StickyModel(textModel.uri, textModel.getVersionId(), stickyOutlineElement, providerID); } protected override isModelValid(model: OutlineModel): boolean { @@ -334,14 +315,15 @@ abstract class StickyModelFromCandidateFoldingProvider extends StickyModelCandid protected _foldingLimitReporter: RangesLimitReporter; - constructor(editor: ICodeEditor) { - super(); + constructor(editor: IActiveCodeEditor) { + super(editor); this._foldingLimitReporter = new RangesLimitReporter(editor); } - protected createStickyModel(textModel: ITextModel, modelVersionId: number, token: CancellationToken, model: FoldingRegions): StickyModel { + protected createStickyModel(token: CancellationToken, model: FoldingRegions): StickyModel { const foldingElement = this._fromFoldingRegions(model); - return new StickyModel(textModel.uri, modelVersionId, foldingElement, undefined); + const textModel = this._editor.getModel(); + return new StickyModel(textModel.uri, textModel.getVersionId(), foldingElement, undefined); } protected override isModelValid(model: FoldingRegions): boolean { @@ -387,41 +369,41 @@ abstract class StickyModelFromCandidateFoldingProvider extends StickyModelCandid class StickyModelFromCandidateIndentationFoldingProvider extends StickyModelFromCandidateFoldingProvider { + private readonly provider: IndentRangeProvider; + constructor( - editor: ICodeEditor, + editor: IActiveCodeEditor, @ILanguageConfigurationService private readonly _languageConfigurationService: ILanguageConfigurationService) { super(editor); - } - public get provider(): LanguageFeatureRegistry | null { - return null; + this.provider = this._register(new IndentRangeProvider(editor.getModel(), this._languageConfigurationService, this._foldingLimitReporter)); } - protected createModelFromProvider(textModel: TextModel, modelVersionId: number, token: CancellationToken): Promise { - const provider = new IndentRangeProvider(textModel, this._languageConfigurationService, this._foldingLimitReporter); - return provider.compute(token); + protected override async createModelFromProvider(token: CancellationToken): Promise { + return this.provider.compute(token); } } class StickyModelFromCandidateSyntaxFoldingProvider extends StickyModelFromCandidateFoldingProvider { - constructor(editor: ICodeEditor, - @ILanguageFeaturesService private readonly _languageFeaturesService: ILanguageFeaturesService) { - super(editor); - } + private readonly provider: SyntaxRangeProvider | undefined; - public get provider(): LanguageFeatureRegistry | null { - return this._languageFeaturesService.foldingRangeProvider; + constructor(editor: IActiveCodeEditor, + onProviderUpdate: () => void, + @ILanguageFeaturesService private readonly _languageFeaturesService: ILanguageFeaturesService + ) { + super(editor); + const selectedProviders = FoldingController.getFoldingRangeProviders(this._languageFeaturesService, editor.getModel()); + if (selectedProviders.length > 0) { + this.provider = this._register(new SyntaxRangeProvider(editor.getModel(), selectedProviders, onProviderUpdate, this._foldingLimitReporter, undefined)); + } } - protected override isProviderValid(textModel: TextModel): boolean { - const selectedProviders = FoldingController.getFoldingRangeProviders(this._languageFeaturesService, textModel); - return selectedProviders.length > 0; + protected override isProviderValid(): boolean { + return this.provider !== undefined; } - protected createModelFromProvider(textModel: TextModel, modelVersionId: number, token: CancellationToken): Promise { - const selectedProviders = FoldingController.getFoldingRangeProviders(this._languageFeaturesService, textModel); - const provider = new SyntaxRangeProvider(textModel, selectedProviders, () => this.createModelFromProvider(textModel, modelVersionId, token), this._foldingLimitReporter, undefined); - return provider.compute(token); + protected override async createModelFromProvider(token: CancellationToken): Promise { + return this.provider?.compute(token) ?? null; } } diff --git a/src/vs/editor/contrib/stickyScroll/browser/stickyScrollProvider.ts b/src/vs/editor/contrib/stickyScroll/browser/stickyScrollProvider.ts index 3388380f97c11..705ef76489e85 100644 --- a/src/vs/editor/contrib/stickyScroll/browser/stickyScrollProvider.ts +++ b/src/vs/editor/contrib/stickyScroll/browser/stickyScrollProvider.ts @@ -3,11 +3,11 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { Disposable, DisposableStore } from 'vs/base/common/lifecycle'; +import { Disposable, DisposableStore, toDisposable } from 'vs/base/common/lifecycle'; import { ICodeEditor } from 'vs/editor/browser/editorBrowser'; import { ILanguageFeaturesService } from 'vs/editor/common/services/languageFeatures'; import { CancellationToken, CancellationTokenSource, } from 'vs/base/common/cancellation'; -import { EditorOption, IEditorStickyScrollOptions } from 'vs/editor/common/config/editorOptions'; +import { EditorOption } from 'vs/editor/common/config/editorOptions'; import { RunOnceScheduler } from 'vs/base/common/async'; import { Range } from 'vs/editor/common/core/range'; import { binarySearch } from 'vs/base/common/arrays'; @@ -45,7 +45,6 @@ export class StickyLineCandidateProvider extends Disposable implements IStickyLi private readonly _updateSoon: RunOnceScheduler; private readonly _sessionStore: DisposableStore; - private _options: Readonly> | null = null; private _model: StickyModel | null = null; private _cts: CancellationTokenSource | null = null; private _stickyModelProvider: IStickyModelProvider | null = null; @@ -69,26 +68,18 @@ export class StickyLineCandidateProvider extends Disposable implements IStickyLi } private readConfiguration() { - - this._stickyModelProvider = null; this._sessionStore.clear(); - this._options = this._editor.getOption(EditorOption.stickyScroll); - if (!this._options.enabled) { + const options = this._editor.getOption(EditorOption.stickyScroll); + if (!options.enabled) { return; } - this._stickyModelProvider = this._sessionStore.add(new StickyModelProvider( - this._editor, - this._languageConfigurationService, - this._languageFeaturesService, - this._options.defaultModel - )); - this._sessionStore.add(this._editor.onDidChangeModel(() => { // We should not show an old model for a different file, it will always be wrong. // So we clear the model here immediately and then trigger an update. this._model = null; + this.updateStickyModelProvider(); this._onDidChangeStickyScroll.fire(); this.update(); @@ -96,6 +87,11 @@ export class StickyLineCandidateProvider extends Disposable implements IStickyLi this._sessionStore.add(this._editor.onDidChangeHiddenAreas(() => this.update())); this._sessionStore.add(this._editor.onDidChangeModelContent(() => this._updateSoon.schedule())); this._sessionStore.add(this._languageFeaturesService.documentSymbolProvider.onDidChange(() => this.update())); + this._sessionStore.add(toDisposable(() => { + this._stickyModelProvider?.dispose(); + this._stickyModelProvider = null; + })); + this.updateStickyModelProvider(); this.update(); } @@ -103,6 +99,21 @@ export class StickyLineCandidateProvider extends Disposable implements IStickyLi return this._model?.version; } + private updateStickyModelProvider() { + this._stickyModelProvider?.dispose(); + this._stickyModelProvider = null; + + const editor = this._editor; + if (editor.hasModel()) { + this._stickyModelProvider = new StickyModelProvider( + editor, + () => this._updateSoon.schedule(), + this._languageConfigurationService, + this._languageFeaturesService + ); + } + } + public async update(): Promise { this._cts?.dispose(true); this._cts = new CancellationTokenSource(); @@ -116,11 +127,7 @@ export class StickyLineCandidateProvider extends Disposable implements IStickyLi this._model = null; return; } - - const textModel = this._editor.getModel(); - const modelVersionId = textModel.getVersionId(); - - const model = await this._stickyModelProvider.update(textModel, modelVersionId, token); + const model = await this._stickyModelProvider.update(token); if (token.isCancellationRequested) { // the computation was canceled, so do not overwrite the model return; diff --git a/src/vs/editor/contrib/stickyScroll/browser/stickyScrollWidget.ts b/src/vs/editor/contrib/stickyScroll/browser/stickyScrollWidget.ts index 06fb2ce428f5f..bdcaafb489137 100644 --- a/src/vs/editor/contrib/stickyScroll/browser/stickyScrollWidget.ts +++ b/src/vs/editor/contrib/stickyScroll/browser/stickyScrollWidget.ts @@ -11,7 +11,7 @@ import { ThemeIcon } from 'vs/base/common/themables'; import 'vs/css!./stickyScroll'; import { ICodeEditor, IOverlayWidget, IOverlayWidgetPosition } from 'vs/editor/browser/editorBrowser'; import { getColumnOfNodeOffset } from 'vs/editor/browser/viewParts/lines/viewLine'; -import { EmbeddedCodeEditorWidget } from 'vs/editor/browser/widget/embeddedCodeEditorWidget'; +import { EmbeddedCodeEditorWidget } from 'vs/editor/browser/widget/codeEditor/embeddedCodeEditorWidget'; import { EditorLayoutInfo, EditorOption, RenderLineNumbersType } from 'vs/editor/common/config/editorOptions'; import { Position } from 'vs/editor/common/core/position'; import { StringBuilder } from 'vs/editor/common/core/stringBuilder'; diff --git a/src/vs/editor/contrib/stickyScroll/test/browser/stickyScroll.test.ts b/src/vs/editor/contrib/stickyScroll/test/browser/stickyScroll.test.ts index 9f15b6ad6815f..04e852765caed 100644 --- a/src/vs/editor/contrib/stickyScroll/test/browser/stickyScroll.test.ts +++ b/src/vs/editor/contrib/stickyScroll/test/browser/stickyScroll.test.ts @@ -137,7 +137,11 @@ suite('Sticky Scroll Tests', () => { enabled: true, maxLineCount: 5, defaultModel: 'outlineModel' - }, serviceCollection: serviceCollection + }, + envConfig: { + outerHeight: 500 + }, + serviceCollection: serviceCollection }, async (editor, _viewModel, instantiationService) => { const languageService = instantiationService.get(ILanguageFeaturesService); const languageConfigurationService = instantiationService.get(ILanguageConfigurationService); @@ -162,7 +166,11 @@ suite('Sticky Scroll Tests', () => { enabled: true, maxLineCount: 5, defaultModel: 'outlineModel' - }, serviceCollection + }, + envConfig: { + outerHeight: 500 + }, + serviceCollection }, async (editor, _viewModel, instantiationService) => { const stickyScrollController: StickyScrollController = editor.registerAndInstantiateContribution(StickyScrollController.ID, StickyScrollController); @@ -211,7 +219,11 @@ suite('Sticky Scroll Tests', () => { enabled: true, maxLineCount: 5, defaultModel: 'outlineModel' - }, serviceCollection + }, + envConfig: { + outerHeight: 500 + }, + serviceCollection }, async (editor, viewModel, instantiationService) => { const stickyScrollController: StickyScrollController = editor.registerAndInstantiateContribution(StickyScrollController.ID, StickyScrollController); @@ -305,7 +317,11 @@ suite('Sticky Scroll Tests', () => { enabled: true, maxLineCount: 5, defaultModel: 'outlineModel' - }, serviceCollection + }, + envConfig: { + outerHeight: 500 + }, + serviceCollection }, async (editor, _viewModel, instantiationService) => { const stickyScrollController: StickyScrollController = editor.registerAndInstantiateContribution(StickyScrollController.ID, StickyScrollController); diff --git a/src/vs/editor/contrib/suggest/browser/suggestController.ts b/src/vs/editor/contrib/suggest/browser/suggestController.ts index fb837e6117208..31eb7c7ea5f58 100644 --- a/src/vs/editor/contrib/suggest/browser/suggestController.ts +++ b/src/vs/editor/contrib/suggest/browser/suggestController.ts @@ -538,11 +538,11 @@ export class SuggestController implements IEditorContribution { basenameHash: { classification: 'PublicNonPersonalData'; purpose: 'FeatureInsight'; comment: 'Hash of the basename of the file into which the completion was inserted' }; fileExtension: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'File extension of the file into which the completion was inserted' }; languageId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Language type of the file into which the completion was inserted' }; - kind: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'The completion item kind' }; - resolveInfo: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'If the item was inserted before resolving was done' }; - resolveDuration: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'How long resolving took to finish' }; - commandDuration: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'How long a completion item command took' }; - additionalEditsAsync: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Info about asynchronously applying additional edits' }; + kind: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The completion item kind' }; + resolveInfo: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'If the item was inserted before resolving was done' }; + resolveDuration: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'How long resolving took to finish' }; + commandDuration: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'How long a completion item command took' }; + additionalEditsAsync: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Info about asynchronously applying additional edits' }; }; this._telemetryService.publicLog2('suggest.acceptedSuggestion', { diff --git a/src/vs/editor/contrib/suggest/browser/suggestWidget.ts b/src/vs/editor/contrib/suggest/browser/suggestWidget.ts index fc41e265fc40c..2eeb94d99b610 100644 --- a/src/vs/editor/contrib/suggest/browser/suggestWidget.ts +++ b/src/vs/editor/contrib/suggest/browser/suggestWidget.ts @@ -16,7 +16,7 @@ import { clamp } from 'vs/base/common/numbers'; import * as strings from 'vs/base/common/strings'; import 'vs/css!./media/suggest'; import { ContentWidgetPositionPreference, ICodeEditor, IContentWidget, IContentWidgetPosition, IEditorMouseEvent } from 'vs/editor/browser/editorBrowser'; -import { EmbeddedCodeEditorWidget } from 'vs/editor/browser/widget/embeddedCodeEditorWidget'; +import { EmbeddedCodeEditorWidget } from 'vs/editor/browser/widget/codeEditor/embeddedCodeEditorWidget'; import { EditorOption } from 'vs/editor/common/config/editorOptions'; import { IPosition } from 'vs/editor/common/core/position'; import { SuggestWidgetStatus } from 'vs/editor/contrib/suggest/browser/suggestWidgetStatus'; diff --git a/src/vs/editor/contrib/toggleTabFocusMode/browser/toggleTabFocusMode.ts b/src/vs/editor/contrib/toggleTabFocusMode/browser/toggleTabFocusMode.ts index 64f4b309e8aeb..7578cd62f8ed1 100644 --- a/src/vs/editor/contrib/toggleTabFocusMode/browser/toggleTabFocusMode.ts +++ b/src/vs/editor/contrib/toggleTabFocusMode/browser/toggleTabFocusMode.ts @@ -24,6 +24,9 @@ export class ToggleTabFocusModeAction extends Action2 { mac: { primary: KeyMod.WinCtrl | KeyMod.Shift | KeyCode.KeyM }, weight: KeybindingWeight.EditorContrib }, + metadata: { + description: nls.localize2('tabMovesFocusDescriptions', "Determines whether the tab key moves focus around the workbench or inserts the tab character in the current editor. This is also called tab trapping, tab navigation, or tab focus mode."), + }, f1: true }); } diff --git a/src/vs/editor/contrib/wordHighlighter/browser/wordHighlighter.ts b/src/vs/editor/contrib/wordHighlighter/browser/wordHighlighter.ts index 8b9790bce2177..cae4374290252 100644 --- a/src/vs/editor/contrib/wordHighlighter/browser/wordHighlighter.ts +++ b/src/vs/editor/contrib/wordHighlighter/browser/wordHighlighter.ts @@ -310,6 +310,11 @@ class WordHighlighter { this._onPositionChanged(e); })); this.toUnhook.add(editor.onDidFocusEditorText((e) => { + if (this.occurrencesHighlight === 'off') { + // Early exit if nothing needs to be done + return; + } + if (!this.workerRequest) { this._run(); } diff --git a/src/vs/editor/contrib/wordOperations/browser/wordOperations.ts b/src/vs/editor/contrib/wordOperations/browser/wordOperations.ts index 6d78b46096532..388d42021d227 100644 --- a/src/vs/editor/contrib/wordOperations/browser/wordOperations.ts +++ b/src/vs/editor/contrib/wordOperations/browser/wordOperations.ts @@ -45,7 +45,7 @@ export abstract class MoveWordCommand extends EditorCommand { if (!editor.hasModel()) { return; } - const wordSeparators = getMapForWordSeparators(editor.getOption(EditorOption.wordSeparators)); + const wordSeparators = getMapForWordSeparators(editor.getOption(EditorOption.wordSeparators), editor.getOption(EditorOption.wordSegmenterLocales)); const model = editor.getModel(); const selections = editor.getSelections(); @@ -187,8 +187,8 @@ export class CursorWordAccessibilityLeft extends WordLeftCommand { }); } - protected override _move(_: WordCharacterClassifier, model: ITextModel, position: Position, wordNavigationType: WordNavigationType): Position { - return super._move(getMapForWordSeparators(EditorOptions.wordSeparators.defaultValue), model, position, wordNavigationType); + protected override _move(wordCharacterClassifier: WordCharacterClassifier, model: ITextModel, position: Position, wordNavigationType: WordNavigationType): Position { + return super._move(getMapForWordSeparators(EditorOptions.wordSeparators.defaultValue, wordCharacterClassifier.intlSegmenterLocales), model, position, wordNavigationType); } } @@ -202,8 +202,8 @@ export class CursorWordAccessibilityLeftSelect extends WordLeftCommand { }); } - protected override _move(_: WordCharacterClassifier, model: ITextModel, position: Position, wordNavigationType: WordNavigationType): Position { - return super._move(getMapForWordSeparators(EditorOptions.wordSeparators.defaultValue), model, position, wordNavigationType); + protected override _move(wordCharacterClassifier: WordCharacterClassifier, model: ITextModel, position: Position, wordNavigationType: WordNavigationType): Position { + return super._move(getMapForWordSeparators(EditorOptions.wordSeparators.defaultValue, wordCharacterClassifier.intlSegmenterLocales), model, position, wordNavigationType); } } @@ -295,8 +295,8 @@ export class CursorWordAccessibilityRight extends WordRightCommand { }); } - protected override _move(_: WordCharacterClassifier, model: ITextModel, position: Position, wordNavigationType: WordNavigationType): Position { - return super._move(getMapForWordSeparators(EditorOptions.wordSeparators.defaultValue), model, position, wordNavigationType); + protected override _move(wordCharacterClassifier: WordCharacterClassifier, model: ITextModel, position: Position, wordNavigationType: WordNavigationType): Position { + return super._move(getMapForWordSeparators(EditorOptions.wordSeparators.defaultValue, wordCharacterClassifier.intlSegmenterLocales), model, position, wordNavigationType); } } @@ -310,8 +310,8 @@ export class CursorWordAccessibilityRightSelect extends WordRightCommand { }); } - protected override _move(_: WordCharacterClassifier, model: ITextModel, position: Position, wordNavigationType: WordNavigationType): Position { - return super._move(getMapForWordSeparators(EditorOptions.wordSeparators.defaultValue), model, position, wordNavigationType); + protected override _move(wordCharacterClassifier: WordCharacterClassifier, model: ITextModel, position: Position, wordNavigationType: WordNavigationType): Position { + return super._move(getMapForWordSeparators(EditorOptions.wordSeparators.defaultValue, wordCharacterClassifier.intlSegmenterLocales), model, position, wordNavigationType); } } @@ -336,7 +336,7 @@ export abstract class DeleteWordCommand extends EditorCommand { if (!editor.hasModel()) { return; } - const wordSeparators = getMapForWordSeparators(editor.getOption(EditorOption.wordSeparators)); + const wordSeparators = getMapForWordSeparators(editor.getOption(EditorOption.wordSeparators), editor.getOption(EditorOption.wordSegmenterLocales)); const model = editor.getModel(); const selections = editor.getSelections(); const autoClosingBrackets = editor.getOption(EditorOption.autoClosingBrackets); @@ -354,7 +354,7 @@ export abstract class DeleteWordCommand extends EditorCommand { autoClosingBrackets, autoClosingQuotes, autoClosingPairs, - autoClosedCharacters: viewModel.getCursorAutoClosedCharacters() + autoClosedCharacters: viewModel.getCursorAutoClosedCharacters(), }, this._wordNavigationType); return new ReplaceCommand(deleteRange, ''); }); @@ -482,7 +482,7 @@ export class DeleteInsideWord extends EditorAction { if (!editor.hasModel()) { return; } - const wordSeparators = getMapForWordSeparators(editor.getOption(EditorOption.wordSeparators)); + const wordSeparators = getMapForWordSeparators(editor.getOption(EditorOption.wordSeparators), editor.getOption(EditorOption.wordSegmenterLocales)); const model = editor.getModel(); const selections = editor.getSelections(); diff --git a/src/vs/editor/contrib/wordOperations/test/browser/wordOperations.test.ts b/src/vs/editor/contrib/wordOperations/test/browser/wordOperations.test.ts index 399d020170e9e..a06bf07200a57 100644 --- a/src/vs/editor/contrib/wordOperations/test/browser/wordOperations.test.ts +++ b/src/vs/editor/contrib/wordOperations/test/browser/wordOperations.test.ts @@ -179,6 +179,44 @@ suite('WordOperations', () => { assert.deepStrictEqual(actual, EXPECTED); }); + test('cursorWordLeft - Recognize words', () => { + const EXPECTED = [ + '|/* |これ|は|テスト|です |/*', + ].join('\n'); + const [text,] = deserializePipePositions(EXPECTED); + const actualStops = testRepeatedActionAndExtractPositions( + text, + new Position(1000, 1000), + ed => cursorWordLeft(ed, true), + ed => ed.getPosition()!, + ed => ed.getPosition()!.equals(new Position(1, 1)), + { + wordSegmenterLocales: 'ja' + } + ); + const actual = serializePipePositions(text, actualStops); + assert.deepStrictEqual(actual, EXPECTED); + }); + + test('cursorWordLeft - Does not recognize words', () => { + const EXPECTED = [ + '|/* |これはテストです |/*', + ].join('\n'); + const [text,] = deserializePipePositions(EXPECTED); + const actualStops = testRepeatedActionAndExtractPositions( + text, + new Position(1000, 1000), + ed => cursorWordLeft(ed, true), + ed => ed.getPosition()!, + ed => ed.getPosition()!.equals(new Position(1, 1)), + { + wordSegmenterLocales: '' + } + ); + const actual = serializePipePositions(text, actualStops); + assert.deepStrictEqual(actual, EXPECTED); + }); + test('cursorWordLeftSelect - issue #74369: cursorWordLeft and cursorWordLeftSelect do not behave consistently', () => { const EXPECTED = [ '|this.|is.|a.|test', @@ -327,6 +365,44 @@ suite('WordOperations', () => { assert.deepStrictEqual(actual, EXPECTED); }); + test('cursorWordRight - Recognize words', () => { + const EXPECTED = [ + '/*| これ|は|テスト|です|/*|', + ].join('\n'); + const [text,] = deserializePipePositions(EXPECTED); + const actualStops = testRepeatedActionAndExtractPositions( + text, + new Position(1, 1), + ed => cursorWordRight(ed), + ed => ed.getPosition()!, + ed => ed.getPosition()!.equals(new Position(1, 14)), + { + wordSegmenterLocales: 'ja' + } + ); + const actual = serializePipePositions(text, actualStops); + assert.deepStrictEqual(actual, EXPECTED); + }); + + test('cursorWordRight - Does not recognize words', () => { + const EXPECTED = [ + '/*| これはテストです|/*|', + ].join('\n'); + const [text,] = deserializePipePositions(EXPECTED); + const actualStops = testRepeatedActionAndExtractPositions( + text, + new Position(1, 1), + ed => cursorWordRight(ed), + ed => ed.getPosition()!, + ed => ed.getPosition()!.equals(new Position(1, 14)), + { + wordSegmenterLocales: '' + } + ); + const actual = serializePipePositions(text, actualStops); + assert.deepStrictEqual(actual, EXPECTED); + }); + test('moveWordEndRight', () => { const EXPECTED = [ ' /*| Just| some| more| text| a|+=| 3| +5|-3| +| 7| */| |', diff --git a/src/vs/editor/contrib/zoneWidget/browser/zoneWidget.ts b/src/vs/editor/contrib/zoneWidget/browser/zoneWidget.ts index d51aaf5eb5c49..57d3dd3819be3 100644 --- a/src/vs/editor/contrib/zoneWidget/browser/zoneWidget.ts +++ b/src/vs/editor/contrib/zoneWidget/browser/zoneWidget.ts @@ -148,7 +148,7 @@ class Arrow { dom.removeCSSRulesContainingSelector(this._ruleName); dom.createCSSRule( `.monaco-editor ${this._ruleName}`, - `border-style: solid; border-color: transparent; border-bottom-color: ${this._color}; border-width: ${this._height}px; bottom: -${this._height}px; margin-left: -${this._height}px; ` + `border-style: solid; border-color: transparent; border-bottom-color: ${this._color}; border-width: ${this._height}px; bottom: -${this._height}px !important; margin-left: -${this._height}px; ` ); } @@ -331,6 +331,11 @@ export abstract class ZoneWidget implements IHorizontalSashLayoutProvider { this.editor.changeViewZones(accessor => { accessor.layoutZone(this._viewZone!.id); }); + this._positionMarkerId.set([{ + range: Range.isIRange(rangeOrPos) ? rangeOrPos : Range.fromPositions(rangeOrPos), + options: ModelDecorationOptions.EMPTY + }]); + } } diff --git a/src/vs/editor/editor.all.ts b/src/vs/editor/editor.all.ts index cedb454f59531..a84a6bdcb3f73 100644 --- a/src/vs/editor/editor.all.ts +++ b/src/vs/editor/editor.all.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import 'vs/editor/browser/coreCommands'; -import 'vs/editor/browser/widget/codeEditorWidget'; +import 'vs/editor/browser/widget/codeEditor/codeEditorWidget'; import 'vs/editor/browser/widget/diffEditor/diffEditor.contribution'; import 'vs/editor/contrib/anchorSelect/browser/anchorSelect'; import 'vs/editor/contrib/bracketMatching/browser/bracketMatching'; @@ -41,8 +41,10 @@ import 'vs/editor/contrib/linkedEditing/browser/linkedEditing'; import 'vs/editor/contrib/links/browser/links'; import 'vs/editor/contrib/longLinesHelper/browser/longLinesHelper'; import 'vs/editor/contrib/multicursor/browser/multicursor'; +import 'vs/editor/contrib/inlineEdit/browser/inlineEdit.contribution'; import 'vs/editor/contrib/parameterHints/browser/parameterHints'; import 'vs/editor/contrib/rename/browser/rename'; +import 'vs/editor/contrib/sectionHeaders/browser/sectionHeaders'; import 'vs/editor/contrib/semanticTokens/browser/documentSemanticTokens'; import 'vs/editor/contrib/semanticTokens/browser/viewportSemanticTokens'; import 'vs/editor/contrib/smartSelect/browser/smartSelect'; diff --git a/src/vs/editor/standalone/browser/quickInput/standaloneQuickInputService.ts b/src/vs/editor/standalone/browser/quickInput/standaloneQuickInputService.ts index 75a3520379b08..709cb064db0c9 100644 --- a/src/vs/editor/standalone/browser/quickInput/standaloneQuickInputService.ts +++ b/src/vs/editor/standalone/browser/quickInput/standaloneQuickInputService.ts @@ -20,7 +20,6 @@ import { QuickInputService } from 'vs/platform/quickinput/browser/quickInputServ import { createSingleCallFunction } from 'vs/base/common/functional'; import { IQuickAccessController } from 'vs/platform/quickinput/common/quickAccess'; import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; -import { IHoverService } from 'vs/platform/hover/browser/hover'; class EditorScopedQuickInputService extends QuickInputService { @@ -33,7 +32,6 @@ class EditorScopedQuickInputService extends QuickInputService { @IThemeService themeService: IThemeService, @ICodeEditorService codeEditorService: ICodeEditorService, @IConfigurationService configurationService: IConfigurationService, - @IHoverService hoverService: IHoverService, ) { super( instantiationService, @@ -41,7 +39,6 @@ class EditorScopedQuickInputService extends QuickInputService { themeService, new EditorScopedLayoutService(editor.getContainerDomNode(), codeEditorService), configurationService, - hoverService ); // Use the passed in code editor as host for the quick input widget @@ -52,6 +49,7 @@ class EditorScopedQuickInputService extends QuickInputService { _serviceBrand: undefined, get mainContainer() { return widget.getDomNode(); }, getContainer() { return widget.getDomNode(); }, + whenContainerStylesLoaded() { return undefined; }, get containers() { return [widget.getDomNode()]; }, get activeContainer() { return widget.getDomNode(); }, get mainContainerDimension() { return editor.getLayoutInfo(); }, @@ -61,7 +59,6 @@ class EditorScopedQuickInputService extends QuickInputService { get onDidLayoutContainer() { return Event.map(editor.onDidLayoutChange, dimension => ({ container: widget.getDomNode(), dimension })); }, get onDidChangeActiveContainer() { return Event.None; }, get onDidAddContainer() { return Event.None; }, - get whenActiveContainerStylesLoaded() { return Promise.resolve(); }, get mainContainerOffset() { return { top: 0, quickPickTop: 0 }; }, get activeContainerOffset() { return { top: 0, quickPickTop: 0 }; }, focus: () => editor.focus() diff --git a/src/vs/editor/standalone/browser/standaloneCodeEditor.ts b/src/vs/editor/standalone/browser/standaloneCodeEditor.ts index 60c16599bf393..84deb53c57d91 100644 --- a/src/vs/editor/standalone/browser/standaloneCodeEditor.ts +++ b/src/vs/editor/standalone/browser/standaloneCodeEditor.ts @@ -7,7 +7,7 @@ import * as aria from 'vs/base/browser/ui/aria/aria'; import { Disposable, IDisposable, toDisposable, DisposableStore } from 'vs/base/common/lifecycle'; import { ICodeEditor, IDiffEditor, IDiffEditorConstructionOptions } from 'vs/editor/browser/editorBrowser'; import { ICodeEditorService } from 'vs/editor/browser/services/codeEditorService'; -import { CodeEditorWidget } from 'vs/editor/browser/widget/codeEditorWidget'; +import { CodeEditorWidget } from 'vs/editor/browser/widget/codeEditor/codeEditorWidget'; import { IDiffEditorOptions, IEditorOptions } from 'vs/editor/common/config/editorOptions'; import { InternalEditorAction } from 'vs/editor/common/editorAction'; import { IModelChangedEvent } from 'vs/editor/common/editorCommon'; @@ -37,8 +37,11 @@ import { ILanguageConfigurationService } from 'vs/editor/common/languages/langua import { IEditorConstructionOptions } from 'vs/editor/browser/config/editorConfiguration'; import { ILanguageFeaturesService } from 'vs/editor/common/services/languageFeatures'; import { DiffEditorWidget } from 'vs/editor/browser/widget/diffEditor/diffEditorWidget'; -import { IAudioCueService } from 'vs/platform/audioCues/browser/audioCueService'; +import { IAccessibilitySignalService } from 'vs/platform/accessibilitySignal/browser/accessibilitySignalService'; import { mainWindow } from 'vs/base/browser/window'; +import { setHoverDelegateFactory } from 'vs/base/browser/ui/hover/hoverDelegateFactory'; +import { IHoverService, WorkbenchHoverDelegate } from 'vs/platform/hover/browser/hover'; +import { setBaseLayerHoverDelegate } from 'vs/base/browser/ui/hover/hoverDelegate2'; /** * Description of an action contribution @@ -53,7 +56,7 @@ export interface IActionDescriptor { */ label: string; /** - * Precondition rule. + * Precondition rule. The value should be a [context key expression](https://code.visualstudio.com/docs/getstarted/keybindings#_when-clause-contexts). */ precondition?: string; /** @@ -270,6 +273,7 @@ export class StandaloneCodeEditor extends CodeEditorWidget implements IStandalon @ICodeEditorService codeEditorService: ICodeEditorService, @ICommandService commandService: ICommandService, @IContextKeyService contextKeyService: IContextKeyService, + @IHoverService hoverService: IHoverService, @IKeybindingService keybindingService: IKeybindingService, @IThemeService themeService: IThemeService, @INotificationService notificationService: INotificationService, @@ -289,6 +293,9 @@ export class StandaloneCodeEditor extends CodeEditorWidget implements IStandalon } createAriaDomNode(options.ariaContainerElement); + + setHoverDelegateFactory((placement, enableInstantHover) => instantiationService.createInstance(WorkbenchHoverDelegate, placement, enableInstantHover, {})); + setBaseLayerHoverDelegate(hoverService); } public addCommand(keybinding: number, handler: ICommandHandler, context?: string): string | null { @@ -411,6 +418,7 @@ export class StandaloneEditor extends StandaloneCodeEditor implements IStandalon @ICodeEditorService codeEditorService: ICodeEditorService, @ICommandService commandService: ICommandService, @IContextKeyService contextKeyService: IContextKeyService, + @IHoverService hoverService: IHoverService, @IKeybindingService keybindingService: IKeybindingService, @IStandaloneThemeService themeService: IStandaloneThemeService, @INotificationService notificationService: INotificationService, @@ -432,7 +440,7 @@ export class StandaloneEditor extends StandaloneCodeEditor implements IStandalon } const _model: ITextModel | null | undefined = options.model; delete options.model; - super(domElement, options, instantiationService, codeEditorService, commandService, contextKeyService, keybindingService, themeService, notificationService, accessibilityService, languageConfigurationService, languageFeaturesService); + super(domElement, options, instantiationService, codeEditorService, commandService, contextKeyService, hoverService, keybindingService, themeService, notificationService, accessibilityService, languageConfigurationService, languageFeaturesService); this._configurationService = configurationService; this._standaloneThemeService = themeService; @@ -499,7 +507,7 @@ export class StandaloneDiffEditor2 extends DiffEditorWidget implements IStandalo @IContextMenuService contextMenuService: IContextMenuService, @IEditorProgressService editorProgressService: IEditorProgressService, @IClipboardService clipboardService: IClipboardService, - @IAudioCueService audioCueService: IAudioCueService, + @IAccessibilitySignalService accessibilitySignalService: IAccessibilitySignalService, ) { const options = { ..._options }; updateConfigurationService(configurationService, options, true); @@ -518,7 +526,7 @@ export class StandaloneDiffEditor2 extends DiffEditorWidget implements IStandalo contextKeyService, instantiationService, codeEditorService, - audioCueService, + accessibilitySignalService, editorProgressService, ); diff --git a/src/vs/editor/standalone/browser/standaloneEditor.ts b/src/vs/editor/standalone/browser/standaloneEditor.ts index 059a4928862c6..d1a70eba3d206 100644 --- a/src/vs/editor/standalone/browser/standaloneEditor.ts +++ b/src/vs/editor/standalone/browser/standaloneEditor.ts @@ -39,7 +39,7 @@ import { ITextResourceEditorInput } from 'vs/platform/editor/common/editor'; import { IKeybindingService } from 'vs/platform/keybinding/common/keybinding'; import { IMarker, IMarkerData, IMarkerService } from 'vs/platform/markers/common/markers'; import { IOpenerService } from 'vs/platform/opener/common/opener'; -import { MultiDiffEditorWidget } from 'vs/editor/browser/widget/multiDiffEditorWidget/multiDiffEditorWidget'; +import { MultiDiffEditorWidget } from 'vs/editor/browser/widget/multiDiffEditor/multiDiffEditorWidget'; /** * Create a new editor under `domElement`. @@ -550,6 +550,7 @@ export function createMonacoEditorAPI(): typeof monaco.editor { EndOfLinePreference: standaloneEnums.EndOfLinePreference, EndOfLineSequence: standaloneEnums.EndOfLineSequence, MinimapPosition: standaloneEnums.MinimapPosition, + MinimapSectionHeaderStyle: standaloneEnums.MinimapSectionHeaderStyle, MouseTargetType: standaloneEnums.MouseTargetType, OverlayWidgetPositionPreference: standaloneEnums.OverlayWidgetPositionPreference, OverviewRulerLane: standaloneEnums.OverviewRulerLane, diff --git a/src/vs/editor/standalone/browser/standaloneLanguages.ts b/src/vs/editor/standalone/browser/standaloneLanguages.ts index ec191dfec137f..5ade938e7c29a 100644 --- a/src/vs/editor/standalone/browser/standaloneLanguages.ts +++ b/src/vs/editor/standalone/browser/standaloneLanguages.ts @@ -459,6 +459,14 @@ export function registerRenameProvider(languageSelector: LanguageSelector, provi return languageFeaturesService.renameProvider.register(languageSelector, provider); } +/** + * Register a new symbol-name provider (e.g., when a symbol is being renamed, show new possible symbol-names) + */ +export function registerNewSymbolNameProvider(languageSelector: LanguageSelector, provider: languages.NewSymbolNamesProvider): IDisposable { + const languageFeaturesService = StandaloneServices.get(ILanguageFeaturesService); + return languageFeaturesService.newSymbolNamesProvider.register(languageSelector, provider); +} + /** * Register a signature help provider (used by e.g. parameter hints). */ @@ -671,6 +679,11 @@ export function registerInlineCompletionsProvider(languageSelector: LanguageSele return languageFeaturesService.inlineCompletionsProvider.register(languageSelector, provider); } +export function registerInlineEditProvider(languageSelector: LanguageSelector, provider: languages.InlineEditProvider): IDisposable { + const languageFeaturesService = StandaloneServices.get(ILanguageFeaturesService); + return languageFeaturesService.inlineEditProvider.register(languageSelector, provider); +} + /** * Register an inlay hints provider. */ @@ -755,6 +768,7 @@ export function createMonacoLanguagesAPI(): typeof monaco.languages { setMonarchTokensProvider: setMonarchTokensProvider, registerReferenceProvider: registerReferenceProvider, registerRenameProvider: registerRenameProvider, + registerNewSymbolNameProvider: registerNewSymbolNameProvider, registerCompletionItemProvider: registerCompletionItemProvider, registerSignatureHelpProvider: registerSignatureHelpProvider, registerHoverProvider: registerHoverProvider, @@ -777,6 +791,7 @@ export function createMonacoLanguagesAPI(): typeof monaco.languages { registerDocumentSemanticTokensProvider: registerDocumentSemanticTokensProvider, registerDocumentRangeSemanticTokensProvider: registerDocumentRangeSemanticTokensProvider, registerInlineCompletionsProvider: registerInlineCompletionsProvider, + registerInlineEditProvider: registerInlineEditProvider, registerInlayHintsProvider: registerInlayHintsProvider, // enums @@ -791,7 +806,10 @@ export function createMonacoLanguagesAPI(): typeof monaco.languages { SignatureHelpTriggerKind: standaloneEnums.SignatureHelpTriggerKind, InlayHintKind: standaloneEnums.InlayHintKind, InlineCompletionTriggerKind: standaloneEnums.InlineCompletionTriggerKind, + InlineEditTriggerKind: standaloneEnums.InlineEditTriggerKind, CodeActionTriggerType: standaloneEnums.CodeActionTriggerType, + NewSymbolNameTag: standaloneEnums.NewSymbolNameTag, + PartialAcceptTriggerKind: standaloneEnums.PartialAcceptTriggerKind, // classes FoldingRangeKind: languages.FoldingRangeKind, diff --git a/src/vs/editor/standalone/browser/standaloneLayoutService.ts b/src/vs/editor/standalone/browser/standaloneLayoutService.ts index cc946b22b9743..8b55de680bab9 100644 --- a/src/vs/editor/standalone/browser/standaloneLayoutService.ts +++ b/src/vs/editor/standalone/browser/standaloneLayoutService.ts @@ -19,7 +19,6 @@ class StandaloneLayoutService implements ILayoutService { readonly onDidLayoutContainer = Event.None; readonly onDidChangeActiveContainer = Event.None; readonly onDidAddContainer = Event.None; - readonly whenActiveContainerStylesLoaded = Promise.resolve(); get mainContainer(): HTMLElement { return firstOrDefault(this._codeEditorService.listCodeEditors())?.getContainerDomNode() ?? mainWindow.document.body; @@ -50,6 +49,8 @@ class StandaloneLayoutService implements ILayoutService { return this.activeContainer; } + whenContainerStylesLoaded() { return undefined; } + focus(): void { this._codeEditorService.getFocusedCodeEditor()?.focus(); } diff --git a/src/vs/editor/standalone/browser/standaloneServices.ts b/src/vs/editor/standalone/browser/standaloneServices.ts index 97841a6012baf..c3d2f3c41c394 100644 --- a/src/vs/editor/standalone/browser/standaloneServices.ts +++ b/src/vs/editor/standalone/browser/standaloneServices.ts @@ -10,7 +10,7 @@ import 'vs/platform/undoRedo/common/undoRedoService'; import 'vs/editor/common/services/languageFeatureDebounce'; import 'vs/editor/common/services/semanticTokensStylingService'; import 'vs/editor/common/services/languageFeaturesService'; -import 'vs/editor/browser/services/hoverService'; +import 'vs/editor/browser/services/hoverService/hoverService'; import * as strings from 'vs/base/common/strings'; import * as dom from 'vs/base/browser/dom'; @@ -88,7 +88,7 @@ import { IQuickInputService } from 'vs/platform/quickinput/common/quickInput'; import { IStorageService, InMemoryStorageService } from 'vs/platform/storage/common/storage'; import { DefaultConfiguration } from 'vs/platform/configuration/common/configurations'; import { WorkspaceEdit } from 'vs/editor/common/languages'; -import { AudioCue, IAudioCueService, Sound } from 'vs/platform/audioCues/browser/audioCueService'; +import { AccessibilitySignal, IAccessibilitySignalService, Sound } from 'vs/platform/accessibilitySignal/browser/accessibilitySignalService'; import { LogService } from 'vs/platform/log/common/logService'; import { getEditorFeatures } from 'vs/editor/common/editorFeatures'; import { onUnexpectedError } from 'vs/base/common/errors'; @@ -1057,33 +1057,33 @@ class StandaloneContextMenuService extends ContextMenuService { } } -class StandaloneAudioService implements IAudioCueService { +class StandaloneAccessbilitySignalService implements IAccessibilitySignalService { _serviceBrand: undefined; - async playAudioCue(cue: AudioCue, options: {}): Promise { + async playSignal(cue: AccessibilitySignal, options: {}): Promise { } - async playAudioCues(cues: AudioCue[]): Promise { + async playSignals(cues: AccessibilitySignal[]): Promise { } - isCueEnabled(cue: AudioCue): boolean { + isSoundEnabled(cue: AccessibilitySignal): boolean { return false; } - isAlertEnabled(cue: AudioCue): boolean { + isAnnouncementEnabled(cue: AccessibilitySignal): boolean { return false; } - onEnabledChanged(cue: AudioCue): Event { + onSoundEnabledChanged(cue: AccessibilitySignal): Event { return Event.None; } - onAlertEnabledChanged(cue: AudioCue): Event { + onAnnouncementEnabledChanged(cue: AccessibilitySignal): Event { return Event.None; } async playSound(cue: Sound, allowManyInParallel?: boolean | undefined): Promise { } - playAudioCueLoop(cue: AudioCue): IDisposable { + playSignalLoop(cue: AccessibilitySignal): IDisposable { return toDisposable(() => { }); } } @@ -1125,7 +1125,7 @@ registerSingleton(IOpenerService, OpenerService, InstantiationType.Eager); registerSingleton(IClipboardService, BrowserClipboardService, InstantiationType.Eager); registerSingleton(IContextMenuService, StandaloneContextMenuService, InstantiationType.Eager); registerSingleton(IMenuService, MenuService, InstantiationType.Eager); -registerSingleton(IAudioCueService, StandaloneAudioService, InstantiationType.Eager); +registerSingleton(IAccessibilitySignalService, StandaloneAccessbilitySignalService, InstantiationType.Eager); /** * We don't want to eagerly instantiate services because embedders get a one time chance diff --git a/src/vs/editor/standalone/common/monarch/monarchCommon.ts b/src/vs/editor/standalone/common/monarch/monarchCommon.ts index 9c7febccbc8ab..7137ada8d6a58 100644 --- a/src/vs/editor/standalone/common/monarch/monarchCommon.ts +++ b/src/vs/editor/standalone/common/monarch/monarchCommon.ts @@ -68,10 +68,10 @@ export function isIAction(what: FuzzyAction): what is IAction { } export interface IRule { - regex: RegExp; action: FuzzyAction; matchOnlyAtLineStart: boolean; name: string; + resolveRegex(state: string): RegExp; } export interface IAction { @@ -175,6 +175,26 @@ export function substituteMatches(lexer: ILexerMin, str: string, id: string, mat }); } +/** + * substituteMatchesRe is used on lexer regex rules and can substitutes predefined patterns: + * $Sn => n'th part of state + * + */ +export function substituteMatchesRe(lexer: ILexerMin, str: string, state: string): string { + const re = /\$[sS](\d\d?)/g; + let stateMatches: string[] | null = null; + return str.replace(re, function (full, s) { + if (stateMatches === null) { // split state on demand + stateMatches = state.split('.'); + stateMatches.unshift(state); + } + if (!empty(s) && s < stateMatches.length) { + return fixCase(lexer, stateMatches[s]); //$Sn + } + return ''; + }); +} + /** * Find the tokenizer rules for a specific state (i.e. next action) */ diff --git a/src/vs/editor/standalone/common/monarch/monarchCompile.ts b/src/vs/editor/standalone/common/monarch/monarchCompile.ts index 44d6bc3e9220b..e9f5f3934c45b 100644 --- a/src/vs/editor/standalone/common/monarch/monarchCompile.ts +++ b/src/vs/editor/standalone/common/monarch/monarchCompile.ts @@ -85,7 +85,8 @@ function createKeywordMatcher(arr: string[], caseInsensitive: boolean = false): * @example /@attr/ will be replaced with the value of lexer[attr] * @example /@@text/ will not be replaced and will become /@text/. */ -function compileRegExp(lexer: monarchCommon.ILexerMin, str: string): RegExp { +function compileRegExp(lexer: monarchCommon.ILexerMin, str: string, handleSn: S): S extends true ? RegExp | DynamicRegExp : RegExp; +function compileRegExp(lexer: monarchCommon.ILexerMin, str: string, handleSn: true | false): RegExp | DynamicRegExp { // @@ must be interpreted as a literal @, so we replace all occurences of @@ with a placeholder character str = str.replace(/@@/g, `\x01`); @@ -116,6 +117,24 @@ function compileRegExp(lexer: monarchCommon.ILexerMin, str: string): RegExp { str = str.replace(/\x01/g, '@'); const flags = (lexer.ignoreCase ? 'i' : '') + (lexer.unicode ? 'u' : ''); + + // handle $Sn + if (handleSn) { + const match = str.match(/\$[sS](\d\d?)/g); + if (match) { + let lastState: string | null = null; + let lastRegEx: RegExp | null = null; + return (state: string) => { + if (lastRegEx && lastState === state) { + return lastRegEx; + } + lastState = state; + lastRegEx = new RegExp(monarchCommon.substituteMatchesRe(lexer, str, state), flags); + return lastRegEx; + }; + } + } + return new RegExp(str, flags); } @@ -196,12 +215,12 @@ function createGuard(lexer: monarchCommon.ILexerMin, ruleName: string, tkey: str else if (op === '~' || op === '!~') { if (pat.indexOf('$') < 0) { // precompile regular expression - const re = compileRegExp(lexer, '^' + pat + '$'); + const re = compileRegExp(lexer, '^' + pat + '$', false); tester = function (s) { return (op === '~' ? re.test(s) : !re.test(s)); }; } else { tester = function (s, id, matches, state) { - const re = compileRegExp(lexer, '^' + monarchCommon.substituteMatches(lexer, pat, id, matches, state) + '$'); + const re = compileRegExp(lexer, '^' + monarchCommon.substituteMatches(lexer, pat, id, matches, state) + '$', false); return re.test(s); }; } @@ -355,11 +374,13 @@ function compileAction(lexer: monarchCommon.ILexerMin, ruleName: string, action: } } +type DynamicRegExp = (state: string) => RegExp; + /** * Helper class for creating matching rules */ class Rule implements monarchCommon.IRule { - public regex: RegExp = new RegExp(''); + private regex: RegExp | DynamicRegExp = new RegExp(''); public action: monarchCommon.FuzzyAction = { token: '' }; public matchOnlyAtLineStart: boolean = false; public name: string = ''; @@ -382,12 +403,20 @@ class Rule implements monarchCommon.IRule { this.matchOnlyAtLineStart = (sregex.length > 0 && sregex[0] === '^'); this.name = this.name + ': ' + sregex; - this.regex = compileRegExp(lexer, '^(?:' + (this.matchOnlyAtLineStart ? sregex.substr(1) : sregex) + ')'); + this.regex = compileRegExp(lexer, '^(?:' + (this.matchOnlyAtLineStart ? sregex.substr(1) : sregex) + ')', true); } public setAction(lexer: monarchCommon.ILexerMin, act: monarchCommon.IAction) { this.action = compileAction(lexer, this.name, act); } + + public resolveRegex(state: string): RegExp { + if (this.regex instanceof RegExp) { + return this.regex; + } else { + return this.regex(state); + } + } } /** diff --git a/src/vs/editor/standalone/common/monarch/monarchLexer.ts b/src/vs/editor/standalone/common/monarch/monarchLexer.ts index d0115c98dc12b..d1d2c2c1f7687 100644 --- a/src/vs/editor/standalone/common/monarch/monarchLexer.ts +++ b/src/vs/editor/standalone/common/monarch/monarchLexer.ts @@ -519,8 +519,8 @@ export class MonarchTokenizer extends Disposable implements languages.ITokenizat } hasEmbeddedPopRule = true; - let regex = rule.regex; - const regexSource = rule.regex.source; + let regex = rule.resolveRegex(state.stack.state); + const regexSource = regex.source; if (regexSource.substr(0, 4) === '^(?:' && regexSource.substr(regexSource.length - 1, 1) === ')') { const flags = (regex.ignoreCase ? 'i' : '') + (regex.unicode ? 'u' : ''); regex = new RegExp(regexSource.substr(4, regexSource.length - 5), flags); @@ -643,7 +643,7 @@ export class MonarchTokenizer extends Disposable implements languages.ITokenizat const restOfLine = line.substr(pos); for (const rule of rules) { if (pos === 0 || !rule.matchOnlyAtLineStart) { - matches = restOfLine.match(rule.regex); + matches = restOfLine.match(rule.resolveRegex(state)); if (matches) { matched = matches[0]; action = rule.action; diff --git a/src/vs/editor/standalone/test/browser/monarch.test.ts b/src/vs/editor/standalone/test/browser/monarch.test.ts index 881ea18973bf4..fd55718ca7d5c 100644 --- a/src/vs/editor/standalone/test/browser/monarch.test.ts +++ b/src/vs/editor/standalone/test/browser/monarch.test.ts @@ -346,4 +346,53 @@ suite('Monarch', () => { disposables.dispose(); }); + test('microsoft/monaco-editor#3128: allow state access within rules', () => { + const disposables = new DisposableStore(); + const configurationService = new StandaloneConfigurationService(); + const languageService = disposables.add(new LanguageService()); + + const tokenizer = disposables.add(createMonarchTokenizer(languageService, 'test', { + ignoreCase: false, + encoding: /u|u8|U|L/, + tokenizer: { + root: [ + // C++ 11 Raw String + [/@encoding?R\"(?:([^ ()\\\t]*))\(/, { token: 'string.raw.begin', next: '@raw.$1' }], + ], + + raw: [ + [/.*\)$S2\"/, 'string.raw', '@pop'], + [/.*/, 'string.raw'] + ], + }, + }, configurationService)); + + const lines = [ + `int main(){`, + ``, + ` auto s = R""""(`, + ` Hello World`, + ` )"""";`, + ``, + ` std::cout << "hello";`, + ``, + `}`, + ]; + + const actualTokens = getTokens(tokenizer, lines); + assert.deepStrictEqual(actualTokens, [ + [new Token(0, 'source.test', 'test')], + [], + [new Token(0, 'source.test', 'test'), new Token(10, 'string.raw.begin.test', 'test')], + [new Token(0, 'string.raw.test', 'test')], + [new Token(0, 'string.raw.test', 'test'), new Token(6, 'source.test', 'test')], + [], + [new Token(0, 'source.test', 'test')], + [], + [new Token(0, 'source.test', 'test')], + ]); + + disposables.dispose(); + }); + }); diff --git a/src/vs/editor/test/browser/commands/shiftCommand.test.ts b/src/vs/editor/test/browser/commands/shiftCommand.test.ts index 0de5fe05983d7..a769b21c4f9fc 100644 --- a/src/vs/editor/test/browser/commands/shiftCommand.test.ts +++ b/src/vs/editor/test/browser/commands/shiftCommand.test.ts @@ -14,7 +14,7 @@ import { Selection } from 'vs/editor/common/core/selection'; import { ILanguageService } from 'vs/editor/common/languages/language'; import { ILanguageConfigurationService } from 'vs/editor/common/languages/languageConfigurationRegistry'; import { getEditOperation, testCommand } from 'vs/editor/test/browser/testCommand'; -import { javascriptOnEnterRules } from 'vs/editor/test/common/modes/supports/javascriptOnEnterRules'; +import { javascriptOnEnterRules } from 'vs/editor/test/common/modes/supports/onEnterRules'; import { TestLanguageConfigurationService } from 'vs/editor/test/common/modes/testLanguageConfigurationService'; import { withEditorModel } from 'vs/editor/test/common/testTextModel'; import { ServicesAccessor } from 'vs/platform/instantiation/common/instantiation'; diff --git a/src/vs/editor/test/browser/commands/trimTrailingWhitespaceCommand.test.ts b/src/vs/editor/test/browser/commands/trimTrailingWhitespaceCommand.test.ts index d440e01d723fd..5b4d0994a62dc 100644 --- a/src/vs/editor/test/browser/commands/trimTrailingWhitespaceCommand.test.ts +++ b/src/vs/editor/test/browser/commands/trimTrailingWhitespaceCommand.test.ts @@ -4,14 +4,19 @@ *--------------------------------------------------------------------------------------------*/ import * as assert from 'assert'; +import { DisposableStore } from 'vs/base/common/lifecycle'; import { ensureNoDisposablesAreLeakedInTestSuite } from 'vs/base/test/common/utils'; import { TrimTrailingWhitespaceCommand, trimTrailingWhitespace } from 'vs/editor/common/commands/trimTrailingWhitespaceCommand'; import { ISingleEditOperation } from 'vs/editor/common/core/editOperation'; import { Position } from 'vs/editor/common/core/position'; import { Range } from 'vs/editor/common/core/range'; import { Selection } from 'vs/editor/common/core/selection'; +import { MetadataConsts, StandardTokenType } from 'vs/editor/common/encodedTokenAttributes'; +import { EncodedTokenizationResult, ITokenizationSupport, TokenizationRegistry } from 'vs/editor/common/languages'; +import { ILanguageService } from 'vs/editor/common/languages/language'; +import { NullState } from 'vs/editor/common/languages/nullTokenize'; import { getEditOperation } from 'vs/editor/test/browser/testCommand'; -import { withEditorModel } from 'vs/editor/test/common/testTextModel'; +import { createModelServices, instantiateTextModel, withEditorModel } from 'vs/editor/test/common/testTextModel'; /** * Create single edit operation @@ -36,7 +41,7 @@ function createSingleEditOp(text: string | null, positionLineNumber: number, pos function assertTrimTrailingWhitespaceCommand(text: string[], expected: ISingleEditOperation[]): void { return withEditorModel(text, (model) => { - const op = new TrimTrailingWhitespaceCommand(new Selection(1, 1, 1, 1), []); + const op = new TrimTrailingWhitespaceCommand(new Selection(1, 1, 1, 1), [], true); const actual = getEditOperation(model, op); assert.deepStrictEqual(actual, expected); }); @@ -44,13 +49,23 @@ function assertTrimTrailingWhitespaceCommand(text: string[], expected: ISingleEd function assertTrimTrailingWhitespace(text: string[], cursors: Position[], expected: ISingleEditOperation[]): void { return withEditorModel(text, (model) => { - const actual = trimTrailingWhitespace(model, cursors); + const actual = trimTrailingWhitespace(model, cursors, true); assert.deepStrictEqual(actual, expected); }); } suite('Editor Commands - Trim Trailing Whitespace Command', () => { + let disposables: DisposableStore; + + setup(() => { + disposables = new DisposableStore(); + }); + + teardown(() => { + disposables.dispose(); + }); + ensureNoDisposablesAreLeakedInTestSuite(); test('remove trailing whitespace', function () { @@ -102,4 +117,73 @@ suite('Editor Commands - Trim Trailing Whitespace Command', () => { ]); }); + test('skips strings and regex if configured', function () { + const instantiationService = createModelServices(disposables); + const languageService = instantiationService.get(ILanguageService); + const languageId = 'testLanguageId'; + const languageIdCodec = languageService.languageIdCodec; + disposables.add(languageService.registerLanguage({ id: languageId })); + const encodedLanguageId = languageIdCodec.encodeLanguageId(languageId); + + const otherMetadata = ( + (encodedLanguageId << MetadataConsts.LANGUAGEID_OFFSET) + | (StandardTokenType.Other << MetadataConsts.TOKEN_TYPE_OFFSET) + | (MetadataConsts.BALANCED_BRACKETS_MASK) + ) >>> 0; + const stringMetadata = ( + (encodedLanguageId << MetadataConsts.LANGUAGEID_OFFSET) + | (StandardTokenType.String << MetadataConsts.TOKEN_TYPE_OFFSET) + | (MetadataConsts.BALANCED_BRACKETS_MASK) + ) >>> 0; + + const tokenizationSupport: ITokenizationSupport = { + getInitialState: () => NullState, + tokenize: undefined!, + tokenizeEncoded: (line, hasEOL, state) => { + switch (line) { + case 'const a = ` ': { + const tokens = new Uint32Array([ + 0, otherMetadata, + 10, stringMetadata, + ]); + return new EncodedTokenizationResult(tokens, state); + } + case ' a string ': { + const tokens = new Uint32Array([ + 0, stringMetadata, + ]); + return new EncodedTokenizationResult(tokens, state); + } + case '`; ': { + const tokens = new Uint32Array([ + 0, stringMetadata, + 1, otherMetadata + ]); + return new EncodedTokenizationResult(tokens, state); + } + } + throw new Error(`Unexpected`); + } + }; + + disposables.add(TokenizationRegistry.register(languageId, tokenizationSupport)); + + const model = disposables.add(instantiateTextModel( + instantiationService, + [ + 'const a = ` ', + ' a string ', + '`; ', + ].join('\n'), + languageId + )); + + model.tokenization.forceTokenization(1); + model.tokenization.forceTokenization(2); + model.tokenization.forceTokenization(3); + + const op = new TrimTrailingWhitespaceCommand(new Selection(1, 1, 1, 1), [], false); + const actual = getEditOperation(model, op); + assert.deepStrictEqual(actual, [createSingleEditOp(null, 3, 3, 3, 5)]); + }); }); diff --git a/src/vs/editor/test/browser/config/editorLayoutProvider.test.ts b/src/vs/editor/test/browser/config/editorLayoutProvider.test.ts index aef1e1cd2fa07..4f644203ef431 100644 --- a/src/vs/editor/test/browser/config/editorLayoutProvider.test.ts +++ b/src/vs/editor/test/browser/config/editorLayoutProvider.test.ts @@ -58,6 +58,9 @@ suite('Editor ViewLayout - EditorLayoutProvider', () => { maxColumn: input.minimapMaxColumn, showSlider: 'mouseover', scale: 1, + showRegionSectionHeaders: true, + showMarkSectionHeaders: true, + sectionHeaderFontSize: 9 }; options._write(EditorOption.minimap, minimapOptions); const scrollbarOptions: InternalEditorScrollbarOptions = { diff --git a/src/vs/editor/test/browser/config/testConfiguration.ts b/src/vs/editor/test/browser/config/testConfiguration.ts index 71f1c4d0e0cfd..b5d42908dedde 100644 --- a/src/vs/editor/test/browser/config/testConfiguration.ts +++ b/src/vs/editor/test/browser/config/testConfiguration.ts @@ -4,25 +4,27 @@ *--------------------------------------------------------------------------------------------*/ import { EditorConfiguration, IEnvConfiguration } from 'vs/editor/browser/config/editorConfiguration'; -import { EditorFontLigatures, EditorFontVariations, IEditorOptions } from 'vs/editor/common/config/editorOptions'; +import { EditorFontLigatures, EditorFontVariations } from 'vs/editor/common/config/editorOptions'; import { BareFontInfo, FontInfo } from 'vs/editor/common/config/fontInfo'; +import { TestCodeEditorCreationOptions } from 'vs/editor/test/browser/testCodeEditor'; import { AccessibilitySupport } from 'vs/platform/accessibility/common/accessibility'; import { TestAccessibilityService } from 'vs/platform/accessibility/test/common/testAccessibilityService'; export class TestConfiguration extends EditorConfiguration { - constructor(opts: IEditorOptions) { + constructor(opts: Readonly) { super(false, opts, null, new TestAccessibilityService()); } protected override _readEnvConfiguration(): IEnvConfiguration { + const envConfig = (this.getRawOptions() as TestCodeEditorCreationOptions).envConfig; return { - extraEditorClassName: '', - outerWidth: 100, - outerHeight: 100, - emptySelectionClipboard: true, - pixelRatio: 1, - accessibilitySupport: AccessibilitySupport.Unknown + extraEditorClassName: envConfig?.extraEditorClassName ?? '', + outerWidth: envConfig?.outerWidth ?? 100, + outerHeight: envConfig?.outerHeight ?? 100, + emptySelectionClipboard: envConfig?.emptySelectionClipboard ?? true, + pixelRatio: envConfig?.pixelRatio ?? 1, + accessibilitySupport: envConfig?.accessibilitySupport ?? AccessibilitySupport.Unknown }; } diff --git a/src/vs/editor/test/browser/controller/cursor.test.ts b/src/vs/editor/test/browser/controller/cursor.test.ts index 854346ddfeab4..a86e95c1303da 100644 --- a/src/vs/editor/test/browser/controller/cursor.test.ts +++ b/src/vs/editor/test/browser/controller/cursor.test.ts @@ -26,7 +26,6 @@ import { TextModel } from 'vs/editor/common/model/textModel'; import { ViewModel } from 'vs/editor/common/viewModel/viewModelImpl'; import { OutgoingViewModelEventKind } from 'vs/editor/common/viewModelEventDispatcher'; import { ITestCodeEditor, TestCodeEditorInstantiationOptions, createCodeEditorServices, instantiateTestCodeEditor, withTestCodeEditor } from 'vs/editor/test/browser/testCodeEditor'; -import { javascriptOnEnterRules } from 'vs/editor/test/common/modes/supports/javascriptOnEnterRules'; import { IRelaxedTextModelCreationOptions, createTextModel, instantiateTextModel } from 'vs/editor/test/common/testTextModel'; import { TestInstantiationService } from 'vs/platform/instantiation/test/common/instantiationServiceMock'; @@ -4469,93 +4468,6 @@ suite('Editor Controller', () => { }); }); - test('issue #36090: JS: editor.autoIndent seems to be broken', () => { - const languageId = 'jsMode'; - - disposables.add(languageService.registerLanguage({ id: languageId })); - disposables.add(languageConfigurationService.register(languageId, { - brackets: [ - ['{', '}'], - ['[', ']'], - ['(', ')'] - ], - indentationRules: { - // ^(.*\*/)?\s*\}.*$ - decreaseIndentPattern: /^((?!.*?\/\*).*\*\/)?\s*[\}\]\)].*$/, - // ^.*\{[^}"']*$ - increaseIndentPattern: /^((?!\/\/).)*(\{[^}"'`]*|\([^)"'`]*|\[[^\]"'`]*)$/ - }, - onEnterRules: javascriptOnEnterRules - })); - - const model = createTextModel( - [ - 'class ItemCtrl {', - ' getPropertiesByItemId(id) {', - ' return this.fetchItem(id)', - ' .then(item => {', - ' return this.getPropertiesOfItem(item);', - ' });', - ' }', - '}', - ].join('\n'), - languageId - ); - - withTestCodeEditor(model, { autoIndent: 'advanced' }, (editor, viewModel) => { - moveTo(editor, viewModel, 7, 6, false); - assertCursor(viewModel, new Selection(7, 6, 7, 6)); - - viewModel.type('\n', 'keyboard'); - assert.strictEqual(model.getValue(), - [ - 'class ItemCtrl {', - ' getPropertiesByItemId(id) {', - ' return this.fetchItem(id)', - ' .then(item => {', - ' return this.getPropertiesOfItem(item);', - ' });', - ' }', - ' ', - '}', - ].join('\n') - ); - assertCursor(viewModel, new Selection(8, 5, 8, 5)); - }); - }); - - test('issue #115304: OnEnter broken for TS', () => { - const languageId = 'jsMode'; - - disposables.add(languageService.registerLanguage({ id: languageId })); - disposables.add(languageConfigurationService.register(languageId, { - onEnterRules: javascriptOnEnterRules - })); - - const model = createTextModel( - [ - '/** */', - 'function f() {}', - ].join('\n'), - languageId - ); - - withTestCodeEditor(model, { autoIndent: 'advanced' }, (editor, viewModel) => { - moveTo(editor, viewModel, 1, 4, false); - assertCursor(viewModel, new Selection(1, 4, 1, 4)); - - viewModel.type('\n', 'keyboard'); - assert.strictEqual(model.getValue(), - [ - '/**', - ' * ', - ' */', - 'function f() {}', - ].join('\n') - ); - assertCursor(viewModel, new Selection(2, 4, 2, 4)); - }); - }); test('issue #38261: TAB key results in bizarre indentation in C++ mode ', () => { const languageId = 'indentRulesMode'; diff --git a/src/vs/editor/browser/diff/testDiffProviderFactoryService.ts b/src/vs/editor/test/browser/diff/testDiffProviderFactoryService.ts similarity index 95% rename from src/vs/editor/browser/diff/testDiffProviderFactoryService.ts rename to src/vs/editor/test/browser/diff/testDiffProviderFactoryService.ts index 08ed249b8b95c..275c4995988ee 100644 --- a/src/vs/editor/browser/diff/testDiffProviderFactoryService.ts +++ b/src/vs/editor/test/browser/diff/testDiffProviderFactoryService.ts @@ -18,7 +18,7 @@ export class TestDiffProviderFactoryService implements IDiffProviderFactoryServi } } -export class SyncDocumentDiffProvider implements IDocumentDiffProvider { +class SyncDocumentDiffProvider implements IDocumentDiffProvider { computeDiff(original: ITextModel, modified: ITextModel, options: IDocumentDiffProviderOptions, cancellationToken: CancellationToken): Promise { const result = linesDiffComputers.getDefault().computeDiff(original.getLinesContent(), modified.getLinesContent(), options); return Promise.resolve({ diff --git a/src/vs/editor/test/browser/testCodeEditor.ts b/src/vs/editor/test/browser/testCodeEditor.ts index 507dbe2cc6f5f..c0a4b9d1cf339 100644 --- a/src/vs/editor/test/browser/testCodeEditor.ts +++ b/src/vs/editor/test/browser/testCodeEditor.ts @@ -5,11 +5,11 @@ import { DisposableStore, IDisposable, toDisposable } from 'vs/base/common/lifecycle'; import { mock } from 'vs/base/test/common/mock'; -import { EditorConfiguration, IEditorConstructionOptions } from 'vs/editor/browser/config/editorConfiguration'; +import { EditorConfiguration } from 'vs/editor/browser/config/editorConfiguration'; import { IActiveCodeEditor, ICodeEditor } from 'vs/editor/browser/editorBrowser'; import { ICodeEditorService } from 'vs/editor/browser/services/codeEditorService'; import { View } from 'vs/editor/browser/view'; -import { CodeEditorWidget, ICodeEditorWidgetOptions } from 'vs/editor/browser/widget/codeEditorWidget'; +import { CodeEditorWidget, ICodeEditorWidgetOptions } from 'vs/editor/browser/widget/codeEditor/codeEditorWidget'; import * as editorOptions from 'vs/editor/common/config/editorOptions'; import { IEditorContribution } from 'vs/editor/common/editorCommon'; import { ILanguageService } from 'vs/editor/common/languages/language'; @@ -30,7 +30,7 @@ import { TestLanguageConfigurationService } from 'vs/editor/test/common/modes/te import { TestEditorWorkerService } from 'vs/editor/test/common/services/testEditorWorkerService'; import { TestTextResourcePropertiesService } from 'vs/editor/test/common/services/testTextResourcePropertiesService'; import { instantiateTextModel } from 'vs/editor/test/common/testTextModel'; -import { IAccessibilityService } from 'vs/platform/accessibility/common/accessibility'; +import { AccessibilitySupport, IAccessibilityService } from 'vs/platform/accessibility/common/accessibility'; import { TestAccessibilityService } from 'vs/platform/accessibility/test/common/testAccessibilityService'; import { IClipboardService } from 'vs/platform/clipboard/common/clipboardService'; import { TestClipboardService } from 'vs/platform/clipboard/test/common/testClipboardService'; @@ -68,7 +68,7 @@ export interface ITestCodeEditor extends IActiveCodeEditor { export class TestCodeEditor extends CodeEditorWidget implements ICodeEditor { //#region testing overrides - protected override _createConfiguration(isSimpleWidget: boolean, options: Readonly): EditorConfiguration { + protected override _createConfiguration(isSimpleWidget: boolean, options: Readonly): EditorConfiguration { return new TestConfiguration(options); } protected override _createView(viewModel: ViewModel): [View, boolean] { @@ -116,6 +116,10 @@ export interface TestCodeEditorCreationOptions extends editorOptions.IEditorOpti * Defaults to true. */ hasTextFocus?: boolean; + /** + * Env configuration + */ + envConfig?: ITestEnvConfiguration; } export interface TestCodeEditorInstantiationOptions extends TestCodeEditorCreationOptions { @@ -125,6 +129,15 @@ export interface TestCodeEditorInstantiationOptions extends TestCodeEditorCreati serviceCollection?: ServiceCollection; } +export interface ITestEnvConfiguration { + extraEditorClassName?: string; + outerWidth?: number; + outerHeight?: number; + emptySelectionClipboard?: boolean; + pixelRatio?: number; + accessibilitySupport?: AccessibilitySupport; +} + export function withTestCodeEditor(text: ITextModel | string | string[] | ITextBufferFactory, options: TestCodeEditorInstantiationOptions, callback: (editor: ITestCodeEditor, viewModel: ViewModel, instantiationService: TestInstantiationService) => void): void { return _withTestCodeEditor(text, options, callback); } diff --git a/src/vs/editor/test/common/core/positionOffsetTransformer.test.ts b/src/vs/editor/test/common/core/positionOffsetTransformer.test.ts new file mode 100644 index 0000000000000..39aead0a84852 --- /dev/null +++ b/src/vs/editor/test/common/core/positionOffsetTransformer.test.ts @@ -0,0 +1,59 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as assert from 'assert'; +import { ensureNoDisposablesAreLeakedInTestSuite } from 'vs/base/test/common/utils'; +import { OffsetRange } from 'vs/editor/common/core/offsetRange'; +import { PositionOffsetTransformer } from 'vs/editor/common/core/positionToOffset'; + +suite('PositionOffsetTransformer', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + const str = '123456\nabcdef\nghijkl\nmnopqr'; + + const t = new PositionOffsetTransformer(str); + test('getPosition', () => { + assert.deepStrictEqual( + new OffsetRange(0, str.length + 2).map(i => t.getPosition(i).toString()), + [ + "(1,1)", + "(1,2)", + "(1,3)", + "(1,4)", + "(1,5)", + "(1,6)", + "(1,7)", + "(2,1)", + "(2,2)", + "(2,3)", + "(2,4)", + "(2,5)", + "(2,6)", + "(2,7)", + "(3,1)", + "(3,2)", + "(3,3)", + "(3,4)", + "(3,5)", + "(3,6)", + "(3,7)", + "(4,1)", + "(4,2)", + "(4,3)", + "(4,4)", + "(4,5)", + "(4,6)", + "(4,7)", + "(4,8)" + ] + ); + }); + + test('getOffset', () => { + for (let i = 0; i < str.length + 2; i++) { + assert.strictEqual(t.getOffset(t.getPosition(i)), i); + } + }); +}); diff --git a/src/vs/editor/test/common/core/random.ts b/src/vs/editor/test/common/core/random.ts new file mode 100644 index 0000000000000..d48f4173f822c --- /dev/null +++ b/src/vs/editor/test/common/core/random.ts @@ -0,0 +1,113 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { numberComparator } from 'vs/base/common/arrays'; +import { OffsetRange } from 'vs/editor/common/core/offsetRange'; +import { Position } from 'vs/editor/common/core/position'; +import { PositionOffsetTransformer } from 'vs/editor/common/core/positionToOffset'; +import { Range } from 'vs/editor/common/core/range'; +import { AbstractText, SingleTextEdit, TextEdit } from 'vs/editor/common/core/textEdit'; + +export abstract class Random { + public static basicAlphabet: string = ' abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; + public static basicAlphabetMultiline: string = ' \n\n\nabcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; + + public static create(seed: number): Random { + return new MersenneTwister(seed); + } + + public abstract nextIntRange(start: number, endExclusive: number): number; + + public nextString(length: number, alphabet = Random.basicAlphabet): string { + let randomText: string = ''; + for (let i = 0; i < length; i++) { + const characterIndex = this.nextIntRange(0, alphabet.length); + randomText += alphabet.charAt(characterIndex); + } + return randomText; + } + + public nextMultiLineString(lineCount: number, lineLengthRange: OffsetRange, alphabet = Random.basicAlphabet): string { + const lines: string[] = []; + for (let i = 0; i < lineCount; i++) { + const lineLength = this.nextIntRange(lineLengthRange.start, lineLengthRange.endExclusive); + lines.push(this.nextString(lineLength, alphabet)); + } + return lines.join('\n'); + } + + public nextConsecutivePositions(source: AbstractText, count: number): Position[] { + const t = new PositionOffsetTransformer(source.getValue()); + const offsets = OffsetRange.ofLength(count).map(() => this.nextIntRange(0, t.text.length)); + offsets.sort(numberComparator); + return offsets.map(offset => t.getPosition(offset)); + } + + public nextRange(source: AbstractText): Range { + const [start, end] = this.nextConsecutivePositions(source, 2); + return Range.fromPositions(start, end); + } + + public nextTextEdit(target: AbstractText, singleTextEditCount: number): TextEdit { + const singleTextEdits: SingleTextEdit[] = []; + + const positions = this.nextConsecutivePositions(target, singleTextEditCount * 2); + + for (let i = 0; i < singleTextEditCount; i++) { + const start = positions[i * 2]; + const end = positions[i * 2 + 1]; + const newText = this.nextString(end.column - start.column, Random.basicAlphabetMultiline); + singleTextEdits.push(new SingleTextEdit(Range.fromPositions(start, end), newText)); + } + + return new TextEdit(singleTextEdits).normalize(); + } +} + +class MersenneTwister extends Random { + private readonly mt = new Array(624); + private index = 0; + + constructor(seed: number) { + super(); + + this.mt[0] = seed >>> 0; + for (let i = 1; i < 624; i++) { + const s = this.mt[i - 1] ^ (this.mt[i - 1] >>> 30); + this.mt[i] = (((((s & 0xffff0000) >>> 16) * 0x6c078965) << 16) + (s & 0x0000ffff) * 0x6c078965 + i) >>> 0; + } + } + + private _nextInt() { + if (this.index === 0) { + this.generateNumbers(); + } + + let y = this.mt[this.index]; + y = y ^ (y >>> 11); + y = y ^ ((y << 7) & 0x9d2c5680); + y = y ^ ((y << 15) & 0xefc60000); + y = y ^ (y >>> 18); + + this.index = (this.index + 1) % 624; + + return y >>> 0; + } + + public nextIntRange(start: number, endExclusive: number) { + const range = endExclusive - start; + return Math.floor(this._nextInt() / (0x100000000 / range)) + start; + } + + private generateNumbers() { + for (let i = 0; i < 624; i++) { + const y = (this.mt[i] & 0x80000000) + (this.mt[(i + 1) % 624] & 0x7fffffff); + this.mt[i] = this.mt[(i + 397) % 624] ^ (y >>> 1); + if ((y % 2) !== 0) { + this.mt[i] = this.mt[i] ^ 0x9908b0df; + } + } + } +} diff --git a/src/vs/editor/test/common/core/textEdit.test.ts b/src/vs/editor/test/common/core/textEdit.test.ts new file mode 100644 index 0000000000000..f02e8a9bd5036 --- /dev/null +++ b/src/vs/editor/test/common/core/textEdit.test.ts @@ -0,0 +1,39 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as assert from 'assert'; +import { ensureNoDisposablesAreLeakedInTestSuite } from 'vs/base/test/common/utils'; +import { OffsetRange } from 'vs/editor/common/core/offsetRange'; +import { StringText } from 'vs/editor/common/core/textEdit'; +import { Random } from 'vs/editor/test/common/core/random'; + +suite('TextEdit', () => { + suite('inverse', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + function runTest(seed: number): void { + const rand = Random.create(seed); + const source = new StringText(rand.nextMultiLineString(10, new OffsetRange(0, 10))); + + const edit = rand.nextTextEdit(source, rand.nextIntRange(1, 5)); + const invEdit = edit.inverse(source); + + const s1 = edit.apply(source); + const s2 = invEdit.applyToString(s1); + + assert.deepStrictEqual(s2, source.value); + } + + test.skip('brute-force', () => { + for (let i = 0; i < 100_000; i++) { + runTest(i); + } + }); + + for (let seed = 0; seed < 20; seed++) { + test(`test ${seed}`, () => runTest(seed)); + } + }); +}); diff --git a/src/vs/editor/test/common/model/bracketPairColorizer/combineTextEditInfos.test.ts b/src/vs/editor/test/common/model/bracketPairColorizer/combineTextEditInfos.test.ts index 611ba267d5b3e..9155b32a9cae5 100644 --- a/src/vs/editor/test/common/model/bracketPairColorizer/combineTextEditInfos.test.ts +++ b/src/vs/editor/test/common/model/bracketPairColorizer/combineTextEditInfos.test.ts @@ -5,16 +5,16 @@ import * as assert from 'assert'; import { ensureNoDisposablesAreLeakedInTestSuite } from 'vs/base/test/common/utils'; -import { ISingleEditOperation } from 'vs/editor/common/core/editOperation'; import { Range } from 'vs/editor/common/core/range'; +import { SingleTextEdit } from 'vs/editor/common/core/textEdit'; import { TextEditInfo } from 'vs/editor/common/model/bracketPairsTextModelPart/bracketPairsTree/beforeEditPositionMapper'; import { combineTextEditInfos } from 'vs/editor/common/model/bracketPairsTextModelPart/bracketPairsTree/combineTextEditInfos'; import { lengthAdd, lengthToObj, lengthToPosition, positionToLength, toLength } from 'vs/editor/common/model/bracketPairsTextModelPart/bracketPairsTree/length'; import { TextModel } from 'vs/editor/common/model/textModel'; +import { Random } from 'vs/editor/test/common/core/random'; import { createTextModel } from 'vs/editor/test/common/testTextModel'; suite('combineTextEditInfos', () => { - ensureNoDisposablesAreLeakedInTestSuite(); for (let seed = 0; seed < 50; seed++) { @@ -25,7 +25,7 @@ suite('combineTextEditInfos', () => { }); function runTest(seed: number) { - const rng = new MersenneTwister(seed); + const rng = Random.create(seed); const str = 'abcde\nfghij\nklmno\npqrst\n'; const textModelS0 = createTextModel(str); @@ -58,7 +58,7 @@ function runTest(seed: number) { textModelS2.dispose(); } -export function getRandomEditInfos(textModel: TextModel, count: number, rng: MersenneTwister, disjoint: boolean = false): TextEditInfo[] { +export function getRandomEditInfos(textModel: TextModel, count: number, rng: Random, disjoint: boolean = false): TextEditInfo[] { const edits: TextEditInfo[] = []; let i = 0; for (let j = 0; j < count; j++) { @@ -68,7 +68,7 @@ export function getRandomEditInfos(textModel: TextModel, count: number, rng: Mer return edits; } -function getRandomEdit(textModel: TextModel, rangeOffsetStart: number, rng: MersenneTwister): TextEditInfo { +function getRandomEdit(textModel: TextModel, rangeOffsetStart: number, rng: Random): TextEditInfo { const textModelLength = textModel.getValueLength(); const offsetStart = rng.nextIntRange(rangeOffsetStart, textModelLength); const offsetEnd = rng.nextIntRange(offsetStart, textModelLength); @@ -79,7 +79,7 @@ function getRandomEdit(textModel: TextModel, rangeOffsetStart: number, rng: Mers return new TextEditInfo(positionToLength(textModel.getPositionAt(offsetStart)), positionToLength(textModel.getPositionAt(offsetEnd)), toLength(lineCount, columnCount)); } -export function toEdit(editInfo: TextEditInfo): ISingleEditOperation { +function toEdit(editInfo: TextEditInfo): SingleTextEdit { const l = lengthToObj(editInfo.newLength); let text = ''; @@ -90,56 +90,11 @@ export function toEdit(editInfo: TextEditInfo): ISingleEditOperation { text += 'C'; } - return { - range: Range.fromPositions( + return new SingleTextEdit( + Range.fromPositions( lengthToPosition(editInfo.startOffset), lengthToPosition(editInfo.endOffset) ), text - }; -} - -// Generated by copilot -export class MersenneTwister { - private readonly mt = new Array(624); - private index = 0; - - constructor(seed: number) { - this.mt[0] = seed >>> 0; - for (let i = 1; i < 624; i++) { - const s = this.mt[i - 1] ^ (this.mt[i - 1] >>> 30); - this.mt[i] = (((((s & 0xffff0000) >>> 16) * 0x6c078965) << 16) + (s & 0x0000ffff) * 0x6c078965 + i) >>> 0; - } - } - - public nextInt() { - if (this.index === 0) { - this.generateNumbers(); - } - - let y = this.mt[this.index]; - y = y ^ (y >>> 11); - y = y ^ ((y << 7) & 0x9d2c5680); - y = y ^ ((y << 15) & 0xefc60000); - y = y ^ (y >>> 18); - - this.index = (this.index + 1) % 624; - - return y >>> 0; - } - - public nextIntRange(start: number, endExclusive: number) { - const range = endExclusive - start; - return Math.floor(this.nextInt() / (0x100000000 / range)) + start; - } - - private generateNumbers() { - for (let i = 0; i < 624; i++) { - const y = (this.mt[i] & 0x80000000) + (this.mt[(i + 1) % 624] & 0x7fffffff); - this.mt[i] = this.mt[(i + 397) % 624] ^ (y >>> 1); - if ((y % 2) !== 0) { - this.mt[i] = this.mt[i] ^ 0x9908b0df; - } - } - } + ); } diff --git a/src/vs/editor/test/common/model/pieceTreeTextBuffer/pieceTreeTextBuffer.test.ts b/src/vs/editor/test/common/model/pieceTreeTextBuffer/pieceTreeTextBuffer.test.ts index 262032dd9e42a..007033dc79072 100644 --- a/src/vs/editor/test/common/model/pieceTreeTextBuffer/pieceTreeTextBuffer.test.ts +++ b/src/vs/editor/test/common/model/pieceTreeTextBuffer/pieceTreeTextBuffer.test.ts @@ -2056,7 +2056,7 @@ suite('chunk based search', () => { ds.add(pieceTree); const pieceTable = pieceTree.getPieceTree(); pieceTable.delete(0, 1); - const ret = pieceTree.findMatchesLineByLine(new Range(1, 1, 1, 1), new SearchData(/abc/, new WordCharacterClassifier(',./'), 'abc'), true, 1000); + const ret = pieceTree.findMatchesLineByLine(new Range(1, 1, 1, 1), new SearchData(/abc/, new WordCharacterClassifier(',./', []), 'abc'), true, 1000); assert.strictEqual(ret.length, 0); }); @@ -2078,7 +2078,7 @@ suite('chunk based search', () => { pieceTable.delete(16, 1); pieceTable.insert(16, ' '); - const ret = pieceTable.findMatchesLineByLine(new Range(1, 1, 4, 13), new SearchData(/\[/gi, new WordCharacterClassifier(',./'), '['), true, 1000); + const ret = pieceTable.findMatchesLineByLine(new Range(1, 1, 4, 13), new SearchData(/\[/gi, new WordCharacterClassifier(',./', []), '['), true, 1000); assert.strictEqual(ret.length, 3); assert.deepStrictEqual(ret[0].range, new Range(2, 3, 2, 4)); diff --git a/src/vs/editor/test/common/model/textModelSearch.test.ts b/src/vs/editor/test/common/model/textModelSearch.test.ts index 16d237c00a5fd..91ec41810f343 100644 --- a/src/vs/editor/test/common/model/textModelSearch.test.ts +++ b/src/vs/editor/test/common/model/textModelSearch.test.ts @@ -19,7 +19,7 @@ suite('TextModelSearch', () => { ensureNoDisposablesAreLeakedInTestSuite(); - const usualWordSeparators = getMapForWordSeparators(USUAL_WORD_SEPARATORS); + const usualWordSeparators = getMapForWordSeparators(USUAL_WORD_SEPARATORS, []); function assertFindMatch(actual: FindMatch | null, expectedRange: Range, expectedMatches: string[] | null = null): void { assert.deepStrictEqual(actual, new FindMatch(expectedRange, expectedMatches)); diff --git a/src/vs/editor/test/common/modes/supports/autoClosingPairsRules.ts b/src/vs/editor/test/common/modes/supports/autoClosingPairsRules.ts new file mode 100644 index 0000000000000..968bd3508926c --- /dev/null +++ b/src/vs/editor/test/common/modes/supports/autoClosingPairsRules.ts @@ -0,0 +1,31 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IAutoClosingPair } from 'vs/editor/common/languages/languageConfiguration'; + +export const latexAutoClosingPairsRules: IAutoClosingPair[] = [ + { open: '\\left(', close: '\\right)' }, + { open: '\\left[', close: '\\right]' }, + { open: '\\left\\{', close: '\\right\\}' }, + { open: '\\bigl(', close: '\\bigr)' }, + { open: '\\bigl[', close: '\\bigr]' }, + { open: '\\bigl\\{', close: '\\bigr\\}' }, + { open: '\\Bigl(', close: '\\Bigr)' }, + { open: '\\Bigl[', close: '\\Bigr]' }, + { open: '\\Bigl\\{', close: '\\Bigr\\}' }, + { open: '\\biggl(', close: '\\biggr)' }, + { open: '\\biggl[', close: '\\biggr]' }, + { open: '\\biggl\\{', close: '\\biggr\\}' }, + { open: '\\Biggl(', close: '\\Biggr)' }, + { open: '\\Biggl[', close: '\\Biggr]' }, + { open: '\\Biggl\\{', close: '\\Biggr\\}' }, + { open: '\\(', close: '\\)' }, + { open: '\\[', close: '\\]' }, + { open: '\\{', close: '\\}' }, + { open: '{', close: '}' }, + { open: '[', close: ']' }, + { open: '(', close: ')' }, + { open: '`', close: '\'' }, +]; diff --git a/src/vs/editor/test/common/modes/supports/bracketRules.ts b/src/vs/editor/test/common/modes/supports/bracketRules.ts new file mode 100644 index 0000000000000..d21b70a6dc363 --- /dev/null +++ b/src/vs/editor/test/common/modes/supports/bracketRules.ts @@ -0,0 +1,106 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { CharacterPair } from 'vs/editor/common/languages/languageConfiguration'; + +const standardBracketRules: CharacterPair[] = [ + ['{', '}'], + ['[', ']'], + ['(', ')'] +]; + +export const rubyBracketRules = standardBracketRules; + +export const cppBracketRules = standardBracketRules; + +export const goBracketRules = standardBracketRules; + +export const phpBracketRules = standardBracketRules; + +export const vbBracketRules = standardBracketRules; + +export const luaBracketRules = standardBracketRules; + +export const htmlBracketRules: CharacterPair[] = [ + [''], + ['{', '}'], + ['(', ')'] +]; + +export const typescriptBracketRules: CharacterPair[] = [ + ['${', '}'], + ['{', '}'], + ['[', ']'], + ['(', ')'] +]; + +export const latexBracketRules: CharacterPair[] = [ + ['{', '}'], + ['[', ']'], + ['(', ')'], + ['[', ')'], + ['(', ']'], + ['\\left(', '\\right)'], + ['\\left(', '\\right.'], + ['\\left.', '\\right)'], + ['\\left[', '\\right]'], + ['\\left[', '\\right.'], + ['\\left.', '\\right]'], + ['\\left\\{', '\\right\\}'], + ['\\left\\{', '\\right.'], + ['\\left.', '\\right\\}'], + ['\\left<', '\\right>'], + ['\\bigl(', '\\bigr)'], + ['\\bigl[', '\\bigr]'], + ['\\bigl\\{', '\\bigr\\}'], + ['\\Bigl(', '\\Bigr)'], + ['\\Bigl[', '\\Bigr]'], + ['\\Bigl\\{', '\\Bigr\\}'], + ['\\biggl(', '\\biggr)'], + ['\\biggl[', '\\biggr]'], + ['\\biggl\\{', '\\biggr\\}'], + ['\\Biggl(', '\\Biggr)'], + ['\\Biggl[', '\\Biggr]'], + ['\\Biggl\\{', '\\Biggr\\}'], + ['\\langle', '\\rangle'], + ['\\lvert', '\\rvert'], + ['\\lVert', '\\rVert'], + ['\\left|', '\\right|'], + ['\\left\\vert', '\\right\\vert'], + ['\\left\\|', '\\right\\|'], + ['\\left\\Vert', '\\right\\Vert'], + ['\\left\\langle', '\\right\\rangle'], + ['\\left\\lvert', '\\right\\rvert'], + ['\\left\\lVert', '\\right\\rVert'], + ['\\bigl\\langle', '\\bigr\\rangle'], + ['\\bigl|', '\\bigr|'], + ['\\bigl\\vert', '\\bigr\\vert'], + ['\\bigl\\lvert', '\\bigr\\rvert'], + ['\\bigl\\|', '\\bigr\\|'], + ['\\bigl\\lVert', '\\bigr\\rVert'], + ['\\bigl\\Vert', '\\bigr\\Vert'], + ['\\Bigl\\langle', '\\Bigr\\rangle'], + ['\\Bigl|', '\\Bigr|'], + ['\\Bigl\\lvert', '\\Bigr\\rvert'], + ['\\Bigl\\vert', '\\Bigr\\vert'], + ['\\Bigl\\|', '\\Bigr\\|'], + ['\\Bigl\\lVert', '\\Bigr\\rVert'], + ['\\Bigl\\Vert', '\\Bigr\\Vert'], + ['\\biggl\\langle', '\\biggr\\rangle'], + ['\\biggl|', '\\biggr|'], + ['\\biggl\\lvert', '\\biggr\\rvert'], + ['\\biggl\\vert', '\\biggr\\vert'], + ['\\biggl\\|', '\\biggr\\|'], + ['\\biggl\\lVert', '\\biggr\\rVert'], + ['\\biggl\\Vert', '\\biggr\\Vert'], + ['\\Biggl\\langle', '\\Biggr\\rangle'], + ['\\Biggl|', '\\Biggr|'], + ['\\Biggl\\lvert', '\\Biggr\\rvert'], + ['\\Biggl\\vert', '\\Biggr\\vert'], + ['\\Biggl\\|', '\\Biggr\\|'], + ['\\Biggl\\lVert', '\\Biggr\\rVert'], + ['\\Biggl\\Vert', '\\Biggr\\Vert'] +]; + diff --git a/src/vs/editor/test/common/modes/supports/indentationRules.ts b/src/vs/editor/test/common/modes/supports/indentationRules.ts new file mode 100644 index 0000000000000..0967de48bff78 --- /dev/null +++ b/src/vs/editor/test/common/modes/supports/indentationRules.ts @@ -0,0 +1,42 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +export const javascriptIndentationRules = { + decreaseIndentPattern: /^((?!.*?\/\*).*\*\/)?\s*[\}\]\)].*$/, + increaseIndentPattern: /^((?!\/\/).)*(\{([^}"'`]*|(\t|[ ])*\/\/.*)|\([^)"'`]*|\[[^\]"'`]*)$/, + // e.g. * ...| or */| or *-----*/| + unIndentedLinePattern: /^(\t|[ ])*[ ]\*[^/]*\*\/\s*$|^(\t|[ ])*[ ]\*\/\s*$|^(\t|[ ])*[ ]\*([ ]([^\*]|\*(?!\/))*)?$/, + indentNextLinePattern: /^((.*=>\s*)|((.*[^\w]+|\s*)(if|while|for)\s*\(.*\)\s*))$/, +}; + +export const rubyIndentationRules = { + decreaseIndentPattern: /^\s*([}\]]([,)]?\s*(#|$)|\.[a-zA-Z_]\w*\b)|(end|rescue|ensure|else|elsif)\b|(in|when)\s)/, + increaseIndentPattern: /^\s*((begin|class|(private|protected)\s+def|def|else|elsif|ensure|for|if|module|rescue|unless|until|when|in|while|case)|([^#]*\sdo\b)|([^#]*=\s*(case|if|unless)))\b([^#\{;]|(\"|'|\/).*\4)*(#.*)?$/, +}; + +export const phpIndentationRules = { + increaseIndentPattern: /({(?!.*}).*|\(|\[|((else(\s)?)?if|else|for(each)?|while|switch|case).*:)\s*((\/[/*].*|)?$|\?>)/, + decreaseIndentPattern: /^(.*\*\/)?\s*((\})|(\)+[;,])|(\]\)*[;,])|\b(else:)|\b((end(if|for(each)?|while|switch));))/, +}; + +export const goIndentationRules = { + decreaseIndentPattern: /^\s*(\bcase\b.*:|\bdefault\b:|}[)}]*[),]?|\)[,]?)$/, + increaseIndentPattern: /^.*(\bcase\b.*:|\bdefault\b:|(\b(func|if|else|switch|select|for|struct)\b.*)?{[^}"'`]*|\([^)"'`]*)$/, +}; + +export const htmlIndentationRules = { + decreaseIndentPattern: /^\s*(<\/(?!html)[-_\.A-Za-z0-9]+\b[^>]*>|-->|\})/, + increaseIndentPattern: /<(?!\?|(?:area|base|br|col|frame|hr|html|img|input|keygen|link|menuitem|meta|param|source|track|wbr)\b|[^>]*\/>)([-_\.A-Za-z0-9]+)(?=\s|>)\b[^>]*>(?!.*<\/\1>)|)|\{[^}"']*$/, +}; + +export const latexIndentationRules = { + decreaseIndentPattern: /^\s*\\end{(?!document)/, + increaseIndentPattern: /\\begin{(?!document)([^}]*)}(?!.*\\end{\1})/, +}; + +export const luaIndentationRules = { + decreaseIndentPattern: /^\s*((\b(elseif|else|end|until)\b)|(\})|(\)))/, + increaseIndentPattern: /^((?!(\-\-)).)*((\b(else|function|then|do|repeat)\b((?!\b(end|until)\b).)*)|(\{\s*))$/, +}; diff --git a/src/vs/editor/test/common/modes/supports/javascriptOnEnterRules.ts b/src/vs/editor/test/common/modes/supports/javascriptOnEnterRules.ts deleted file mode 100644 index 5c8f580f22866..0000000000000 --- a/src/vs/editor/test/common/modes/supports/javascriptOnEnterRules.ts +++ /dev/null @@ -1,33 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { IndentAction } from 'vs/editor/common/languages/languageConfiguration'; - -export const javascriptOnEnterRules = [ - { - // e.g. /** | */ - beforeText: /^\s*\/\*\*(?!\/)([^\*]|\*(?!\/))*$/, - afterText: /^\s*\*\/$/, - action: { indentAction: IndentAction.IndentOutdent, appendText: ' * ' } - }, { - // e.g. /** ...| - beforeText: /^\s*\/\*\*(?!\/)([^\*]|\*(?!\/))*$/, - action: { indentAction: IndentAction.None, appendText: ' * ' } - }, { - // e.g. * ...| - beforeText: /^(\t|[ ])*[ ]\*([ ]([^\*]|\*(?!\/))*)?$/, - previousLineText: /(?=^(\s*(\/\*\*|\*)).*)(?=(?!(\s*\*\/)))/, - action: { indentAction: IndentAction.None, appendText: '* ' } - }, { - // e.g. */| - beforeText: /^(\t|[ ])*[ ]\*\/\s*$/, - action: { indentAction: IndentAction.None, removeText: 1 } - }, - { - // e.g. *-----*/| - beforeText: /^(\t|[ ])*[ ]\*[^/]*\*\/\s*$/, - action: { indentAction: IndentAction.None, removeText: 1 } - } -]; diff --git a/src/vs/editor/test/common/modes/supports/onEnter.test.ts b/src/vs/editor/test/common/modes/supports/onEnter.test.ts index 44b1af8d341ad..1daa14e160745 100644 --- a/src/vs/editor/test/common/modes/supports/onEnter.test.ts +++ b/src/vs/editor/test/common/modes/supports/onEnter.test.ts @@ -5,7 +5,7 @@ import * as assert from 'assert'; import { CharacterPair, IndentAction } from 'vs/editor/common/languages/languageConfiguration'; import { OnEnterSupport } from 'vs/editor/common/languages/supports/onEnter'; -import { javascriptOnEnterRules } from 'vs/editor/test/common/modes/supports/javascriptOnEnterRules'; +import { javascriptOnEnterRules } from 'vs/editor/test/common/modes/supports/onEnterRules'; import { EditorAutoIndentStrategy } from 'vs/editor/common/config/editorOptions'; import { ensureNoDisposablesAreLeakedInTestSuite } from 'vs/base/test/common/utils'; diff --git a/src/vs/editor/test/common/modes/supports/onEnterRules.ts b/src/vs/editor/test/common/modes/supports/onEnterRules.ts new file mode 100644 index 0000000000000..94869ad640f8e --- /dev/null +++ b/src/vs/editor/test/common/modes/supports/onEnterRules.ts @@ -0,0 +1,142 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IndentAction } from 'vs/editor/common/languages/languageConfiguration'; + +export const javascriptOnEnterRules = [ + { + // e.g. /** | */ + beforeText: /^\s*\/\*\*(?!\/)([^\*]|\*(?!\/))*$/, + afterText: /^\s*\*\/$/, + action: { indentAction: IndentAction.IndentOutdent, appendText: ' * ' } + }, { + // e.g. /** ...| + beforeText: /^\s*\/\*\*(?!\/)([^\*]|\*(?!\/))*$/, + action: { indentAction: IndentAction.None, appendText: ' * ' } + }, { + // e.g. * ...| + beforeText: /^(\t|[ ])*[ ]\*([ ]([^\*]|\*(?!\/))*)?$/, + previousLineText: /(?=^(\s*(\/\*\*|\*)).*)(?=(?!(\s*\*\/)))/, + action: { indentAction: IndentAction.None, appendText: '* ' } + }, { + // e.g. */| + beforeText: /^(\t|[ ])*[ ]\*\/\s*$/, + action: { indentAction: IndentAction.None, removeText: 1 } + }, + { + // e.g. *-----*/| + beforeText: /^(\t|[ ])*[ ]\*[^/]*\*\/\s*$/, + action: { indentAction: IndentAction.None, removeText: 1 } + }, + { + beforeText: /^\s*(\bcase\s.+:|\bdefault:)$/, + afterText: /^(?!\s*(\bcase\b|\bdefault\b))/, + action: { indentAction: IndentAction.Indent } + }, + { + previousLineText: /^\s*(((else ?)?if|for|while)\s*\(.*\)\s*|else\s*)$/, + beforeText: /^\s+([^{i\s]|i(?!f\b))/, + action: { indentAction: IndentAction.Outdent } + }, + // Indent when pressing enter from inside () + { + beforeText: /^.*\([^\)]*$/, + afterText: /^\s*\).*$/, + action: { indentAction: IndentAction.IndentOutdent, appendText: '\t' } + }, + // Indent when pressing enter from inside {} + { + beforeText: /^.*\{[^\}]*$/, + afterText: /^\s*\}.*$/, + action: { indentAction: IndentAction.IndentOutdent, appendText: '\t' } + }, + // Indent when pressing enter from inside [] + { + beforeText: /^.*\[[^\]]*$/, + afterText: /^\s*\].*$/, + action: { indentAction: IndentAction.IndentOutdent, appendText: '\t' } + }, +]; + +export const phpOnEnterRules = [ + { + beforeText: /^\s*\/\*\*(?!\/)([^\*]|\*(?!\/))*$/, + afterText: /^\s*\*\/$/, + action: { + indentAction: IndentAction.IndentOutdent, + appendText: ' * ', + } + }, + { + beforeText: /^\s*\/\*\*(?!\/)([^\*]|\*(?!\/))*$/, + action: { + indentAction: IndentAction.None, + appendText: ' * ', + } + }, + { + beforeText: /^(\t|(\ \ ))*\ \*(\ ([^\*]|\*(?!\/))*)?$/, + action: { + indentAction: IndentAction.None, + appendText: '* ', + } + }, + { + beforeText: /^(\t|(\ \ ))*\ \*\/\s*$/, + action: { + indentAction: IndentAction.None, + removeText: 1, + } + }, + { + beforeText: /^(\t|(\ \ ))*\ \*[^/]*\*\/\s*$/, + action: { + indentAction: IndentAction.None, + removeText: 1, + } + }, + { + beforeText: /^\s+([^{i\s]|i(?!f\b))/, + previousLineText: /^\s*(((else ?)?if|for(each)?|while)\s*\(.*\)\s*|else\s*)$/, + action: { + indentAction: IndentAction.Outdent + } + }, +]; + +export const cppOnEnterRules = [ + { + previousLineText: /^\s*(((else ?)?if|for|while)\s*\(.*\)\s*|else\s*)$/, + beforeText: /^\s+([^{i\s]|i(?!f\b))/, + action: { + indentAction: IndentAction.Outdent + } + } +]; + +export const htmlOnEnterRules = [ + { + beforeText: /<(?!(?:area|base|br|col|embed|hr|img|input|keygen|link|menuitem|meta|param|source|track|wbr))([_:\w][_:\w-.\d]*)(?:(?:[^'"/>]|"[^"]*"|'[^']*')*?(?!\/)>)[^<]*$/i, + afterText: /^<\/([_:\w][_:\w-.\d]*)\s*>/i, + action: { + indentAction: IndentAction.IndentOutdent + } + }, + { + beforeText: /<(?!(?:area|base|br|col|embed|hr|img|input|keygen|link|menuitem|meta|param|source|track|wbr))([_:\w][_:\w-.\d]*)(?:(?:[^'"/>]|"[^"]*"|'[^']*')*?(?!\/)>)[^<]*$/i, + action: { + indentAction: IndentAction.Indent + } + } +]; + +/* +export enum IndentAction { + None = 0, + Indent = 1, + IndentOutdent = 2, + Outdent = 3 +} +*/ diff --git a/src/vs/editor/test/common/services/testEditorWorkerService.ts b/src/vs/editor/test/common/services/testEditorWorkerService.ts index e6693d821e99d..e7d5154f9f68e 100644 --- a/src/vs/editor/test/common/services/testEditorWorkerService.ts +++ b/src/vs/editor/test/common/services/testEditorWorkerService.ts @@ -9,6 +9,7 @@ import { DiffAlgorithmName, IEditorWorkerService, IUnicodeHighlightsResult } fro import { TextEdit, IInplaceReplaceSupportResult } from 'vs/editor/common/languages'; import { IDocumentDiff, IDocumentDiffProviderOptions } from 'vs/editor/common/diff/documentDiffProvider'; import { IChange } from 'vs/editor/common/diff/legacyLinesDiffComputer'; +import { SectionHeader } from 'vs/editor/common/services/findSectionHeaders'; export class TestEditorWorkerService implements IEditorWorkerService { @@ -25,4 +26,5 @@ export class TestEditorWorkerService implements IEditorWorkerService { async computeWordRanges(resource: URI, range: IRange): Promise<{ [word: string]: IRange[] } | null> { return null; } canNavigateValueSet(resource: URI): boolean { return false; } async navigateValueSet(resource: URI, range: IRange, up: boolean): Promise { return null; } + async findSectionHeaders(uri: URI): Promise { return []; } } diff --git a/src/vs/editor/test/node/diffing/defaultLinesDiffComputer.test.ts b/src/vs/editor/test/node/diffing/defaultLinesDiffComputer.test.ts index 664ef33cf4f37..995472ca78f5b 100644 --- a/src/vs/editor/test/node/diffing/defaultLinesDiffComputer.test.ts +++ b/src/vs/editor/test/node/diffing/defaultLinesDiffComputer.test.ts @@ -11,8 +11,11 @@ import { getLineRangeMapping } from 'vs/editor/common/diff/defaultLinesDiffCompu import { LinesSliceCharSequence } from 'vs/editor/common/diff/defaultLinesDiffComputer/linesSliceCharSequence'; import { MyersDiffAlgorithm } from 'vs/editor/common/diff/defaultLinesDiffComputer/algorithms/myersDiffAlgorithm'; import { DynamicProgrammingDiffing } from 'vs/editor/common/diff/defaultLinesDiffComputer/algorithms/dynamicProgrammingDiffing'; +import { ensureNoDisposablesAreLeakedInTestSuite } from 'vs/base/test/common/utils'; suite('myers', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + test('1', () => { const s1 = new LinesSliceCharSequence(['hello world'], new OffsetRange(0, 1), true); const s2 = new LinesSliceCharSequence(['hallo welt'], new OffsetRange(0, 1), true); @@ -23,6 +26,8 @@ suite('myers', () => { }); suite('lineRangeMapping', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + test('Simple', () => { assert.deepStrictEqual( getLineRangeMapping( @@ -68,6 +73,8 @@ suite('lineRangeMapping', () => { }); suite('LinesSliceCharSequence', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + const sequence = new LinesSliceCharSequence( [ 'line1: foo', diff --git a/src/vs/editor/test/node/diffing/fixtures.test.ts b/src/vs/editor/test/node/diffing/fixtures.test.ts index e200d808c222a..e944d133befbf 100644 --- a/src/vs/editor/test/node/diffing/fixtures.test.ts +++ b/src/vs/editor/test/node/diffing/fixtures.test.ts @@ -12,8 +12,11 @@ import { DetailedLineRangeMapping } from 'vs/editor/common/diff/rangeMapping'; import { LegacyLinesDiffComputer } from 'vs/editor/common/diff/legacyLinesDiffComputer'; import { DefaultLinesDiffComputer } from 'vs/editor/common/diff/defaultLinesDiffComputer/defaultLinesDiffComputer'; import { Range } from 'vs/editor/common/core/range'; +import { ensureNoDisposablesAreLeakedInTestSuite } from 'vs/base/test/common/utils'; suite('diffing fixtures', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + setup(() => { setUnexpectedErrorHandler(e => { throw e; diff --git a/src/vs/editor/test/node/diffing/fixtures/issue-204948/1.txt b/src/vs/editor/test/node/diffing/fixtures/issue-204948/1.txt new file mode 100644 index 0000000000000..42f5b92add2d0 --- /dev/null +++ b/src/vs/editor/test/node/diffing/fixtures/issue-204948/1.txt @@ -0,0 +1,9 @@ + "@babel/types" "^7.22.15" + +"@babel/traverse@^7.23.9": + version "7.23.9" + resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.23.7.tgz#9a7bf285c928cb99b5ead19c3b1ce5b310c9c305" + integrity sha512-tY3mM8rH9jM0YHFGyfC0/xf+SB5eKUu7HPj7/k3fpi9dAlsMc5YbQvDi0Sh2QTPXqMhyaAtzAr807TIyfQrmyg== + dependencies: + "@babel/code-frame" "^7.23.5" + "@babel/generator" "^7.23.6" \ No newline at end of file diff --git a/src/vs/editor/test/node/diffing/fixtures/issue-204948/2.txt b/src/vs/editor/test/node/diffing/fixtures/issue-204948/2.txt new file mode 100644 index 0000000000000..2b5867f0165f6 --- /dev/null +++ b/src/vs/editor/test/node/diffing/fixtures/issue-204948/2.txt @@ -0,0 +1,9 @@ + "@babel/types" "^7.22.15" + +"@babel/traverse@^7.23.9": + version "7.23.9" + resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.23.7.tgz#9a7bf285c928cb99b5ead19c3b1ce5b310c9c305" + integrity sha512-I/4UJ9vs90OkBtY6iiiTORVMyIhJ4kAVmsKo9KFc8UOxMeUfi2hvtIBsET5u9GizXE6/GFSuKCTNfgCswuEjRg== + dependencies: + "@babel/code-frame" "^7.23.5" + "@babel/generator" "^7.23.6" \ No newline at end of file diff --git a/src/vs/editor/test/node/diffing/fixtures/issue-204948/advanced.expected.diff.json b/src/vs/editor/test/node/diffing/fixtures/issue-204948/advanced.expected.diff.json new file mode 100644 index 0000000000000..4dd454f4a4513 --- /dev/null +++ b/src/vs/editor/test/node/diffing/fixtures/issue-204948/advanced.expected.diff.json @@ -0,0 +1,22 @@ +{ + "original": { + "content": " \"@babel/types\" \"^7.22.15\"\n\n\"@babel/traverse@^7.23.9\":\n version \"7.23.9\"\n resolved \"https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.23.7.tgz#9a7bf285c928cb99b5ead19c3b1ce5b310c9c305\"\n integrity sha512-tY3mM8rH9jM0YHFGyfC0/xf+SB5eKUu7HPj7/k3fpi9dAlsMc5YbQvDi0Sh2QTPXqMhyaAtzAr807TIyfQrmyg==\n dependencies:\n \"@babel/code-frame\" \"^7.23.5\"\n \"@babel/generator\" \"^7.23.6\"", + "fileName": "./1.txt" + }, + "modified": { + "content": " \"@babel/types\" \"^7.22.15\"\n\n\"@babel/traverse@^7.23.9\":\n version \"7.23.9\"\n resolved \"https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.23.7.tgz#9a7bf285c928cb99b5ead19c3b1ce5b310c9c305\"\n integrity sha512-I/4UJ9vs90OkBtY6iiiTORVMyIhJ4kAVmsKo9KFc8UOxMeUfi2hvtIBsET5u9GizXE6/GFSuKCTNfgCswuEjRg==\n dependencies:\n \"@babel/code-frame\" \"^7.23.5\"\n \"@babel/generator\" \"^7.23.6\"", + "fileName": "./2.txt" + }, + "diffs": [ + { + "originalRange": "[6,7)", + "modifiedRange": "[6,7)", + "innerChanges": [ + { + "originalRange": "[6,20 -> 7,1]", + "modifiedRange": "[6,20 -> 7,1]" + } + ] + } + ] +} \ No newline at end of file diff --git a/src/vs/editor/test/node/diffing/fixtures/issue-204948/legacy.expected.diff.json b/src/vs/editor/test/node/diffing/fixtures/issue-204948/legacy.expected.diff.json new file mode 100644 index 0000000000000..97e5b8d4e12cc --- /dev/null +++ b/src/vs/editor/test/node/diffing/fixtures/issue-204948/legacy.expected.diff.json @@ -0,0 +1,22 @@ +{ + "original": { + "content": " \"@babel/types\" \"^7.22.15\"\n\n\"@babel/traverse@^7.23.9\":\n version \"7.23.9\"\n resolved \"https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.23.7.tgz#9a7bf285c928cb99b5ead19c3b1ce5b310c9c305\"\n integrity sha512-tY3mM8rH9jM0YHFGyfC0/xf+SB5eKUu7HPj7/k3fpi9dAlsMc5YbQvDi0Sh2QTPXqMhyaAtzAr807TIyfQrmyg==\n dependencies:\n \"@babel/code-frame\" \"^7.23.5\"\n \"@babel/generator\" \"^7.23.6\"", + "fileName": "./1.txt" + }, + "modified": { + "content": " \"@babel/types\" \"^7.22.15\"\n\n\"@babel/traverse@^7.23.9\":\n version \"7.23.9\"\n resolved \"https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.23.7.tgz#9a7bf285c928cb99b5ead19c3b1ce5b310c9c305\"\n integrity sha512-I/4UJ9vs90OkBtY6iiiTORVMyIhJ4kAVmsKo9KFc8UOxMeUfi2hvtIBsET5u9GizXE6/GFSuKCTNfgCswuEjRg==\n dependencies:\n \"@babel/code-frame\" \"^7.23.5\"\n \"@babel/generator\" \"^7.23.6\"", + "fileName": "./2.txt" + }, + "diffs": [ + { + "originalRange": "[6,7)", + "modifiedRange": "[6,7)", + "innerChanges": [ + { + "originalRange": "[6,20 -> 6,105]", + "modifiedRange": "[6,20 -> 6,105]" + } + ] + } + ] +} \ No newline at end of file diff --git a/src/vs/monaco.d.ts b/src/vs/monaco.d.ts index ed1b548900fe5..ae56ff86935a1 100644 --- a/src/vs/monaco.d.ts +++ b/src/vs/monaco.d.ts @@ -1251,7 +1251,7 @@ declare namespace monaco.editor { */ label: string; /** - * Precondition rule. + * Precondition rule. The value should be a [context key expression](https://code.visualstudio.com/docs/getstarted/keybindings#_when-clause-contexts). */ precondition?: string; /** @@ -1613,6 +1613,14 @@ declare namespace monaco.editor { Gutter = 2 } + /** + * Section header style. + */ + export enum MinimapSectionHeaderStyle { + Normal = 1, + Underlined = 2 + } + export interface IDecorationOptions { /** * CSS color to render. @@ -1656,6 +1664,14 @@ declare namespace monaco.editor { * The position in the minimap. */ position: MinimapPosition; + /** + * If the decoration is for a section header, which header style. + */ + sectionHeaderStyle?: MinimapSectionHeaderStyle | null; + /** + * If the decoration is for a section header, the header text. + */ + sectionHeaderText?: string | null; } /** @@ -3102,6 +3118,13 @@ declare namespace monaco.editor { * Defaults to empty array. */ rulers?: (number | IRulerOption)[]; + /** + * Locales used for segmenting lines into words when doing word related navigations or operations. + * + * Specify the BCP 47 language tag of the word you wish to recognize (e.g., ja, zh-CN, zh-Hant-TW, etc.). + * Defaults to empty array + */ + wordSegmenterLocales?: string | string[]; /** * A string containing the word separators used when doing word navigation. * Defaults to `~!@#$%^&*()-=+[{]}\\|;:\'",.<>/? @@ -3455,6 +3478,7 @@ declare namespace monaco.editor { */ suggest?: ISuggestOptions; inlineSuggest?: IInlineSuggestOptions; + experimentalInlineEdit?: IInlineEditOptions; /** * Smart select options. */ @@ -3817,6 +3841,10 @@ declare namespace monaco.editor { * Default to true. */ renderMarginRevertIcon?: boolean; + /** + * Indicates if the gutter menu should be rendered. + */ + renderGutterMenu?: boolean; /** * Original model should be editable? * Defaults to false. @@ -4278,6 +4306,18 @@ declare namespace monaco.editor { * Relative size of the font in the minimap. Defaults to 1. */ scale?: number; + /** + * Whether to show named regions as section headers. Defaults to true. + */ + showRegionSectionHeaders?: boolean; + /** + * Whether to show MARK: comments as section headers. Defaults to true. + */ + showMarkSectionHeaders?: boolean; + /** + * Font size of section headers. Defaults to 9. + */ + sectionHeaderFontSize?: number; } /** @@ -4508,6 +4548,23 @@ declare namespace monaco.editor { fontFamily?: string | 'default'; } + export interface IInlineEditOptions { + /** + * Enable or disable the rendering of automatic inline edit. + */ + enabled?: boolean; + showToolbar?: 'always' | 'onHover' | 'never'; + /** + * Font family for inline suggestions. + */ + fontFamily?: string | 'default'; + /** + * Does not clear active inline suggestions when the editor loses focus. + */ + keepOnBlur?: boolean; + backgroundColoring?: boolean; + } + export interface IBracketPairColorizationOptions { /** * Enable or disable bracket pair colorization. @@ -4843,91 +4900,93 @@ declare namespace monaco.editor { hover = 60, inDiffEditor = 61, inlineSuggest = 62, - letterSpacing = 63, - lightbulb = 64, - lineDecorationsWidth = 65, - lineHeight = 66, - lineNumbers = 67, - lineNumbersMinChars = 68, - linkedEditing = 69, - links = 70, - matchBrackets = 71, - minimap = 72, - mouseStyle = 73, - mouseWheelScrollSensitivity = 74, - mouseWheelZoom = 75, - multiCursorMergeOverlapping = 76, - multiCursorModifier = 77, - multiCursorPaste = 78, - multiCursorLimit = 79, - occurrencesHighlight = 80, - overviewRulerBorder = 81, - overviewRulerLanes = 82, - padding = 83, - pasteAs = 84, - parameterHints = 85, - peekWidgetDefaultFocus = 86, - definitionLinkOpensInPeek = 87, - quickSuggestions = 88, - quickSuggestionsDelay = 89, - readOnly = 90, - readOnlyMessage = 91, - renameOnType = 92, - renderControlCharacters = 93, - renderFinalNewline = 94, - renderLineHighlight = 95, - renderLineHighlightOnlyWhenFocus = 96, - renderValidationDecorations = 97, - renderWhitespace = 98, - revealHorizontalRightPadding = 99, - roundedSelection = 100, - rulers = 101, - scrollbar = 102, - scrollBeyondLastColumn = 103, - scrollBeyondLastLine = 104, - scrollPredominantAxis = 105, - selectionClipboard = 106, - selectionHighlight = 107, - selectOnLineNumbers = 108, - showFoldingControls = 109, - showUnused = 110, - snippetSuggestions = 111, - smartSelect = 112, - smoothScrolling = 113, - stickyScroll = 114, - stickyTabStops = 115, - stopRenderingLineAfter = 116, - suggest = 117, - suggestFontSize = 118, - suggestLineHeight = 119, - suggestOnTriggerCharacters = 120, - suggestSelection = 121, - tabCompletion = 122, - tabIndex = 123, - unicodeHighlighting = 124, - unusualLineTerminators = 125, - useShadowDOM = 126, - useTabStops = 127, - wordBreak = 128, - wordSeparators = 129, - wordWrap = 130, - wordWrapBreakAfterCharacters = 131, - wordWrapBreakBeforeCharacters = 132, - wordWrapColumn = 133, - wordWrapOverride1 = 134, - wordWrapOverride2 = 135, - wrappingIndent = 136, - wrappingStrategy = 137, - showDeprecated = 138, - inlayHints = 139, - editorClassName = 140, - pixelRatio = 141, - tabFocusMode = 142, - layoutInfo = 143, - wrappingInfo = 144, - defaultColorDecorators = 145, - colorDecoratorsActivatedOn = 146, - inlineCompletionsAccessibilityVerbose = 147 + inlineEdit = 63, + letterSpacing = 64, + lightbulb = 65, + lineDecorationsWidth = 66, + lineHeight = 67, + lineNumbers = 68, + lineNumbersMinChars = 69, + linkedEditing = 70, + links = 71, + matchBrackets = 72, + minimap = 73, + mouseStyle = 74, + mouseWheelScrollSensitivity = 75, + mouseWheelZoom = 76, + multiCursorMergeOverlapping = 77, + multiCursorModifier = 78, + multiCursorPaste = 79, + multiCursorLimit = 80, + occurrencesHighlight = 81, + overviewRulerBorder = 82, + overviewRulerLanes = 83, + padding = 84, + pasteAs = 85, + parameterHints = 86, + peekWidgetDefaultFocus = 87, + definitionLinkOpensInPeek = 88, + quickSuggestions = 89, + quickSuggestionsDelay = 90, + readOnly = 91, + readOnlyMessage = 92, + renameOnType = 93, + renderControlCharacters = 94, + renderFinalNewline = 95, + renderLineHighlight = 96, + renderLineHighlightOnlyWhenFocus = 97, + renderValidationDecorations = 98, + renderWhitespace = 99, + revealHorizontalRightPadding = 100, + roundedSelection = 101, + rulers = 102, + scrollbar = 103, + scrollBeyondLastColumn = 104, + scrollBeyondLastLine = 105, + scrollPredominantAxis = 106, + selectionClipboard = 107, + selectionHighlight = 108, + selectOnLineNumbers = 109, + showFoldingControls = 110, + showUnused = 111, + snippetSuggestions = 112, + smartSelect = 113, + smoothScrolling = 114, + stickyScroll = 115, + stickyTabStops = 116, + stopRenderingLineAfter = 117, + suggest = 118, + suggestFontSize = 119, + suggestLineHeight = 120, + suggestOnTriggerCharacters = 121, + suggestSelection = 122, + tabCompletion = 123, + tabIndex = 124, + unicodeHighlighting = 125, + unusualLineTerminators = 126, + useShadowDOM = 127, + useTabStops = 128, + wordBreak = 129, + wordSegmenterLocales = 130, + wordSeparators = 131, + wordWrap = 132, + wordWrapBreakAfterCharacters = 133, + wordWrapBreakBeforeCharacters = 134, + wordWrapColumn = 135, + wordWrapOverride1 = 136, + wordWrapOverride2 = 137, + wrappingIndent = 138, + wrappingStrategy = 139, + showDeprecated = 140, + inlayHints = 141, + editorClassName = 142, + pixelRatio = 143, + tabFocusMode = 144, + layoutInfo = 145, + wrappingInfo = 146, + defaultColorDecorators = 147, + colorDecoratorsActivatedOn = 148, + inlineCompletionsAccessibilityVerbose = 149 } export const EditorOptions: { @@ -5052,6 +5111,7 @@ declare namespace monaco.editor { stopRenderingLineAfter: IEditorOption; suggest: IEditorOption>>; inlineSuggest: IEditorOption>>; + inlineEdit: IEditorOption>>; inlineCompletionsAccessibilityVerbose: IEditorOption; suggestFontSize: IEditorOption; suggestLineHeight: IEditorOption; @@ -5064,6 +5124,7 @@ declare namespace monaco.editor { useShadowDOM: IEditorOption; useTabStops: IEditorOption; wordBreak: IEditorOption; + wordSegmenterLocales: IEditorOption; wordSeparators: IEditorOption; wordWrap: IEditorOption; wordWrapBreakAfterCharacters: IEditorOption; @@ -5591,6 +5652,7 @@ declare namespace monaco.editor { export interface IPasteEvent { readonly range: Range; readonly languageId: string | null; + readonly clipboardEvent?: ClipboardEvent; } export interface IDiffEditorConstructionOptions extends IDiffEditorOptions, IEditorConstructionOptions { @@ -6331,6 +6393,11 @@ declare namespace monaco.languages { */ export function registerRenameProvider(languageSelector: LanguageSelector, provider: RenameProvider): IDisposable; + /** + * Register a new symbol-name provider (e.g., when a symbol is being renamed, show new possible symbol-names) + */ + export function registerNewSymbolNameProvider(languageSelector: LanguageSelector, provider: NewSymbolNamesProvider): IDisposable; + /** * Register a signature help provider (used by e.g. parameter hints). */ @@ -6449,6 +6516,8 @@ declare namespace monaco.languages { */ export function registerInlineCompletionsProvider(languageSelector: LanguageSelector, provider: InlineCompletionsProvider): IDisposable; + export function registerInlineEditProvider(languageSelector: LanguageSelector, provider: InlineEditProvider): IDisposable; + /** * Register an inlay hints provider. */ @@ -6929,6 +6998,22 @@ declare namespace monaco.languages { dispose?(): void; } + /** + * Info provided on partial acceptance. + */ + export interface PartialAcceptInfo { + kind: PartialAcceptTriggerKind; + } + + /** + * How a partial acceptance was triggered. + */ + export enum PartialAcceptTriggerKind { + Word = 0, + Line = 1, + Suggest = 2 + } + /** * How a suggest provider was triggered. */ @@ -7075,7 +7160,7 @@ declare namespace monaco.languages { /** * Will be called when an item is partially accepted. */ - handlePartialAccept?(completions: T, item: T['items'][number], acceptedCharacters: number): void; + handlePartialAccept?(completions: T, item: T['items'][number], acceptedCharacters: number, info: PartialAcceptInfo): void; /** * Will be called when a completions list is no longer in use and can be garbage-collected. */ @@ -7766,6 +7851,19 @@ declare namespace monaco.languages { resolveRenameLocation?(model: editor.ITextModel, position: Position, token: CancellationToken): ProviderResult; } + export enum NewSymbolNameTag { + AIGenerated = 1 + } + + export interface NewSymbolName { + readonly newSymbolName: string; + readonly tags?: readonly NewSymbolNameTag[]; + } + + export interface NewSymbolNamesProvider { + provideNewSymbolNames(model: editor.ITextModel, range: IRange, token: CancellationToken): ProviderResult; + } + export interface Command { id: string; title: string; @@ -7782,7 +7880,7 @@ declare namespace monaco.languages { body: string; range: IRange | undefined; uri: Uri; - owner: string; + uniqueOwner: string; isReply: boolean; } @@ -7895,6 +7993,27 @@ declare namespace monaco.languages { provideMappedEdits(document: editor.ITextModel, codeBlocks: string[], context: MappedEditsContext, token: CancellationToken): Promise; } + export interface IInlineEdit { + text: string; + range: IRange; + accepted?: Command; + rejected?: Command; + } + + export interface IInlineEditContext { + triggerKind: InlineEditTriggerKind; + } + + export enum InlineEditTriggerKind { + Invoke = 0, + Automatic = 1 + } + + export interface InlineEditProvider { + provideInlineEdit(model: editor.ITextModel, context: IInlineEditContext, token: CancellationToken): ProviderResult; + freeInlineEdit(edit: T): void; + } + export interface ILanguageExtensionPoint { id: string; extensions?: string[]; diff --git a/src/vs/nls.mock.ts b/src/vs/nls.mock.ts index d9ee1ecd2c6da..5323c6c6340d8 100644 --- a/src/vs/nls.mock.ts +++ b/src/vs/nls.mock.ts @@ -8,7 +8,7 @@ export interface ILocalizeInfo { comment: string[]; } -interface ILocalizedString { +export interface ILocalizedString { original: string; value: string; } diff --git a/src/vs/platform/accessibilitySignal/browser/accessibilitySignalService.ts b/src/vs/platform/accessibilitySignal/browser/accessibilitySignalService.ts new file mode 100644 index 0000000000000..de4044b74f4c6 --- /dev/null +++ b/src/vs/platform/accessibilitySignal/browser/accessibilitySignalService.ts @@ -0,0 +1,614 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable, IDisposable, toDisposable } from 'vs/base/common/lifecycle'; +import { FileAccess } from 'vs/base/common/network'; +import { IAccessibilityService } from 'vs/platform/accessibility/common/accessibility'; +import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; +import { createDecorator } from 'vs/platform/instantiation/common/instantiation'; +import { Event } from 'vs/base/common/event'; +import { localize } from 'vs/nls'; +import { observableFromEvent, derived } from 'vs/base/common/observable'; +import { ITelemetryService } from 'vs/platform/telemetry/common/telemetry'; + +export const IAccessibilitySignalService = createDecorator('accessibilitySignalService'); + +export interface IAccessibilitySignalService { + readonly _serviceBrand: undefined; + playSignal(signal: AccessibilitySignal, options?: IAccessbilitySignalOptions): Promise; + playSignals(signals: (AccessibilitySignal | { signal: AccessibilitySignal; source: string })[]): Promise; + isSoundEnabled(signal: AccessibilitySignal): boolean; + isAnnouncementEnabled(signal: AccessibilitySignal): boolean; + onSoundEnabledChanged(signal: AccessibilitySignal): Event; + onAnnouncementEnabledChanged(signal: AccessibilitySignal): Event; + + playSound(signal: Sound, allowManyInParallel?: boolean): Promise; + playSignalLoop(signal: AccessibilitySignal, milliseconds: number): IDisposable; +} + +export interface IAccessbilitySignalOptions { + allowManyInParallel?: boolean; + + /** + * The source that triggered the signal (e.g. "diffEditor.cursorPositionChanged"). + */ + source?: string; + + /** + * For actions like save or format, depending on the + * configured value, we will only + * play the sound if the user triggered the action. + */ + userGesture?: boolean; +} + +export class AccessibilitySignalService extends Disposable implements IAccessibilitySignalService { + readonly _serviceBrand: undefined; + private readonly sounds: Map = new Map(); + private readonly screenReaderAttached = observableFromEvent( + this.accessibilityService.onDidChangeScreenReaderOptimized, + () => /** @description accessibilityService.onDidChangeScreenReaderOptimized */ this.accessibilityService.isScreenReaderOptimized() + ); + private readonly sentTelemetry = new Set(); + + constructor( + @IConfigurationService private readonly configurationService: IConfigurationService, + @IAccessibilityService private readonly accessibilityService: IAccessibilityService, + @ITelemetryService private readonly telemetryService: ITelemetryService, + ) { + super(); + } + + public async playSignal(signal: AccessibilitySignal, options: IAccessbilitySignalOptions = {}): Promise { + const announcementMessage = signal.announcementMessage; + if (this.isAnnouncementEnabled(signal, options.userGesture) && announcementMessage) { + this.accessibilityService.status(announcementMessage); + } + + if (this.isSoundEnabled(signal, options.userGesture)) { + this.sendSignalTelemetry(signal, options.source); + await this.playSound(signal.sound.getSound(), options.allowManyInParallel); + } + } + + public async playSignals(signals: (AccessibilitySignal | { signal: AccessibilitySignal; source: string })[]): Promise { + for (const signal of signals) { + this.sendSignalTelemetry('signal' in signal ? signal.signal : signal, 'source' in signal ? signal.source : undefined); + } + const signalArray = signals.map(s => 'signal' in s ? s.signal : s); + const announcements = signalArray.filter(signal => this.isAnnouncementEnabled(signal)).map(s => s.announcementMessage); + if (announcements.length) { + this.accessibilityService.status(announcements.join(', ')); + } + + // Some sounds are reused. Don't play the same sound twice. + const sounds = new Set(signalArray.filter(signal => this.isSoundEnabled(signal)).map(signal => signal.sound.getSound())); + await Promise.all(Array.from(sounds).map(sound => this.playSound(sound, true))); + + } + + + private sendSignalTelemetry(signal: AccessibilitySignal, source: string | undefined): void { + const isScreenReaderOptimized = this.accessibilityService.isScreenReaderOptimized(); + const key = signal.name + (source ? `::${source}` : '') + (isScreenReaderOptimized ? '{screenReaderOptimized}' : ''); + // Only send once per user session + if (this.sentTelemetry.has(key) || this.getVolumeInPercent() === 0) { + return; + } + this.sentTelemetry.add(key); + + this.telemetryService.publicLog2<{ + signal: string; + source: string; + isScreenReaderOptimized: boolean; + }, { + owner: 'hediet'; + + signal: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The signal that was played.' }; + source: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The source that triggered the signal (e.g. "diffEditorNavigation").' }; + isScreenReaderOptimized: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether the user is using a screen reader' }; + + comment: 'This data is collected to understand how signals are used and if more signals should be added.'; + }>('signal.played', { + signal: signal.name, + source: source ?? '', + isScreenReaderOptimized, + }); + } + + private getVolumeInPercent(): number { + const volume = this.configurationService.getValue('accessibilitySignals.volume'); + if (typeof volume !== 'number') { + return 50; + } + + return Math.max(Math.min(volume, 100), 0); + } + + private readonly playingSounds = new Set(); + + public async playSound(sound: Sound, allowManyInParallel = false): Promise { + if (!allowManyInParallel && this.playingSounds.has(sound)) { + return; + } + this.playingSounds.add(sound); + const url = FileAccess.asBrowserUri(`vs/platform/accessibilitySignal/browser/media/${sound.fileName}`).toString(true); + + try { + const sound = this.sounds.get(url); + if (sound) { + sound.volume = this.getVolumeInPercent() / 100; + sound.currentTime = 0; + await sound.play(); + } else { + const playedSound = await playAudio(url, this.getVolumeInPercent() / 100); + this.sounds.set(url, playedSound); + } + } catch (e) { + if (!e.message.includes('play() can only be initiated by a user gesture')) { + // tracking this issue in #178642, no need to spam the console + console.error('Error while playing sound', e); + } + } finally { + this.playingSounds.delete(sound); + } + } + + public playSignalLoop(signal: AccessibilitySignal, milliseconds: number): IDisposable { + let playing = true; + const playSound = () => { + if (playing) { + this.playSignal(signal, { allowManyInParallel: true }).finally(() => { + setTimeout(() => { + if (playing) { + playSound(); + } + }, milliseconds); + }); + } + }; + playSound(); + return toDisposable(() => playing = false); + } + + private readonly obsoleteAccessibilitySignalsEnabled = observableFromEvent( + Event.filter(this.configurationService.onDidChangeConfiguration, (e) => + e.affectsConfiguration('accessibilitySignals.enabled') + ), + () => /** @description config: accessibilitySignals.enabled */ this.configurationService.getValue<'on' | 'off' | 'auto' | 'userGesture' | 'always' | 'never'>('accessibilitySignals.enabled') + ); + + private readonly isSoundEnabledCache = new Cache((event: { readonly signal: AccessibilitySignal; readonly userGesture?: boolean }) => { + const settingObservable = observableFromEvent( + Event.filter(this.configurationService.onDidChangeConfiguration, (e) => + e.affectsConfiguration(event.signal.legacySoundSettingsKey) || e.affectsConfiguration(event.signal.settingsKey) + ), + () => this.configurationService.getValue<'on' | 'off' | 'auto' | 'userGesture' | 'always' | 'never'>(event.signal.settingsKey + '.sound') + ); + return derived(reader => { + /** @description sound enabled */ + const setting = settingObservable.read(reader); + if ( + setting === 'on' || + (setting === 'auto' && this.screenReaderAttached.read(reader)) + ) { + return true; + } else if (setting === 'always' || setting === 'userGesture' && event.userGesture) { + return true; + } + + const obsoleteSetting = this.obsoleteAccessibilitySignalsEnabled.read(reader); + if ( + obsoleteSetting === 'on' || + (obsoleteSetting === 'auto' && this.screenReaderAttached.read(reader)) + ) { + return true; + } + + return false; + }); + }, JSON.stringify); + + private readonly isAnnouncementEnabledCache = new Cache((event: { readonly signal: AccessibilitySignal; readonly userGesture?: boolean }) => { + const settingObservable = observableFromEvent( + Event.filter(this.configurationService.onDidChangeConfiguration, (e) => + e.affectsConfiguration(event.signal.legacyAnnouncementSettingsKey!) || e.affectsConfiguration(event.signal.settingsKey) + ), + () => event.signal.announcementMessage ? this.configurationService.getValue<'auto' | 'off' | 'userGesture' | 'always' | 'never'>(event.signal.settingsKey + '.announcement') : false + ); + return derived(reader => { + /** @description announcement enabled */ + const setting = settingObservable.read(reader); + if ( + !this.screenReaderAttached.read(reader) + ) { + return false; + } + return setting === 'auto' || setting === 'always' || setting === 'userGesture' && event.userGesture; + }); + }, JSON.stringify); + + public isAnnouncementEnabled(signal: AccessibilitySignal, userGesture?: boolean): boolean { + if (!signal.announcementMessage) { + return false; + } + return this.isAnnouncementEnabledCache.get({ signal, userGesture }).get() ?? false; + } + + public isSoundEnabled(signal: AccessibilitySignal, userGesture?: boolean): boolean { + return this.isSoundEnabledCache.get({ signal, userGesture }).get() ?? false; + } + + public onSoundEnabledChanged(signal: AccessibilitySignal): Event { + return Event.fromObservableLight(this.isSoundEnabledCache.get({ signal })); + } + + public onAnnouncementEnabledChanged(signal: AccessibilitySignal): Event { + return Event.fromObservableLight(this.isAnnouncementEnabledCache.get({ signal })); + } +} + + +/** + * Play the given audio url. + * @volume value between 0 and 1 + */ +function playAudio(url: string, volume: number): Promise { + return new Promise((resolve, reject) => { + const audio = new Audio(url); + audio.volume = volume; + audio.addEventListener('ended', () => { + resolve(audio); + }); + audio.addEventListener('error', (e) => { + // When the error event fires, ended might not be called + reject(e.error); + }); + audio.play().catch(e => { + // When play fails, the error event is not fired. + reject(e); + }); + }); +} + +class Cache { + private readonly map = new Map(); + constructor(private readonly getValue: (value: TArg) => TValue, private readonly getKey: (value: TArg) => unknown) { + } + + public get(arg: TArg): TValue { + if (this.map.has(arg)) { + return this.map.get(arg)!; + } + + const value = this.getValue(arg); + const key = this.getKey(arg); + this.map.set(key, value); + return value; + } +} + +/** + * Corresponds to the audio files in ./media. +*/ +export class Sound { + private static register(options: { fileName: string }): Sound { + const sound = new Sound(options.fileName); + return sound; + } + + public static readonly error = Sound.register({ fileName: 'error.mp3' }); + public static readonly warning = Sound.register({ fileName: 'warning.mp3' }); + public static readonly foldedArea = Sound.register({ fileName: 'foldedAreas.mp3' }); + public static readonly break = Sound.register({ fileName: 'break.mp3' }); + public static readonly quickFixes = Sound.register({ fileName: 'quickFixes.mp3' }); + public static readonly taskCompleted = Sound.register({ fileName: 'taskCompleted.mp3' }); + public static readonly taskFailed = Sound.register({ fileName: 'taskFailed.mp3' }); + public static readonly terminalBell = Sound.register({ fileName: 'terminalBell.mp3' }); + public static readonly diffLineInserted = Sound.register({ fileName: 'diffLineInserted.mp3' }); + public static readonly diffLineDeleted = Sound.register({ fileName: 'diffLineDeleted.mp3' }); + public static readonly diffLineModified = Sound.register({ fileName: 'diffLineModified.mp3' }); + public static readonly chatRequestSent = Sound.register({ fileName: 'chatRequestSent.mp3' }); + public static readonly chatResponsePending = Sound.register({ fileName: 'chatResponsePending.mp3' }); + public static readonly chatResponseReceived1 = Sound.register({ fileName: 'chatResponseReceived1.mp3' }); + public static readonly chatResponseReceived2 = Sound.register({ fileName: 'chatResponseReceived2.mp3' }); + public static readonly chatResponseReceived3 = Sound.register({ fileName: 'chatResponseReceived3.mp3' }); + public static readonly chatResponseReceived4 = Sound.register({ fileName: 'chatResponseReceived4.mp3' }); + public static readonly clear = Sound.register({ fileName: 'clear.mp3' }); + public static readonly save = Sound.register({ fileName: 'save.mp3' }); + public static readonly format = Sound.register({ fileName: 'format.mp3' }); + public static readonly voiceRecordingStarted = Sound.register({ fileName: 'voiceRecordingStarted.mp3' }); + public static readonly voiceRecordingStopped = Sound.register({ fileName: 'voiceRecordingStopped.mp3' }); + + private constructor(public readonly fileName: string) { } +} + +export class SoundSource { + constructor( + public readonly randomOneOf: Sound[] + ) { } + + public getSound(deterministic = false): Sound { + if (deterministic || this.randomOneOf.length === 1) { + return this.randomOneOf[0]; + } else { + const index = Math.floor(Math.random() * this.randomOneOf.length); + return this.randomOneOf[index]; + } + } +} + +export const enum AccessibilityAlertSettingId { + Save = 'accessibility.alert.save', + Format = 'accessibility.alert.format', + Clear = 'accessibility.alert.clear', + Breakpoint = 'accessibility.alert.breakpoint', + Error = 'accessibility.alert.error', + Warning = 'accessibility.alert.warning', + FoldedArea = 'accessibility.alert.foldedArea', + TerminalQuickFix = 'accessibility.alert.terminalQuickFix', + TerminalBell = 'accessibility.alert.terminalBell', + TerminalCommandFailed = 'accessibility.alert.terminalCommandFailed', + TaskCompleted = 'accessibility.alert.taskCompleted', + TaskFailed = 'accessibility.alert.taskFailed', + ChatRequestSent = 'accessibility.alert.chatRequestSent', + NotebookCellCompleted = 'accessibility.alert.notebookCellCompleted', + NotebookCellFailed = 'accessibility.alert.notebookCellFailed', + OnDebugBreak = 'accessibility.alert.onDebugBreak', + NoInlayHints = 'accessibility.alert.noInlayHints', + LineHasBreakpoint = 'accessibility.alert.lineHasBreakpoint', + ChatResponsePending = 'accessibility.alert.chatResponsePending' +} + + +export class AccessibilitySignal { + private static _signals = new Set(); + private static register(options: { + name: string; + sound: Sound | { + /** + * Gaming and other apps often play a sound variant when the same event happens again + * for an improved experience. This option enables playing a random sound. + */ + randomOneOf: Sound[]; + }; + legacySoundSettingsKey: string; + settingsKey: string; + legacyAnnouncementSettingsKey?: AccessibilityAlertSettingId; + announcementMessage?: string; + }): AccessibilitySignal { + const soundSource = new SoundSource('randomOneOf' in options.sound ? options.sound.randomOneOf : [options.sound]); + const signal = new AccessibilitySignal(soundSource, options.name, options.legacySoundSettingsKey, options.settingsKey, options.legacyAnnouncementSettingsKey, options.announcementMessage); + AccessibilitySignal._signals.add(signal); + return signal; + } + + public static get allAccessibilitySignals() { + return [...this._signals]; + } + + public static readonly error = AccessibilitySignal.register({ + name: localize('accessibilitySignals.lineHasError.name', 'Error on Line'), + sound: Sound.error, + legacySoundSettingsKey: 'audioCues.lineHasError', + legacyAnnouncementSettingsKey: AccessibilityAlertSettingId.Error, + announcementMessage: localize('accessibility.signals.lineHasError', 'Error'), + settingsKey: 'accessibility.signals.lineHasError' + }); + public static readonly warning = AccessibilitySignal.register({ + name: localize('accessibilitySignals.lineHasWarning.name', 'Warning on Line'), + sound: Sound.warning, + legacySoundSettingsKey: 'audioCues.lineHasWarning', + legacyAnnouncementSettingsKey: AccessibilityAlertSettingId.Warning, + announcementMessage: localize('accessibility.signals.lineHasWarning', 'Warning'), + settingsKey: 'accessibility.signals.lineHasWarning' + }); + public static readonly foldedArea = AccessibilitySignal.register({ + name: localize('accessibilitySignals.lineHasFoldedArea.name', 'Folded Area on Line'), + sound: Sound.foldedArea, + legacySoundSettingsKey: 'audioCues.lineHasFoldedArea', + legacyAnnouncementSettingsKey: AccessibilityAlertSettingId.FoldedArea, + announcementMessage: localize('accessibility.signals.lineHasFoldedArea', 'Folded'), + settingsKey: 'accessibility.signals.lineHasFoldedArea' + }); + public static readonly break = AccessibilitySignal.register({ + name: localize('accessibilitySignals.lineHasBreakpoint.name', 'Breakpoint on Line'), + sound: Sound.break, + legacySoundSettingsKey: 'audioCues.lineHasBreakpoint', + legacyAnnouncementSettingsKey: AccessibilityAlertSettingId.Breakpoint, + announcementMessage: localize('accessibility.signals.lineHasBreakpoint', 'Breakpoint'), + settingsKey: 'accessibility.signals.lineHasBreakpoint' + }); + public static readonly inlineSuggestion = AccessibilitySignal.register({ + name: localize('accessibilitySignals.lineHasInlineSuggestion.name', 'Inline Suggestion on Line'), + sound: Sound.quickFixes, + legacySoundSettingsKey: 'audioCues.lineHasInlineSuggestion', + settingsKey: 'accessibility.signals.lineHasInlineSuggestion' + }); + + public static readonly terminalQuickFix = AccessibilitySignal.register({ + name: localize('accessibilitySignals.terminalQuickFix.name', 'Terminal Quick Fix'), + sound: Sound.quickFixes, + legacySoundSettingsKey: 'audioCues.terminalQuickFix', + legacyAnnouncementSettingsKey: AccessibilityAlertSettingId.TerminalQuickFix, + announcementMessage: localize('accessibility.signals.terminalQuickFix', 'Quick Fix'), + settingsKey: 'accessibility.signals.terminalQuickFix' + }); + + public static readonly onDebugBreak = AccessibilitySignal.register({ + name: localize('accessibilitySignals.onDebugBreak.name', 'Debugger Stopped on Breakpoint'), + sound: Sound.break, + legacySoundSettingsKey: 'audioCues.onDebugBreak', + legacyAnnouncementSettingsKey: AccessibilityAlertSettingId.OnDebugBreak, + announcementMessage: localize('accessibility.signals.onDebugBreak', 'Breakpoint'), + settingsKey: 'accessibility.signals.onDebugBreak' + }); + + public static readonly noInlayHints = AccessibilitySignal.register({ + name: localize('accessibilitySignals.noInlayHints', 'No Inlay Hints on Line'), + sound: Sound.error, + legacySoundSettingsKey: 'audioCues.noInlayHints', + legacyAnnouncementSettingsKey: AccessibilityAlertSettingId.NoInlayHints, + announcementMessage: localize('accessibility.signals.noInlayHints', 'No Inlay Hints'), + settingsKey: 'accessibility.signals.noInlayHints' + }); + + public static readonly taskCompleted = AccessibilitySignal.register({ + name: localize('accessibilitySignals.taskCompleted', 'Task Completed'), + sound: Sound.taskCompleted, + legacySoundSettingsKey: 'audioCues.taskCompleted', + legacyAnnouncementSettingsKey: AccessibilityAlertSettingId.TaskCompleted, + announcementMessage: localize('accessibility.signals.taskCompleted', 'Task Completed'), + settingsKey: 'accessibility.signals.taskCompleted' + }); + + public static readonly taskFailed = AccessibilitySignal.register({ + name: localize('accessibilitySignals.taskFailed', 'Task Failed'), + sound: Sound.taskFailed, + legacySoundSettingsKey: 'audioCues.taskFailed', + legacyAnnouncementSettingsKey: AccessibilityAlertSettingId.TaskFailed, + announcementMessage: localize('accessibility.signals.taskFailed', 'Task Failed'), + settingsKey: 'accessibility.signals.taskFailed' + }); + + public static readonly terminalCommandFailed = AccessibilitySignal.register({ + name: localize('accessibilitySignals.terminalCommandFailed', 'Terminal Command Failed'), + sound: Sound.error, + legacySoundSettingsKey: 'audioCues.terminalCommandFailed', + legacyAnnouncementSettingsKey: AccessibilityAlertSettingId.TerminalCommandFailed, + announcementMessage: localize('accessibility.signals.terminalCommandFailed', 'Command Failed'), + settingsKey: 'accessibility.signals.terminalCommandFailed' + }); + + public static readonly terminalBell = AccessibilitySignal.register({ + name: localize('accessibilitySignals.terminalBell', 'Terminal Bell'), + sound: Sound.terminalBell, + legacySoundSettingsKey: 'audioCues.terminalBell', + legacyAnnouncementSettingsKey: AccessibilityAlertSettingId.TerminalBell, + announcementMessage: localize('accessibility.signals.terminalBell', 'Terminal Bell'), + settingsKey: 'accessibility.signals.terminalBell' + }); + + public static readonly notebookCellCompleted = AccessibilitySignal.register({ + name: localize('accessibilitySignals.notebookCellCompleted', 'Notebook Cell Completed'), + sound: Sound.taskCompleted, + legacySoundSettingsKey: 'audioCues.notebookCellCompleted', + legacyAnnouncementSettingsKey: AccessibilityAlertSettingId.NotebookCellCompleted, + announcementMessage: localize('accessibility.signals.notebookCellCompleted', 'Notebook Cell Completed'), + settingsKey: 'accessibility.signals.notebookCellCompleted' + }); + + public static readonly notebookCellFailed = AccessibilitySignal.register({ + name: localize('accessibilitySignals.notebookCellFailed', 'Notebook Cell Failed'), + sound: Sound.taskFailed, + legacySoundSettingsKey: 'audioCues.notebookCellFailed', + legacyAnnouncementSettingsKey: AccessibilityAlertSettingId.NotebookCellFailed, + announcementMessage: localize('accessibility.signals.notebookCellFailed', 'Notebook Cell Failed'), + settingsKey: 'accessibility.signals.notebookCellFailed' + }); + + public static readonly diffLineInserted = AccessibilitySignal.register({ + name: localize('accessibilitySignals.diffLineInserted', 'Diff Line Inserted'), + sound: Sound.diffLineInserted, + legacySoundSettingsKey: 'audioCues.diffLineInserted', + settingsKey: 'accessibility.signals.diffLineInserted' + }); + + public static readonly diffLineDeleted = AccessibilitySignal.register({ + name: localize('accessibilitySignals.diffLineDeleted', 'Diff Line Deleted'), + sound: Sound.diffLineDeleted, + legacySoundSettingsKey: 'audioCues.diffLineDeleted', + settingsKey: 'accessibility.signals.diffLineDeleted' + }); + + public static readonly diffLineModified = AccessibilitySignal.register({ + name: localize('accessibilitySignals.diffLineModified', 'Diff Line Modified'), + sound: Sound.diffLineModified, + legacySoundSettingsKey: 'audioCues.diffLineModified', + settingsKey: 'accessibility.signals.diffLineModified' + }); + + public static readonly chatRequestSent = AccessibilitySignal.register({ + name: localize('accessibilitySignals.chatRequestSent', 'Chat Request Sent'), + sound: Sound.chatRequestSent, + legacySoundSettingsKey: 'audioCues.chatRequestSent', + legacyAnnouncementSettingsKey: AccessibilityAlertSettingId.ChatRequestSent, + announcementMessage: localize('accessibility.signals.chatRequestSent', 'Chat Request Sent'), + settingsKey: 'accessibility.signals.chatRequestSent' + }); + + public static readonly chatResponseReceived = AccessibilitySignal.register({ + name: localize('accessibilitySignals.chatResponseReceived', 'Chat Response Received'), + legacySoundSettingsKey: 'audioCues.chatResponseReceived', + sound: { + randomOneOf: [ + Sound.chatResponseReceived1, + Sound.chatResponseReceived2, + Sound.chatResponseReceived3, + Sound.chatResponseReceived4 + ] + }, + settingsKey: 'accessibility.signals.chatResponseReceived' + }); + + public static readonly chatResponsePending = AccessibilitySignal.register({ + name: localize('accessibilitySignals.chatResponsePending', 'Chat Response Pending'), + sound: Sound.chatResponsePending, + legacySoundSettingsKey: 'audioCues.chatResponsePending', + legacyAnnouncementSettingsKey: AccessibilityAlertSettingId.ChatResponsePending, + announcementMessage: localize('accessibility.signals.chatResponsePending', 'Chat Response Pending'), + settingsKey: 'accessibility.signals.chatResponsePending' + }); + + public static readonly clear = AccessibilitySignal.register({ + name: localize('accessibilitySignals.clear', 'Clear'), + sound: Sound.clear, + legacySoundSettingsKey: 'audioCues.clear', + legacyAnnouncementSettingsKey: AccessibilityAlertSettingId.Clear, + announcementMessage: localize('accessibility.signals.clear', 'Clear'), + settingsKey: 'accessibility.signals.clear' + }); + + public static readonly save = AccessibilitySignal.register({ + name: localize('accessibilitySignals.save', 'Save'), + sound: Sound.save, + legacySoundSettingsKey: 'audioCues.save', + legacyAnnouncementSettingsKey: AccessibilityAlertSettingId.Save, + announcementMessage: localize('accessibility.signals.save', 'Save'), + settingsKey: 'accessibility.signals.save' + }); + + public static readonly format = AccessibilitySignal.register({ + name: localize('accessibilitySignals.format', 'Format'), + sound: Sound.format, + legacySoundSettingsKey: 'audioCues.format', + legacyAnnouncementSettingsKey: AccessibilityAlertSettingId.Format, + announcementMessage: localize('accessibility.signals.format', 'Format'), + settingsKey: 'accessibility.signals.format' + }); + + public static readonly voiceRecordingStarted = AccessibilitySignal.register({ + name: localize('accessibilitySignals.voiceRecordingStarted', 'Voice Recording Started'), + sound: Sound.voiceRecordingStarted, + legacySoundSettingsKey: 'audioCues.voiceRecordingStarted', + settingsKey: 'accessibility.signals.voiceRecordingStarted' + }); + + public static readonly voiceRecordingStopped = AccessibilitySignal.register({ + name: localize('accessibilitySignals.voiceRecordingStopped', 'Voice Recording Stopped'), + sound: Sound.voiceRecordingStopped, + legacySoundSettingsKey: 'audioCues.voiceRecordingStopped', + settingsKey: 'accessibility.signals.voiceRecordingStopped' + }); + + private constructor( + public readonly sound: SoundSource, + public readonly name: string, + public readonly legacySoundSettingsKey: string, + public readonly settingsKey: string, + public readonly legacyAnnouncementSettingsKey?: string, + public readonly announcementMessage?: string, + ) { } +} diff --git a/src/vs/platform/audioCues/browser/media/break.mp3 b/src/vs/platform/accessibilitySignal/browser/media/break.mp3 similarity index 100% rename from src/vs/platform/audioCues/browser/media/break.mp3 rename to src/vs/platform/accessibilitySignal/browser/media/break.mp3 diff --git a/src/vs/platform/audioCues/browser/media/chatRequestSent.mp3 b/src/vs/platform/accessibilitySignal/browser/media/chatRequestSent.mp3 similarity index 100% rename from src/vs/platform/audioCues/browser/media/chatRequestSent.mp3 rename to src/vs/platform/accessibilitySignal/browser/media/chatRequestSent.mp3 diff --git a/src/vs/platform/audioCues/browser/media/chatResponsePending.mp3 b/src/vs/platform/accessibilitySignal/browser/media/chatResponsePending.mp3 similarity index 100% rename from src/vs/platform/audioCues/browser/media/chatResponsePending.mp3 rename to src/vs/platform/accessibilitySignal/browser/media/chatResponsePending.mp3 diff --git a/src/vs/platform/audioCues/browser/media/chatResponseReceived1.mp3 b/src/vs/platform/accessibilitySignal/browser/media/chatResponseReceived1.mp3 similarity index 100% rename from src/vs/platform/audioCues/browser/media/chatResponseReceived1.mp3 rename to src/vs/platform/accessibilitySignal/browser/media/chatResponseReceived1.mp3 diff --git a/src/vs/platform/audioCues/browser/media/chatResponseReceived2.mp3 b/src/vs/platform/accessibilitySignal/browser/media/chatResponseReceived2.mp3 similarity index 100% rename from src/vs/platform/audioCues/browser/media/chatResponseReceived2.mp3 rename to src/vs/platform/accessibilitySignal/browser/media/chatResponseReceived2.mp3 diff --git a/src/vs/platform/audioCues/browser/media/chatResponseReceived3.mp3 b/src/vs/platform/accessibilitySignal/browser/media/chatResponseReceived3.mp3 similarity index 100% rename from src/vs/platform/audioCues/browser/media/chatResponseReceived3.mp3 rename to src/vs/platform/accessibilitySignal/browser/media/chatResponseReceived3.mp3 diff --git a/src/vs/platform/audioCues/browser/media/chatResponseReceived4.mp3 b/src/vs/platform/accessibilitySignal/browser/media/chatResponseReceived4.mp3 similarity index 100% rename from src/vs/platform/audioCues/browser/media/chatResponseReceived4.mp3 rename to src/vs/platform/accessibilitySignal/browser/media/chatResponseReceived4.mp3 diff --git a/src/vs/platform/audioCues/browser/media/clear.mp3 b/src/vs/platform/accessibilitySignal/browser/media/clear.mp3 similarity index 100% rename from src/vs/platform/audioCues/browser/media/clear.mp3 rename to src/vs/platform/accessibilitySignal/browser/media/clear.mp3 diff --git a/src/vs/platform/audioCues/browser/media/diffLineDeleted.mp3 b/src/vs/platform/accessibilitySignal/browser/media/diffLineDeleted.mp3 similarity index 100% rename from src/vs/platform/audioCues/browser/media/diffLineDeleted.mp3 rename to src/vs/platform/accessibilitySignal/browser/media/diffLineDeleted.mp3 diff --git a/src/vs/platform/audioCues/browser/media/diffLineInserted.mp3 b/src/vs/platform/accessibilitySignal/browser/media/diffLineInserted.mp3 similarity index 100% rename from src/vs/platform/audioCues/browser/media/diffLineInserted.mp3 rename to src/vs/platform/accessibilitySignal/browser/media/diffLineInserted.mp3 diff --git a/src/vs/platform/audioCues/browser/media/diffLineModified.mp3 b/src/vs/platform/accessibilitySignal/browser/media/diffLineModified.mp3 similarity index 100% rename from src/vs/platform/audioCues/browser/media/diffLineModified.mp3 rename to src/vs/platform/accessibilitySignal/browser/media/diffLineModified.mp3 diff --git a/src/vs/platform/audioCues/browser/media/error.mp3 b/src/vs/platform/accessibilitySignal/browser/media/error.mp3 similarity index 100% rename from src/vs/platform/audioCues/browser/media/error.mp3 rename to src/vs/platform/accessibilitySignal/browser/media/error.mp3 diff --git a/src/vs/platform/audioCues/browser/media/foldedAreas.mp3 b/src/vs/platform/accessibilitySignal/browser/media/foldedAreas.mp3 similarity index 100% rename from src/vs/platform/audioCues/browser/media/foldedAreas.mp3 rename to src/vs/platform/accessibilitySignal/browser/media/foldedAreas.mp3 diff --git a/src/vs/platform/audioCues/browser/media/format.mp3 b/src/vs/platform/accessibilitySignal/browser/media/format.mp3 similarity index 100% rename from src/vs/platform/audioCues/browser/media/format.mp3 rename to src/vs/platform/accessibilitySignal/browser/media/format.mp3 diff --git a/src/vs/platform/audioCues/browser/media/quickFixes.mp3 b/src/vs/platform/accessibilitySignal/browser/media/quickFixes.mp3 similarity index 100% rename from src/vs/platform/audioCues/browser/media/quickFixes.mp3 rename to src/vs/platform/accessibilitySignal/browser/media/quickFixes.mp3 diff --git a/src/vs/platform/audioCues/browser/media/save.mp3 b/src/vs/platform/accessibilitySignal/browser/media/save.mp3 similarity index 100% rename from src/vs/platform/audioCues/browser/media/save.mp3 rename to src/vs/platform/accessibilitySignal/browser/media/save.mp3 diff --git a/src/vs/platform/audioCues/browser/media/taskCompleted.mp3 b/src/vs/platform/accessibilitySignal/browser/media/taskCompleted.mp3 similarity index 100% rename from src/vs/platform/audioCues/browser/media/taskCompleted.mp3 rename to src/vs/platform/accessibilitySignal/browser/media/taskCompleted.mp3 diff --git a/src/vs/platform/audioCues/browser/media/taskFailed.mp3 b/src/vs/platform/accessibilitySignal/browser/media/taskFailed.mp3 similarity index 100% rename from src/vs/platform/audioCues/browser/media/taskFailed.mp3 rename to src/vs/platform/accessibilitySignal/browser/media/taskFailed.mp3 diff --git a/src/vs/platform/accessibilitySignal/browser/media/terminalBell.mp3 b/src/vs/platform/accessibilitySignal/browser/media/terminalBell.mp3 new file mode 100644 index 0000000000000..7c6b7fe832348 Binary files /dev/null and b/src/vs/platform/accessibilitySignal/browser/media/terminalBell.mp3 differ diff --git a/src/vs/platform/accessibilitySignal/browser/media/voiceRecordingStarted.mp3 b/src/vs/platform/accessibilitySignal/browser/media/voiceRecordingStarted.mp3 new file mode 100644 index 0000000000000..488754fdd584a Binary files /dev/null and b/src/vs/platform/accessibilitySignal/browser/media/voiceRecordingStarted.mp3 differ diff --git a/src/vs/platform/accessibilitySignal/browser/media/voiceRecordingStopped.mp3 b/src/vs/platform/accessibilitySignal/browser/media/voiceRecordingStopped.mp3 new file mode 100644 index 0000000000000..0532cf6b15a44 Binary files /dev/null and b/src/vs/platform/accessibilitySignal/browser/media/voiceRecordingStopped.mp3 differ diff --git a/src/vs/platform/audioCues/browser/media/warning.mp3 b/src/vs/platform/accessibilitySignal/browser/media/warning.mp3 similarity index 100% rename from src/vs/platform/audioCues/browser/media/warning.mp3 rename to src/vs/platform/accessibilitySignal/browser/media/warning.mp3 diff --git a/src/vs/platform/actionWidget/browser/actionList.ts b/src/vs/platform/actionWidget/browser/actionList.ts index 4b0b93f695dbd..8eeeee2df29ab 100644 --- a/src/vs/platform/actionWidget/browser/actionList.ts +++ b/src/vs/platform/actionWidget/browser/actionList.ts @@ -130,9 +130,9 @@ class ActionItemRenderer implements IListRenderer, IAction data.container.title = element.label; } else if (actionTitle && previewTitle) { if (this._supportsPreview && element.canPreview) { - data.container.title = localize({ key: 'label-preview', comment: ['placeholders are keybindings, e.g "F2 to apply, Shift+F2 to preview"'] }, "{0} to apply, {1} to preview", actionTitle, previewTitle); + data.container.title = localize({ key: 'label-preview', comment: ['placeholders are keybindings, e.g "F2 to Apply, Shift+F2 to Preview"'] }, "{0} to Apply, {1} to Preview", actionTitle, previewTitle); } else { - data.container.title = localize({ key: 'label', comment: ['placeholder is a keybinding, e.g "F2 to apply"'] }, "{0} to apply", actionTitle); + data.container.title = localize({ key: 'label', comment: ['placeholder is a keybinding, e.g "F2 to Apply"'] }, "{0} to Apply", actionTitle); } } else { data.container.title = ''; @@ -140,7 +140,7 @@ class ActionItemRenderer implements IListRenderer, IAction } disposeTemplate(_templateData: IActionMenuTemplateData): void { - // noop + _templateData.keybinding.dispose(); } } diff --git a/src/vs/platform/actions/browser/buttonbar.ts b/src/vs/platform/actions/browser/buttonbar.ts index 21d4c4c5fc63e..165caec0bf1f4 100644 --- a/src/vs/platform/actions/browser/buttonbar.ts +++ b/src/vs/platform/actions/browser/buttonbar.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { ButtonBar, IButton } from 'vs/base/browser/ui/button/button'; +import { createInstantHoverDelegate } from 'vs/base/browser/ui/hover/hoverDelegateFactory'; import { ActionRunner, IAction, IActionRunner, SubmenuAction, WorkbenchActionExecutedClassification, WorkbenchActionExecutedEvent } from 'vs/base/common/actions'; import { Emitter, Event } from 'vs/base/common/event'; import { DisposableStore } from 'vs/base/common/lifecycle'; @@ -12,6 +13,7 @@ import { localize } from 'vs/nls'; import { MenuId, IMenuService, MenuItemAction } from 'vs/platform/actions/common/actions'; import { IContextKeyService } from 'vs/platform/contextkey/common/contextkey'; import { IContextMenuService } from 'vs/platform/contextview/browser/contextView'; +import { IHoverService } from 'vs/platform/hover/browser/hover'; import { IKeybindingService } from 'vs/platform/keybinding/common/keybinding'; import { ITelemetryService } from 'vs/platform/telemetry/common/telemetry'; @@ -29,6 +31,7 @@ export interface IWorkbenchButtonBarOptions { export class WorkbenchButtonBar extends ButtonBar { protected readonly _store = new DisposableStore(); + protected readonly _updateStore = new DisposableStore(); private readonly _actionRunner: IActionRunner; private readonly _onDidChange = new Emitter(); @@ -41,6 +44,7 @@ export class WorkbenchButtonBar extends ButtonBar { @IContextMenuService private readonly _contextMenuService: IContextMenuService, @IKeybindingService private readonly _keybindingService: IKeybindingService, @ITelemetryService telemetryService: ITelemetryService, + @IHoverService private readonly _hoverService: IHoverService, ) { super(container); @@ -57,6 +61,7 @@ export class WorkbenchButtonBar extends ButtonBar { override dispose() { this._onDidChange.dispose(); + this._updateStore.dispose(); this._store.dispose(); super.dispose(); } @@ -65,8 +70,12 @@ export class WorkbenchButtonBar extends ButtonBar { const conifgProvider: IButtonConfigProvider = this._options?.buttonConfigProvider ?? (() => ({ showLabel: true })); + this._updateStore.clear(); this.clear(); + // Support instamt hover between buttons + const hoverDelegate = this._updateStore.add(createInstantHoverDelegate()); + for (let i = 0; i < actions.length; i++) { const secondary = i > 0; @@ -107,15 +116,16 @@ export class WorkbenchButtonBar extends ButtonBar { } } const kb = this._keybindingService.lookupKeybinding(action.id); + let tooltip: string; if (kb) { - btn.element.title = localize('labelWithKeybinding', "{0} ({1})", action.label, kb.getLabel()); + tooltip = localize('labelWithKeybinding', "{0} ({1})", action.label, kb.getLabel()); } else { - btn.element.title = action.label; - + tooltip = action.label; } - btn.onDidClick(async () => { + this._updateStore.add(this._hoverService.setupUpdatableHover(hoverDelegate, btn.element, tooltip)); + this._updateStore.add(btn.onDidClick(async () => { this._actionRunner.run(action); - }); + })); } this._onDidChange.fire(this); } @@ -132,8 +142,9 @@ export class MenuWorkbenchButtonBar extends WorkbenchButtonBar { @IContextMenuService contextMenuService: IContextMenuService, @IKeybindingService keybindingService: IKeybindingService, @ITelemetryService telemetryService: ITelemetryService, + @IHoverService hoverService: IHoverService, ) { - super(container, options, contextMenuService, keybindingService, telemetryService); + super(container, options, contextMenuService, keybindingService, telemetryService, hoverService); const menu = menuService.createMenu(menuId, contextKeyService); this._store.add(menu); diff --git a/src/vs/platform/actions/browser/dropdownWithPrimaryActionViewItem.ts b/src/vs/platform/actions/browser/dropdownWithPrimaryActionViewItem.ts index 99e25133cd8b8..6988311054379 100644 --- a/src/vs/platform/actions/browser/dropdownWithPrimaryActionViewItem.ts +++ b/src/vs/platform/actions/browser/dropdownWithPrimaryActionViewItem.ts @@ -19,10 +19,13 @@ import { INotificationService } from 'vs/platform/notification/common/notificati import { IThemeService } from 'vs/platform/theme/common/themeService'; import { IContextMenuService } from 'vs/platform/contextview/browser/contextView'; import { IAccessibilityService } from 'vs/platform/accessibility/common/accessibility'; +import { IHoverDelegate } from 'vs/base/browser/ui/hover/hoverDelegate'; export interface IDropdownWithPrimaryActionViewItemOptions { actionRunner?: IActionRunner; getKeyBinding?: (action: IAction) => ResolvedKeybinding | undefined; + hoverDelegate?: IHoverDelegate; + menuAsChild?: boolean; } export class DropdownWithPrimaryActionViewItem extends BaseActionViewItem { @@ -48,17 +51,18 @@ export class DropdownWithPrimaryActionViewItem extends BaseActionViewItem { @IThemeService _themeService: IThemeService, @IAccessibilityService _accessibilityService: IAccessibilityService ) { - super(null, primaryAction); - this._primaryAction = new MenuEntryActionViewItem(primaryAction, undefined, _keybindingService, _notificationService, _contextKeyService, _themeService, _contextMenuProvider, _accessibilityService); + super(null, primaryAction, { hoverDelegate: _options?.hoverDelegate }); + this._primaryAction = new MenuEntryActionViewItem(primaryAction, { hoverDelegate: _options?.hoverDelegate }, _keybindingService, _notificationService, _contextKeyService, _themeService, _contextMenuProvider, _accessibilityService); if (_options?.actionRunner) { this._primaryAction.actionRunner = _options.actionRunner; } this._dropdown = new DropdownMenuActionViewItem(dropdownAction, dropdownMenuActions, this._contextMenuProvider, { - menuAsChild: true, + menuAsChild: _options?.menuAsChild ?? true, classNames: className ? ['codicon', 'codicon-chevron-down', className] : ['codicon', 'codicon-chevron-down'], actionRunner: this._options?.actionRunner, - keybindingProvider: this._options?.getKeyBinding + keybindingProvider: this._options?.getKeyBinding, + hoverDelegate: _options?.hoverDelegate }); } @@ -130,7 +134,10 @@ export class DropdownWithPrimaryActionViewItem extends BaseActionViewItem { this._dropdown.dispose(); this._dropdown = new DropdownMenuActionViewItem(dropdownAction, dropdownMenuActions, this._contextMenuProvider, { menuAsChild: true, - classNames: ['codicon', dropdownIcon || 'codicon-chevron-down'] + classNames: ['codicon', dropdownIcon || 'codicon-chevron-down'], + actionRunner: this._options?.actionRunner, + hoverDelegate: this._options?.hoverDelegate, + keybindingProvider: this._options?.getKeyBinding }); if (this._dropdownContainer) { this._dropdown.render(this._dropdownContainer); diff --git a/src/vs/platform/actions/browser/menuEntryActionViewItem.ts b/src/vs/platform/actions/browser/menuEntryActionViewItem.ts index 8374e100d3cb6..da596a8fc6c02 100644 --- a/src/vs/platform/actions/browser/menuEntryActionViewItem.ts +++ b/src/vs/platform/actions/browser/menuEntryActionViewItem.ts @@ -26,7 +26,7 @@ import { IStorageService, StorageScope, StorageTarget } from 'vs/platform/storag import { IThemeService } from 'vs/platform/theme/common/themeService'; import { ThemeIcon } from 'vs/base/common/themables'; import { isDark } from 'vs/platform/theme/common/theme'; -import { IHoverDelegate } from 'vs/base/browser/ui/iconLabel/iconHoverDelegate'; +import { IHoverDelegate } from 'vs/base/browser/ui/hover/hoverDelegate'; import { assertType } from 'vs/base/common/types'; import { asCssVariable, selectBorder } from 'vs/platform/theme/common/colorRegistry'; import { defaultSelectBoxStyles } from 'vs/platform/theme/browser/defaultStyles'; @@ -515,7 +515,7 @@ class SubmenuEntrySelectActionViewItem extends SelectActionViewItem { /** * Creates action view items for menu actions or submenu actions. */ -export function createActionViewItem(instaService: IInstantiationService, action: IAction, options?: IDropdownMenuActionViewItemOptions | IMenuEntryActionViewItemOptions): undefined | MenuEntryActionViewItem | SubmenuEntryActionViewItem | BaseActionViewItem { +export function createActionViewItem(instaService: IInstantiationService, action: IAction, options: IDropdownMenuActionViewItemOptions | IMenuEntryActionViewItemOptions | undefined): undefined | MenuEntryActionViewItem | SubmenuEntryActionViewItem | BaseActionViewItem { if (action instanceof MenuItemAction) { return instaService.createInstance(MenuEntryActionViewItem, action, options); } else if (action instanceof SubmenuItemAction) { diff --git a/src/vs/platform/actions/browser/toolbar.ts b/src/vs/platform/actions/browser/toolbar.ts index a9061e12c94cb..444fe76700d1c 100644 --- a/src/vs/platform/actions/browser/toolbar.ts +++ b/src/vs/platform/actions/browser/toolbar.ts @@ -212,26 +212,31 @@ export class WorkbenchToolBar extends ToolBar { } } + const primaryActions = []; + + if (action instanceof MenuItemAction && action.menuKeybinding) { + primaryActions.push(action.menuKeybinding); + } + // add "hide foo" actions - let hideAction: IAction; if (!noHide && (action instanceof MenuItemAction || action instanceof SubmenuItemAction)) { if (!action.hideActions) { // no context menu for MenuItemAction instances that support no hiding // those are fake actions and need to be cleaned up return; } - hideAction = action.hideActions.hide; + primaryActions.push(action.hideActions.hide); } else { - hideAction = toAction({ + primaryActions.push(toAction({ id: 'label', label: localize('hide', "Hide"), enabled: false, run() { } - }); + })); } - const actions = Separator.join([hideAction], toggleActions); + const actions = Separator.join(primaryActions, toggleActions); // add "Reset Menu" action if (this._options?.resetMenu && !menuIds) { diff --git a/src/vs/platform/actions/common/actions.ts b/src/vs/platform/actions/common/actions.ts index 376627398acaa..dc32c7728dac2 100644 --- a/src/vs/platform/actions/common/actions.ts +++ b/src/vs/platform/actions/common/actions.ts @@ -108,10 +108,13 @@ export class MenuId { static readonly OpenEditorsContextShare = new MenuId('OpenEditorsContextShare'); static readonly ProblemsPanelContext = new MenuId('ProblemsPanelContext'); static readonly SCMInputBox = new MenuId('SCMInputBox'); + static readonly SCMChangesSeparator = new MenuId('SCMChangesSeparator'); static readonly SCMIncomingChanges = new MenuId('SCMIncomingChanges'); static readonly SCMIncomingChangesContext = new MenuId('SCMIncomingChangesContext'); + static readonly SCMIncomingChangesSetting = new MenuId('SCMIncomingChangesSetting'); static readonly SCMOutgoingChanges = new MenuId('SCMOutgoingChanges'); static readonly SCMOutgoingChangesContext = new MenuId('SCMOutgoingChangesContext'); + static readonly SCMOutgoingChangesSetting = new MenuId('SCMOutgoingChangesSetting'); static readonly SCMIncomingChangesAllChangesContext = new MenuId('SCMIncomingChangesAllChangesContext'); static readonly SCMIncomingChangesHistoryItemContext = new MenuId('SCMIncomingChangesHistoryItemContext'); static readonly SCMOutgoingChangesAllChangesContext = new MenuId('SCMOutgoingChangesAllChangesContext'); @@ -159,11 +162,13 @@ export class MenuId { static readonly CommentThreadCommentContext = new MenuId('CommentThreadCommentContext'); static readonly CommentTitle = new MenuId('CommentTitle'); static readonly CommentActions = new MenuId('CommentActions'); + static readonly CommentsViewThreadActions = new MenuId('CommentsViewThreadActions'); static readonly InteractiveToolbar = new MenuId('InteractiveToolbar'); static readonly InteractiveCellTitle = new MenuId('InteractiveCellTitle'); static readonly InteractiveCellDelete = new MenuId('InteractiveCellDelete'); static readonly InteractiveCellExecute = new MenuId('InteractiveCellExecute'); static readonly InteractiveInputExecute = new MenuId('InteractiveInputExecute'); + static readonly IssueReporter = new MenuId('IssueReporter'); static readonly NotebookToolbar = new MenuId('NotebookToolbar'); static readonly NotebookStickyScrollContext = new MenuId('NotebookStickyScrollContext'); static readonly NotebookCellTitle = new MenuId('NotebookCellTitle'); @@ -178,6 +183,8 @@ export class MenuId { static readonly NotebookDiffCellMetadataTitle = new MenuId('NotebookDiffCellMetadataTitle'); static readonly NotebookDiffCellOutputsTitle = new MenuId('NotebookDiffCellOutputsTitle'); static readonly NotebookOutputToolbar = new MenuId('NotebookOutputToolbar'); + static readonly NotebookOutlineFilter = new MenuId('NotebookOutlineFilter'); + static readonly NotebookOutlineActionMenu = new MenuId('NotebookOutlineActionMenu'); static readonly NotebookEditorLayoutConfigure = new MenuId('NotebookEditorLayoutConfigure'); static readonly NotebookKernelSource = new MenuId('NotebookKernelSource'); static readonly BulkEditTitle = new MenuId('BulkEditTitle'); @@ -190,6 +197,7 @@ export class MenuId { static readonly SidebarTitle = new MenuId('SidebarTitle'); static readonly PanelTitle = new MenuId('PanelTitle'); static readonly AuxiliaryBarTitle = new MenuId('AuxiliaryBarTitle'); + static readonly AuxiliaryBarHeader = new MenuId('AuxiliaryBarHeader'); static readonly TerminalInstanceContext = new MenuId('TerminalInstanceContext'); static readonly TerminalEditorInstanceContext = new MenuId('TerminalEditorInstanceContext'); static readonly TerminalNewDropdownContext = new MenuId('TerminalNewDropdownContext'); @@ -198,19 +206,26 @@ export class MenuId { static readonly TerminalStickyScrollContext = new MenuId('TerminalStickyScrollContext'); static readonly WebviewContext = new MenuId('WebviewContext'); static readonly InlineCompletionsActions = new MenuId('InlineCompletionsActions'); + static readonly InlineEditActions = new MenuId('InlineEditActions'); static readonly NewFile = new MenuId('NewFile'); static readonly MergeInput1Toolbar = new MenuId('MergeToolbar1Toolbar'); static readonly MergeInput2Toolbar = new MenuId('MergeToolbar2Toolbar'); static readonly MergeBaseToolbar = new MenuId('MergeBaseToolbar'); static readonly MergeInputResultToolbar = new MenuId('MergeToolbarResultToolbar'); static readonly InlineSuggestionToolbar = new MenuId('InlineSuggestionToolbar'); + static readonly InlineEditToolbar = new MenuId('InlineEditToolbar'); static readonly ChatContext = new MenuId('ChatContext'); static readonly ChatCodeBlock = new MenuId('ChatCodeblock'); + static readonly ChatCompareBlock = new MenuId('ChatCompareBlock'); static readonly ChatMessageTitle = new MenuId('ChatMessageTitle'); static readonly ChatExecute = new MenuId('ChatExecute'); + static readonly ChatExecuteSecondary = new MenuId('ChatExecuteSecondary'); static readonly ChatInputSide = new MenuId('ChatInputSide'); static readonly AccessibleView = new MenuId('AccessibleView'); static readonly MultiDiffEditorFileToolbar = new MenuId('MultiDiffEditorFileToolbar'); + static readonly DiffEditorHunkToolbar = new MenuId('DiffEditorHunkToolbar'); + static readonly DiffEditorSelectionToolbar = new MenuId('DiffEditorSelectionToolbar'); + /** * Create or reuse a `MenuId` with the given identifier @@ -465,6 +480,7 @@ export class MenuItemAction implements IAction { alt: ICommandAction | undefined, options: IMenuActionOptions | undefined, readonly hideActions: IMenuItemHide | undefined, + readonly menuKeybinding: IAction | undefined, @IContextKeyService contextKeyService: IContextKeyService, @ICommandService private _commandService: ICommandService ) { @@ -499,7 +515,7 @@ export class MenuItemAction implements IAction { } this.item = item; - this.alt = alt ? new MenuItemAction(alt, undefined, options, hideActions, contextKeyService, _commandService) : undefined; + this.alt = alt ? new MenuItemAction(alt, undefined, options, hideActions, undefined, contextKeyService, _commandService) : undefined; this._options = options; this.class = icon && ThemeIcon.asClassName(icon); diff --git a/src/vs/platform/actions/common/menuService.ts b/src/vs/platform/actions/common/menuService.ts index 64a42990a54b5..59d30b4e16d4d 100644 --- a/src/vs/platform/actions/common/menuService.ts +++ b/src/vs/platform/actions/common/menuService.ts @@ -10,7 +10,7 @@ import { IMenu, IMenuActionOptions, IMenuChangeEvent, IMenuCreateOptions, IMenuI import { ICommandAction, ILocalizedString } from 'vs/platform/action/common/action'; import { ICommandService } from 'vs/platform/commands/common/commands'; import { ContextKeyExpression, IContextKeyService } from 'vs/platform/contextkey/common/contextkey'; -import { Separator, toAction } from 'vs/base/common/actions'; +import { IAction, Separator, toAction } from 'vs/base/common/actions'; import { IStorageService, StorageScope, StorageTarget } from 'vs/platform/storage/common/storage'; import { removeFastWithoutKeepingOrder } from 'vs/base/common/arrays'; import { localize } from 'vs/nls'; @@ -162,7 +162,7 @@ class MenuInfo { private readonly _hiddenStates: PersistedMenuHideState, private readonly _collectContextKeysForSubmenus: boolean, @ICommandService private readonly _commandService: ICommandService, - @IContextKeyService private readonly _contextKeyService: IContextKeyService, + @IContextKeyService private readonly _contextKeyService: IContextKeyService ) { this.refresh(); } @@ -245,8 +245,8 @@ class MenuInfo { const menuHide = createMenuHide(this._id, isMenuItem ? item.command : item, this._hiddenStates); if (isMenuItem) { // MenuItemAction - activeActions.push(new MenuItemAction(item.command, item.alt, options, menuHide, this._contextKeyService, this._commandService)); - + const menuKeybinding = createMenuKeybindingAction(this._id, item.command, this._commandService); + activeActions.push(new MenuItemAction(item.command, item.alt, options, menuHide, menuKeybinding, this._contextKeyService, this._commandService)); } else { // SubmenuItemAction const groups = new MenuInfo(item.submenu, this._hiddenStates, this._collectContextKeysForSubmenus, this._commandService, this._contextKeyService).createActionGroups(options); @@ -336,7 +336,7 @@ class MenuImpl implements IMenu { hiddenStates: PersistedMenuHideState, options: Required, @ICommandService commandService: ICommandService, - @IContextKeyService contextKeyService: IContextKeyService, + @IContextKeyService contextKeyService: IContextKeyService ) { this._menuInfo = new MenuInfo(id, hiddenStates, options.emitEventsForSubmenuChanges, commandService, contextKeyService); @@ -437,3 +437,20 @@ function createMenuHide(menu: MenuId, command: ICommandAction | ISubmenuItem, st get isHidden() { return !toggle.checked; }, }; } + +function createMenuKeybindingAction(menu: MenuId, command: ICommandAction | ISubmenuItem, commandService: ICommandService): IAction | undefined { + if (isISubmenuItem(command)) { + return undefined; + } + + const configureKeybindingAction = toAction({ + id: `configureKeybinding/${menu.id}/${command.id}`, + label: localize('configure keybinding', "Configure Keybinding"), + run() { + const when = command.precondition?.serialize(); + commandService.executeCommand('workbench.action.openGlobalKeybindings', `@command:${command.id}` + (when ? ` +when:${when}` : '')); + } + }); + + return configureKeybindingAction; +} diff --git a/src/vs/platform/audioCues/browser/audioCueService.ts b/src/vs/platform/audioCues/browser/audioCueService.ts deleted file mode 100644 index 0e35e7f9e3ca6..0000000000000 --- a/src/vs/platform/audioCues/browser/audioCueService.ts +++ /dev/null @@ -1,568 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { Disposable, IDisposable, toDisposable } from 'vs/base/common/lifecycle'; -import { FileAccess } from 'vs/base/common/network'; -import { IAccessibilityService } from 'vs/platform/accessibility/common/accessibility'; -import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; -import { createDecorator } from 'vs/platform/instantiation/common/instantiation'; -import { Event } from 'vs/base/common/event'; -import { localize } from 'vs/nls'; -import { observableFromEvent, derived } from 'vs/base/common/observable'; -import { ITelemetryService } from 'vs/platform/telemetry/common/telemetry'; - -export const IAudioCueService = createDecorator('audioCue'); - -export interface IAudioCueService { - readonly _serviceBrand: undefined; - playAudioCue(cue: AudioCue, options?: IAudioCueOptions): Promise; - playAudioCues(cues: (AudioCue | { cue: AudioCue; source: string })[]): Promise; - isCueEnabled(cue: AudioCue): boolean; - isAlertEnabled(cue: AudioCue): boolean; - onEnabledChanged(cue: AudioCue): Event; - onAlertEnabledChanged(cue: AudioCue): Event; - - playSound(cue: Sound, allowManyInParallel?: boolean): Promise; - playAudioCueLoop(cue: AudioCue, milliseconds: number): IDisposable; -} - -export interface IAudioCueOptions { - allowManyInParallel?: boolean; - source?: string; - /** - * For actions like save or format, depending on the - * configured value, we will only - * play the sound if the user triggered the action. - */ - userGesture?: boolean; -} - -export class AudioCueService extends Disposable implements IAudioCueService { - readonly _serviceBrand: undefined; - private readonly sounds: Map = new Map(); - private readonly screenReaderAttached = observableFromEvent( - this.accessibilityService.onDidChangeScreenReaderOptimized, - () => /** @description accessibilityService.onDidChangeScreenReaderOptimized */ this.accessibilityService.isScreenReaderOptimized() - ); - private readonly sentTelemetry = new Set(); - - constructor( - @IConfigurationService private readonly configurationService: IConfigurationService, - @IAccessibilityService private readonly accessibilityService: IAccessibilityService, - @ITelemetryService private readonly telemetryService: ITelemetryService, - ) { - super(); - } - - public async playAudioCue(cue: AudioCue, options: IAudioCueOptions = {}): Promise { - const alertMessage = cue.alertMessage; - if (this.isAlertEnabled(cue, options.userGesture) && alertMessage) { - this.accessibilityService.status(alertMessage); - } - - if (this.isCueEnabled(cue, options.userGesture)) { - this.sendAudioCueTelemetry(cue, options.source); - await this.playSound(cue.sound.getSound(), options.allowManyInParallel); - } - } - - public async playAudioCues(cues: (AudioCue | { cue: AudioCue; source: string })[]): Promise { - for (const cue of cues) { - this.sendAudioCueTelemetry('cue' in cue ? cue.cue : cue, 'source' in cue ? cue.source : undefined); - } - const cueArray = cues.map(c => 'cue' in c ? c.cue : c); - const alerts = cueArray.filter(cue => this.isAlertEnabled(cue)).map(c => c.alertMessage); - if (alerts.length) { - this.accessibilityService.status(alerts.join(', ')); - } - - // Some audio cues might reuse sounds. Don't play the same sound twice. - const sounds = new Set(cueArray.filter(cue => this.isCueEnabled(cue)).map(cue => cue.sound.getSound())); - await Promise.all(Array.from(sounds).map(sound => this.playSound(sound, true))); - - } - - - private sendAudioCueTelemetry(cue: AudioCue, source: string | undefined): void { - const isScreenReaderOptimized = this.accessibilityService.isScreenReaderOptimized(); - const key = cue.name + (source ? `::${source}` : '') + (isScreenReaderOptimized ? '{screenReaderOptimized}' : ''); - // Only send once per user session - if (this.sentTelemetry.has(key) || this.getVolumeInPercent() === 0) { - return; - } - this.sentTelemetry.add(key); - - this.telemetryService.publicLog2<{ - audioCue: string; - source: string; - isScreenReaderOptimized: boolean; - }, { - owner: 'hediet'; - - audioCue: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The audio cue that was played.' }; - source: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The source that triggered the audio cue (e.g. "diffEditorNavigation").' }; - isScreenReaderOptimized: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether the user is using a screen reader' }; - - comment: 'This data is collected to understand how audio cues are used and if more audio cues should be added.'; - }>('audioCue.played', { - audioCue: cue.name, - source: source ?? '', - isScreenReaderOptimized, - }); - } - - private getVolumeInPercent(): number { - const volume = this.configurationService.getValue('audioCues.volume'); - if (typeof volume !== 'number') { - return 50; - } - - return Math.max(Math.min(volume, 100), 0); - } - - private readonly playingSounds = new Set(); - - public async playSound(sound: Sound, allowManyInParallel = false): Promise { - if (!allowManyInParallel && this.playingSounds.has(sound)) { - return; - } - this.playingSounds.add(sound); - const url = FileAccess.asBrowserUri(`vs/platform/audioCues/browser/media/${sound.fileName}`).toString(true); - - try { - const sound = this.sounds.get(url); - if (sound) { - sound.volume = this.getVolumeInPercent() / 100; - sound.currentTime = 0; - await sound.play(); - } else { - const playedSound = await playAudio(url, this.getVolumeInPercent() / 100); - this.sounds.set(url, playedSound); - } - } catch (e) { - if (!e.message.includes('play() can only be initiated by a user gesture')) { - // tracking this issue in #178642, no need to spam the console - console.error('Error while playing sound', e); - } - } finally { - this.playingSounds.delete(sound); - } - } - - public playAudioCueLoop(cue: AudioCue, milliseconds: number): IDisposable { - let playing = true; - const playSound = () => { - if (playing) { - this.playAudioCue(cue, { allowManyInParallel: true }).finally(() => { - setTimeout(() => { - if (playing) { - playSound(); - } - }, milliseconds); - }); - } - }; - playSound(); - return toDisposable(() => playing = false); - } - - private readonly obsoleteAudioCuesEnabled = observableFromEvent( - Event.filter(this.configurationService.onDidChangeConfiguration, (e) => - e.affectsConfiguration('audioCues.enabled') - ), - () => /** @description config: audioCues.enabled */ this.configurationService.getValue<'on' | 'off' | 'auto' | 'userGesture' | 'always' | 'never'>('audioCues.enabled') - ); - - private readonly isCueEnabledCache = new Cache((event: { readonly cue: AudioCue; readonly userGesture?: boolean }) => { - const settingObservable = observableFromEvent( - Event.filter(this.configurationService.onDidChangeConfiguration, (e) => - e.affectsConfiguration(event.cue.settingsKey) - ), - () => this.configurationService.getValue<'on' | 'off' | 'auto' | 'userGesture' | 'always' | 'never'>(event.cue.settingsKey) - ); - return derived(reader => { - /** @description audio cue enabled */ - const setting = settingObservable.read(reader); - if ( - setting === 'on' || - (setting === 'auto' && this.screenReaderAttached.read(reader)) - ) { - return true; - } else if (setting === 'always' || setting === 'userGesture' && event.userGesture) { - return true; - } - - const obsoleteSetting = this.obsoleteAudioCuesEnabled.read(reader); - if ( - obsoleteSetting === 'on' || - (obsoleteSetting === 'auto' && this.screenReaderAttached.read(reader)) - ) { - return true; - } - - return false; - }); - }, JSON.stringify); - - private readonly isAlertEnabledCache = new Cache((event: { readonly cue: AudioCue; readonly userGesture?: boolean }) => { - const settingObservable = observableFromEvent( - Event.filter(this.configurationService.onDidChangeConfiguration, (e) => - e.affectsConfiguration(event.cue.alertSettingsKey!) - ), - () => event.cue.alertSettingsKey ? this.configurationService.getValue(event.cue.alertSettingsKey) : false - ); - return derived(reader => { - /** @description alert enabled */ - const setting = settingObservable.read(reader); - if ( - !this.screenReaderAttached.read(reader) - ) { - return false; - } - return setting === true || setting === 'always' || setting === 'userGesture' && event.userGesture; - }); - }, JSON.stringify); - - public isAlertEnabled(cue: AudioCue, userGesture?: boolean): boolean { - if (!cue.alertSettingsKey) { - return false; - } - return this.isAlertEnabledCache.get({ cue, userGesture }).get() ?? false; - } - - public isCueEnabled(cue: AudioCue, userGesture?: boolean): boolean { - return this.isCueEnabledCache.get({ cue, userGesture }).get() ?? false; - } - - public onEnabledChanged(cue: AudioCue): Event { - return Event.fromObservableLight(this.isCueEnabledCache.get({ cue })); - } - - public onAlertEnabledChanged(cue: AudioCue): Event { - return Event.fromObservableLight(this.isAlertEnabledCache.get({ cue })); - } -} - - -/** - * Play the given audio url. - * @volume value between 0 and 1 - */ -function playAudio(url: string, volume: number): Promise { - return new Promise((resolve, reject) => { - const audio = new Audio(url); - audio.volume = volume; - audio.addEventListener('ended', () => { - resolve(audio); - }); - audio.addEventListener('error', (e) => { - // When the error event fires, ended might not be called - reject(e.error); - }); - audio.play().catch(e => { - // When play fails, the error event is not fired. - reject(e); - }); - }); -} - -class Cache { - private readonly map = new Map(); - constructor(private readonly getValue: (value: TArg) => TValue, private readonly getKey: (value: TArg) => unknown) { - } - - public get(arg: TArg): TValue { - if (this.map.has(arg)) { - return this.map.get(arg)!; - } - - const value = this.getValue(arg); - const key = this.getKey(arg); - this.map.set(key, value); - return value; - } -} - -/** - * Corresponds to the audio files in ./media. -*/ -export class Sound { - private static register(options: { fileName: string }): Sound { - const sound = new Sound(options.fileName); - return sound; - } - - public static readonly error = Sound.register({ fileName: 'error.mp3' }); - public static readonly warning = Sound.register({ fileName: 'warning.mp3' }); - public static readonly foldedArea = Sound.register({ fileName: 'foldedAreas.mp3' }); - public static readonly break = Sound.register({ fileName: 'break.mp3' }); - public static readonly quickFixes = Sound.register({ fileName: 'quickFixes.mp3' }); - public static readonly taskCompleted = Sound.register({ fileName: 'taskCompleted.mp3' }); - public static readonly taskFailed = Sound.register({ fileName: 'taskFailed.mp3' }); - public static readonly terminalBell = Sound.register({ fileName: 'terminalBell.mp3' }); - public static readonly diffLineInserted = Sound.register({ fileName: 'diffLineInserted.mp3' }); - public static readonly diffLineDeleted = Sound.register({ fileName: 'diffLineDeleted.mp3' }); - public static readonly diffLineModified = Sound.register({ fileName: 'diffLineModified.mp3' }); - public static readonly chatRequestSent = Sound.register({ fileName: 'chatRequestSent.mp3' }); - public static readonly chatResponsePending = Sound.register({ fileName: 'chatResponsePending.mp3' }); - public static readonly chatResponseReceived1 = Sound.register({ fileName: 'chatResponseReceived1.mp3' }); - public static readonly chatResponseReceived2 = Sound.register({ fileName: 'chatResponseReceived2.mp3' }); - public static readonly chatResponseReceived3 = Sound.register({ fileName: 'chatResponseReceived3.mp3' }); - public static readonly chatResponseReceived4 = Sound.register({ fileName: 'chatResponseReceived4.mp3' }); - public static readonly clear = Sound.register({ fileName: 'clear.mp3' }); - public static readonly save = Sound.register({ fileName: 'save.mp3' }); - public static readonly format = Sound.register({ fileName: 'format.mp3' }); - - private constructor(public readonly fileName: string) { } -} - -export class SoundSource { - constructor( - public readonly randomOneOf: Sound[] - ) { } - - public getSound(deterministic = false): Sound { - if (deterministic || this.randomOneOf.length === 1) { - return this.randomOneOf[0]; - } else { - const index = Math.floor(Math.random() * this.randomOneOf.length); - return this.randomOneOf[index]; - } - } -} - -export const enum AccessibilityAlertSettingId { - Save = 'accessibility.alert.save', - Format = 'accessibility.alert.format', - Clear = 'accessibility.alert.clear', - Breakpoint = 'accessibility.alert.breakpoint', - Error = 'accessibility.alert.error', - Warning = 'accessibility.alert.warning', - FoldedArea = 'accessibility.alert.foldedArea', - TerminalQuickFix = 'accessibility.alert.terminalQuickFix', - TerminalBell = 'accessibility.alert.terminalBell', - TerminalCommandFailed = 'accessibility.alert.terminalCommandFailed', - TaskCompleted = 'accessibility.alert.taskCompleted', - TaskFailed = 'accessibility.alert.taskFailed', - ChatRequestSent = 'accessibility.alert.chatRequestSent', - NotebookCellCompleted = 'accessibility.alert.notebookCellCompleted', - NotebookCellFailed = 'accessibility.alert.notebookCellFailed', - OnDebugBreak = 'accessibility.alert.onDebugBreak', - NoInlayHints = 'accessibility.alert.noInlayHints', - LineHasBreakpoint = 'accessibility.alert.lineHasBreakpoint', - ChatResponsePending = 'accessibility.alert.chatResponsePending' -} - - -export class AudioCue { - private static _audioCues = new Set(); - private static register(options: { - name: string; - sound: Sound | { - /** - * Gaming and other apps often play a sound variant when the same event happens again - * for an improved experience. This option enables audio cues to play a random sound. - */ - randomOneOf: Sound[]; - }; - settingsKey: string; - alertSettingsKey?: AccessibilityAlertSettingId; - alertMessage?: string; - }): AudioCue { - const soundSource = new SoundSource('randomOneOf' in options.sound ? options.sound.randomOneOf : [options.sound]); - const audioCue = new AudioCue(soundSource, options.name, options.settingsKey, options.alertSettingsKey, options.alertMessage); - AudioCue._audioCues.add(audioCue); - return audioCue; - } - - public static get allAudioCues() { - return [...this._audioCues]; - } - - public static readonly error = AudioCue.register({ - name: localize('audioCues.lineHasError.name', 'Error on Line'), - sound: Sound.error, - settingsKey: 'audioCues.lineHasError', - alertSettingsKey: AccessibilityAlertSettingId.Error, - alertMessage: localize('audioCues.lineHasError.alertMessage', 'Error') - }); - public static readonly warning = AudioCue.register({ - name: localize('audioCues.lineHasWarning.name', 'Warning on Line'), - sound: Sound.warning, - settingsKey: 'audioCues.lineHasWarning', - alertSettingsKey: AccessibilityAlertSettingId.Warning, - alertMessage: localize('audioCues.lineHasWarning.alertMessage', 'Warning') - }); - public static readonly foldedArea = AudioCue.register({ - name: localize('audioCues.lineHasFoldedArea.name', 'Folded Area on Line'), - sound: Sound.foldedArea, - settingsKey: 'audioCues.lineHasFoldedArea', - alertSettingsKey: AccessibilityAlertSettingId.FoldedArea, - alertMessage: localize('audioCues.lineHasFoldedArea.alertMessage', 'Folded') - }); - public static readonly break = AudioCue.register({ - name: localize('audioCues.lineHasBreakpoint.name', 'Breakpoint on Line'), - sound: Sound.break, - settingsKey: 'audioCues.lineHasBreakpoint', - alertSettingsKey: AccessibilityAlertSettingId.Breakpoint, - alertMessage: localize('audioCues.lineHasBreakpoint.alertMessage', 'Breakpoint') - }); - public static readonly inlineSuggestion = AudioCue.register({ - name: localize('audioCues.lineHasInlineSuggestion.name', 'Inline Suggestion on Line'), - sound: Sound.quickFixes, - settingsKey: 'audioCues.lineHasInlineSuggestion', - }); - - public static readonly terminalQuickFix = AudioCue.register({ - name: localize('audioCues.terminalQuickFix.name', 'Terminal Quick Fix'), - sound: Sound.quickFixes, - settingsKey: 'audioCues.terminalQuickFix', - alertSettingsKey: AccessibilityAlertSettingId.TerminalQuickFix, - alertMessage: localize('audioCues.terminalQuickFix.alertMessage', 'Quick Fix') - }); - - public static readonly onDebugBreak = AudioCue.register({ - name: localize('audioCues.onDebugBreak.name', 'Debugger Stopped on Breakpoint'), - sound: Sound.break, - settingsKey: 'audioCues.onDebugBreak', - alertSettingsKey: AccessibilityAlertSettingId.OnDebugBreak, - alertMessage: localize('audioCues.onDebugBreak.alertMessage', 'Breakpoint') - }); - - public static readonly noInlayHints = AudioCue.register({ - name: localize('audioCues.noInlayHints', 'No Inlay Hints on Line'), - sound: Sound.error, - settingsKey: 'audioCues.noInlayHints', - alertSettingsKey: AccessibilityAlertSettingId.NoInlayHints, - alertMessage: localize('audioCues.noInlayHints.alertMessage', 'No Inlay Hints') - }); - - public static readonly taskCompleted = AudioCue.register({ - name: localize('audioCues.taskCompleted', 'Task Completed'), - sound: Sound.taskCompleted, - settingsKey: 'audioCues.taskCompleted', - alertSettingsKey: AccessibilityAlertSettingId.TaskCompleted, - alertMessage: localize('audioCues.taskCompleted.alertMessage', 'Task Completed') - }); - - public static readonly taskFailed = AudioCue.register({ - name: localize('audioCues.taskFailed', 'Task Failed'), - sound: Sound.taskFailed, - settingsKey: 'audioCues.taskFailed', - alertSettingsKey: AccessibilityAlertSettingId.TaskFailed, - alertMessage: localize('audioCues.taskFailed.alertMessage', 'Task Failed') - }); - - public static readonly terminalCommandFailed = AudioCue.register({ - name: localize('audioCues.terminalCommandFailed', 'Terminal Command Failed'), - sound: Sound.error, - settingsKey: 'audioCues.terminalCommandFailed', - alertSettingsKey: AccessibilityAlertSettingId.TerminalCommandFailed, - alertMessage: localize('audioCues.terminalCommandFailed.alertMessage', 'Command Failed') - }); - - public static readonly terminalBell = AudioCue.register({ - name: localize('audioCues.terminalBell', 'Terminal Bell'), - sound: Sound.terminalBell, - settingsKey: 'audioCues.terminalBell', - alertSettingsKey: AccessibilityAlertSettingId.TerminalBell, - alertMessage: localize('audioCues.terminalBell.alertMessage', 'Terminal Bell') - }); - - public static readonly notebookCellCompleted = AudioCue.register({ - name: localize('audioCues.notebookCellCompleted', 'Notebook Cell Completed'), - sound: Sound.taskCompleted, - settingsKey: 'audioCues.notebookCellCompleted', - alertSettingsKey: AccessibilityAlertSettingId.NotebookCellCompleted, - alertMessage: localize('audioCues.notebookCellCompleted.alertMessage', 'Notebook Cell Completed') - }); - - public static readonly notebookCellFailed = AudioCue.register({ - name: localize('audioCues.notebookCellFailed', 'Notebook Cell Failed'), - sound: Sound.taskFailed, - settingsKey: 'audioCues.notebookCellFailed', - alertSettingsKey: AccessibilityAlertSettingId.NotebookCellFailed, - alertMessage: localize('audioCues.notebookCellFailed.alertMessage', 'Notebook Cell Failed') - }); - - public static readonly diffLineInserted = AudioCue.register({ - name: localize('audioCues.diffLineInserted', 'Diff Line Inserted'), - sound: Sound.diffLineInserted, - settingsKey: 'audioCues.diffLineInserted', - }); - - public static readonly diffLineDeleted = AudioCue.register({ - name: localize('audioCues.diffLineDeleted', 'Diff Line Deleted'), - sound: Sound.diffLineDeleted, - settingsKey: 'audioCues.diffLineDeleted', - }); - - public static readonly diffLineModified = AudioCue.register({ - name: localize('audioCues.diffLineModified', 'Diff Line Modified'), - sound: Sound.diffLineModified, - settingsKey: 'audioCues.diffLineModified', - }); - - public static readonly chatRequestSent = AudioCue.register({ - name: localize('audioCues.chatRequestSent', 'Chat Request Sent'), - sound: Sound.chatRequestSent, - settingsKey: 'audioCues.chatRequestSent', - alertSettingsKey: AccessibilityAlertSettingId.ChatRequestSent, - alertMessage: localize('audioCues.chatRequestSent.alertMessage', 'Chat Request Sent') - }); - - public static readonly chatResponseReceived = AudioCue.register({ - name: localize('audioCues.chatResponseReceived', 'Chat Response Received'), - settingsKey: 'audioCues.chatResponseReceived', - sound: { - randomOneOf: [ - Sound.chatResponseReceived1, - Sound.chatResponseReceived2, - Sound.chatResponseReceived3, - Sound.chatResponseReceived4 - ] - }, - }); - - public static readonly chatResponsePending = AudioCue.register({ - name: localize('audioCues.chatResponsePending', 'Chat Response Pending'), - sound: Sound.chatResponsePending, - settingsKey: 'audioCues.chatResponsePending', - alertSettingsKey: AccessibilityAlertSettingId.ChatResponsePending, - alertMessage: localize('audioCues.chatResponsePending.alertMessage', 'Chat Response Pending') - }); - - public static readonly clear = AudioCue.register({ - name: localize('audioCues.clear', 'Clear'), - sound: Sound.clear, - settingsKey: 'audioCues.clear', - alertSettingsKey: AccessibilityAlertSettingId.Clear, - alertMessage: localize('audioCues.clear.alertMessage', 'Clear') - }); - - public static readonly save = AudioCue.register({ - name: localize('audioCues.save', 'Save'), - sound: Sound.save, - settingsKey: 'audioCues.save', - alertSettingsKey: AccessibilityAlertSettingId.Save, - alertMessage: localize('audioCues.save.alertMessage', 'Save') - }); - - public static readonly format = AudioCue.register({ - name: localize('audioCues.format', 'Format'), - sound: Sound.format, - settingsKey: 'audioCues.format', - alertSettingsKey: AccessibilityAlertSettingId.Format, - alertMessage: localize('audioCues.format.alertMessage', 'Format') - }); - - private constructor( - public readonly sound: SoundSource, - public readonly name: string, - public readonly settingsKey: string, - public readonly alertSettingsKey?: string, - public readonly alertMessage?: string - ) { } -} diff --git a/src/vs/platform/audioCues/browser/media/terminalBell.mp3 b/src/vs/platform/audioCues/browser/media/terminalBell.mp3 deleted file mode 100644 index f00aa6de2bbad..0000000000000 Binary files a/src/vs/platform/audioCues/browser/media/terminalBell.mp3 and /dev/null differ diff --git a/src/vs/platform/auxiliaryWindow/electron-main/auxiliaryWindows.ts b/src/vs/platform/auxiliaryWindow/electron-main/auxiliaryWindows.ts index 699659d0c77c8..4ce7e22bbff26 100644 --- a/src/vs/platform/auxiliaryWindow/electron-main/auxiliaryWindows.ts +++ b/src/vs/platform/auxiliaryWindow/electron-main/auxiliaryWindows.ts @@ -5,7 +5,6 @@ import { BrowserWindowConstructorOptions, HandlerDetails, WebContents } from 'electron'; import { Event } from 'vs/base/common/event'; -import { Schemas, VSCODE_AUTHORITY } from 'vs/base/common/network'; import { IAuxiliaryWindow } from 'vs/platform/auxiliaryWindow/electron-main/auxiliaryWindow'; import { createDecorator } from 'vs/platform/instantiation/common/instantiation'; @@ -30,7 +29,3 @@ export interface IAuxiliaryWindowsMainService { getWindows(): readonly IAuxiliaryWindow[]; } - -export function isAuxiliaryWindow(webContents: WebContents): boolean { - return webContents?.opener?.url.startsWith(`${Schemas.vscodeFileResource}://${VSCODE_AUTHORITY}/`); -} diff --git a/src/vs/platform/auxiliaryWindow/electron-main/auxiliaryWindowsMainService.ts b/src/vs/platform/auxiliaryWindow/electron-main/auxiliaryWindowsMainService.ts index 786f2ef963945..1174386d1c2f2 100644 --- a/src/vs/platform/auxiliaryWindow/electron-main/auxiliaryWindowsMainService.ts +++ b/src/vs/platform/auxiliaryWindow/electron-main/auxiliaryWindowsMainService.ts @@ -12,7 +12,7 @@ import { AuxiliaryWindow, IAuxiliaryWindow } from 'vs/platform/auxiliaryWindow/e import { IAuxiliaryWindowsMainService } from 'vs/platform/auxiliaryWindow/electron-main/auxiliaryWindows'; import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; import { ILogService } from 'vs/platform/log/common/log'; -import { IWindowState } from 'vs/platform/window/electron-main/window'; +import { IWindowState, defaultAuxWindowState } from 'vs/platform/window/electron-main/window'; import { WindowStateValidator, defaultBrowserWindowOptions, getLastFocused } from 'vs/platform/windows/electron-main/windows'; export class AuxiliaryWindowsMainService extends Disposable implements IAuxiliaryWindowsMainService { @@ -79,7 +79,7 @@ export class AuxiliaryWindowsMainService extends Disposable implements IAuxiliar }); } - private validateWindowState(details: HandlerDetails): IWindowState | undefined { + private validateWindowState(details: HandlerDetails): IWindowState { const windowState: IWindowState = {}; const features = details.features.split(','); // for example: popup=yes,left=270,top=14.5,width=800,height=600 @@ -101,7 +101,11 @@ export class AuxiliaryWindowsMainService extends Disposable implements IAuxiliar } } - return WindowStateValidator.validateWindowState(this.logService, windowState); + const state = WindowStateValidator.validateWindowState(this.logService, windowState) ?? defaultAuxWindowState(); + + this.logService.trace('[aux window] using window state', state); + + return state; } registerWindow(webContents: WebContents): void { diff --git a/src/vs/platform/contextview/browser/contextView.ts b/src/vs/platform/contextview/browser/contextView.ts index 10158c8d75cc4..87c58811d8d52 100644 --- a/src/vs/platform/contextview/browser/contextView.ts +++ b/src/vs/platform/contextview/browser/contextView.ts @@ -43,6 +43,9 @@ export interface IContextViewDelegate { focus?(): void; anchorAlignment?: AnchorAlignment; anchorAxisAlignment?: AnchorAxisAlignment; + + // context views with higher layers are rendered over contet views with lower layers + layer?: number; // Default: 0 } export const IContextMenuService = createDecorator('contextMenuService'); diff --git a/src/vs/platform/contextview/browser/contextViewService.ts b/src/vs/platform/contextview/browser/contextViewService.ts index f47285746fed1..372e162a6387c 100644 --- a/src/vs/platform/contextview/browser/contextViewService.ts +++ b/src/vs/platform/contextview/browser/contextViewService.ts @@ -3,18 +3,17 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { ContextView, ContextViewDOMPosition } from 'vs/base/browser/ui/contextview/contextview'; -import { Disposable, IDisposable, toDisposable } from 'vs/base/common/lifecycle'; +import { ContextView, ContextViewDOMPosition, IContextViewProvider } from 'vs/base/browser/ui/contextview/contextview'; +import { Disposable, IDisposable, MutableDisposable, toDisposable } from 'vs/base/common/lifecycle'; import { ILayoutService } from 'vs/platform/layout/browser/layoutService'; import { IContextViewDelegate, IContextViewService } from './contextView'; import { getWindow } from 'vs/base/browser/dom'; -export class ContextViewService extends Disposable implements IContextViewService { - declare readonly _serviceBrand: undefined; +export class ContextViewHandler extends Disposable implements IContextViewProvider { - private currentViewDisposable: IDisposable = Disposable.None; - private readonly contextView = this._register(new ContextView(this.layoutService.mainContainer, ContextViewDOMPosition.ABSOLUTE)); + private readonly currentViewDisposable = this._register(new MutableDisposable()); + protected readonly contextView = this._register(new ContextView(this.layoutService.mainContainer, ContextViewDOMPosition.ABSOLUTE)); constructor( @ILayoutService private readonly layoutService: ILayoutService @@ -51,14 +50,10 @@ export class ContextViewService extends Disposable implements IContextViewServic } }); - this.currentViewDisposable = disposable; + this.currentViewDisposable.value = disposable; return disposable; } - getContextViewElement(): HTMLElement { - return this.contextView.getViewElement(); - } - layout(): void { this.contextView.layout(); } @@ -66,11 +61,13 @@ export class ContextViewService extends Disposable implements IContextViewServic hideContextView(data?: any): void { this.contextView.hide(data); } +} + +export class ContextViewService extends ContextViewHandler implements IContextViewService { - override dispose(): void { - super.dispose(); + declare readonly _serviceBrand: undefined; - this.currentViewDisposable.dispose(); - this.currentViewDisposable = Disposable.None; + getContextViewElement(): HTMLElement { + return this.contextView.getViewElement(); } } diff --git a/src/vs/platform/diagnostics/node/diagnosticsService.ts b/src/vs/platform/diagnostics/node/diagnosticsService.ts index 0be311f5051b7..7ca4d9c278c05 100644 --- a/src/vs/platform/diagnostics/node/diagnosticsService.ts +++ b/src/vs/platform/diagnostics/node/diagnosticsService.ts @@ -539,8 +539,8 @@ export class DiagnosticsService implements IDiagnosticsService { owner: 'lramos15'; comment: 'Helps us gain insights into what type of files are being used in a workspace'; rendererSessionId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The ID of the session.' }; - type: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'The type of file' }; - count: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'How many types of that file are present' }; + type: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The type of file' }; + count: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'How many types of that file are present' }; }; type WorkspaceStatsFileEvent = { rendererSessionId: string; diff --git a/src/vs/platform/editor/common/editor.ts b/src/vs/platform/editor/common/editor.ts index 24e2e4c5506c2..159bea6fc8ea8 100644 --- a/src/vs/platform/editor/common/editor.ts +++ b/src/vs/platform/editor/common/editor.ts @@ -288,6 +288,19 @@ export interface IEditorOptions { * applied when opening the editor. */ viewState?: object; + + /** + * A transient editor will attempt to appear as preview and certain components + * (such as history tracking) may decide to ignore the editor when it becomes + * active. + * This option is meant to be used only when the editor is used for a short + * period of time, for example when opening a preview of the editor from a + * picker control in the background while navigating through results of the picker. + * + * Note: an editor that is already opened in a group that is not transient, will + * not turn transient. + */ + transient?: boolean; } export interface ITextEditorSelection { diff --git a/src/vs/platform/environment/common/argv.ts b/src/vs/platform/environment/common/argv.ts index cc157af7ab3fe..f8d2ed05f8cd6 100644 --- a/src/vs/platform/environment/common/argv.ts +++ b/src/vs/platform/environment/common/argv.ts @@ -38,7 +38,6 @@ export interface NativeParsedArgs { add?: boolean; goto?: boolean; 'new-window'?: boolean; - 'unity-launch'?: boolean; // Always open a new window, except if opening the first window or opening a file or folder as part of the launch. 'reuse-window'?: boolean; locale?: string; 'user-data-dir'?: string; @@ -140,4 +139,5 @@ export interface NativeParsedArgs { 'log-net-log'?: string; 'vmodule'?: string; 'disable-dev-shm-usage'?: boolean; + 'ozone-platform'?: string; } diff --git a/src/vs/platform/environment/node/argv.ts b/src/vs/platform/environment/node/argv.ts index a5d96361f08d1..91b5d5aeabaf7 100644 --- a/src/vs/platform/environment/node/argv.ts +++ b/src/vs/platform/environment/node/argv.ts @@ -157,7 +157,6 @@ export const OPTIONS: OptionDescriptions> = { 'crash-reporter-directory': { type: 'string' }, 'crash-reporter-id': { type: 'string' }, 'skip-add-to-recently-opened': { type: 'boolean' }, - 'unity-launch': { type: 'boolean' }, 'open-url': { type: 'boolean' }, 'file-write': { type: 'boolean' }, 'file-chmod': { type: 'boolean' }, @@ -204,6 +203,7 @@ export const OPTIONS: OptionDescriptions> = { '_urls': { type: 'string[]' }, 'disable-dev-shm-usage': { type: 'boolean' }, 'profile-temp': { type: 'boolean' }, + 'ozone-platform': { type: 'string' }, _: { type: 'string[]' } // main arguments }; @@ -214,7 +214,7 @@ export interface ErrorReporter { onEmptyValue(id: string): void; onDeprecatedOption(deprecatedId: string, message: string): void; - getSubcommandReporter?(commmand: string): ErrorReporter; + getSubcommandReporter?(command: string): ErrorReporter; } const ignoringReporter = { diff --git a/src/vs/platform/extensionManagement/common/abstractExtensionManagementService.ts b/src/vs/platform/extensionManagement/common/abstractExtensionManagementService.ts index ced410cb52e1b..d43b6b3c8d430 100644 --- a/src/vs/platform/extensionManagement/common/abstractExtensionManagementService.ts +++ b/src/vs/platform/extensionManagement/common/abstractExtensionManagementService.ts @@ -16,9 +16,10 @@ import * as nls from 'vs/nls'; import { ExtensionManagementError, IExtensionGalleryService, IExtensionIdentifier, IExtensionManagementParticipant, IGalleryExtension, ILocalExtension, InstallOperation, IExtensionsControlManifest, StatisticType, isTargetPlatformCompatible, TargetPlatformToString, ExtensionManagementErrorCode, - InstallOptions, InstallVSIXOptions, UninstallOptions, Metadata, InstallExtensionEvent, DidUninstallExtensionEvent, InstallExtensionResult, UninstallExtensionEvent, IExtensionManagementService, InstallExtensionInfo, EXTENSION_INSTALL_DEP_PACK_CONTEXT, ExtensionGalleryError + InstallOptions, UninstallOptions, Metadata, InstallExtensionEvent, DidUninstallExtensionEvent, InstallExtensionResult, UninstallExtensionEvent, IExtensionManagementService, InstallExtensionInfo, EXTENSION_INSTALL_DEP_PACK_CONTEXT, ExtensionGalleryError, + IProductVersion } from 'vs/platform/extensionManagement/common/extensionManagement'; -import { areSameExtensions, ExtensionKey, getGalleryExtensionTelemetryData, getLocalExtensionTelemetryData } from 'vs/platform/extensionManagement/common/extensionManagementUtil'; +import { areSameExtensions, ExtensionKey, getGalleryExtensionId, getGalleryExtensionTelemetryData, getLocalExtensionTelemetryData } from 'vs/platform/extensionManagement/common/extensionManagementUtil'; import { ExtensionType, IExtensionManifest, isApplicationScopedExtension, TargetPlatform } from 'vs/platform/extensions/common/extensions'; import { ILogService } from 'vs/platform/log/common/log'; import { IProductService } from 'vs/platform/product/common/productService'; @@ -27,10 +28,11 @@ import { IUriIdentityService } from 'vs/platform/uriIdentity/common/uriIdentity' import { IUserDataProfilesService } from 'vs/platform/userDataProfile/common/userDataProfile'; export type ExtensionVerificationStatus = boolean | string; -export type InstallableExtension = { readonly manifest: IExtensionManifest; extension: IGalleryExtension | URI; options: InstallOptions & InstallVSIXOptions }; +export type InstallableExtension = { readonly manifest: IExtensionManifest; extension: IGalleryExtension | URI; options: InstallOptions }; -export type InstallExtensionTaskOptions = InstallOptions & InstallVSIXOptions & { readonly profileLocation: URI }; +export type InstallExtensionTaskOptions = InstallOptions & { readonly profileLocation: URI; readonly productVersion: IProductVersion }; export interface IInstallExtensionTask { + readonly manifest: IExtensionManifest; readonly identifier: IExtensionIdentifier; readonly source: IGalleryExtension | URI; readonly operation: InstallOperation; @@ -124,7 +126,7 @@ export abstract class AbstractExtensionManagementService extends Disposable impl await Promise.allSettled(extensions.map(async ({ extension, options }) => { try { - const compatible = await this.checkAndGetCompatibleVersion(extension, !!options?.installGivenVersion, !!options?.installPreReleaseVersion); + const compatible = await this.checkAndGetCompatibleVersion(extension, !!options?.installGivenVersion, !!options?.installPreReleaseVersion, options.productVersion ?? { version: this.productService.version, date: this.productService.date }); installableExtensions.push({ ...compatible, options }); } catch (error) { results.push({ identifier: extension.identifier, operation: InstallOperation.Install, source: extension, error }); @@ -203,17 +205,15 @@ export abstract class AbstractExtensionManagementService extends Disposable impl } protected async installExtensions(extensions: InstallableExtension[]): Promise { - const results: InstallExtensionResult[] = []; - - const installingExtensionsMap = new Map(); + const installExtensionResultsMap = new Map(); + const installingExtensionsMap = new Map(); const alreadyRequestedInstallations: Promise[] = []; - const successResults: (InstallExtensionResult & { local: ILocalExtension; profileLocation: URI })[] = []; const getInstallExtensionTaskKey = (extension: IGalleryExtension, profileLocation: URI) => `${ExtensionKey.create(extension).toString()}-${profileLocation.toString()}`; - const createInstallExtensionTask = (manifest: IExtensionManifest, extension: IGalleryExtension | URI, options: InstallExtensionTaskOptions): void => { + const createInstallExtensionTask = (manifest: IExtensionManifest, extension: IGalleryExtension | URI, options: InstallExtensionTaskOptions, root: IInstallExtensionTask | undefined): void => { const installExtensionTask = this.createInstallExtensionTask(manifest, extension, options); - const key = URI.isUri(extension) ? extension.path : `${extension.identifier.id.toLowerCase()}-${options.profileLocation.toString()}`; - installingExtensionsMap.set(key, { task: installExtensionTask, manifest }); + const key = `${getGalleryExtensionId(manifest.publisher, manifest.name)}-${options.profileLocation.toString()}`; + installingExtensionsMap.set(key, { task: installExtensionTask, root }); this._onInstallExtension.fire({ identifier: installExtensionTask.identifier, source: extension, profileLocation: options.profileLocation }); this.logService.info('Installing extension:', installExtensionTask.identifier.id); // only cache gallery extensions tasks @@ -228,9 +228,10 @@ export abstract class AbstractExtensionManagementService extends Disposable impl const isApplicationScoped = options.isApplicationScoped || options.isBuiltin || isApplicationScopedExtension(manifest); const installExtensionTaskOptions: InstallExtensionTaskOptions = { ...options, - installOnlyNewlyAddedFromExtensionPack: URI.isUri(extension) ? options.installOnlyNewlyAddedFromExtensionPack : true, /* always true for gallery extensions */ + installOnlyNewlyAddedFromExtensionPack: options.installOnlyNewlyAddedFromExtensionPack ?? !URI.isUri(extension) /* always true for gallery extensions */, isApplicationScoped, - profileLocation: isApplicationScoped ? this.userDataProfilesService.defaultProfile.extensionsResource : options.profileLocation ?? this.getCurrentExtensionsManifestLocation() + profileLocation: isApplicationScoped ? this.userDataProfilesService.defaultProfile.extensionsResource : options.profileLocation ?? this.getCurrentExtensionsManifestLocation(), + productVersion: options.productVersion ?? { version: this.productService.version, date: this.productService.date } }; const existingInstallExtensionTask = !URI.isUri(extension) ? this.installingExtensions.get(getInstallExtensionTaskKey(extension, installExtensionTaskOptions.profileLocation)) : undefined; @@ -238,19 +239,19 @@ export abstract class AbstractExtensionManagementService extends Disposable impl this.logService.info('Extension is already requested to install', existingInstallExtensionTask.task.identifier.id); alreadyRequestedInstallations.push(existingInstallExtensionTask.task.waitUntilTaskIsFinished()); } else { - createInstallExtensionTask(manifest, extension, installExtensionTaskOptions); + createInstallExtensionTask(manifest, extension, installExtensionTaskOptions, undefined); } } // collect and start installing all dependencies and pack extensions - await Promise.all([...installingExtensionsMap.values()].map(async ({ task, manifest }) => { + await Promise.all([...installingExtensionsMap.values()].map(async ({ task }) => { if (task.options.donotIncludePackAndDependencies) { this.logService.info('Installing the extension without checking dependencies and pack', task.identifier.id); } else { try { - const allDepsAndPackExtensionsToInstall = await this.getAllDepsAndPackExtensions(task.identifier, manifest, !!task.options.installOnlyNewlyAddedFromExtensionPack, !!task.options.installPreReleaseVersion, task.options.profileLocation); - const installed = await this.getInstalled(undefined, task.options.profileLocation); - const options: InstallExtensionTaskOptions = { ...task.options, donotIncludePackAndDependencies: true, context: { ...task.options.context, [EXTENSION_INSTALL_DEP_PACK_CONTEXT]: true } }; + const allDepsAndPackExtensionsToInstall = await this.getAllDepsAndPackExtensions(task.identifier, task.manifest, !!task.options.installOnlyNewlyAddedFromExtensionPack, !!task.options.installPreReleaseVersion, task.options.profileLocation, task.options.productVersion); + const installed = await this.getInstalled(undefined, task.options.profileLocation, task.options.productVersion); + const options: InstallExtensionTaskOptions = { ...task.options, context: { ...task.options.context, [EXTENSION_INSTALL_DEP_PACK_CONTEXT]: true } }; for (const { gallery, manifest } of distinct(allDepsAndPackExtensionsToInstall, ({ gallery }) => gallery.identifier.id)) { if (installingExtensionsMap.has(`${gallery.identifier.id.toLowerCase()}-${options.profileLocation.toString()}`)) { continue; @@ -275,17 +276,17 @@ export abstract class AbstractExtensionManagementService extends Disposable impl })); } } else if (!installed.some(({ identifier }) => areSameExtensions(identifier, gallery.identifier))) { - createInstallExtensionTask(manifest, gallery, options); + createInstallExtensionTask(manifest, gallery, options, task); } } } catch (error) { // Installing through VSIX if (URI.isUri(task.source)) { // Ignore installing dependencies and packs - if (isNonEmptyArray(manifest.extensionDependencies)) { + if (isNonEmptyArray(task.manifest.extensionDependencies)) { this.logService.warn(`Cannot install dependencies of extension:`, task.identifier.id, error.message); } - if (isNonEmptyArray(manifest.extensionPack)) { + if (isNonEmptyArray(task.manifest.extensionPack)) { this.logService.warn(`Cannot install packed extensions of extension:`, task.identifier.id, error.message); } } else { @@ -297,7 +298,7 @@ export abstract class AbstractExtensionManagementService extends Disposable impl })); // Install extensions in parallel and wait until all extensions are installed / failed - await this.joinAllSettled([...installingExtensionsMap.values()].map(async ({ task }) => { + await this.joinAllSettled([...installingExtensionsMap.entries()].map(async ([key, { task }]) => { const startTime = new Date().getTime(); try { const local = await task.run(); @@ -318,9 +319,9 @@ export abstract class AbstractExtensionManagementService extends Disposable impl } catch (error) { /* ignore */ } } } - - successResults.push({ local, identifier: task.identifier, operation: task.operation, source: task.source, context: task.options.context, profileLocation: task.profileLocation, applicationScoped: local.isApplicationScoped }); + installExtensionResultsMap.set(key, { local, identifier: task.identifier, operation: task.operation, source: task.source, context: task.options.context, profileLocation: task.profileLocation, applicationScoped: local.isApplicationScoped }); } catch (error) { + installExtensionResultsMap.set(key, { error, identifier: task.identifier, operation: task.operation, source: task.source, context: task.options.context, profileLocation: task.profileLocation, applicationScoped: task.options.isApplicationScoped }); this.logService.error('Error while installing the extension', task.identifier.id, getErrorMessage(error)); throw error; } @@ -329,31 +330,69 @@ export abstract class AbstractExtensionManagementService extends Disposable impl if (alreadyRequestedInstallations.length) { await this.joinAllSettled(alreadyRequestedInstallations); } - - for (const result of successResults) { - this.logService.info(`Extension installed successfully:`, result.identifier.id); - results.push(result); - } - return results; + return [...installExtensionResultsMap.values()]; } catch (error) { - // rollback installed extensions - if (successResults.length) { - await Promise.allSettled(successResults.map(async ({ local, profileLocation }) => { + const getAllDepsAndPacks = (extension: ILocalExtension, profileLocation: URI, allDepsOrPacks: string[]) => { + const depsOrPacks = []; + if (extension.manifest.extensionDependencies?.length) { + depsOrPacks.push(...extension.manifest.extensionDependencies); + } + if (extension.manifest.extensionPack?.length) { + depsOrPacks.push(...extension.manifest.extensionPack); + } + for (const id of depsOrPacks) { + if (allDepsOrPacks.includes(id.toLowerCase())) { + continue; + } + allDepsOrPacks.push(id.toLowerCase()); + const installed = installExtensionResultsMap.get(`${id.toLowerCase()}-${profileLocation.toString()}`); + if (installed?.local) { + allDepsOrPacks = getAllDepsAndPacks(installed.local, profileLocation, allDepsOrPacks); + } + } + return allDepsOrPacks; + }; + const getErrorResult = (task: IInstallExtensionTask) => ({ identifier: task.identifier, operation: InstallOperation.Install, source: task.source, context: task.options.context, profileLocation: task.profileLocation, error }); + + const rollbackTasks: IUninstallExtensionTask[] = []; + for (const [key, { task, root }] of installingExtensionsMap) { + const result = installExtensionResultsMap.get(key); + if (!result) { + task.cancel(); + installExtensionResultsMap.set(key, getErrorResult(task)); + } + // If the extension is installed by a root task and the root task is failed, then uninstall the extension + else if (result.local && root && !installExtensionResultsMap.get(`${root.identifier.id.toLowerCase()}-${task.profileLocation.toString()}`)?.local) { + rollbackTasks.push(this.createUninstallExtensionTask(result.local, { versionOnly: true, profileLocation: task.profileLocation })); + installExtensionResultsMap.set(key, getErrorResult(task)); + } + } + for (const [key, { task }] of installingExtensionsMap) { + const result = installExtensionResultsMap.get(key); + if (!result?.local) { + continue; + } + if (task.options.donotIncludePackAndDependencies) { + continue; + } + const depsOrPacks = getAllDepsAndPacks(result.local, task.profileLocation, [result.local.identifier.id.toLowerCase()]).slice(1); + if (depsOrPacks.some(depOrPack => installingExtensionsMap.has(`${depOrPack.toLowerCase()}-${task.profileLocation.toString()}`) && !installExtensionResultsMap.get(`${depOrPack.toLowerCase()}-${task.profileLocation.toString()}`)?.local)) { + rollbackTasks.push(this.createUninstallExtensionTask(result.local, { versionOnly: true, profileLocation: task.profileLocation })); + installExtensionResultsMap.set(key, getErrorResult(task)); + } + } + + if (rollbackTasks.length) { + await Promise.allSettled(rollbackTasks.map(async rollbackTask => { try { - await this.createUninstallExtensionTask(local, { versionOnly: true, profileLocation }).run(); - this.logService.info('Rollback: Uninstalled extension', local.identifier.id); + await rollbackTask.run(); + this.logService.info('Rollback: Uninstalled extension', rollbackTask.extension.identifier.id); } catch (error) { - this.logService.warn('Rollback: Error while uninstalling extension', local.identifier.id, getErrorMessage(error)); + this.logService.warn('Rollback: Error while uninstalling extension', rollbackTask.extension.identifier.id, getErrorMessage(error)); } })); } - // cancel all tasks and collect error results - for (const { task } of installingExtensionsMap.values()) { - task.cancel(); - results.push({ identifier: task.identifier, operation: InstallOperation.Install, source: task.source, context: task.options.context, profileLocation: task.profileLocation, error }); - } - throw error; } finally { // Finally, remove all the tasks from the cache @@ -362,7 +401,13 @@ export abstract class AbstractExtensionManagementService extends Disposable impl this.installingExtensions.delete(getInstallExtensionTaskKey(task.source, task.profileLocation)); } } - if (results.length) { + if (installExtensionResultsMap.size) { + const results = [...installExtensionResultsMap.values()]; + for (const result of results) { + if (result.local) { + this.logService.info(`Extension installed successfully:`, result.identifier.id); + } + } this._onDidInstallExtensions.fire(results); } } @@ -400,17 +445,21 @@ export abstract class AbstractExtensionManagementService extends Disposable impl errors.push(r.reason); } } + // If there are errors, throw the error. - if (errors.length) { throw joinErrors(errors); } + if (errors.length) { + throw joinErrors(errors); + } + return results; } - private async getAllDepsAndPackExtensions(extensionIdentifier: IExtensionIdentifier, manifest: IExtensionManifest, getOnlyNewlyAddedFromExtensionPack: boolean, installPreRelease: boolean, profile: URI | undefined): Promise<{ gallery: IGalleryExtension; manifest: IExtensionManifest }[]> { + private async getAllDepsAndPackExtensions(extensionIdentifier: IExtensionIdentifier, manifest: IExtensionManifest, getOnlyNewlyAddedFromExtensionPack: boolean, installPreRelease: boolean, profile: URI | undefined, productVersion: IProductVersion): Promise<{ gallery: IGalleryExtension; manifest: IExtensionManifest }[]> { if (!this.galleryService.isEnabled()) { return []; } - const installed = await this.getInstalled(undefined, profile); + const installed = await this.getInstalled(undefined, profile, productVersion); const knownIdentifiers: IExtensionIdentifier[] = []; const allDependenciesAndPacks: { gallery: IGalleryExtension; manifest: IExtensionManifest }[] = []; @@ -442,7 +491,7 @@ export abstract class AbstractExtensionManagementService extends Disposable impl const isDependency = dependecies.some(id => areSameExtensions({ id }, galleryExtension.identifier)); let compatible; try { - compatible = await this.checkAndGetCompatibleVersion(galleryExtension, false, installPreRelease); + compatible = await this.checkAndGetCompatibleVersion(galleryExtension, false, installPreRelease, productVersion); } catch (error) { if (!isDependency) { this.logService.info('Skipping the packed extension as it cannot be installed', galleryExtension.identifier.id, getErrorMessage(error)); @@ -462,7 +511,7 @@ export abstract class AbstractExtensionManagementService extends Disposable impl return allDependenciesAndPacks; } - private async checkAndGetCompatibleVersion(extension: IGalleryExtension, sameVersion: boolean, installPreRelease: boolean): Promise<{ extension: IGalleryExtension; manifest: IExtensionManifest }> { + private async checkAndGetCompatibleVersion(extension: IGalleryExtension, sameVersion: boolean, installPreRelease: boolean, productVersion: IProductVersion): Promise<{ extension: IGalleryExtension; manifest: IExtensionManifest }> { let compatibleExtension: IGalleryExtension | null; const extensionsControlManifest = await this.getExtensionsControlManifest(); @@ -473,7 +522,7 @@ export abstract class AbstractExtensionManagementService extends Disposable impl const deprecationInfo = extensionsControlManifest.deprecated[extension.identifier.id.toLowerCase()]; if (deprecationInfo?.extension?.autoMigrate) { this.logService.info(`The '${extension.identifier.id}' extension is deprecated, fetching the compatible '${deprecationInfo.extension.id}' extension instead.`); - compatibleExtension = (await this.galleryService.getExtensions([{ id: deprecationInfo.extension.id, preRelease: deprecationInfo.extension.preRelease }], { targetPlatform: await this.getTargetPlatform(), compatible: true }, CancellationToken.None))[0]; + compatibleExtension = (await this.galleryService.getExtensions([{ id: deprecationInfo.extension.id, preRelease: deprecationInfo.extension.preRelease }], { targetPlatform: await this.getTargetPlatform(), compatible: true, productVersion }, CancellationToken.None))[0]; if (!compatibleExtension) { throw new ExtensionManagementError(nls.localize('notFoundDeprecatedReplacementExtension', "Can't install '{0}' extension since it was deprecated and the replacement extension '{1}' can't be found.", extension.identifier.id, deprecationInfo.extension.id), ExtensionManagementErrorCode.Deprecated); } @@ -485,7 +534,7 @@ export abstract class AbstractExtensionManagementService extends Disposable impl throw new ExtensionManagementError(nls.localize('incompatible platform', "The '{0}' extension is not available in {1} for {2}.", extension.identifier.id, this.productService.nameLong, TargetPlatformToString(targetPlatform)), ExtensionManagementErrorCode.IncompatibleTargetPlatform); } - compatibleExtension = await this.getCompatibleVersion(extension, sameVersion, installPreRelease); + compatibleExtension = await this.getCompatibleVersion(extension, sameVersion, installPreRelease, productVersion); if (!compatibleExtension) { /** If no compatible release version is found, check if the extension has a release version or not and throw relevant error */ if (!installPreRelease && extension.properties.isPreReleaseVersion && (await this.galleryService.getExtensions([extension.identifier], CancellationToken.None))[0]) { @@ -508,23 +557,23 @@ export abstract class AbstractExtensionManagementService extends Disposable impl return { extension: compatibleExtension, manifest }; } - protected async getCompatibleVersion(extension: IGalleryExtension, sameVersion: boolean, includePreRelease: boolean): Promise { + protected async getCompatibleVersion(extension: IGalleryExtension, sameVersion: boolean, includePreRelease: boolean, productVersion: IProductVersion): Promise { const targetPlatform = await this.getTargetPlatform(); let compatibleExtension: IGalleryExtension | null = null; if (!sameVersion && extension.hasPreReleaseVersion && extension.properties.isPreReleaseVersion !== includePreRelease) { - compatibleExtension = (await this.galleryService.getExtensions([{ ...extension.identifier, preRelease: includePreRelease }], { targetPlatform, compatible: true }, CancellationToken.None))[0] || null; + compatibleExtension = (await this.galleryService.getExtensions([{ ...extension.identifier, preRelease: includePreRelease }], { targetPlatform, compatible: true, productVersion }, CancellationToken.None))[0] || null; } - if (!compatibleExtension && await this.galleryService.isExtensionCompatible(extension, includePreRelease, targetPlatform)) { + if (!compatibleExtension && await this.galleryService.isExtensionCompatible(extension, includePreRelease, targetPlatform, productVersion)) { compatibleExtension = extension; } if (!compatibleExtension) { if (sameVersion) { - compatibleExtension = (await this.galleryService.getExtensions([{ ...extension.identifier, version: extension.version }], { targetPlatform, compatible: true }, CancellationToken.None))[0] || null; + compatibleExtension = (await this.galleryService.getExtensions([{ ...extension.identifier, version: extension.version }], { targetPlatform, compatible: true, productVersion }, CancellationToken.None))[0] || null; } else { - compatibleExtension = await this.galleryService.getCompatibleExtension(extension, includePreRelease, targetPlatform); + compatibleExtension = await this.galleryService.getCompatibleExtension(extension, includePreRelease, targetPlatform, productVersion); } } @@ -715,10 +764,10 @@ export abstract class AbstractExtensionManagementService extends Disposable impl abstract zip(extension: ILocalExtension): Promise; abstract unzip(zipLocation: URI): Promise; abstract getManifest(vsix: URI): Promise; - abstract install(vsix: URI, options?: InstallVSIXOptions): Promise; + abstract install(vsix: URI, options?: InstallOptions): Promise; abstract installFromLocation(location: URI, profileLocation: URI): Promise; abstract installExtensionsFromProfile(extensions: IExtensionIdentifier[], fromProfileLocation: URI, toProfileLocation: URI): Promise; - abstract getInstalled(type?: ExtensionType, profileLocation?: URI): Promise; + abstract getInstalled(type?: ExtensionType, profileLocation?: URI, productVersion?: IProductVersion): Promise; abstract copyExtensions(fromProfileLocation: URI, toProfileLocation: URI): Promise; abstract download(extension: IGalleryExtension, operation: InstallOperation, donotVerifySignature: boolean): Promise; abstract reinstallFromGallery(extension: ILocalExtension): Promise; @@ -835,7 +884,7 @@ export abstract class AbstractExtensionTask { return this.cancellablePromise!; } - async run(): Promise { + run(): Promise { if (!this.cancellablePromise) { this.cancellablePromise = createCancelablePromise(token => this.doRun(token)); } diff --git a/src/vs/platform/extensionManagement/common/extensionGalleryService.ts b/src/vs/platform/extensionManagement/common/extensionGalleryService.ts index 6afa575b1a0fd..0c9ec9e98e8f7 100644 --- a/src/vs/platform/extensionManagement/common/extensionGalleryService.ts +++ b/src/vs/platform/extensionManagement/common/extensionGalleryService.ts @@ -15,7 +15,7 @@ import { URI } from 'vs/base/common/uri'; import { IHeaders, IRequestContext, IRequestOptions } from 'vs/base/parts/request/common/request'; import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; import { IEnvironmentService } from 'vs/platform/environment/common/environment'; -import { getTargetPlatform, IExtensionGalleryService, IExtensionIdentifier, IExtensionInfo, IGalleryExtension, IGalleryExtensionAsset, IGalleryExtensionAssets, IGalleryExtensionVersion, InstallOperation, IQueryOptions, IExtensionsControlManifest, isNotWebExtensionInWebTargetPlatform, isTargetPlatformCompatible, ITranslation, SortBy, SortOrder, StatisticType, toTargetPlatform, WEB_EXTENSION_TAG, IExtensionQueryOptions, IDeprecationInfo, ISearchPrefferedResults, ExtensionGalleryError, ExtensionGalleryErrorCode } from 'vs/platform/extensionManagement/common/extensionManagement'; +import { getTargetPlatform, IExtensionGalleryService, IExtensionIdentifier, IExtensionInfo, IGalleryExtension, IGalleryExtensionAsset, IGalleryExtensionAssets, IGalleryExtensionVersion, InstallOperation, IQueryOptions, IExtensionsControlManifest, isNotWebExtensionInWebTargetPlatform, isTargetPlatformCompatible, ITranslation, SortBy, SortOrder, StatisticType, toTargetPlatform, WEB_EXTENSION_TAG, IExtensionQueryOptions, IDeprecationInfo, ISearchPrefferedResults, ExtensionGalleryError, ExtensionGalleryErrorCode, IProductVersion } from 'vs/platform/extensionManagement/common/extensionManagement'; import { adoptToGalleryExtensionId, areSameExtensions, getGalleryExtensionId, getGalleryExtensionTelemetryData } from 'vs/platform/extensionManagement/common/extensionManagementUtil'; import { IExtensionManifest, TargetPlatform } from 'vs/platform/extensions/common/extensions'; import { isEngineValid } from 'vs/platform/extensions/common/extensionValidator'; @@ -68,7 +68,7 @@ interface IRawGalleryExtension { readonly extensionId: string; readonly extensionName: string; readonly displayName: string; - readonly shortDescription: string; + readonly shortDescription?: string; readonly publisher: IRawGalleryExtensionPublisher; readonly versions: IRawGalleryExtensionVersion[]; readonly statistics: IRawGalleryExtensionStatistics[]; @@ -167,9 +167,8 @@ enum Flags { IncludeLatestVersionOnly = 0x200, /** - * This flag switches the asset uri to use GetAssetByName instead of CDN - * When this is used, values of base asset uri and base asset uri fallback are switched - * When this is used, source of asset files are pointed to Gallery service always even if CDN is available + * The Unpublished extension flag indicates that the extension can't be installed/downloaded. + * Users who have installed such an extension can continue to use the extension. */ Unpublished = 0x1000, @@ -296,6 +295,7 @@ type GalleryServiceAdditionalQueryEvent = { }; interface IExtensionCriteria { + readonly productVersion: IProductVersion; readonly targetPlatform: TargetPlatform; readonly compatible: boolean; readonly includePreRelease: boolean | (IExtensionIdentifier & { includePreRelease: boolean })[]; @@ -533,7 +533,7 @@ function toExtension(galleryExtension: IRawGalleryExtension, version: IRawGaller publisherDisplayName: galleryExtension.publisher.displayName, publisherDomain: galleryExtension.publisher.domain ? { link: galleryExtension.publisher.domain, verified: !!galleryExtension.publisher.isDomainVerified } : undefined, publisherSponsorLink: getSponsorLink(latestVersion), - description: galleryExtension.shortDescription || '', + description: galleryExtension.shortDescription ?? '', installCount: getStatistic(galleryExtension.statistics, 'install'), rating: getStatistic(galleryExtension.statistics, 'averagerating'), ratingCount: getStatistic(galleryExtension.statistics, 'ratingcount'), @@ -663,14 +663,14 @@ abstract class AbstractExtensionGalleryService implements IExtensionGalleryServi query = query.withSource(options.source); } - const { extensions } = await this.queryGalleryExtensions(query, { targetPlatform: options.targetPlatform ?? CURRENT_TARGET_PLATFORM, includePreRelease: includePreReleases, versions, compatible: !!options.compatible }, token); + const { extensions } = await this.queryGalleryExtensions(query, { targetPlatform: options.targetPlatform ?? CURRENT_TARGET_PLATFORM, includePreRelease: includePreReleases, versions, compatible: !!options.compatible, productVersion: options.productVersion ?? { version: this.productService.version, date: this.productService.date } }, token); if (options.source) { extensions.forEach((e, index) => setTelemetry(e, index, options.source)); } return extensions; } - async getCompatibleExtension(extension: IGalleryExtension, includePreRelease: boolean, targetPlatform: TargetPlatform): Promise { + async getCompatibleExtension(extension: IGalleryExtension, includePreRelease: boolean, targetPlatform: TargetPlatform, productVersion: IProductVersion = { version: this.productService.version, date: this.productService.date }): Promise { if (isNotWebExtensionInWebTargetPlatform(extension.allTargetPlatforms, targetPlatform)) { return null; } @@ -681,11 +681,11 @@ abstract class AbstractExtensionGalleryService implements IExtensionGalleryServi .withFlags(Flags.IncludeVersions) .withPage(1, 1) .withFilter(FilterType.ExtensionId, extension.identifier.uuid); - const { extensions } = await this.queryGalleryExtensions(query, { targetPlatform, compatible: true, includePreRelease }, CancellationToken.None); + const { extensions } = await this.queryGalleryExtensions(query, { targetPlatform, compatible: true, includePreRelease, productVersion }, CancellationToken.None); return extensions[0] || null; } - async isExtensionCompatible(extension: IGalleryExtension, includePreRelease: boolean, targetPlatform: TargetPlatform): Promise { + async isExtensionCompatible(extension: IGalleryExtension, includePreRelease: boolean, targetPlatform: TargetPlatform, productVersion: IProductVersion = { version: this.productService.version, date: this.productService.date }): Promise { if (!isTargetPlatformCompatible(extension.properties.targetPlatform, extension.allTargetPlatforms, targetPlatform)) { return false; } @@ -703,10 +703,10 @@ abstract class AbstractExtensionGalleryService implements IExtensionGalleryServi } engine = manifest.engines.vscode; } - return isEngineValid(engine, this.productService.version, this.productService.date); + return isEngineValid(engine, productVersion.version, productVersion.date); } - private async isValidVersion(rawGalleryExtensionVersion: IRawGalleryExtensionVersion, versionType: 'release' | 'prerelease' | 'any', compatible: boolean, allTargetPlatforms: TargetPlatform[], targetPlatform: TargetPlatform): Promise { + private async isValidVersion(extension: string, rawGalleryExtensionVersion: IRawGalleryExtensionVersion, versionType: 'release' | 'prerelease' | 'any', compatible: boolean, allTargetPlatforms: TargetPlatform[], targetPlatform: TargetPlatform, productVersion: IProductVersion = { version: this.productService.version, date: this.productService.date }): Promise { if (!isTargetPlatformCompatible(getTargetPlatformForExtensionVersion(rawGalleryExtensionVersion), allTargetPlatforms, targetPlatform)) { return false; } @@ -717,8 +717,8 @@ abstract class AbstractExtensionGalleryService implements IExtensionGalleryServi if (compatible) { try { - const engine = await this.getEngine(rawGalleryExtensionVersion); - if (!isEngineValid(engine, this.productService.version, this.productService.date)) { + const engine = await this.getEngine(extension, rawGalleryExtensionVersion); + if (!isEngineValid(engine, productVersion.version, productVersion.date)) { return false; } } catch (error) { @@ -785,7 +785,7 @@ abstract class AbstractExtensionGalleryService implements IExtensionGalleryServi } const runQuery = async (query: Query, token: CancellationToken) => { - const { extensions, total } = await this.queryGalleryExtensions(query, { targetPlatform: CURRENT_TARGET_PLATFORM, compatible: false, includePreRelease: !!options.includePreRelease }, token); + const { extensions, total } = await this.queryGalleryExtensions(query, { targetPlatform: CURRENT_TARGET_PLATFORM, compatible: false, includePreRelease: !!options.includePreRelease, productVersion: options.productVersion ?? { version: this.productService.version, date: this.productService.date } }, token); extensions.forEach((e, index) => setTelemetry(e, ((query.pageNumber - 1) * query.pageSize) + index, options.source)); return { extensions, total }; }; @@ -914,7 +914,7 @@ abstract class AbstractExtensionGalleryService implements IExtensionGalleryServi continue; } // Allow any version if includePreRelease flag is set otherwise only release versions are allowed - if (await this.isValidVersion(rawGalleryExtensionVersion, includePreRelease ? 'any' : 'release', criteria.compatible, allTargetPlatforms, criteria.targetPlatform)) { + if (await this.isValidVersion(getGalleryExtensionId(rawGalleryExtension.publisher.publisherName, rawGalleryExtension.extensionName), rawGalleryExtensionVersion, includePreRelease ? 'any' : 'release', criteria.compatible, allTargetPlatforms, criteria.targetPlatform, criteria.productVersion)) { return toExtension(rawGalleryExtension, rawGalleryExtensionVersion, allTargetPlatforms, queryContext); } if (version && rawGalleryExtensionVersion.version === version) { @@ -1046,7 +1046,7 @@ abstract class AbstractExtensionGalleryService implements IExtensionGalleryServi } : extension.assets.download; const headers: IHeaders | undefined = extension.queryContext?.[ACTIVITY_HEADER_NAME] ? { [ACTIVITY_HEADER_NAME]: extension.queryContext[ACTIVITY_HEADER_NAME] } : undefined; - const context = await this.getAsset(downloadAsset, headers ? { headers } : undefined); + const context = await this.getAsset(extension.identifier.id, downloadAsset, AssetType.VSIX, headers ? { headers } : undefined); await this.fileService.writeFile(location, context.stream); log(new Date().getTime() - startTime); } @@ -1058,13 +1058,13 @@ abstract class AbstractExtensionGalleryService implements IExtensionGalleryServi this.logService.trace('ExtensionGalleryService#downloadSignatureArchive', extension.identifier.id); - const context = await this.getAsset(extension.assets.signature); + const context = await this.getAsset(extension.identifier.id, extension.assets.signature, AssetType.Signature); await this.fileService.writeFile(location, context.stream); } async getReadme(extension: IGalleryExtension, token: CancellationToken): Promise { if (extension.assets.readme) { - const context = await this.getAsset(extension.assets.readme, {}, token); + const context = await this.getAsset(extension.identifier.id, extension.assets.readme, AssetType.Details, {}, token); const content = await asTextOrError(context); return content || ''; } @@ -1073,27 +1073,27 @@ abstract class AbstractExtensionGalleryService implements IExtensionGalleryServi async getManifest(extension: IGalleryExtension, token: CancellationToken): Promise { if (extension.assets.manifest) { - const context = await this.getAsset(extension.assets.manifest, {}, token); + const context = await this.getAsset(extension.identifier.id, extension.assets.manifest, AssetType.Manifest, {}, token); const text = await asTextOrError(context); return text ? JSON.parse(text) : null; } return null; } - private async getManifestFromRawExtensionVersion(rawExtensionVersion: IRawGalleryExtensionVersion, token: CancellationToken): Promise { + private async getManifestFromRawExtensionVersion(extension: string, rawExtensionVersion: IRawGalleryExtensionVersion, token: CancellationToken): Promise { const manifestAsset = getVersionAsset(rawExtensionVersion, AssetType.Manifest); if (!manifestAsset) { throw new Error('Manifest was not found'); } const headers = { 'Accept-Encoding': 'gzip' }; - const context = await this.getAsset(manifestAsset, { headers }); + const context = await this.getAsset(extension, manifestAsset, AssetType.Manifest, { headers }); return await asJson(context); } async getCoreTranslation(extension: IGalleryExtension, languageId: string): Promise { const asset = extension.assets.coreTranslations.filter(t => t[0] === languageId.toUpperCase())[0]; if (asset) { - const context = await this.getAsset(asset[1]); + const context = await this.getAsset(extension.identifier.id, asset[1], asset[0]); const text = await asTextOrError(context); return text ? JSON.parse(text) : null; } @@ -1102,7 +1102,7 @@ abstract class AbstractExtensionGalleryService implements IExtensionGalleryServi async getChangelog(extension: IGalleryExtension, token: CancellationToken): Promise { if (extension.assets.changelog) { - const context = await this.getAsset(extension.assets.changelog, {}, token); + const context = await this.getAsset(extension.identifier.id, extension.assets.changelog, AssetType.Changelog, {}, token); const content = await asTextOrError(context); return content || ''; } @@ -1133,7 +1133,7 @@ abstract class AbstractExtensionGalleryService implements IExtensionGalleryServi const validVersions: IRawGalleryExtensionVersion[] = []; await Promise.all(galleryExtensions[0].versions.map(async (version) => { try { - if (await this.isValidVersion(version, includePreRelease ? 'any' : 'release', true, allTargetPlatforms, targetPlatform)) { + if (await this.isValidVersion(extension.identifier.id, version, includePreRelease ? 'any' : 'release', true, allTargetPlatforms, targetPlatform)) { validVersions.push(version); } } catch (error) { /* Ignore error and skip version */ } @@ -1151,7 +1151,7 @@ abstract class AbstractExtensionGalleryService implements IExtensionGalleryServi return result; } - private async getAsset(asset: IGalleryExtensionAsset, options: IRequestOptions = {}, token: CancellationToken = CancellationToken.None): Promise { + private async getAsset(extension: string, asset: IGalleryExtensionAsset, assetType: string, options: IRequestOptions = {}, token: CancellationToken = CancellationToken.None): Promise { const commonHeaders = await this.commonHeadersPromise; const baseOptions = { type: 'GET' }; const headers = { ...commonHeaders, ...(options.headers || {}) }; @@ -1177,24 +1177,37 @@ abstract class AbstractExtensionGalleryService implements IExtensionGalleryServi type GalleryServiceCDNFallbackClassification = { owner: 'sandy081'; comment: 'Fallback request information when the primary asset request to CDN fails'; - url: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'asset url that failed' }; + extension: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'extension name' }; + assetType: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'asset that failed' }; message: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'error message' }; }; type GalleryServiceCDNFallbackEvent = { - url: string; + extension: string; + assetType: string; message: string; }; - this.telemetryService.publicLog2('galleryService:cdnFallback', { url, message }); + this.telemetryService.publicLog2('galleryService:cdnFallback', { extension, assetType, message }); const fallbackOptions = { ...options, url: fallbackUrl }; return this.requestService.request(fallbackOptions, token); } } - private async getEngine(rawExtensionVersion: IRawGalleryExtensionVersion): Promise { + private async getEngine(extension: string, rawExtensionVersion: IRawGalleryExtensionVersion): Promise { let engine = getEngine(rawExtensionVersion); if (!engine) { - const manifest = await this.getManifestFromRawExtensionVersion(rawExtensionVersion, CancellationToken.None); + type GalleryServiceEngineFallbackClassification = { + owner: 'sandy081'; + comment: 'Fallback request when engine is not found in properties of an extension version'; + extension: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'extension name' }; + version: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'version' }; + }; + type GalleryServiceEngineFallbackEvent = { + extension: string; + version: string; + }; + this.telemetryService.publicLog2('galleryService:engineFallback', { extension, version: rawExtensionVersion.version }); + const manifest = await this.getManifestFromRawExtensionVersion(extension, rawExtensionVersion, CancellationToken.None); if (!manifest) { throw new Error('Manifest was not found'); } diff --git a/src/vs/platform/extensionManagement/common/extensionManagement.ts b/src/vs/platform/extensionManagement/common/extensionManagement.ts index 756acf86b45ba..9dae82eba0716 100644 --- a/src/vs/platform/extensionManagement/common/extensionManagement.ts +++ b/src/vs/platform/extensionManagement/common/extensionManagement.ts @@ -20,6 +20,11 @@ export const EXTENSION_INSTALL_SKIP_WALKTHROUGH_CONTEXT = 'skipWalkthrough'; export const EXTENSION_INSTALL_SYNC_CONTEXT = 'extensionsSync'; export const EXTENSION_INSTALL_DEP_PACK_CONTEXT = 'dependecyOrPackExtensionInstall'; +export interface IProductVersion { + readonly version: string; + readonly date?: string; +} + export function TargetPlatformToString(targetPlatform: TargetPlatform) { switch (targetPlatform) { case TargetPlatform.WIN32_X64: return 'Windows 64 bit'; @@ -222,6 +227,8 @@ export interface IGalleryExtension { supportLink?: string; } +export type InstallSource = 'gallery' | 'vsix' | 'resource'; + export interface IGalleryMetadata { id: string; publisherId: string; @@ -240,9 +247,11 @@ export type Metadata = Partial; export interface ILocalExtension extends IExtension { + isWorkspaceScoped: boolean; isMachineScoped: boolean; isApplicationScoped: boolean; publisherId: string | null; @@ -253,6 +262,7 @@ export interface ILocalExtension extends IExtension { preRelease: boolean; updated: boolean; pinned: boolean; + source: InstallSource; } export const enum SortBy { @@ -281,6 +291,7 @@ export interface IQueryOptions { sortOrder?: SortOrder; source?: string; includePreRelease?: boolean; + productVersion?: IProductVersion; } export const enum StatisticType { @@ -330,6 +341,7 @@ export interface IExtensionInfo extends IExtensionIdentifier { export interface IExtensionQueryOptions { targetPlatform?: TargetPlatform; + productVersion?: IProductVersion; compatible?: boolean; queryAllVersions?: boolean; source?: string; @@ -347,8 +359,8 @@ export interface IExtensionGalleryService { query(options: IQueryOptions, token: CancellationToken): Promise>; getExtensions(extensionInfos: ReadonlyArray, token: CancellationToken): Promise; getExtensions(extensionInfos: ReadonlyArray, options: IExtensionQueryOptions, token: CancellationToken): Promise; - isExtensionCompatible(extension: IGalleryExtension, includePreRelease: boolean, targetPlatform: TargetPlatform): Promise; - getCompatibleExtension(extension: IGalleryExtension, includePreRelease: boolean, targetPlatform: TargetPlatform): Promise; + isExtensionCompatible(extension: IGalleryExtension, includePreRelease: boolean, targetPlatform: TargetPlatform, productVersion?: IProductVersion): Promise; + getCompatibleExtension(extension: IGalleryExtension, includePreRelease: boolean, targetPlatform: TargetPlatform, productVersion?: IProductVersion): Promise; getAllCompatibleVersions(extension: IGalleryExtension, includePreRelease: boolean, targetPlatform: TargetPlatform): Promise; download(extension: IGalleryExtension, location: URI, operation: InstallOperation): Promise; downloadSignatureArchive(extension: IGalleryExtension, location: URI): Promise; @@ -365,6 +377,7 @@ export interface InstallExtensionEvent { readonly source: URI | IGalleryExtension; readonly profileLocation?: URI; readonly applicationScoped?: boolean; + readonly workspaceScoped?: boolean; } export interface InstallExtensionResult { @@ -376,12 +389,14 @@ export interface InstallExtensionResult { readonly context?: IStringDictionary; readonly profileLocation?: URI; readonly applicationScoped?: boolean; + readonly workspaceScoped?: boolean; } export interface UninstallExtensionEvent { readonly identifier: IExtensionIdentifier; readonly profileLocation?: URI; readonly applicationScoped?: boolean; + readonly workspaceScoped?: boolean; } export interface DidUninstallExtensionEvent { @@ -389,6 +404,7 @@ export interface DidUninstallExtensionEvent { readonly error?: string; readonly profileLocation?: URI; readonly applicationScoped?: boolean; + readonly workspaceScoped?: boolean; } export enum ExtensionManagementErrorCode { @@ -443,6 +459,7 @@ export class ExtensionGalleryError extends Error { export type InstallOptions = { isBuiltin?: boolean; + isWorkspaceScoped?: boolean; isMachineScoped?: boolean; isApplicationScoped?: boolean; pinned?: boolean; @@ -453,16 +470,17 @@ export type InstallOptions = { donotVerifySignature?: boolean; operation?: InstallOperation; profileLocation?: URI; + installOnlyNewlyAddedFromExtensionPack?: boolean; + productVersion?: IProductVersion; /** * Context passed through to InstallExtensionResult */ context?: IStringDictionary; }; -export type InstallVSIXOptions = InstallOptions & { installOnlyNewlyAddedFromExtensionPack?: boolean }; export type UninstallOptions = { readonly donotIncludePack?: boolean; readonly donotCheckDependents?: boolean; readonly versionOnly?: boolean; readonly remove?: boolean; readonly profileLocation?: URI }; export interface IExtensionManagementParticipant { - postInstall(local: ILocalExtension, source: URI | IGalleryExtension, options: InstallOptions | InstallVSIXOptions, token: CancellationToken): Promise; + postInstall(local: ILocalExtension, source: URI | IGalleryExtension, options: InstallOptions, token: CancellationToken): Promise; postUninstall(local: ILocalExtension, options: UninstallOptions, token: CancellationToken): Promise; } @@ -481,7 +499,7 @@ export interface IExtensionManagementService { zip(extension: ILocalExtension): Promise; unzip(zipLocation: URI): Promise; getManifest(vsix: URI): Promise; - install(vsix: URI, options?: InstallVSIXOptions): Promise; + install(vsix: URI, options?: InstallOptions): Promise; canInstall(extension: IGalleryExtension): Promise; installFromGallery(extension: IGalleryExtension, options?: InstallOptions): Promise; installGalleryExtensions(extensions: InstallExtensionInfo[]): Promise; @@ -490,7 +508,7 @@ export interface IExtensionManagementService { uninstall(extension: ILocalExtension, options?: UninstallOptions): Promise; toggleAppliationScope(extension: ILocalExtension, fromProfileLocation: URI): Promise; reinstallFromGallery(extension: ILocalExtension): Promise; - getInstalled(type?: ExtensionType, profileLocation?: URI): Promise; + getInstalled(type?: ExtensionType, profileLocation?: URI, productVersion?: IProductVersion): Promise; getExtensionsControlManifest(): Promise; copyExtensions(fromProfileLocation: URI, toProfileLocation: URI): Promise; updateMetadata(local: ILocalExtension, metadata: Partial, profileLocation?: URI): Promise; diff --git a/src/vs/platform/extensionManagement/common/extensionManagementCLI.ts b/src/vs/platform/extensionManagement/common/extensionManagementCLI.ts index 05ee97927fec7..a2040014745c0 100644 --- a/src/vs/platform/extensionManagement/common/extensionManagementCLI.ts +++ b/src/vs/platform/extensionManagement/common/extensionManagementCLI.ts @@ -100,7 +100,7 @@ export class ExtensionManagementCLI { } } - const installed = await this.extensionManagementService.getInstalled(ExtensionType.User, installOptions.profileLocation); + const installed = await this.extensionManagementService.getInstalled(undefined, installOptions.profileLocation); if (installVSIXInfos.length) { await Promise.all(installVSIXInfos.map(async ({ vsix, installOptions }) => { @@ -137,7 +137,7 @@ export class ExtensionManagementCLI { } } - this.logger.trace(localize('updateExtensionsQuery', "Fetching latest versions for {0} extensions", installedExtensionsQuery.length)); + this.logger.trace(localize({ key: 'updateExtensionsQuery', comment: ['Placeholder is for the count of extensions'] }, "Fetching latest versions for {0} extensions", installedExtensionsQuery.length)); const availableVersions = await this.extensionGalleryService.getExtensions(installedExtensionsQuery, { compatible: true }, CancellationToken.None); const extensionsToUpdate: InstallExtensionInfo[] = []; diff --git a/src/vs/platform/extensionManagement/common/extensionManagementIpc.ts b/src/vs/platform/extensionManagement/common/extensionManagementIpc.ts index d4f15349aadcf..6c3e289db7d9e 100644 --- a/src/vs/platform/extensionManagement/common/extensionManagementIpc.ts +++ b/src/vs/platform/extensionManagement/common/extensionManagementIpc.ts @@ -9,7 +9,7 @@ import { cloneAndChange } from 'vs/base/common/objects'; import { URI, UriComponents } from 'vs/base/common/uri'; import { DefaultURITransformer, IURITransformer, transformAndReviveIncomingURIs } from 'vs/base/common/uriIpc'; import { IChannel, IServerChannel } from 'vs/base/parts/ipc/common/ipc'; -import { IExtensionIdentifier, IExtensionTipsService, IGalleryExtension, ILocalExtension, IExtensionsControlManifest, isTargetPlatformCompatible, InstallOptions, InstallVSIXOptions, UninstallOptions, Metadata, IExtensionManagementService, DidUninstallExtensionEvent, InstallExtensionEvent, InstallExtensionResult, UninstallExtensionEvent, InstallOperation, InstallExtensionInfo } from 'vs/platform/extensionManagement/common/extensionManagement'; +import { IExtensionIdentifier, IExtensionTipsService, IGalleryExtension, ILocalExtension, IExtensionsControlManifest, isTargetPlatformCompatible, InstallOptions, UninstallOptions, Metadata, IExtensionManagementService, DidUninstallExtensionEvent, InstallExtensionEvent, InstallExtensionResult, UninstallExtensionEvent, InstallOperation, InstallExtensionInfo, IProductVersion } from 'vs/platform/extensionManagement/common/extensionManagement'; import { ExtensionType, IExtensionManifest, TargetPlatform } from 'vs/platform/extensions/common/extensions'; function transformIncomingURI(uri: UriComponents, transformer: IURITransformer | null): URI; @@ -139,7 +139,7 @@ export class ExtensionManagementChannel implements IServerChannel { return this.service.reinstallFromGallery(transformIncomingExtension(args[0], uriTransformer)); } case 'getInstalled': { - const extensions = await this.service.getInstalled(args[0], transformIncomingURI(args[1], uriTransformer)); + const extensions = await this.service.getInstalled(args[0], transformIncomingURI(args[1], uriTransformer), args[2]); return extensions.map(e => transformOutgoingExtension(e, uriTransformer)); } case 'toggleAppliationScope': { @@ -237,7 +237,7 @@ export class ExtensionManagementChannelClient extends Disposable implements IExt return Promise.resolve(this.channel.call('unzip', [zipLocation])); } - install(vsix: URI, options?: InstallVSIXOptions): Promise { + install(vsix: URI, options?: InstallOptions): Promise { return Promise.resolve(this.channel.call('install', [vsix, options])).then(local => transformIncomingExtension(local, null)); } @@ -264,6 +264,9 @@ export class ExtensionManagementChannelClient extends Disposable implements IExt } uninstall(extension: ILocalExtension, options?: UninstallOptions): Promise { + if (extension.isWorkspaceScoped) { + throw new Error('Cannot uninstall a workspace extension'); + } return Promise.resolve(this.channel.call('uninstall', [extension, options])); } @@ -271,8 +274,8 @@ export class ExtensionManagementChannelClient extends Disposable implements IExt return Promise.resolve(this.channel.call('reinstallFromGallery', [extension])).then(local => transformIncomingExtension(local, null)); } - getInstalled(type: ExtensionType | null = null, extensionsProfileResource?: URI): Promise { - return Promise.resolve(this.channel.call('getInstalled', [type, extensionsProfileResource])) + getInstalled(type: ExtensionType | null = null, extensionsProfileResource?: URI, productVersion?: IProductVersion): Promise { + return Promise.resolve(this.channel.call('getInstalled', [type, extensionsProfileResource, productVersion])) .then(extensions => extensions.map(extension => transformIncomingExtension(extension, null))); } diff --git a/src/vs/platform/extensionManagement/common/extensionsProfileScannerService.ts b/src/vs/platform/extensionManagement/common/extensionsProfileScannerService.ts index 4fcf403d999a1..d3d1816b40bf7 100644 --- a/src/vs/platform/extensionManagement/common/extensionsProfileScannerService.ts +++ b/src/vs/platform/extensionManagement/common/extensionsProfileScannerService.ts @@ -83,7 +83,7 @@ export interface IExtensionsProfileScannerService { readonly onDidRemoveExtensions: Event; scanProfileExtensions(profileLocation: URI, options?: IProfileExtensionsScanOptions): Promise; - addExtensionsToProfile(extensions: [IExtension, Metadata | undefined][], profileLocation: URI): Promise; + addExtensionsToProfile(extensions: [IExtension, Metadata | undefined][], profileLocation: URI, keepExistingVersions?: boolean): Promise; updateMetadata(extensions: [IExtension, Metadata | undefined][], profileLocation: URI): Promise; removeExtensionFromProfile(extension: IExtension, profileLocation: URI): Promise; } @@ -120,18 +120,22 @@ export abstract class AbstractExtensionsProfileScannerService extends Disposable return this.withProfileExtensions(profileLocation, undefined, options); } - async addExtensionsToProfile(extensions: [IExtension, Metadata | undefined][], profileLocation: URI): Promise { + async addExtensionsToProfile(extensions: [IExtension, Metadata | undefined][], profileLocation: URI, keepExistingVersions?: boolean): Promise { const extensionsToRemove: IScannedProfileExtension[] = []; const extensionsToAdd: IScannedProfileExtension[] = []; try { await this.withProfileExtensions(profileLocation, existingExtensions => { const result: IScannedProfileExtension[] = []; - for (const existing of existingExtensions) { - if (extensions.some(([e]) => areSameExtensions(e.identifier, existing.identifier) && e.manifest.version !== existing.version)) { - // Remove the existing extension with different version - extensionsToRemove.push(existing); - } else { - result.push(existing); + if (keepExistingVersions) { + result.push(...existingExtensions); + } else { + for (const existing of existingExtensions) { + if (extensions.some(([e]) => areSameExtensions(e.identifier, existing.identifier) && e.manifest.version !== existing.version)) { + // Remove the existing extension with different version + extensionsToRemove.push(existing); + } else { + result.push(existing); + } } } for (const [extension, metadata] of extensions) { @@ -302,7 +306,7 @@ export abstract class AbstractExtensionsProfileScannerService extends Disposable type ErrorClassification = { owner: 'sandy081'; comment: 'Information about the error that occurred while scanning'; - code: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'error code' }; + code: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'error code' }; }; const error = new ExtensionsProfileScanningError(`Invalid extensions content in ${file.toString()}`, ExtensionsProfileScanningErrorCode.ERROR_INVALID_CONTENT); this.telemetryService.publicLogError2<{ code: string }, ErrorClassification>('extensionsProfileScanningError', { code: error.code }); diff --git a/src/vs/platform/extensionManagement/common/extensionsScannerService.ts b/src/vs/platform/extensionManagement/common/extensionsScannerService.ts index 4ac28d17b5f84..0b64c8b9b33be 100644 --- a/src/vs/platform/extensionManagement/common/extensionsScannerService.ts +++ b/src/vs/platform/extensionManagement/common/extensionsScannerService.ts @@ -22,7 +22,7 @@ import { isEmptyObject } from 'vs/base/common/types'; import { URI } from 'vs/base/common/uri'; import { localize } from 'vs/nls'; import { IEnvironmentService } from 'vs/platform/environment/common/environment'; -import { Metadata } from 'vs/platform/extensionManagement/common/extensionManagement'; +import { IProductVersion, Metadata } from 'vs/platform/extensionManagement/common/extensionManagement'; import { areSameExtensions, computeTargetPlatform, ExtensionKey, getExtensionId, getGalleryExtensionId } from 'vs/platform/extensionManagement/common/extensionManagementUtil'; import { ExtensionType, ExtensionIdentifier, IExtensionManifest, TargetPlatform, IExtensionIdentifier, IRelaxedExtensionManifest, UNDEFINED_PUBLISHER, IExtensionDescription, BUILTIN_MANIFEST_CACHE_FILE, USER_MANIFEST_CACHE_FILE, ExtensionIdentifierMap } from 'vs/platform/extensions/common/extensions'; import { validateExtensionManifest } from 'vs/platform/extensions/common/extensionValidator'; @@ -108,6 +108,7 @@ export type ScanOptions = { readonly checkControlFile?: boolean; readonly language?: string; readonly useCache?: boolean; + readonly productVersion?: IProductVersion; }; export const IExtensionsScannerService = createDecorator('IExtensionsScannerService'); @@ -126,6 +127,7 @@ export interface IExtensionsScannerService { scanExtensionsUnderDevelopment(scanOptions: ScanOptions, existingExtensions: IScannedExtension[]): Promise; scanExistingExtension(extensionLocation: URI, extensionType: ExtensionType, scanOptions: ScanOptions): Promise; scanOneOrMultipleExtensions(extensionLocation: URI, extensionType: ExtensionType, scanOptions: ScanOptions): Promise; + scanMultipleExtensions(extensionLocations: URI[], extensionType: ExtensionType, scanOptions: ScanOptions): Promise; scanMetadata(extensionLocation: URI): Promise; updateMetadata(extensionLocation: URI, metadata: Partial): Promise; @@ -195,7 +197,7 @@ export abstract class AbstractExtensionsScannerService extends Disposable implem const location = scanOptions.profileLocation ?? this.userExtensionsLocation; this.logService.trace('Started scanning user extensions', location); const profileScanOptions: IProfileExtensionsScanOptions | undefined = this.uriIdentityService.extUri.isEqual(scanOptions.profileLocation, this.userDataProfilesService.defaultProfile.extensionsResource) ? { bailOutWhenFileNotFound: true } : undefined; - const extensionsScannerInput = await this.createExtensionScannerInput(location, !!scanOptions.profileLocation, ExtensionType.User, !scanOptions.includeUninstalled, scanOptions.language, true, profileScanOptions); + const extensionsScannerInput = await this.createExtensionScannerInput(location, !!scanOptions.profileLocation, ExtensionType.User, !scanOptions.includeUninstalled, scanOptions.language, true, profileScanOptions, scanOptions.productVersion ?? this.getProductVersion()); const extensionsScanner = scanOptions.useCache && !extensionsScannerInput.devMode && extensionsScannerInput.excludeObsolete ? this.userExtensionsCachedScanner : this.extensionsScanner; let extensions: IRelaxedScannedExtension[]; try { @@ -217,7 +219,7 @@ export abstract class AbstractExtensionsScannerService extends Disposable implem if (this.environmentService.isExtensionDevelopment && this.environmentService.extensionDevelopmentLocationURI) { const extensions = (await Promise.all(this.environmentService.extensionDevelopmentLocationURI.filter(extLoc => extLoc.scheme === Schemas.file) .map(async extensionDevelopmentLocationURI => { - const input = await this.createExtensionScannerInput(extensionDevelopmentLocationURI, false, ExtensionType.User, true, scanOptions.language, false /* do not validate */, undefined); + const input = await this.createExtensionScannerInput(extensionDevelopmentLocationURI, false, ExtensionType.User, true, scanOptions.language, false /* do not validate */, undefined, scanOptions.productVersion ?? this.getProductVersion()); const extensions = await this.extensionsScanner.scanOneOrMultipleExtensions(input); return extensions.map(extension => { // Override the extension type from the existing extensions @@ -233,7 +235,7 @@ export abstract class AbstractExtensionsScannerService extends Disposable implem } async scanExistingExtension(extensionLocation: URI, extensionType: ExtensionType, scanOptions: ScanOptions): Promise { - const extensionsScannerInput = await this.createExtensionScannerInput(extensionLocation, false, extensionType, true, scanOptions.language, true, undefined); + const extensionsScannerInput = await this.createExtensionScannerInput(extensionLocation, false, extensionType, true, scanOptions.language, true, undefined, scanOptions.productVersion ?? this.getProductVersion()); const extension = await this.extensionsScanner.scanExtension(extensionsScannerInput); if (!extension) { return null; @@ -245,11 +247,20 @@ export abstract class AbstractExtensionsScannerService extends Disposable implem } async scanOneOrMultipleExtensions(extensionLocation: URI, extensionType: ExtensionType, scanOptions: ScanOptions): Promise { - const extensionsScannerInput = await this.createExtensionScannerInput(extensionLocation, false, extensionType, true, scanOptions.language, true, undefined); + const extensionsScannerInput = await this.createExtensionScannerInput(extensionLocation, false, extensionType, true, scanOptions.language, true, undefined, scanOptions.productVersion ?? this.getProductVersion()); const extensions = await this.extensionsScanner.scanOneOrMultipleExtensions(extensionsScannerInput); return this.applyScanOptions(extensions, extensionType, scanOptions, true); } + async scanMultipleExtensions(extensionLocations: URI[], extensionType: ExtensionType, scanOptions: ScanOptions): Promise { + const extensions: IRelaxedScannedExtension[] = []; + await Promise.all(extensionLocations.map(async extensionLocation => { + const scannedExtensions = await this.scanOneOrMultipleExtensions(extensionLocation, extensionType, scanOptions); + extensions.push(...scannedExtensions); + })); + return this.applyScanOptions(extensions, extensionType, scanOptions, true); + } + async scanMetadata(extensionLocation: URI): Promise { const manifestLocation = joinPath(extensionLocation, 'package.json'); const content = (await this.fileService.readFile(manifestLocation)).value.toString(); @@ -392,7 +403,7 @@ export abstract class AbstractExtensionsScannerService extends Disposable implem private async scanDefaultSystemExtensions(useCache: boolean, language: string | undefined): Promise { this.logService.trace('Started scanning system extensions'); - const extensionsScannerInput = await this.createExtensionScannerInput(this.systemExtensionsLocation, false, ExtensionType.System, true, language, true, undefined); + const extensionsScannerInput = await this.createExtensionScannerInput(this.systemExtensionsLocation, false, ExtensionType.System, true, language, true, undefined, this.getProductVersion()); const extensionsScanner = useCache && !extensionsScannerInput.devMode ? this.systemExtensionsCachedScanner : this.extensionsScanner; const result = await extensionsScanner.scanExtensions(extensionsScannerInput); this.logService.trace('Scanned system extensions:', result.length); @@ -422,7 +433,7 @@ export abstract class AbstractExtensionsScannerService extends Disposable implem break; } } - const result = await Promise.all(devSystemExtensionsLocations.map(async location => this.extensionsScanner.scanExtension((await this.createExtensionScannerInput(location, false, ExtensionType.System, true, language, true, undefined))))); + const result = await Promise.all(devSystemExtensionsLocations.map(async location => this.extensionsScanner.scanExtension((await this.createExtensionScannerInput(location, false, ExtensionType.System, true, language, true, undefined, this.getProductVersion()))))); this.logService.trace('Scanned dev system extensions:', result.length); return coalesce(result); } @@ -436,7 +447,7 @@ export abstract class AbstractExtensionsScannerService extends Disposable implem } } - private async createExtensionScannerInput(location: URI, profile: boolean, type: ExtensionType, excludeObsolete: boolean, language: string | undefined, validate: boolean, profileScanOptions: IProfileExtensionsScanOptions | undefined): Promise { + private async createExtensionScannerInput(location: URI, profile: boolean, type: ExtensionType, excludeObsolete: boolean, language: string | undefined, validate: boolean, profileScanOptions: IProfileExtensionsScanOptions | undefined, productVersion: IProductVersion): Promise { const translations = await this.getTranslations(language ?? platform.language); const mtime = await this.getMtime(location); const applicationExtensionsLocation = profile && !this.uriIdentityService.extUri.isEqual(location, this.userDataProfilesService.defaultProfile.extensionsResource) ? this.userDataProfilesService.defaultProfile.extensionsResource : undefined; @@ -451,8 +462,8 @@ export abstract class AbstractExtensionsScannerService extends Disposable implem type, excludeObsolete, validate, - this.productService.version, - this.productService.date, + productVersion.version, + productVersion.date, this.productService.commit, !this.environmentService.isBuilt, language, @@ -472,6 +483,13 @@ export abstract class AbstractExtensionsScannerService extends Disposable implem return undefined; } + private getProductVersion(): IProductVersion { + return { + version: this.productService.version, + date: this.productService.date, + }; + } + } export class ExtensionScannerInput { diff --git a/src/vs/platform/extensionManagement/electron-sandbox/extensionsProfileScannerService.ts b/src/vs/platform/extensionManagement/electron-sandbox/extensionsProfileScannerService.ts index 7864019ad8314..98f5a2194f9c2 100644 --- a/src/vs/platform/extensionManagement/electron-sandbox/extensionsProfileScannerService.ts +++ b/src/vs/platform/extensionManagement/electron-sandbox/extensionsProfileScannerService.ts @@ -7,10 +7,11 @@ import { ILogService } from 'vs/platform/log/common/log'; import { IUserDataProfilesService } from 'vs/platform/userDataProfile/common/userDataProfile'; import { IUriIdentityService } from 'vs/platform/uriIdentity/common/uriIdentity'; import { ITelemetryService } from 'vs/platform/telemetry/common/telemetry'; -import { AbstractExtensionsProfileScannerService } from 'vs/platform/extensionManagement/common/extensionsProfileScannerService'; +import { AbstractExtensionsProfileScannerService, IExtensionsProfileScannerService } from 'vs/platform/extensionManagement/common/extensionsProfileScannerService'; import { IFileService } from 'vs/platform/files/common/files'; import { INativeEnvironmentService } from 'vs/platform/environment/common/environment'; import { URI } from 'vs/base/common/uri'; +import { InstantiationType, registerSingleton } from 'vs/platform/instantiation/common/extensions'; export class ExtensionsProfileScannerService extends AbstractExtensionsProfileScannerService { constructor( @@ -24,3 +25,5 @@ export class ExtensionsProfileScannerService extends AbstractExtensionsProfileSc super(URI.file(environmentService.extensionsPath), fileService, userDataProfilesService, uriIdentityService, telemetryService, logService); } } + +registerSingleton(IExtensionsProfileScannerService, ExtensionsProfileScannerService, InstantiationType.Delayed); diff --git a/src/vs/platform/extensionManagement/node/extensionManagementService.ts b/src/vs/platform/extensionManagement/node/extensionManagementService.ts index 217d7720d15c4..f3435c9ad9cba 100644 --- a/src/vs/platform/extensionManagement/node/extensionManagementService.ts +++ b/src/vs/platform/extensionManagement/node/extensionManagementService.ts @@ -28,7 +28,8 @@ import { INativeEnvironmentService } from 'vs/platform/environment/common/enviro import { AbstractExtensionManagementService, AbstractExtensionTask, ExtensionVerificationStatus, IInstallExtensionTask, InstallExtensionTaskOptions, IUninstallExtensionTask, joinErrors, toExtensionManagementError, UninstallExtensionTaskOptions } from 'vs/platform/extensionManagement/common/abstractExtensionManagementService'; import { ExtensionManagementError, ExtensionManagementErrorCode, IExtensionGalleryService, IExtensionIdentifier, IExtensionManagementService, IGalleryExtension, ILocalExtension, InstallOperation, - Metadata, InstallVSIXOptions + Metadata, InstallOptions, + IProductVersion } from 'vs/platform/extensionManagement/common/extensionManagement'; import { areSameExtensions, computeTargetPlatform, ExtensionKey, getGalleryExtensionId, groupByExtension } from 'vs/platform/extensionManagement/common/extensionManagementUtil'; import { IExtensionsProfileScannerService, IScannedProfileExtension } from 'vs/platform/extensionManagement/common/extensionsProfileScannerService'; @@ -128,8 +129,8 @@ export class ExtensionManagementService extends AbstractExtensionManagementServi } } - getInstalled(type?: ExtensionType, profileLocation: URI = this.userDataProfilesService.defaultProfile.extensionsResource): Promise { - return this.extensionsScanner.scanExtensions(type ?? null, profileLocation); + getInstalled(type?: ExtensionType, profileLocation: URI = this.userDataProfilesService.defaultProfile.extensionsResource, productVersion: IProductVersion = { version: this.productService.version, date: this.productService.date }): Promise { + return this.extensionsScanner.scanExtensions(type ?? null, profileLocation, productVersion); } scanAllUserInstalledExtensions(): Promise { @@ -140,7 +141,7 @@ export class ExtensionManagementService extends AbstractExtensionManagementServi return this.extensionsScanner.scanUserExtensionAtLocation(location); } - async install(vsix: URI, options: InstallVSIXOptions = {}): Promise { + async install(vsix: URI, options: InstallOptions = {}): Promise { this.logService.trace('ExtensionManagementService#install', vsix.toString()); const { location, cleanup } = await this.downloadVsix(vsix); @@ -172,14 +173,14 @@ export class ExtensionManagementService extends AbstractExtensionManagementServi if (!local || !local.manifest.name || !local.manifest.version) { throw new Error(`Cannot find a valid extension from the location ${location.toString()}`); } - await this.addExtensionsToProfile([[local, undefined]], profileLocation); + await this.addExtensionsToProfile([[local, { source: 'resource' }]], profileLocation); this.logService.info('Successfully installed extension', local.identifier.id, profileLocation.toString()); return local; } async installExtensionsFromProfile(extensions: IExtensionIdentifier[], fromProfileLocation: URI, toProfileLocation: URI): Promise { this.logService.trace('ExtensionManagementService#installExtensionsFromProfile', extensions, fromProfileLocation.toString(), toProfileLocation.toString()); - const extensionsToInstall = (await this.extensionsScanner.scanExtensions(ExtensionType.User, fromProfileLocation)).filter(e => extensions.some(id => areSameExtensions(id, e.identifier))); + const extensionsToInstall = (await this.getInstalled(ExtensionType.User, fromProfileLocation)).filter(e => extensions.some(id => areSameExtensions(id, e.identifier))); if (extensionsToInstall.length) { const metadata = await Promise.all(extensionsToInstall.map(e => this.extensionsScanner.scanMetadata(e, fromProfileLocation))); await this.addExtensionsToProfile(extensionsToInstall.map((e, index) => [e, metadata[index]]), toProfileLocation); @@ -236,7 +237,7 @@ export class ExtensionManagementService extends AbstractExtensionManagementServi } copyExtensions(fromProfileLocation: URI, toProfileLocation: URI): Promise { - return this.extensionsScanner.copyExtensions(fromProfileLocation, toProfileLocation); + return this.extensionsScanner.copyExtensions(fromProfileLocation, toProfileLocation, { version: this.productService.version, date: this.productService.date }); } markAsUninstalled(...extensions: IExtension[]): Promise { @@ -287,7 +288,7 @@ export class ExtensionManagementService extends AbstractExtensionManagementServi const key = ExtensionKey.create(extension).toString(); let installExtensionTask = this.installGalleryExtensionsTasks.get(key); if (!installExtensionTask) { - this.installGalleryExtensionsTasks.set(key, installExtensionTask = new InstallGalleryExtensionTask(manifest, extension, options, this.extensionsDownloader, this.extensionsScanner, this.uriIdentityService, this.userDataProfilesService, this.extensionsScannerService, this.extensionsProfileScannerService, this.logService)); + this.installGalleryExtensionsTasks.set(key, installExtensionTask = new InstallGalleryExtensionTask(manifest, extension, options, this.extensionsDownloader, this.extensionsScanner, this.uriIdentityService, this.userDataProfilesService, this.extensionsScannerService, this.extensionsProfileScannerService, this.logService, this.telemetryService)); installExtensionTask.waitUntilTaskIsFinished().finally(() => this.installGalleryExtensionsTasks.delete(key)); } return installExtensionTask; @@ -333,7 +334,7 @@ export class ExtensionManagementService extends AbstractExtensionManagementServi } } if (added) { - const extensions = await this.extensionsScanner.scanExtensions(ExtensionType.User, added.profileLocation); + const extensions = await this.getInstalled(ExtensionType.User, added.profileLocation); const addedExtensions = extensions.filter(e => added.extensions.some(identifier => areSameExtensions(identifier, e.identifier))); this._onDidInstallExtensions.fire(addedExtensions.map(local => { this.logService.info('Extensions added from another source', local.identifier.id, added.profileLocation.toString()); @@ -449,8 +450,8 @@ export class ExtensionsScanner extends Disposable { await this.removeUninstalledExtensions(); } - async scanExtensions(type: ExtensionType | null, profileLocation: URI): Promise { - const userScanOptions: ScanOptions = { includeInvalid: true, profileLocation }; + async scanExtensions(type: ExtensionType | null, profileLocation: URI, productVersion: IProductVersion): Promise { + const userScanOptions: ScanOptions = { includeInvalid: true, profileLocation, productVersion }; let scannedExtensions: IScannedExtension[] = []; if (type === null || type === ExtensionType.System) { scannedExtensions.push(...await this.extensionsScannerService.scanAllExtensions({ includeInvalid: true }, userScanOptions, false)); @@ -613,8 +614,8 @@ export class ExtensionsScanner extends Disposable { return this.scanLocalExtension(extension.location, extension.type, toProfileLocation); } - async copyExtensions(fromProfileLocation: URI, toProfileLocation: URI): Promise { - const fromExtensions = await this.scanExtensions(ExtensionType.User, fromProfileLocation); + async copyExtensions(fromProfileLocation: URI, toProfileLocation: URI, productVersion: IProductVersion): Promise { + const fromExtensions = await this.scanExtensions(ExtensionType.User, fromProfileLocation, productVersion); const extensions: [ILocalExtension, Metadata | undefined][] = await Promise.all(fromExtensions .filter(e => !e.isApplicationScoped) /* remove application scoped extensions */ .map(async e => ([e, await this.scanMetadata(e, fromProfileLocation)]))); @@ -714,6 +715,8 @@ export class ExtensionsScanner extends Disposable { installedTimestamp: extension.metadata?.installedTimestamp, updated: !!extension.metadata?.updated, pinned: !!extension.metadata?.pinned, + isWorkspaceScoped: false, + source: extension.metadata?.source ?? (extension.identifier.uuid ? 'gallery' : 'vsix') }; } @@ -800,6 +803,7 @@ abstract class InstallExtensionTask extends AbstractExtensionTask { let installed; try { - installed = await this.extensionsScanner.scanExtensions(null, this.options.profileLocation); + installed = await this.extensionsScanner.scanExtensions(null, this.options.profileLocation, this.options.productVersion); } catch (error) { throw new ExtensionManagementError(error, ExtensionManagementErrorCode.Scanning); } @@ -905,10 +910,11 @@ export class InstallGalleryExtensionTask extends InstallExtensionTask { pinned: this.options.installGivenVersion ? true : (this.options.pinned ?? existingExtension?.pinned), preRelease: isBoolean(this.options.preRelease) ? this.options.preRelease - : this.options.installPreReleaseVersion || this.gallery.properties.isPreReleaseVersion || existingExtension?.preRelease + : this.options.installPreReleaseVersion || this.gallery.properties.isPreReleaseVersion || existingExtension?.preRelease, + source: 'gallery', }; - if (existingExtension?.manifest.version === this.gallery.version) { + if (existingExtension && existingExtension.type !== ExtensionType.System && existingExtension.manifest.version === this.gallery.version) { try { const local = await this.extensionsScanner.updateMetadata(existingExtension, metadata); return [local, metadata]; @@ -917,6 +923,42 @@ export class InstallGalleryExtensionTask extends InstallExtensionTask { } } + try { + return await this.downloadAndInstallExtension(metadata, token); + } catch (error) { + if (error instanceof ExtensionManagementError && (error.code === ExtensionManagementErrorCode.CorruptZip || error.code === ExtensionManagementErrorCode.IncompleteZip)) { + this.logService.info(`Downloaded VSIX is invalid. Trying to download and install again...`, this.gallery.identifier.id); + type RetryInstallingInvalidVSIXClassification = { + owner: 'sandy081'; + comment: 'Event reporting the retry of installing an invalid VSIX'; + extensionId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Extension Id' }; + succeeded?: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Success value' }; + }; + type RetryInstallingInvalidVSIXEvent = { + extensionId: string; + succeeded: boolean; + }; + try { + const result = await this.downloadAndInstallExtension(metadata, token); + this.telemetryService.publicLog2('extensiongallery:install:retry', { + extensionId: this.gallery.identifier.id, + succeeded: true + }); + return result; + } catch (error) { + this.telemetryService.publicLog2('extensiongallery:install:retry', { + extensionId: this.gallery.identifier.id, + succeeded: false + }); + throw error; + } + } else { + throw error; + } + } + } + + private async downloadAndInstallExtension(metadata: Metadata, token: CancellationToken): Promise<[ILocalExtension, Metadata]> { const { location, verificationStatus } = await this.extensionsDownloader.download(this.gallery, this._operation, !this.options.donotVerifySignature); try { this._verificationStatus = verificationStatus; @@ -947,7 +989,7 @@ export class InstallGalleryExtensionTask extends InstallExtensionTask { class InstallVSIXTask extends InstallExtensionTask { constructor( - private readonly manifest: IExtensionManifest, + manifest: IExtensionManifest, private readonly location: URI, options: InstallExtensionTaskOptions, private readonly galleryService: IExtensionGalleryService, @@ -958,7 +1000,7 @@ class InstallVSIXTask extends InstallExtensionTask { extensionsProfileScannerService: IExtensionsProfileScannerService, logService: ILogService, ) { - super({ id: getGalleryExtensionId(manifest.publisher, manifest.name) }, location, options, extensionsScanner, uriIdentityService, userDataProfilesService, extensionsScannerService, extensionsProfileScannerService, logService); + super(manifest, { id: getGalleryExtensionId(manifest.publisher, manifest.name) }, location, options, extensionsScanner, uriIdentityService, userDataProfilesService, extensionsScannerService, extensionsProfileScannerService, logService); } protected override async doRun(token: CancellationToken): Promise { @@ -969,7 +1011,7 @@ class InstallVSIXTask extends InstallExtensionTask { protected async install(token: CancellationToken): Promise<[ILocalExtension, Metadata]> { const extensionKey = new ExtensionKey(this.identifier, this.manifest.version); - const installedExtensions = await this.extensionsScanner.scanExtensions(ExtensionType.User, this.options.profileLocation); + const installedExtensions = await this.extensionsScanner.scanExtensions(ExtensionType.User, this.options.profileLocation, this.options.productVersion); const existing = installedExtensions.find(i => areSameExtensions(this.identifier, i.identifier)); const metadata: Metadata = { isApplicationScoped: this.options.isApplicationScoped || existing?.isApplicationScoped, @@ -977,6 +1019,7 @@ class InstallVSIXTask extends InstallExtensionTask { isBuiltin: this.options.isBuiltin || existing?.isBuiltin, installedTimestamp: Date.now(), pinned: this.options.installGivenVersion ? true : (this.options.pinned ?? existing?.pinned), + source: 'vsix', }; if (existing) { diff --git a/src/vs/platform/extensionManagement/node/extensionSignatureVerificationService.ts b/src/vs/platform/extensionManagement/node/extensionSignatureVerificationService.ts index 4f33aa1f48e77..05d218b50b572 100644 --- a/src/vs/platform/extensionManagement/node/extensionSignatureVerificationService.ts +++ b/src/vs/platform/extensionManagement/node/extensionSignatureVerificationService.ts @@ -6,6 +6,7 @@ import { getErrorMessage } from 'vs/base/common/errors'; import { createDecorator } from 'vs/platform/instantiation/common/instantiation'; import { ILogService } from 'vs/platform/log/common/log'; +import { ITelemetryService } from 'vs/platform/telemetry/common/telemetry'; export const IExtensionSignatureVerificationService = createDecorator('IExtensionSignatureVerificationService'); @@ -47,14 +48,15 @@ export class ExtensionSignatureVerificationService implements IExtensionSignatur private moduleLoadingPromise: Promise | undefined; constructor( - @ILogService private readonly logService: ILogService + @ILogService private readonly logService: ILogService, + @ITelemetryService private readonly telemetryService: ITelemetryService, ) { } private vsceSign(): Promise { if (!this.moduleLoadingPromise) { this.moduleLoadingPromise = new Promise( (resolve, reject) => require( - ['node-vsce-sign'], + ['@vscode/vsce-sign'], async (obj) => { const instance = obj; @@ -75,6 +77,35 @@ export class ExtensionSignatureVerificationService implements IExtensionSignatur return false; } - return module.verify(vsixFilePath, signatureArchiveFilePath, verbose); + const startTime = new Date().getTime(); + let verified: boolean | undefined; + let error: ExtensionSignatureVerificationError | undefined; + + try { + verified = await module.verify(vsixFilePath, signatureArchiveFilePath, verbose); + return verified; + } catch (e) { + error = e; + throw e; + } finally { + const duration = new Date().getTime() - startTime; + type ExtensionSignatureVerificationClassification = { + owner: 'sandy081'; + comment: 'Extension signature verification event'; + duration: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; 'isMeasurement': true; comment: 'amount of time taken to verify the signature' }; + verified?: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'verified status when succeeded' }; + error?: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'error code when failed' }; + }; + type ExtensionSignatureVerificationEvent = { + duration: number; + verified?: boolean; + error?: string; + }; + this.telemetryService.publicLog2('extensionsignature:verification', { + duration, + verified, + error: error ? (error.code ?? 'unknown') : undefined, + }); + } } } diff --git a/src/vs/platform/extensionManagement/test/node/installGalleryExtensionTask.test.ts b/src/vs/platform/extensionManagement/test/node/installGalleryExtensionTask.test.ts index 1767e8931cbaf..1b27c8504d881 100644 --- a/src/vs/platform/extensionManagement/test/node/installGalleryExtensionTask.test.ts +++ b/src/vs/platform/extensionManagement/test/node/installGalleryExtensionTask.test.ts @@ -102,7 +102,7 @@ class TestInstallGalleryExtensionTask extends InstallGalleryExtensionTask { engines: { vscode: '*' }, }, extension, - { profileLocation: userDataProfilesService.defaultProfile.extensionsResource }, + { profileLocation: userDataProfilesService.defaultProfile.extensionsResource, productVersion: { version: '' } }, extensionDownloader, new TestExtensionsScanner(), uriIdentityService, @@ -110,6 +110,7 @@ class TestInstallGalleryExtensionTask extends InstallGalleryExtensionTask { extensionsScannerService, extensionsProfileScannerService, logService, + NullTelemetryService ); } diff --git a/src/vs/platform/extensionRecommendations/common/extensionRecommendations.ts b/src/vs/platform/extensionRecommendations/common/extensionRecommendations.ts index 118cbf8d5ec9d..07639a7e7b622 100644 --- a/src/vs/platform/extensionRecommendations/common/extensionRecommendations.ts +++ b/src/vs/platform/extensionRecommendations/common/extensionRecommendations.ts @@ -3,6 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { URI } from 'vs/base/common/uri'; import { createDecorator } from 'vs/platform/instantiation/common/instantiation'; export const enum RecommendationSource { @@ -43,6 +44,6 @@ export interface IExtensionRecommendationNotificationService { hasToIgnoreRecommendationNotifications(): boolean; promptImportantExtensionsInstallNotification(recommendations: IExtensionRecommendations): Promise; - promptWorkspaceRecommendations(recommendations: string[]): Promise; + promptWorkspaceRecommendations(recommendations: Array): Promise; } diff --git a/src/vs/platform/extensionResourceLoader/common/extensionResourceLoader.ts b/src/vs/platform/extensionResourceLoader/common/extensionResourceLoader.ts index e63c48d0c2ffb..f1660961c5826 100644 --- a/src/vs/platform/extensionResourceLoader/common/extensionResourceLoader.ts +++ b/src/vs/platform/extensionResourceLoader/common/extensionResourceLoader.ts @@ -17,10 +17,9 @@ import { IStorageService } from 'vs/platform/storage/common/storage'; import { TelemetryLevel } from 'vs/platform/telemetry/common/telemetry'; import { getTelemetryLevel, supportsTelemetry } from 'vs/platform/telemetry/common/telemetryUtils'; import { RemoteAuthorities } from 'vs/base/common/network'; -import { getRemoteServerRootPath } from 'vs/platform/remote/common/remoteHosts'; import { TargetPlatform } from 'vs/platform/extensions/common/extensions'; -const WEB_EXTENSION_RESOURCE_END_POINT = 'web-extension-resource'; +const WEB_EXTENSION_RESOURCE_END_POINT_SEGMENT = '/web-extension-resource/'; export const IExtensionResourceLoaderService = createDecorator('extensionResourceLoaderService'); @@ -67,7 +66,6 @@ export abstract class AbstractExtensionResourceLoaderService implements IExtensi readonly _serviceBrand: undefined; - private readonly _webExtensionResourceEndPoint: string; private readonly _extensionGalleryResourceUrlTemplate: string | undefined; private readonly _extensionGalleryAuthority: string | undefined; @@ -78,7 +76,6 @@ export abstract class AbstractExtensionResourceLoaderService implements IExtensi private readonly _environmentService: IEnvironmentService, private readonly _configurationService: IConfigurationService, ) { - this._webExtensionResourceEndPoint = `${getRemoteServerRootPath(_productService)}/${WEB_EXTENSION_RESOURCE_END_POINT}/`; if (_productService.extensionsGallery) { this._extensionGalleryResourceUrlTemplate = _productService.extensionsGallery.resourceUrlTemplate; this._extensionGalleryAuthority = this._extensionGalleryResourceUrlTemplate ? this._getExtensionGalleryAuthority(URI.parse(this._extensionGalleryResourceUrlTemplate)) : undefined; @@ -144,7 +141,9 @@ export abstract class AbstractExtensionResourceLoaderService implements IExtensi } protected _isWebExtensionResourceEndPoint(uri: URI): boolean { - return uri.path.startsWith(this._webExtensionResourceEndPoint); + const uriPath = uri.path, serverRootPath = RemoteAuthorities.getServerRootPath(); + // test if the path starts with the server root path followed by the web extension resource end point segment + return uriPath.startsWith(serverRootPath) && uriPath.startsWith(WEB_EXTENSION_RESOURCE_END_POINT_SEGMENT, serverRootPath.length); } } diff --git a/src/vs/platform/extensions/common/extensions.ts b/src/vs/platform/extensions/common/extensions.ts index 413c1db06f189..331aba1b55f45 100644 --- a/src/vs/platform/extensions/common/extensions.ts +++ b/src/vs/platform/extensions/common/extensions.ts @@ -255,6 +255,8 @@ export const EXTENSION_CATEGORIES = [ 'Testing', 'Themes', 'Visualization', + 'AI', + 'Chat', 'Other', ]; diff --git a/src/vs/platform/externalTerminal/node/externalTerminalService.ts b/src/vs/platform/externalTerminal/node/externalTerminalService.ts index 71dbac899b377..5086c95a8024d 100644 --- a/src/vs/platform/externalTerminal/node/externalTerminalService.ts +++ b/src/vs/platform/externalTerminal/node/externalTerminalService.ts @@ -80,8 +80,7 @@ export class WindowsExternalTerminalService extends ExternalTerminalService impl return new Promise((resolve, reject) => { const title = `"${dir} - ${TERMINAL_TITLE}"`; - const command = `""${args.join('" "')}" & pause"`; // use '|' to only pause on non-zero exit code - + const command = `"${args.join('" "')}" & pause`; // use '|' to only pause on non-zero exit code // merge environment variables into a copy of the process.env const env = Object.assign({}, getSanitizedEnvironment(process), envVars); @@ -107,10 +106,10 @@ export class WindowsExternalTerminalService extends ExternalTerminalService impl // prefer to use the window terminal to spawn if it's available instead // of start, since that allows ctrl+c handling (#81322) spawnExec = wt; - cmdArgs = ['-d', dir, exec, '/c', command]; + cmdArgs = ['-d', '.', exec, '/c', command]; } else { spawnExec = WindowsExternalTerminalService.CMD; - cmdArgs = ['/c', 'start', title, '/wait', exec, '/c', command]; + cmdArgs = ['/c', 'start', title, '/wait', exec, '/c', `"${command}"`]; } const cmd = cp.spawn(spawnExec, cmdArgs, options); diff --git a/src/vs/platform/files/common/diskFileSystemProvider.ts b/src/vs/platform/files/common/diskFileSystemProvider.ts index 5f5b623201b0d..b45744820b704 100644 --- a/src/vs/platform/files/common/diskFileSystemProvider.ts +++ b/src/vs/platform/files/common/diskFileSystemProvider.ts @@ -211,6 +211,10 @@ export abstract class AbstractDiskFileSystemProvider extends Disposable implemen this._onDidWatchError.fire(msg.message); } + this.logWatcherMessage(msg); + } + + protected logWatcherMessage(msg: ILogMessage): void { this.logService[msg.type](msg.message); } diff --git a/src/vs/platform/files/common/files.ts b/src/vs/platform/files/common/files.ts index 0bc285f082ee7..e8bcce418a894 100644 --- a/src/vs/platform/files/common/files.ts +++ b/src/vs/platform/files/common/files.ts @@ -243,14 +243,6 @@ export interface IFileService { */ createWatcher(resource: URI, options: IWatchOptionsWithoutCorrelation): IFileSystemWatcher; - /** - * Allows to start a watcher that reports file/folder change events on the provided resource. - * - * The watcher runs correlated and thus, file events will be reported on the returned - * `IFileSystemWatcher` and not on the generic `IFileService.onDidFilesChange` event. - */ - watch(resource: URI, options: IWatchOptionsWithCorrelation): IFileSystemWatcher; - /** * Allows to start a watcher that reports file/folder change events on the provided resource. * diff --git a/src/vs/platform/files/common/watcher.ts b/src/vs/platform/files/common/watcher.ts index ae97f833078cd..844eda6cac052 100644 --- a/src/vs/platform/files/common/watcher.ts +++ b/src/vs/platform/files/common/watcher.ts @@ -5,7 +5,7 @@ import { Event } from 'vs/base/common/event'; import { GLOBSTAR, IRelativePattern, parse, ParsedPattern } from 'vs/base/common/glob'; -import { Disposable, DisposableStore, MutableDisposable } from 'vs/base/common/lifecycle'; +import { Disposable, DisposableStore, IDisposable, MutableDisposable } from 'vs/base/common/lifecycle'; import { isAbsolute } from 'vs/base/common/path'; import { isLinux } from 'vs/base/common/platform'; import { URI } from 'vs/base/common/uri'; @@ -43,6 +43,14 @@ interface IWatchRequest { readonly correlationId?: number; } +export interface IWatchRequestWithCorrelation extends IWatchRequest { + readonly correlationId: number; +} + +export function isWatchRequestWithCorrelation(request: IWatchRequest): request is IWatchRequestWithCorrelation { + return typeof request.correlationId === 'number'; +} + export interface INonRecursiveWatchRequest extends IWatchRequest { /** @@ -71,7 +79,7 @@ export function isRecursiveWatchRequest(request: IWatchRequest): request is IRec export type IUniversalWatchRequest = IRecursiveWatchRequest | INonRecursiveWatchRequest; -interface IWatcher { +export interface IWatcher { /** * A normalized file change event from the raw events @@ -114,6 +122,20 @@ export interface IRecursiveWatcher extends IWatcher { watch(requests: IRecursiveWatchRequest[]): Promise; } +export interface IRecursiveWatcherWithSubscribe extends IRecursiveWatcher { + + /** + * Subscribe to file events for the given path. The callback is called + * whenever a file event occurs for the path. I fthe watcher failed, + * the error parameter is set to `true`. + * + * @returns an `IDisposable` to stop listening to events or `undefined` + * if no events can be watched for the path given the current set of + * recursive watch requests. + */ + subscribe(path: string, callback: (error: boolean, change?: IFileChange) => void): IDisposable | undefined; +} + export interface IRecursiveWatcherOptions { /** diff --git a/src/vs/platform/files/electron-main/diskFileSystemProviderServer.ts b/src/vs/platform/files/electron-main/diskFileSystemProviderServer.ts index fdc7e6a9117c3..64cbede2fd14e 100644 --- a/src/vs/platform/files/electron-main/diskFileSystemProviderServer.ts +++ b/src/vs/platform/files/electron-main/diskFileSystemProviderServer.ts @@ -16,6 +16,7 @@ import { ILogService } from 'vs/platform/log/common/log'; import { AbstractDiskFileSystemProviderChannel, AbstractSessionFileWatcher, ISessionFileWatcher } from 'vs/platform/files/node/diskFileSystemProviderServer'; import { DefaultURITransformer, IURITransformer } from 'vs/base/common/uriIpc'; import { IEnvironmentService } from 'vs/platform/environment/common/environment'; +import { toErrorMessage } from 'vs/base/common/errorMessage'; export class DiskFileSystemProviderChannel extends AbstractDiskFileSystemProviderChannel { @@ -47,7 +48,7 @@ export class DiskFileSystemProviderChannel extends AbstractDiskFileSystemProvide try { await shell.trashItem(filePath); } catch (error) { - throw createFileSystemProviderError(isWindows ? localize('binFailed', "Failed to move '{0}' to the recycle bin", basename(filePath)) : localize('trashFailed', "Failed to move '{0}' to the trash", basename(filePath)), FileSystemProviderErrorCode.Unknown); + throw createFileSystemProviderError(isWindows ? localize('binFailed', "Failed to move '{0}' to the recycle bin ({1})", basename(filePath), toErrorMessage(error)) : localize('trashFailed', "Failed to move '{0}' to the trash ({1})", basename(filePath), toErrorMessage(error)), FileSystemProviderErrorCode.Unknown); } } diff --git a/src/vs/platform/files/node/watcher/baseWatcher.ts b/src/vs/platform/files/node/watcher/baseWatcher.ts new file mode 100644 index 0000000000000..0f78d72b7295a --- /dev/null +++ b/src/vs/platform/files/node/watcher/baseWatcher.ts @@ -0,0 +1,251 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { watchFile, unwatchFile, Stats } from 'fs'; +import { Disposable, DisposableMap, DisposableStore, toDisposable } from 'vs/base/common/lifecycle'; +import { ILogMessage, IRecursiveWatcherWithSubscribe, IUniversalWatchRequest, IWatchRequestWithCorrelation, IWatcher, isWatchRequestWithCorrelation } from 'vs/platform/files/common/watcher'; +import { Emitter, Event } from 'vs/base/common/event'; +import { FileChangeType, IFileChange } from 'vs/platform/files/common/files'; +import { URI } from 'vs/base/common/uri'; +import { DeferredPromise } from 'vs/base/common/async'; + +export abstract class BaseWatcher extends Disposable implements IWatcher { + + protected readonly _onDidChangeFile = this._register(new Emitter()); + readonly onDidChangeFile = this._onDidChangeFile.event; + + protected readonly _onDidLogMessage = this._register(new Emitter()); + readonly onDidLogMessage = this._onDidLogMessage.event; + + protected readonly _onDidWatchFail = this._register(new Emitter()); + private readonly onDidWatchFail = this._onDidWatchFail.event; + + private readonly allNonCorrelatedWatchRequests = new Set(); + private readonly allCorrelatedWatchRequests = new Map(); + + private readonly suspendedWatchRequests = this._register(new DisposableMap()); + private readonly suspendedWatchRequestsWithPolling = new Set(); + + protected readonly suspendedWatchRequestPollingInterval: number = 5007; // node.js default + + private joinWatch = new DeferredPromise(); + + constructor() { + super(); + + this._register(this.onDidWatchFail(request => this.handleDidWatchFail(request))); + } + + private handleDidWatchFail(request: IUniversalWatchRequest): void { + if (!this.isCorrelated(request)) { + + // For now, limit failed watch monitoring to requests with a correlationId + // to experiment with this feature in a controlled way. Monitoring requests + // requires us to install polling watchers (via `fs.watchFile()`) and thus + // should be used sparingly. + // + // TODO@bpasero revisit this in the future to have a more general approach + // for suspend/resume and drop the `legacyMonitorRequest` in parcel. + // One issue is that we need to be able to uniquely identify a request and + // without correlation that is actually harder... + + return; + } + + this.suspendWatchRequest(request); + } + + protected isCorrelated(request: IUniversalWatchRequest): request is IWatchRequestWithCorrelation { + return isWatchRequestWithCorrelation(request); + } + + async watch(requests: IUniversalWatchRequest[]): Promise { + if (!this.joinWatch.isSettled) { + this.joinWatch.complete(); + } + this.joinWatch = new DeferredPromise(); + + try { + this.allCorrelatedWatchRequests.clear(); + this.allNonCorrelatedWatchRequests.clear(); + + // Figure out correlated vs. non-correlated requests + for (const request of requests) { + if (this.isCorrelated(request)) { + this.allCorrelatedWatchRequests.set(request.correlationId, request); + } else { + this.allNonCorrelatedWatchRequests.add(request); + } + } + + // Remove all suspended correlated watch requests that are no longer watched + for (const [correlationId] of this.suspendedWatchRequests) { + if (!this.allCorrelatedWatchRequests.has(correlationId)) { + this.suspendedWatchRequests.deleteAndDispose(correlationId); + this.suspendedWatchRequestsWithPolling.delete(correlationId); + } + } + + return await this.updateWatchers(); + } finally { + this.joinWatch.complete(); + } + } + + private updateWatchers(): Promise { + return this.doWatch([ + ...this.allNonCorrelatedWatchRequests, + ...Array.from(this.allCorrelatedWatchRequests.values()).filter(request => !this.suspendedWatchRequests.has(request.correlationId)) + ]); + } + + isSuspended(request: IUniversalWatchRequest): 'polling' | boolean { + if (typeof request.correlationId !== 'number') { + return false; + } + + return this.suspendedWatchRequestsWithPolling.has(request.correlationId) ? 'polling' : this.suspendedWatchRequests.has(request.correlationId); + } + + private async suspendWatchRequest(request: IWatchRequestWithCorrelation): Promise { + if (this.suspendedWatchRequests.has(request.correlationId)) { + return; // already suspended + } + + const disposables = new DisposableStore(); + this.suspendedWatchRequests.set(request.correlationId, disposables); + + // It is possible that a watch request fails right during watch() + // phase while other requests succeed. To increase the chance of + // reusing another watcher for suspend/resume tracking, we await + // all watch requests having processed. + + await this.joinWatch.p; + + if (disposables.isDisposed) { + return; + } + + this.monitorSuspendedWatchRequest(request, disposables); + + this.updateWatchers(); + } + + private resumeWatchRequest(request: IWatchRequestWithCorrelation): void { + this.suspendedWatchRequests.deleteAndDispose(request.correlationId); + this.suspendedWatchRequestsWithPolling.delete(request.correlationId); + + this.updateWatchers(); + } + + private monitorSuspendedWatchRequest(request: IWatchRequestWithCorrelation, disposables: DisposableStore): void { + if (this.doMonitorWithExistingWatcher(request, disposables)) { + this.trace(`reusing an existing recursive watcher to monitor ${request.path}`); + this.suspendedWatchRequestsWithPolling.delete(request.correlationId); + } else { + this.doMonitorWithNodeJS(request, disposables); + this.suspendedWatchRequestsWithPolling.add(request.correlationId); + } + } + + private doMonitorWithExistingWatcher(request: IWatchRequestWithCorrelation, disposables: DisposableStore): boolean { + const subscription = this.recursiveWatcher?.subscribe(request.path, (error, change) => { + if (disposables.isDisposed) { + return; // return early if already disposed + } + + if (error) { + this.monitorSuspendedWatchRequest(request, disposables); + } else if (change?.type === FileChangeType.ADDED) { + this.onMonitoredPathAdded(request); + } + }); + + if (subscription) { + disposables.add(subscription); + + return true; + } + + return false; + } + + private doMonitorWithNodeJS(request: IWatchRequestWithCorrelation, disposables: DisposableStore): void { + let pathNotFound = false; + + const watchFileCallback: (curr: Stats, prev: Stats) => void = (curr, prev) => { + if (disposables.isDisposed) { + return; // return early if already disposed + } + + const currentPathNotFound = this.isPathNotFound(curr); + const previousPathNotFound = this.isPathNotFound(prev); + const oldPathNotFound = pathNotFound; + pathNotFound = currentPathNotFound; + + // Watch path created: resume watching request + if (!currentPathNotFound && (previousPathNotFound || oldPathNotFound)) { + this.onMonitoredPathAdded(request); + } + }; + + this.trace(`starting fs.watchFile() on ${request.path} (correlationId: ${request.correlationId})`); + try { + watchFile(request.path, { persistent: false, interval: this.suspendedWatchRequestPollingInterval }, watchFileCallback); + } catch (error) { + this.warn(`fs.watchFile() failed with error ${error} on path ${request.path} (correlationId: ${request.correlationId})`); + } + + disposables.add(toDisposable(() => { + this.trace(`stopping fs.watchFile() on ${request.path} (correlationId: ${request.correlationId})`); + + try { + unwatchFile(request.path, watchFileCallback); + } catch (error) { + this.warn(`fs.unwatchFile() failed with error ${error} on path ${request.path} (correlationId: ${request.correlationId})`); + } + })); + } + + private onMonitoredPathAdded(request: IWatchRequestWithCorrelation) { + this.trace(`detected ${request.path} exists again, resuming watcher (correlationId: ${request.correlationId})`); + + // Emit as event + const event: IFileChange = { resource: URI.file(request.path), type: FileChangeType.ADDED, cId: request.correlationId }; + this._onDidChangeFile.fire([event]); + this.traceEvent(event, request); + + // Resume watching + this.resumeWatchRequest(request); + } + + private isPathNotFound(stats: Stats): boolean { + return stats.ctimeMs === 0 && stats.ino === 0; + } + + async stop(): Promise { + this.suspendedWatchRequests.clearAndDisposeAll(); + this.suspendedWatchRequestsWithPolling.clear(); + } + + protected traceEvent(event: IFileChange, request: IUniversalWatchRequest): void { + const traceMsg = ` >> normalized ${event.type === FileChangeType.ADDED ? '[ADDED]' : event.type === FileChangeType.DELETED ? '[DELETED]' : '[CHANGED]'} ${event.resource.fsPath}`; + this.trace(typeof request.correlationId === 'number' ? `${traceMsg} (correlationId: ${request.correlationId})` : traceMsg); + } + + protected requestToString(request: IUniversalWatchRequest): string { + return `${request.path} (excludes: ${request.excludes.length > 0 ? request.excludes : ''}, includes: ${request.includes && request.includes.length > 0 ? JSON.stringify(request.includes) : ''}, correlationId: ${typeof request.correlationId === 'number' ? request.correlationId : ''})`; + } + + protected abstract doWatch(requests: IUniversalWatchRequest[]): Promise; + + protected abstract readonly recursiveWatcher: IRecursiveWatcherWithSubscribe | undefined; + + protected abstract trace(message: string): void; + protected abstract warn(message: string): void; + + abstract onDidError: Event; + abstract setVerboseLogging(enabled: boolean): Promise; +} diff --git a/src/vs/platform/files/node/watcher/nodejs/nodejsClient.ts b/src/vs/platform/files/node/watcher/nodejs/nodejsClient.ts index 11eb6b8a1091b..3a2f6446996c4 100644 --- a/src/vs/platform/files/node/watcher/nodejs/nodejsClient.ts +++ b/src/vs/platform/files/node/watcher/nodejs/nodejsClient.ts @@ -21,6 +21,6 @@ export class NodeJSWatcherClient extends AbstractNonRecursiveWatcherClient { } protected override createWatcher(disposables: DisposableStore): INonRecursiveWatcher { - return disposables.add(new NodeJSWatcher()); + return disposables.add(new NodeJSWatcher(undefined /* no recursive watching support here */)) satisfies INonRecursiveWatcher; } } diff --git a/src/vs/platform/files/node/watcher/nodejs/nodejsWatcher.ts b/src/vs/platform/files/node/watcher/nodejs/nodejsWatcher.ts index dac55a138c5fc..7f5ddeeb54434 100644 --- a/src/vs/platform/files/node/watcher/nodejs/nodejsWatcher.ts +++ b/src/vs/platform/files/node/watcher/nodejs/nodejsWatcher.ts @@ -3,13 +3,13 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { Event, Emitter } from 'vs/base/common/event'; +import { Event } from 'vs/base/common/event'; import { patternsEquals } from 'vs/base/common/glob'; -import { Disposable } from 'vs/base/common/lifecycle'; +import { BaseWatcher } from 'vs/platform/files/node/watcher/baseWatcher'; import { isLinux } from 'vs/base/common/platform'; -import { IFileChange } from 'vs/platform/files/common/files'; -import { ILogMessage, INonRecursiveWatchRequest, INonRecursiveWatcher } from 'vs/platform/files/common/watcher'; +import { INonRecursiveWatchRequest, INonRecursiveWatcher, IRecursiveWatcherWithSubscribe } from 'vs/platform/files/common/watcher'; import { NodeJSFileWatcherLibrary } from 'vs/platform/files/node/watcher/nodejs/nodejsWatcherLib'; +import { isEqual } from 'vs/base/common/extpath'; export interface INodeJSWatcherInstance { @@ -24,90 +24,104 @@ export interface INodeJSWatcherInstance { readonly request: INonRecursiveWatchRequest; } -export class NodeJSWatcher extends Disposable implements INonRecursiveWatcher { - - private readonly _onDidChangeFile = this._register(new Emitter()); - readonly onDidChangeFile = this._onDidChangeFile.event; - - private readonly _onDidLogMessage = this._register(new Emitter()); - readonly onDidLogMessage = this._onDidLogMessage.event; +export class NodeJSWatcher extends BaseWatcher implements INonRecursiveWatcher { readonly onDidError = Event.None; - protected readonly watchers = new Map(); + readonly watchers = new Set(); private verboseLogging = false; - async watch(requests: INonRecursiveWatchRequest[]): Promise { + constructor(protected readonly recursiveWatcher: IRecursiveWatcherWithSubscribe | undefined) { + super(); + } + + protected override async doWatch(requests: INonRecursiveWatchRequest[]): Promise { // Figure out duplicates to remove from the requests - const normalizedRequests = this.normalizeRequests(requests); + requests = this.removeDuplicateRequests(requests); - // Gather paths that we should start watching - const requestsToStartWatching = normalizedRequests.filter(request => { - const watcher = this.watchers.get(request.path); - if (!watcher) { - return true; // not yet watching that path + // Figure out which watchers to start and which to stop + const requestsToStart: INonRecursiveWatchRequest[] = []; + const watchersToStop = new Set(Array.from(this.watchers)); + for (const request of requests) { + const watcher = this.findWatcher(request); + if (watcher && patternsEquals(watcher.request.excludes, request.excludes) && patternsEquals(watcher.request.includes, request.includes)) { + watchersToStop.delete(watcher); // keep watcher + } else { + requestsToStart.push(request); // start watching } - - // Re-watch path if excludes or includes have changed - return !patternsEquals(watcher.request.excludes, request.excludes) || !patternsEquals(watcher.request.includes, request.includes); - }); - - // Gather paths that we should stop watching - const pathsToStopWatching = Array.from(this.watchers.values()).filter(({ request }) => { - return !normalizedRequests.find(normalizedRequest => normalizedRequest.path === request.path && patternsEquals(normalizedRequest.excludes, request.excludes) && patternsEquals(normalizedRequest.includes, request.includes)); - }).map(({ request }) => request.path); + } // Logging - if (requestsToStartWatching.length) { - this.trace(`Request to start watching: ${requestsToStartWatching.map(request => `${request.path} (excludes: ${request.excludes.length > 0 ? request.excludes : ''}, includes: ${request.includes && request.includes.length > 0 ? JSON.stringify(request.includes) : ''}, correlationId: ${typeof request.correlationId === 'number' ? request.correlationId : ''})`).join(',')}`); + if (requestsToStart.length) { + this.trace(`Request to start watching: ${requestsToStart.map(request => this.requestToString(request)).join(',')}`); } - if (pathsToStopWatching.length) { - this.trace(`Request to stop watching: ${pathsToStopWatching.join(',')}`); + if (watchersToStop.size) { + this.trace(`Request to stop watching: ${Array.from(watchersToStop).map(watcher => this.requestToString(watcher.request)).join(',')}`); } // Stop watching as instructed - for (const pathToStopWatching of pathsToStopWatching) { - this.stopWatching(pathToStopWatching); + for (const watcher of watchersToStop) { + this.stopWatching(watcher); } // Start watching as instructed - for (const request of requestsToStartWatching) { + for (const request of requestsToStart) { this.startWatching(request); } } + private findWatcher(request: INonRecursiveWatchRequest): INodeJSWatcherInstance | undefined { + for (const watcher of this.watchers) { + + // Requests or watchers with correlation always match on that + if (typeof request.correlationId === 'number' || typeof watcher.request.correlationId === 'number') { + if (watcher.request.correlationId === request.correlationId) { + return watcher; + } + } + + // Non-correlated requests or watchers match on path + else { + if (isEqual(watcher.request.path, request.path, !isLinux /* ignorecase */)) { + return watcher; + } + } + } + + return undefined; + } + private startWatching(request: INonRecursiveWatchRequest): void { // Start via node.js lib - const instance = new NodeJSFileWatcherLibrary(request, changes => this._onDidChangeFile.fire(changes), msg => this._onDidLogMessage.fire(msg), this.verboseLogging); + const instance = new NodeJSFileWatcherLibrary(request, this.recursiveWatcher, changes => this._onDidChangeFile.fire(changes), () => this._onDidWatchFail.fire(request), msg => this._onDidLogMessage.fire(msg), this.verboseLogging); // Remember as watcher instance const watcher: INodeJSWatcherInstance = { request, instance }; - this.watchers.set(request.path, watcher); + this.watchers.add(watcher); } - async stop(): Promise { - for (const [path] of this.watchers) { - this.stopWatching(path); - } + override async stop(): Promise { + await super.stop(); - this.watchers.clear(); + for (const watcher of this.watchers) { + this.stopWatching(watcher); + } } - private stopWatching(path: string): void { - const watcher = this.watchers.get(path); - if (watcher) { - this.watchers.delete(path); + private stopWatching(watcher: INodeJSWatcherInstance): void { + this.trace(`stopping file watcher`, watcher); - watcher.instance.dispose(); - } + this.watchers.delete(watcher); + + watcher.instance.dispose(); } - private normalizeRequests(requests: INonRecursiveWatchRequest[]): INonRecursiveWatchRequest[] { + private removeDuplicateRequests(requests: INonRecursiveWatchRequest[]): INonRecursiveWatchRequest[] { const mapCorrelationtoRequests = new Map>(); // Ignore requests for the same paths that have the same correlation @@ -120,6 +134,10 @@ export class NodeJSWatcher extends Disposable implements INonRecursiveWatcher { mapCorrelationtoRequests.set(request.correlationId, requestsForCorrelation); } + if (requestsForCorrelation.has(path)) { + this.trace(`ignoring a request for watching who's path is already watched: ${this.requestToString(request)}`); + } + requestsForCorrelation.set(path, request); } @@ -129,18 +147,22 @@ export class NodeJSWatcher extends Disposable implements INonRecursiveWatcher { async setVerboseLogging(enabled: boolean): Promise { this.verboseLogging = enabled; - for (const [, watcher] of this.watchers) { + for (const watcher of this.watchers) { watcher.instance.setVerboseLogging(enabled); } } - private trace(message: string): void { + protected trace(message: string, watcher?: INodeJSWatcherInstance): void { if (this.verboseLogging) { - this._onDidLogMessage.fire({ type: 'trace', message: this.toMessage(message) }); + this._onDidLogMessage.fire({ type: 'trace', message: this.toMessage(message, watcher) }); } } + protected warn(message: string): void { + this._onDidLogMessage.fire({ type: 'warn', message: this.toMessage(message) }); + } + private toMessage(message: string, watcher?: INodeJSWatcherInstance): string { - return watcher ? `[File Watcher (node.js)] ${message} (path: ${watcher.request.path})` : `[File Watcher (node.js)] ${message}`; + return watcher ? `[File Watcher (node.js)] ${message} (${this.requestToString(watcher.request)})` : `[File Watcher (node.js)] ${message}`; } } diff --git a/src/vs/platform/files/node/watcher/nodejs/nodejsWatcherLib.ts b/src/vs/platform/files/node/watcher/nodejs/nodejsWatcherLib.ts index 8f0f7d6a87fb5..f5a2f74734028 100644 --- a/src/vs/platform/files/node/watcher/nodejs/nodejsWatcherLib.ts +++ b/src/vs/platform/files/node/watcher/nodejs/nodejsWatcherLib.ts @@ -16,7 +16,7 @@ import { URI } from 'vs/base/common/uri'; import { realcase } from 'vs/base/node/extpath'; import { Promises } from 'vs/base/node/pfs'; import { FileChangeType, IFileChange } from 'vs/platform/files/common/files'; -import { ILogMessage, coalesceEvents, INonRecursiveWatchRequest, parseWatcherPatterns } from 'vs/platform/files/common/watcher'; +import { ILogMessage, coalesceEvents, INonRecursiveWatchRequest, parseWatcherPatterns, IRecursiveWatcherWithSubscribe } from 'vs/platform/files/common/watcher'; export class NodeJSFileWatcherLibrary extends Disposable { @@ -56,10 +56,18 @@ export class NodeJSFileWatcherLibrary extends Disposable { readonly ready = this.watch(); + private _isReusingRecursiveWatcher = false; + get isReusingRecursiveWatcher(): boolean { return this._isReusingRecursiveWatcher; } + + private didFail = false; + get failed(): boolean { return this.didFail; } + constructor( - private request: INonRecursiveWatchRequest, - private onDidFilesChange: (changes: IFileChange[]) => void, - private onLogMessage?: (msg: ILogMessage) => void, + private readonly request: INonRecursiveWatchRequest, + private readonly recursiveWatcher: IRecursiveWatcherWithSubscribe | undefined, + private readonly onDidFilesChange: (changes: IFileChange[]) => void, + private readonly onDidWatchFail?: () => void, + private readonly onLogMessage?: (msg: ILogMessage) => void, private verboseLogging?: boolean ) { super(); @@ -73,19 +81,30 @@ export class NodeJSFileWatcherLibrary extends Disposable { return; } - // Watch via node.js const stat = await Promises.stat(realPath); - this._register(await this.doWatch(realPath, stat.isDirectory())); + if (this.cts.token.isCancellationRequested) { + return; + } + + this._register(await this.doWatch(realPath, stat.isDirectory())); } catch (error) { if (error.code !== 'ENOENT') { this.error(error); } else { - this.trace(error); + this.trace(`ignoring a path for watching who's stat info failed to resolve: ${this.request.path} (error: ${error})`); } + + this.notifyWatchFailed(); } } + private notifyWatchFailed(): void { + this.didFail = true; + + this.onDidWatchFail?.(); + } + private async normalizePath(request: INonRecursiveWatchRequest): Promise { let realPath = request.path; @@ -97,7 +116,7 @@ export class NodeJSFileWatcherLibrary extends Disposable { // Second check for casing difference // Note: this will be a no-op on Linux platforms if (request.path === realPath) { - realPath = await realcase(request.path) ?? request.path; + realPath = await realcase(request.path, this.cts.token) ?? request.path; } // Correct watch path as needed @@ -111,41 +130,95 @@ export class NodeJSFileWatcherLibrary extends Disposable { return realPath; } - private async doWatch(path: string, isDirectory: boolean): Promise { + private async doWatch(realPath: string, isDirectory: boolean): Promise { + const disposables = new DisposableStore(); + + if (this.doWatchWithExistingWatcher(realPath, isDirectory, disposables)) { + this.trace(`reusing an existing recursive watcher for ${this.request.path}`); + this._isReusingRecursiveWatcher = true; + } else { + this._isReusingRecursiveWatcher = false; + await this.doWatchWithNodeJS(realPath, isDirectory, disposables); + } + + return disposables; + } + + private doWatchWithExistingWatcher(realPath: string, isDirectory: boolean, disposables: DisposableStore): boolean { + if (isDirectory) { + return false; // only supported for files where we have the full path known upfront + } + + const resource = URI.file(this.request.path); + const subscription = this.recursiveWatcher?.subscribe(this.request.path, async (error, change) => { + if (disposables.isDisposed) { + return; // return early if already disposed + } + + if (error) { + const watchDisposable = await this.doWatch(realPath, isDirectory); + if (!disposables.isDisposed) { + disposables.add(watchDisposable); + } else { + watchDisposable.dispose(); + } + } else if (change) { + if (typeof change.cId === 'number' || typeof this.request.correlationId === 'number') { + // Re-emit this change with the correlation id of the request + // so that the client can correlate the event with the request + // properly. Without correlation, we do not have to do that + // because the event will appear on the global listener already. + this.onDidFilesChange([{ resource, type: change.type, cId: this.request.correlationId }]); + } + } + }); + + if (subscription) { + disposables.add(subscription); + + return true; + } + + return false; + } + + private async doWatchWithNodeJS(realPath: string, isDirectory: boolean, disposables: DisposableStore): Promise { // macOS: watching samba shares can crash VSCode so we do // a simple check for the file path pointing to /Volumes // (https://github.com/microsoft/vscode/issues/106879) // TODO@electron this needs a revisit when the crash is // fixed or mitigated upstream. - if (isMacintosh && isEqualOrParent(path, '/Volumes/', true)) { - this.error(`Refusing to watch ${path} for changes using fs.watch() for possibly being a network share where watching is unreliable and unstable.`); + if (isMacintosh && isEqualOrParent(realPath, '/Volumes/', true)) { + this.error(`Refusing to watch ${realPath} for changes using fs.watch() for possibly being a network share where watching is unreliable and unstable.`); - return Disposable.None; + return; } const cts = new CancellationTokenSource(this.cts.token); + disposables.add(toDisposable(() => cts.dispose(true))); - const disposables = new DisposableStore(); + const watcherDisposables = new DisposableStore(); // we need a separate disposable store because we re-create the watcher from within in some cases + disposables.add(watcherDisposables); try { const requestResource = URI.file(this.request.path); - const pathBasename = basename(path); + const pathBasename = basename(realPath); // Creating watcher can fail with an exception - const watcher = watch(path); - disposables.add(toDisposable(() => { + const watcher = watch(realPath); + watcherDisposables.add(toDisposable(() => { watcher.removeAllListeners(); watcher.close(); })); - this.trace(`Started watching: '${path}'`); + this.trace(`Started watching: '${realPath}'`); // Folder: resolve children to emit proper events const folderChildren = new Set(); if (isDirectory) { try { - for (const child of await Promises.readdir(path)) { + for (const child of await Promises.readdir(realPath)) { folderChildren.add(child); } } catch (error) { @@ -153,8 +226,12 @@ export class NodeJSFileWatcherLibrary extends Disposable { } } + if (cts.token.isCancellationRequested) { + return; + } + const mapPathToStatDisposable = new Map(); - disposables.add(toDisposable(() => { + watcherDisposables.add(toDisposable(() => { for (const [, disposable] of mapPathToStatDisposable) { disposable.dispose(); } @@ -162,11 +239,13 @@ export class NodeJSFileWatcherLibrary extends Disposable { })); watcher.on('error', (code: number, signal: string) => { - this.error(`Failed to watch ${path} for changes using fs.watch() (${code}, ${signal})`); + if (cts.token.isCancellationRequested) { + return; + } - // The watcher is no longer functional reliably - // so we go ahead and dispose it - this.dispose(); + this.error(`Failed to watch ${realPath} for changes using fs.watch() (${code}, ${signal})`); + + this.notifyWatchFailed(); }); watcher.on('change', (type, raw) => { @@ -224,16 +303,13 @@ export class NodeJSFileWatcherLibrary extends Disposable { // file watching specifically we want to handle // the atomic-write cases where the file is being // deleted and recreated with different contents. - // - // Same as with recursive watching, we do not - // emit a delete event in this case. - if (changedFileName === pathBasename && !await Promises.exists(path)) { - this.warn('Watcher shutdown because watched path got deleted'); + if (changedFileName === pathBasename && !await Promises.exists(realPath)) { + this.onWatchedPathDeleted(requestResource); - // The watcher is no longer functional reliably - // so we go ahead and dispose it - this.dispose(); + return; + } + if (cts.token.isCancellationRequested) { return; } @@ -241,7 +317,7 @@ export class NodeJSFileWatcherLibrary extends Disposable { // file system, we need to use `existsChildStrictCase` helper // because otherwise we would wrongly assume a file exists // when it was renamed to same name but different case. - const fileExists = await this.existsChildStrictCase(join(path, changedFileName)); + const fileExists = await this.existsChildStrictCase(join(realPath, changedFileName)); if (cts.token.isCancellationRequested) { return; // ignore if disposed by now @@ -313,7 +389,7 @@ export class NodeJSFileWatcherLibrary extends Disposable { // because the watcher is disposed then. const timeoutHandle = setTimeout(async () => { - const fileExists = await Promises.exists(path); + const fileExists = await Promises.exists(realPath); if (cts.token.isCancellationRequested) { return; // ignore if disposed by now @@ -323,26 +399,19 @@ export class NodeJSFileWatcherLibrary extends Disposable { if (fileExists) { this.onFileChange({ resource: requestResource, type: FileChangeType.UPDATED, cId: this.request.correlationId }, true /* skip excludes/includes (file is explicitly watched) */); - disposables.add(await this.doWatch(path, false)); + watcherDisposables.add(await this.doWatch(realPath, false)); } - // File seems to be really gone, so emit a deleted event and dispose + // File seems to be really gone, so emit a deleted and failed event else { - this.onFileChange({ resource: requestResource, type: FileChangeType.DELETED, cId: this.request.correlationId }, true /* skip excludes/includes (file is explicitly watched) */); - - // Important to flush the event delivery - // before disposing the watcher, otherwise - // we will loose this event. - this.fileChangesAggregator.flush(); - - this.dispose(); + this.onWatchedPathDeleted(requestResource); } }, NodeJSFileWatcherLibrary.FILE_DELETE_HANDLER_DELAY); // Very important to dispose the watcher which now points to a stale inode // and wire in a new disposable that tracks our timeout that is installed - disposables.clear(); - disposables.add(toDisposable(() => clearTimeout(timeoutHandle))); + watcherDisposables.clear(); + watcherDisposables.add(toDisposable(() => clearTimeout(timeoutHandle))); } // File changed @@ -352,15 +421,22 @@ export class NodeJSFileWatcherLibrary extends Disposable { } }); } catch (error) { - if (await Promises.exists(path) && !cts.token.isCancellationRequested) { - this.error(`Failed to watch ${path} for changes using fs.watch() (${error.toString()})`); + if (!cts.token.isCancellationRequested) { + this.error(`Failed to watch ${realPath} for changes using fs.watch() (${error.toString()})`); } + + this.notifyWatchFailed(); } + } - return toDisposable(() => { - cts.dispose(true); - disposables.dispose(); - }); + private onWatchedPathDeleted(resource: URI): void { + this.warn('Watcher shutdown because watched path got deleted'); + + // Emit events and flush in case the watcher gets disposed + this.onFileChange({ resource, type: FileChangeType.DELETED, cId: this.request.correlationId }, true /* skip excludes/includes (file is explicitly watched) */); + this.fileChangesAggregator.flush(); + + this.notifyWatchFailed(); } private onFileChange(event: IFileChange, skipIncludeExcludeChecks = false): void { @@ -454,8 +530,6 @@ export class NodeJSFileWatcherLibrary extends Disposable { } override dispose(): void { - this.trace(`stopping file watcher on ${this.request.path}`); - this.cts.dispose(true); super.dispose(); @@ -476,7 +550,7 @@ export async function watchFileContents(path: string, onData: (chunk: Uint8Array let isReading = false; const request: INonRecursiveWatchRequest = { path, excludes: [], recursive: false }; - const watcher = new NodeJSFileWatcherLibrary(request, changes => { + const watcher = new NodeJSFileWatcherLibrary(request, undefined, changes => { (async () => { for (const { type } of changes) { if (type === FileChangeType.UPDATED) { diff --git a/src/vs/platform/files/node/watcher/parcel/parcelWatcher.ts b/src/vs/platform/files/node/watcher/parcel/parcelWatcher.ts index d1b978043ede3..f8e743f116317 100644 --- a/src/vs/platform/files/node/watcher/parcel/parcelWatcher.ts +++ b/src/vs/platform/files/node/watcher/parcel/parcelWatcher.ts @@ -10,10 +10,10 @@ import { URI } from 'vs/base/common/uri'; import { DeferredPromise, RunOnceScheduler, RunOnceWorker, ThrottledWorker } from 'vs/base/common/async'; import { CancellationToken, CancellationTokenSource } from 'vs/base/common/cancellation'; import { toErrorMessage } from 'vs/base/common/errorMessage'; -import { Emitter } from 'vs/base/common/event'; -import { randomPath } from 'vs/base/common/extpath'; -import { GLOBSTAR, ParsedPattern, patternsEquals } from 'vs/base/common/glob'; -import { Disposable } from 'vs/base/common/lifecycle'; +import { Emitter, Event } from 'vs/base/common/event'; +import { randomPath, isEqual, isEqualOrParent } from 'vs/base/common/extpath'; +import { GLOBSTAR, patternsEquals } from 'vs/base/common/glob'; +import { BaseWatcher } from 'vs/platform/files/node/watcher/baseWatcher'; import { TernarySearchTree } from 'vs/base/common/ternarySearchTree'; import { normalizeNFC } from 'vs/base/common/normalization'; import { dirname, normalize } from 'vs/base/common/path'; @@ -21,44 +21,121 @@ import { isLinux, isMacintosh, isWindows } from 'vs/base/common/platform'; import { realcaseSync, realpathSync } from 'vs/base/node/extpath'; import { NodeJSFileWatcherLibrary } from 'vs/platform/files/node/watcher/nodejs/nodejsWatcherLib'; import { FileChangeType, IFileChange } from 'vs/platform/files/common/files'; -import { ILogMessage, coalesceEvents, IRecursiveWatchRequest, IRecursiveWatcher, parseWatcherPatterns } from 'vs/platform/files/common/watcher'; - -export interface IParcelWatcherInstance { - - /** - * Signals when the watcher is ready to watch. - */ - readonly ready: Promise; - - /** - * The watch request associated to the watcher. - */ - readonly request: IRecursiveWatchRequest; - - /** - * How often this watcher has been restarted in case of an unexpected - * shutdown. - */ - readonly restarts: number; - - /** - * The cancellation token associated with the lifecycle of the watcher. - */ - readonly token: CancellationToken; - - /** - * An event aggregator to coalesce events and reduce duplicates. - */ - readonly worker: RunOnceWorker; - - /** - * Stops and disposes the watcher. This operation is async to await - * unsubscribe call in Parcel. - */ - stop(): Promise; +import { coalesceEvents, IRecursiveWatchRequest, parseWatcherPatterns, IRecursiveWatcherWithSubscribe } from 'vs/platform/files/common/watcher'; +import { Disposable, DisposableStore, IDisposable, toDisposable } from 'vs/base/common/lifecycle'; + +export class ParcelWatcherInstance extends Disposable { + + private readonly _onDidStop = this._register(new Emitter<{ joinRestart?: Promise }>()); + readonly onDidStop = this._onDidStop.event; + + private readonly _onDidFail = this._register(new Emitter()); + readonly onDidFail = this._onDidFail.event; + + private didFail = false; + get failed(): boolean { return this.didFail; } + + private didStop = false; + get stopped(): boolean { return this.didStop; } + + private readonly includes = this.request.includes ? parseWatcherPatterns(this.request.path, this.request.includes) : undefined; + private readonly excludes = this.request.excludes ? parseWatcherPatterns(this.request.path, this.request.excludes) : undefined; + + private readonly subscriptions = new Map void>>(); + + constructor( + /** + * Signals when the watcher is ready to watch. + */ + readonly ready: Promise, + readonly request: IRecursiveWatchRequest, + /** + * How often this watcher has been restarted in case of an unexpected + * shutdown. + */ + readonly restarts: number, + /** + * The cancellation token associated with the lifecycle of the watcher. + */ + readonly token: CancellationToken, + /** + * An event aggregator to coalesce events and reduce duplicates. + */ + readonly worker: RunOnceWorker, + private readonly stopFn: () => Promise + ) { + super(); + + this._register(toDisposable(() => this.subscriptions.clear())); + } + + subscribe(path: string, callback: (change: IFileChange) => void): IDisposable { + path = URI.file(path).fsPath; // make sure to store the path in `fsPath` form to match it with events later + + let subscriptions = this.subscriptions.get(path); + if (!subscriptions) { + subscriptions = new Set(); + this.subscriptions.set(path, subscriptions); + } + + subscriptions.add(callback); + + return toDisposable(() => { + const subscriptions = this.subscriptions.get(path); + if (subscriptions) { + subscriptions.delete(callback); + + if (subscriptions.size === 0) { + this.subscriptions.delete(path); + } + } + }); + } + + get subscriptionsCount(): number { + return this.subscriptions.size; + } + + notifyFileChange(path: string, change: IFileChange): void { + const subscriptions = this.subscriptions.get(path); + if (subscriptions) { + for (const subscription of subscriptions) { + subscription(change); + } + } + } + + notifyWatchFailed(): void { + this.didFail = true; + + this._onDidFail.fire(); + } + + include(path: string): boolean { + if (!this.includes || this.includes.length === 0) { + return true; // no specific includes defined, include all + } + + return this.includes.some(include => include(path)); + } + + exclude(path: string): boolean { + return Boolean(this.excludes?.some(exclude => exclude(path))); + } + + async stop(joinRestart: Promise | undefined): Promise { + this.didStop = true; + + try { + await this.stopFn(); + } finally { + this._onDidStop.fire({ joinRestart }); + this.dispose(); + } + } } -export class ParcelWatcher extends Disposable implements IRecursiveWatcher { +export class ParcelWatcher extends BaseWatcher implements IRecursiveWatcherWithSubscribe { private static readonly MAP_PARCEL_WATCHER_ACTION_TO_FILE_CHANGE = new Map( [ @@ -70,16 +147,10 @@ export class ParcelWatcher extends Disposable implements IRecursiveWatcher { private static readonly PARCEL_WATCHER_BACKEND = isWindows ? 'windows' : isLinux ? 'inotify' : 'fs-events'; - private readonly _onDidChangeFile = this._register(new Emitter()); - readonly onDidChangeFile = this._onDidChangeFile.event; - - private readonly _onDidLogMessage = this._register(new Emitter()); - readonly onDidLogMessage = this._onDidLogMessage.event; - private readonly _onDidError = this._register(new Emitter()); readonly onDidError = this._onDidError.event; - protected readonly watchers = new Map(); + readonly watchers = new Set(); // A delay for collecting file changes from Parcel // before collecting them for coalescing and emitting. @@ -120,50 +191,39 @@ export class ParcelWatcher extends Disposable implements IRecursiveWatcher { process.on('unhandledRejection', error => this.onUnexpectedError(error)); } - async watch(requests: IRecursiveWatchRequest[]): Promise { + protected override async doWatch(requests: IRecursiveWatchRequest[]): Promise { // Figure out duplicates to remove from the requests - const normalizedRequests = this.normalizeRequests(requests); + requests = this.removeDuplicateRequests(requests); - // Gather paths that we should start watching - const requestsToStartWatching = normalizedRequests.filter(request => { - const watcher = this.watchers.get(request.path); - if (!watcher) { - return true; // not yet watching that path + // Figure out which watchers to start and which to stop + const requestsToStart: IRecursiveWatchRequest[] = []; + const watchersToStop = new Set(Array.from(this.watchers)); + for (const request of requests) { + const watcher = this.findWatcher(request); + if (watcher && patternsEquals(watcher.request.excludes, request.excludes) && patternsEquals(watcher.request.includes, request.includes) && watcher.request.pollingInterval === request.pollingInterval) { + watchersToStop.delete(watcher); // keep watcher + } else { + requestsToStart.push(request); // start watching } - - // Re-watch path if excludes/includes have changed or polling interval - return !patternsEquals(watcher.request.excludes, request.excludes) || !patternsEquals(watcher.request.includes, request.includes) || watcher.request.pollingInterval !== request.pollingInterval; - }); - - // Gather paths that we should stop watching - const pathsToStopWatching = Array.from(this.watchers.values()).filter(({ request }) => { - return !normalizedRequests.find(normalizedRequest => { - return normalizedRequest.path === request.path && - patternsEquals(normalizedRequest.excludes, request.excludes) && - patternsEquals(normalizedRequest.includes, request.includes) && - normalizedRequest.pollingInterval === request.pollingInterval; - - }); - }).map(({ request }) => request.path); + } // Logging - - if (requestsToStartWatching.length) { - this.trace(`Request to start watching: ${requestsToStartWatching.map(request => `${request.path} (excludes: ${request.excludes.length > 0 ? request.excludes : ''}, includes: ${request.includes && request.includes.length > 0 ? JSON.stringify(request.includes) : ''}, correlationId: ${typeof request.correlationId === 'number' ? request.correlationId : ''})`).join(',')}`); + if (requestsToStart.length) { + this.trace(`Request to start watching: ${requestsToStart.map(request => this.requestToString(request)).join(',')}`); } - if (pathsToStopWatching.length) { - this.trace(`Request to stop watching: ${pathsToStopWatching.join(',')}`); + if (watchersToStop.size) { + this.trace(`Request to stop watching: ${Array.from(watchersToStop).map(watcher => this.requestToString(watcher.request)).join(',')}`); } // Stop watching as instructed - for (const pathToStopWatching of pathsToStopWatching) { - await this.stopWatching(pathToStopWatching); + for (const watcher of watchersToStop) { + await this.stopWatching(watcher); } // Start watching as instructed - for (const request of requestsToStartWatching) { + for (const request of requestsToStart) { if (request.pollingInterval) { this.startPolling(request, request.pollingInterval); } else { @@ -172,6 +232,27 @@ export class ParcelWatcher extends Disposable implements IRecursiveWatcher { } } + private findWatcher(request: IRecursiveWatchRequest): ParcelWatcherInstance | undefined { + for (const watcher of this.watchers) { + + // Requests or watchers with correlation always match on that + if (typeof request.correlationId === 'number' || typeof watcher.request.correlationId === 'number') { + if (watcher.request.correlationId === request.correlationId) { + return watcher; + } + } + + // Non-correlated requests or watchers match on path + else { + if (isEqual(watcher.request.path, request.path, !isLinux /* ignorecase */)) { + return watcher; + } + } + } + + return undefined; + } + private startPolling(request: IRecursiveWatchRequest, pollingInterval: number, restarts = 0): void { const cts = new CancellationTokenSource(); @@ -180,13 +261,13 @@ export class ParcelWatcher extends Disposable implements IRecursiveWatcher { const snapshotFile = randomPath(tmpdir(), 'vscode-watcher-snapshot'); // Remember as watcher instance - const watcher: IParcelWatcherInstance = { + const watcher: ParcelWatcherInstance = new ParcelWatcherInstance( + instance.p, request, - ready: instance.p, restarts, - token: cts.token, - worker: new RunOnceWorker(events => this.handleParcelEvents(events, watcher), ParcelWatcher.FILE_CHANGES_HANDLER_DELAY), - stop: async () => { + cts.token, + new RunOnceWorker(events => this.handleParcelEvents(events, watcher), ParcelWatcher.FILE_CHANGES_HANDLER_DELAY), + async () => { cts.dispose(true); watcher.worker.flush(); @@ -195,15 +276,12 @@ export class ParcelWatcher extends Disposable implements IRecursiveWatcher { pollingWatcher.dispose(); unlinkSync(snapshotFile); } - }; - this.watchers.set(request.path, watcher); + ); + this.watchers.add(watcher); // Path checks for symbolic links / wrong casing const { realPath, realPathDiffers, realPathLength } = this.normalizePath(request); - // Warm up include patterns for usage - const includePatterns = request.includes ? parseWatcherPatterns(request.path, request.includes) : undefined; - this.trace(`Started watching: '${realPath}' with polling interval '${pollingInterval}'`); let counter = 0; @@ -224,7 +302,7 @@ export class ParcelWatcher extends Disposable implements IRecursiveWatcher { } // Handle & emit events - this.onParcelEvents(parcelEvents, watcher, includePatterns, realPathDiffers, realPathLength); + this.onParcelEvents(parcelEvents, watcher, realPathDiffers, realPathLength); } // Store a snapshot of files to the snapshot file @@ -251,13 +329,13 @@ export class ParcelWatcher extends Disposable implements IRecursiveWatcher { const instance = new DeferredPromise(); // Remember as watcher instance - const watcher: IParcelWatcherInstance = { + const watcher: ParcelWatcherInstance = new ParcelWatcherInstance( + instance.p, request, - ready: instance.p, restarts, - token: cts.token, - worker: new RunOnceWorker(events => this.handleParcelEvents(events, watcher), ParcelWatcher.FILE_CHANGES_HANDLER_DELAY), - stop: async () => { + cts.token, + new RunOnceWorker(events => this.handleParcelEvents(events, watcher), ParcelWatcher.FILE_CHANGES_HANDLER_DELAY), + async () => { cts.dispose(true); watcher.worker.flush(); @@ -266,15 +344,12 @@ export class ParcelWatcher extends Disposable implements IRecursiveWatcher { const watcherInstance = await instance.p; await watcherInstance?.unsubscribe(); } - }; - this.watchers.set(request.path, watcher); + ); + this.watchers.add(watcher); // Path checks for symbolic links / wrong casing const { realPath, realPathDiffers, realPathLength } = this.normalizePath(request); - // Warm up include patterns for usage - const includePatterns = request.includes ? parseWatcherPatterns(request.path, request.includes) : undefined; - parcelWatcher.subscribe(realPath, (error, parcelEvents) => { if (watcher.token.isCancellationRequested) { return; // return early when disposed @@ -289,7 +364,7 @@ export class ParcelWatcher extends Disposable implements IRecursiveWatcher { } // Handle & emit events - this.onParcelEvents(parcelEvents, watcher, includePatterns, realPathDiffers, realPathLength); + this.onParcelEvents(parcelEvents, watcher, realPathDiffers, realPathLength); }, { backend: ParcelWatcher.PARCEL_WATCHER_BACKEND, ignore: watcher.request.excludes @@ -301,10 +376,13 @@ export class ParcelWatcher extends Disposable implements IRecursiveWatcher { this.onUnexpectedError(error, watcher); instance.complete(undefined); + + watcher.notifyWatchFailed(); + this._onDidWatchFail.fire(request); }); } - private onParcelEvents(parcelEvents: parcelWatcher.Event[], watcher: IParcelWatcherInstance, includes: ParsedPattern[] | undefined, realPathDiffers: boolean, realPathLength: number): void { + private onParcelEvents(parcelEvents: parcelWatcher.Event[], watcher: ParcelWatcherInstance, realPathDiffers: boolean, realPathLength: number): void { if (parcelEvents.length === 0) { return; } @@ -315,7 +393,7 @@ export class ParcelWatcher extends Disposable implements IRecursiveWatcher { this.normalizeEvents(parcelEvents, watcher.request, realPathDiffers, realPathLength); // Check for includes - const includedEvents = this.handleIncludes(watcher, parcelEvents, includes); + const includedEvents = this.handleIncludes(watcher, parcelEvents); // Add to event aggregator for later processing for (const includedEvent of includedEvents) { @@ -323,7 +401,7 @@ export class ParcelWatcher extends Disposable implements IRecursiveWatcher { } } - private handleIncludes(watcher: IParcelWatcherInstance, parcelEvents: parcelWatcher.Event[], includes: ParsedPattern[] | undefined): IFileChange[] { + private handleIncludes(watcher: ParcelWatcherInstance, parcelEvents: parcelWatcher.Event[]): IFileChange[] { const events: IFileChange[] = []; for (const { path, type: parcelEventType } of parcelEvents) { @@ -333,7 +411,7 @@ export class ParcelWatcher extends Disposable implements IRecursiveWatcher { } // Apply include filter if any - if (includes && includes.length > 0 && !includes.some(include => include(path))) { + if (!watcher.include(path)) { if (this.verboseLogging) { this.trace(` >> ignored (not included) ${path}`); } @@ -345,7 +423,7 @@ export class ParcelWatcher extends Disposable implements IRecursiveWatcher { return events; } - private handleParcelEvents(parcelEvents: IFileChange[], watcher: IParcelWatcherInstance): void { + private handleParcelEvents(parcelEvents: IFileChange[], watcher: ParcelWatcherInstance): void { // Coalesce events: merge events of same kind const coalescedEvents = coalesceEvents(parcelEvents); @@ -362,16 +440,21 @@ export class ParcelWatcher extends Disposable implements IRecursiveWatcher { } } - private emitEvents(events: IFileChange[], watcher: IParcelWatcherInstance): void { + private emitEvents(events: IFileChange[], watcher: ParcelWatcherInstance): void { if (events.length === 0) { return; } - // Logging - if (this.verboseLogging) { - for (const event of events) { - const traceMsg = ` >> normalized ${event.type === FileChangeType.ADDED ? '[ADDED]' : event.type === FileChangeType.DELETED ? '[DELETED]' : '[CHANGED]'} ${event.resource.fsPath}`; - this.trace(typeof watcher.request.correlationId === 'number' ? `${traceMsg} (correlationId: ${watcher.request.correlationId})` : traceMsg); + for (const event of events) { + + // Emit to instance subscriptions if any + if (watcher.subscriptionsCount > 0) { + watcher.notifyFileChange(event.resource.fsPath, event); + } + + // Logging + if (this.verboseLogging) { + this.traceEvent(event, watcher.request); } } @@ -383,7 +466,7 @@ export class ParcelWatcher extends Disposable implements IRecursiveWatcher { this.warn(`started ignoring events due to too many file change events at once (incoming: ${events.length}, most recent change: ${events[0].resource.fsPath}). Use 'files.watcherExclude' setting to exclude folders with lots of changing files (e.g. compilation output).`); } else { if (this.throttledFileChangesEmitter.pending > 0) { - this.trace(`started throttling events due to large amount of file change events at once (pending: ${this.throttledFileChangesEmitter.pending}, most recent change: ${events[0].resource.fsPath}). Use 'files.watcherExclude' setting to exclude folders with lots of changing files (e.g. compilation output).`); + this.trace(`started throttling events due to large amount of file change events at once (pending: ${this.throttledFileChangesEmitter.pending}, most recent change: ${events[0].resource.fsPath}). Use 'files.watcherExclude' setting to exclude folders with lots of changing files (e.g. compilation output).`, watcher); } } } @@ -441,60 +524,89 @@ export class ParcelWatcher extends Disposable implements IRecursiveWatcher { } } - private filterEvents(events: IFileChange[], watcher: IParcelWatcherInstance): { events: IFileChange[]; rootDeleted?: boolean } { + private filterEvents(events: IFileChange[], watcher: ParcelWatcherInstance): { events: IFileChange[]; rootDeleted?: boolean } { const filteredEvents: IFileChange[] = []; let rootDeleted = false; for (const event of events) { - if (event.type === FileChangeType.DELETED && event.resource.fsPath === watcher.request.path) { + rootDeleted = event.type === FileChangeType.DELETED && isEqual(event.resource.fsPath, watcher.request.path, !isLinux); + + if (rootDeleted && !this.isCorrelated(watcher.request)) { // Explicitly exclude changes to root if we have any // to avoid VS Code closing all opened editors which // can happen e.g. in case of network connectivity // issues // (https://github.com/microsoft/vscode/issues/136673) + // + // Update 2024: with the new correlated events, we + // really do not want to skip over file events any + // more, so we only ignore this event for non-correlated + // watch requests. - rootDeleted = true; - } else { - filteredEvents.push(event); + continue; } + + filteredEvents.push(event); } return { events: filteredEvents, rootDeleted }; } - private onWatchedPathDeleted(watcher: IParcelWatcherInstance): void { + private onWatchedPathDeleted(watcher: ParcelWatcherInstance): void { this.warn('Watcher shutdown because watched path got deleted', watcher); + let legacyMonitored = false; + if (!this.isCorrelated(watcher.request)) { + // Do monitoring of the request path parent unless this request + // can be handled via suspend/resume in the super class + legacyMonitored = this.legacyMonitorRequest(watcher); + } + + if (!legacyMonitored) { + watcher.notifyWatchFailed(); + this._onDidWatchFail.fire(watcher.request); + } + } + + private legacyMonitorRequest(watcher: ParcelWatcherInstance): boolean { const parentPath = dirname(watcher.request.path); if (existsSync(parentPath)) { - const nodeWatcher = new NodeJSFileWatcherLibrary({ path: parentPath, excludes: [], recursive: false, correlationId: watcher.request.correlationId }, changes => { + this.trace('Trying to watch on the parent path to restart the watcher...', watcher); + + const nodeWatcher = new NodeJSFileWatcherLibrary({ path: parentPath, excludes: [], recursive: false, correlationId: watcher.request.correlationId }, undefined, changes => { if (watcher.token.isCancellationRequested) { return; // return early when disposed } // Watcher path came back! Restart watching... for (const { resource, type } of changes) { - if (resource.fsPath === watcher.request.path && (type === FileChangeType.ADDED || type === FileChangeType.UPDATED)) { - this.warn('Watcher restarts because watched path got created again', watcher); + if (isEqual(resource.fsPath, watcher.request.path, !isLinux) && (type === FileChangeType.ADDED || type === FileChangeType.UPDATED)) { + if (this.isPathValid(watcher.request.path)) { + this.warn('Watcher restarts because watched path got created again', watcher); - // Stop watching that parent folder - nodeWatcher.dispose(); + // Stop watching that parent folder + nodeWatcher.dispose(); - // Restart the file watching - this.restartWatching(watcher); + // Restart the file watching + this.restartWatching(watcher); - break; + break; + } } } - }, msg => this._onDidLogMessage.fire(msg), this.verboseLogging); + }, undefined, msg => this._onDidLogMessage.fire(msg), this.verboseLogging); // Make sure to stop watching when the watcher is disposed watcher.token.onCancellationRequested(() => nodeWatcher.dispose()); + + return true; } + + return false; } - private onUnexpectedError(error: unknown, watcher?: IParcelWatcherInstance): void { + private onUnexpectedError(error: unknown, watcher?: ParcelWatcherInstance): void { const msg = toErrorMessage(error); // Specially handle ENOSPC errors that can happen when @@ -520,15 +632,15 @@ export class ParcelWatcher extends Disposable implements IRecursiveWatcher { } } - async stop(): Promise { - for (const [path] of this.watchers) { - await this.stopWatching(path); - } + override async stop(): Promise { + await super.stop(); - this.watchers.clear(); + for (const watcher of this.watchers) { + await this.stopWatching(watcher); + } } - protected restartWatching(watcher: IParcelWatcherInstance, delay = 800): void { + protected restartWatching(watcher: ParcelWatcherInstance, delay = 800): void { // Restart watcher delayed to accomodate for // changes on disk that have triggered the @@ -538,15 +650,21 @@ export class ParcelWatcher extends Disposable implements IRecursiveWatcher { return; // return early when disposed } - // Await the watcher having stopped, as this is - // needed to properly re-watch the same path - await this.stopWatching(watcher.request.path); + const restartPromise = new DeferredPromise(); + try { - // Start watcher again counting the restarts - if (watcher.request.pollingInterval) { - this.startPolling(watcher.request, watcher.request.pollingInterval, watcher.restarts + 1); - } else { - this.startWatching(watcher.request, watcher.restarts + 1); + // Await the watcher having stopped, as this is + // needed to properly re-watch the same path + await this.stopWatching(watcher, restartPromise.p); + + // Start watcher again counting the restarts + if (watcher.request.pollingInterval) { + this.startPolling(watcher.request, watcher.request.pollingInterval, watcher.restarts + 1); + } else { + this.startWatching(watcher.request, watcher.restarts + 1); + } + } finally { + restartPromise.complete(); } }, delay); @@ -554,29 +672,26 @@ export class ParcelWatcher extends Disposable implements IRecursiveWatcher { watcher.token.onCancellationRequested(() => scheduler.dispose()); } - private async stopWatching(path: string): Promise { - const watcher = this.watchers.get(path); - if (watcher) { - this.trace(`stopping file watcher on ${watcher.request.path}`); + private async stopWatching(watcher: ParcelWatcherInstance, joinRestart?: Promise): Promise { + this.trace(`stopping file watcher`, watcher); - this.watchers.delete(path); + this.watchers.delete(watcher); - try { - await watcher.stop(); - } catch (error) { - this.error(`Unexpected error stopping watcher: ${toErrorMessage(error)}`, watcher); - } + try { + await watcher.stop(joinRestart); + } catch (error) { + this.error(`Unexpected error stopping watcher: ${toErrorMessage(error)}`, watcher); } } - protected normalizeRequests(requests: IRecursiveWatchRequest[], validatePaths = true): IRecursiveWatchRequest[] { + protected removeDuplicateRequests(requests: IRecursiveWatchRequest[], validatePaths = true): IRecursiveWatchRequest[] { // Sort requests by path length to have shortest first // to have a way to prevent children to be watched if // parents exist. requests.sort((requestA, requestB) => requestA.path.length - requestB.path.length); - // Map request paths to correlation and ignore identical paths + // Ignore requests for the same paths that have the same correlation const mapCorrelationtoRequests = new Map>(); for (const request of requests) { if (request.excludes.includes(GLOBSTAR)) { @@ -591,6 +706,10 @@ export class ParcelWatcher extends Disposable implements IRecursiveWatcher { mapCorrelationtoRequests.set(request.correlationId, requestsForCorrelation); } + if (requestsForCorrelation.has(path)) { + this.trace(`ignoring a request for watching who's path is already watched: ${this.requestToString(request)}`); + } + requestsForCorrelation.set(path, request); } @@ -616,31 +735,24 @@ export class ParcelWatcher extends Disposable implements IRecursiveWatcher { try { const realpath = realpathSync(request.path); if (realpath === request.path) { - this.trace(`ignoring a path for watching who's parent is already watched: ${request.path}`); + this.trace(`ignoring a request for watching who's parent is already watched: ${this.requestToString(request)}`); continue; } } catch (error) { - this.trace(`ignoring a path for watching who's realpath failed to resolve: ${request.path} (error: ${error})`); + this.trace(`ignoring a request for watching who's realpath failed to resolve: ${this.requestToString(request)} (error: ${error})`); + + this._onDidWatchFail.fire(request); continue; } } // Check for invalid paths - if (validatePaths) { - try { - const stat = statSync(request.path); - if (!stat.isDirectory()) { - this.trace(`ignoring a path for watching that is a file and not a folder: ${request.path}`); - - continue; - } - } catch (error) { - this.trace(`ignoring a path for watching who's stat info failed to resolve: ${request.path} (error: ${error})`); + if (validatePaths && !this.isPathValid(request.path)) { + this._onDidWatchFail.fire(request); - continue; - } + continue; } requestTrie.set(request.path, request); @@ -652,25 +764,80 @@ export class ParcelWatcher extends Disposable implements IRecursiveWatcher { return normalizedRequests; } + private isPathValid(path: string): boolean { + try { + const stat = statSync(path); + if (!stat.isDirectory()) { + this.trace(`ignoring a path for watching that is a file and not a folder: ${path}`); + + return false; + } + } catch (error) { + this.trace(`ignoring a path for watching who's stat info failed to resolve: ${path} (error: ${error})`); + + return false; + } + + return true; + } + + subscribe(path: string, callback: (error: boolean, change?: IFileChange) => void): IDisposable | undefined { + for (const watcher of this.watchers) { + if (watcher.failed) { + continue; // watcher has already failed + } + + if (!isEqualOrParent(path, watcher.request.path, !isLinux)) { + continue; // watcher does not consider this path + } + + if ( + watcher.exclude(path) || + !watcher.include(path) + ) { + continue; // parcel instance does not consider this path + } + + const disposables = new DisposableStore(); + + disposables.add(Event.once(watcher.onDidStop)(async e => { + await e.joinRestart; // if we are restarting, await that so that we can possibly reuse this watcher again + if (disposables.isDisposed) { + return; + } + + callback(true /* error */); + })); + disposables.add(Event.once(watcher.onDidFail)(() => callback(true /* error */))); + disposables.add(watcher.subscribe(path, change => callback(false, change))); + + return disposables; + } + + return undefined; + } + async setVerboseLogging(enabled: boolean): Promise { this.verboseLogging = enabled; } - private trace(message: string) { + protected trace(message: string, watcher?: ParcelWatcherInstance): void { if (this.verboseLogging) { - this._onDidLogMessage.fire({ type: 'trace', message: this.toMessage(message) }); + this._onDidLogMessage.fire({ type: 'trace', message: this.toMessage(message, watcher) }); } } - private warn(message: string, watcher?: IParcelWatcherInstance) { + protected warn(message: string, watcher?: ParcelWatcherInstance) { this._onDidLogMessage.fire({ type: 'warn', message: this.toMessage(message, watcher) }); } - private error(message: string, watcher: IParcelWatcherInstance | undefined) { + private error(message: string, watcher: ParcelWatcherInstance | undefined) { this._onDidLogMessage.fire({ type: 'error', message: this.toMessage(message, watcher) }); } - private toMessage(message: string, watcher?: IParcelWatcherInstance): string { + private toMessage(message: string, watcher?: ParcelWatcherInstance): string { return watcher ? `[File Watcher (parcel)] ${message} (path: ${watcher.request.path})` : `[File Watcher (parcel)] ${message}`; } + + protected get recursiveWatcher() { return this; } } diff --git a/src/vs/platform/files/node/watcher/watcher.ts b/src/vs/platform/files/node/watcher/watcher.ts index e266239cb654a..3ea9fc6121335 100644 --- a/src/vs/platform/files/node/watcher/watcher.ts +++ b/src/vs/platform/files/node/watcher/watcher.ts @@ -4,40 +4,61 @@ *--------------------------------------------------------------------------------------------*/ import { Disposable } from 'vs/base/common/lifecycle'; -import { INonRecursiveWatchRequest, IRecursiveWatchRequest, IUniversalWatcher, IUniversalWatchRequest } from 'vs/platform/files/common/watcher'; -import { Event } from 'vs/base/common/event'; +import { ILogMessage, IUniversalWatcher, IUniversalWatchRequest } from 'vs/platform/files/common/watcher'; +import { Emitter, Event } from 'vs/base/common/event'; import { ParcelWatcher } from 'vs/platform/files/node/watcher/parcel/parcelWatcher'; import { NodeJSWatcher } from 'vs/platform/files/node/watcher/nodejs/nodejsWatcher'; import { Promises } from 'vs/base/common/async'; +import { computeStats } from 'vs/platform/files/node/watcher/watcherStats'; export class UniversalWatcher extends Disposable implements IUniversalWatcher { private readonly recursiveWatcher = this._register(new ParcelWatcher()); - private readonly nonRecursiveWatcher = this._register(new NodeJSWatcher()); + private readonly nonRecursiveWatcher = this._register(new NodeJSWatcher(this.recursiveWatcher)); readonly onDidChangeFile = Event.any(this.recursiveWatcher.onDidChangeFile, this.nonRecursiveWatcher.onDidChangeFile); - readonly onDidLogMessage = Event.any(this.recursiveWatcher.onDidLogMessage, this.nonRecursiveWatcher.onDidLogMessage); readonly onDidError = Event.any(this.recursiveWatcher.onDidError, this.nonRecursiveWatcher.onDidError); + private readonly _onDidLogMessage = this._register(new Emitter()); + readonly onDidLogMessage = Event.any(this._onDidLogMessage.event, this.recursiveWatcher.onDidLogMessage, this.nonRecursiveWatcher.onDidLogMessage); + + private requests: IUniversalWatchRequest[] = []; + async watch(requests: IUniversalWatchRequest[]): Promise { - const recursiveWatchRequests: IRecursiveWatchRequest[] = []; - const nonRecursiveWatchRequests: INonRecursiveWatchRequest[] = []; - - for (const request of requests) { - if (request.recursive) { - recursiveWatchRequests.push(request); - } else { - nonRecursiveWatchRequests.push(request); + this.requests = requests; + + // Watch recursively first to give recursive watchers a chance + // to step in for non-recursive watch requests, thus reducing + // watcher duplication. + + let error: Error | undefined; + try { + await this.recursiveWatcher.watch(requests.filter(request => request.recursive)); + } catch (e) { + error = e; + } + + try { + await this.nonRecursiveWatcher.watch(requests.filter(request => !request.recursive)); + } catch (e) { + if (!error) { + error = e; } } - await Promises.settled([ - this.recursiveWatcher.watch(recursiveWatchRequests), - this.nonRecursiveWatcher.watch(nonRecursiveWatchRequests) - ]); + if (error) { + throw error; + } } async setVerboseLogging(enabled: boolean): Promise { + + // Log stats + if (enabled && this.requests.length > 0) { + this._onDidLogMessage.fire({ type: 'trace', message: computeStats(this.requests, this.recursiveWatcher, this.nonRecursiveWatcher) }); + } + + // Forward to watchers await Promises.settled([ this.recursiveWatcher.setVerboseLogging(enabled), this.nonRecursiveWatcher.setVerboseLogging(enabled) diff --git a/src/vs/platform/files/node/watcher/watcherStats.ts b/src/vs/platform/files/node/watcher/watcherStats.ts new file mode 100644 index 0000000000000..31bcddf9cd072 --- /dev/null +++ b/src/vs/platform/files/node/watcher/watcherStats.ts @@ -0,0 +1,207 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IUniversalWatchRequest } from 'vs/platform/files/common/watcher'; +import { INodeJSWatcherInstance, NodeJSWatcher } from 'vs/platform/files/node/watcher/nodejs/nodejsWatcher'; +import { ParcelWatcher, ParcelWatcherInstance } from 'vs/platform/files/node/watcher/parcel/parcelWatcher'; + +export function computeStats( + requests: IUniversalWatchRequest[], + recursiveWatcher: ParcelWatcher, + nonRecursiveWatcher: NodeJSWatcher +): string { + const lines: string[] = []; + + const recursiveRequests = sortByPathPrefix(requests.filter(request => request.recursive)); + const recursiveRequestsStatus = computeRequestStatus(recursiveRequests, recursiveWatcher); + const recursiveWatcherStatus = computeRecursiveWatchStatus(recursiveWatcher); + + const nonRecursiveRequests = sortByPathPrefix(requests.filter(request => !request.recursive)); + const nonRecursiveRequestsStatus = computeRequestStatus(nonRecursiveRequests, nonRecursiveWatcher); + const nonRecursiveWatcherStatus = computeNonRecursiveWatchStatus(nonRecursiveWatcher); + + lines.push('[Summary]'); + lines.push(`- Recursive Requests: total: ${recursiveRequests.length}, suspended: ${recursiveRequestsStatus.suspended}, polling: ${recursiveRequestsStatus.polling}`); + lines.push(`- Non-Recursive Requests: total: ${nonRecursiveRequests.length}, suspended: ${nonRecursiveRequestsStatus.suspended}, polling: ${nonRecursiveRequestsStatus.polling}`); + lines.push(`- Recursive Watchers: total: ${recursiveWatcher.watchers.size}, active: ${recursiveWatcherStatus.active}, failed: ${recursiveWatcherStatus.failed}, stopped: ${recursiveWatcherStatus.stopped}`); + lines.push(`- Non-Recursive Watchers: total: ${nonRecursiveWatcher.watchers.size}, active: ${nonRecursiveWatcherStatus.active}, failed: ${nonRecursiveWatcherStatus.failed}, reusing: ${nonRecursiveWatcherStatus.reusing}`); + lines.push(`- I/O Handles Impact: total: ${recursiveRequestsStatus.polling + nonRecursiveRequestsStatus.polling + recursiveWatcherStatus.active + nonRecursiveWatcherStatus.active}`); + + lines.push(`\n[Recursive Requests (${recursiveRequests.length}, suspended: ${recursiveRequestsStatus.suspended}, polling: ${recursiveRequestsStatus.polling})]:`); + for (const request of recursiveRequests) { + fillRequestStats(lines, request, recursiveWatcher); + } + + lines.push(`\n[Non-Recursive Requests (${nonRecursiveRequests.length}, suspended: ${nonRecursiveRequestsStatus.suspended}, polling: ${nonRecursiveRequestsStatus.polling})]:`); + for (const request of nonRecursiveRequests) { + fillRequestStats(lines, request, nonRecursiveWatcher); + } + + fillRecursiveWatcherStats(lines, recursiveWatcher); + fillNonRecursiveWatcherStats(lines, nonRecursiveWatcher); + + let maxLength = 0; + for (const line of lines) { + maxLength = Math.max(maxLength, line.split('\t')[0].length); + } + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const parts = line.split('\t'); + if (parts.length === 2) { + const padding = ' '.repeat(maxLength - parts[0].length); + lines[i] = `${parts[0]}${padding}\t${parts[1]}`; + } + } + + return `\n\n[File Watcher] request stats:\n\n${lines.join('\n')}\n\n`; +} + +function computeRequestStatus(requests: IUniversalWatchRequest[], watcher: ParcelWatcher | NodeJSWatcher): { suspended: number; polling: number } { + let polling = 0; + let suspended = 0; + + for (const request of requests) { + const isSuspended = watcher.isSuspended(request); + if (isSuspended === false) { + continue; + } + + suspended++; + + if (isSuspended === 'polling') { + polling++; + } + } + + return { suspended, polling }; +} + +function computeRecursiveWatchStatus(recursiveWatcher: ParcelWatcher): { active: number; failed: number; stopped: number } { + let active = 0; + let failed = 0; + let stopped = 0; + + for (const watcher of recursiveWatcher.watchers.values()) { + if (!watcher.failed && !watcher.stopped) { + active++; + } + if (watcher.failed) { + failed++; + } + if (watcher.stopped) { + stopped++; + } + } + + return { active, failed, stopped }; +} + +function computeNonRecursiveWatchStatus(nonRecursiveWatcher: NodeJSWatcher): { active: number; failed: number; reusing: number } { + let active = 0; + let failed = 0; + let reusing = 0; + + for (const watcher of nonRecursiveWatcher.watchers) { + if (!watcher.instance.failed && !watcher.instance.isReusingRecursiveWatcher) { + active++; + } + if (watcher.instance.failed) { + failed++; + } + if (watcher.instance.isReusingRecursiveWatcher) { + reusing++; + } + } + + return { active, failed, reusing }; +} + +function sortByPathPrefix(requests: IUniversalWatchRequest[]): IUniversalWatchRequest[]; +function sortByPathPrefix(requests: INodeJSWatcherInstance[]): INodeJSWatcherInstance[]; +function sortByPathPrefix(requests: ParcelWatcherInstance[]): ParcelWatcherInstance[]; +function sortByPathPrefix(requests: IUniversalWatchRequest[] | INodeJSWatcherInstance[] | ParcelWatcherInstance[]): IUniversalWatchRequest[] | INodeJSWatcherInstance[] | ParcelWatcherInstance[] { + requests.sort((r1, r2) => { + const p1 = isUniversalWatchRequest(r1) ? r1.path : r1.request.path; + const p2 = isUniversalWatchRequest(r2) ? r2.path : r2.request.path; + + const minLength = Math.min(p1.length, p2.length); + for (let i = 0; i < minLength; i++) { + if (p1[i] !== p2[i]) { + return (p1[i] < p2[i]) ? -1 : 1; + } + } + + return p1.length - p2.length; + }); + + return requests; +} + +function isUniversalWatchRequest(obj: unknown): obj is IUniversalWatchRequest { + const candidate = obj as IUniversalWatchRequest | undefined; + + return typeof candidate?.path === 'string'; +} + +function fillRequestStats(lines: string[], request: IUniversalWatchRequest, watcher: ParcelWatcher | NodeJSWatcher): void { + const decorations = []; + const suspended = watcher.isSuspended(request); + if (suspended !== false) { + if (suspended === 'polling') { + decorations.push('[SUSPENDED ]'); + } else { + decorations.push('[SUSPENDED ]'); + } + } + + lines.push(`${request.path}\t${decorations.length > 0 ? decorations.join(' ') + ' ' : ''}(${requestDetailsToString(request)})`); +} + +function requestDetailsToString(request: IUniversalWatchRequest): string { + return `excludes: ${request.excludes.length > 0 ? request.excludes : ''}, includes: ${request.includes && request.includes.length > 0 ? JSON.stringify(request.includes) : ''}, correlationId: ${typeof request.correlationId === 'number' ? request.correlationId : ''}`; +} + +function fillRecursiveWatcherStats(lines: string[], recursiveWatcher: ParcelWatcher): void { + const watchers = sortByPathPrefix(Array.from(recursiveWatcher.watchers.values())); + + const { active, failed, stopped } = computeRecursiveWatchStatus(recursiveWatcher); + lines.push(`\n[Recursive Watchers (${watchers.length}, active: ${active}, failed: ${failed}, stopped: ${stopped})]:`); + + for (const watcher of watchers) { + const decorations = []; + if (watcher.failed) { + decorations.push('[FAILED]'); + } + if (watcher.stopped) { + decorations.push('[STOPPED]'); + } + if (watcher.subscriptionsCount > 0) { + decorations.push(`[SUBSCRIBED:${watcher.subscriptionsCount}]`); + } + if (watcher.restarts > 0) { + decorations.push(`[RESTARTED:${watcher.restarts}]`); + } + lines.push(`${watcher.request.path}\t${decorations.length > 0 ? decorations.join(' ') + ' ' : ''}(${requestDetailsToString(watcher.request)})`); + } +} + +function fillNonRecursiveWatcherStats(lines: string[], nonRecursiveWatcher: NodeJSWatcher): void { + const watchers = sortByPathPrefix(Array.from(nonRecursiveWatcher.watchers.values())); + + const { active, failed, reusing } = computeNonRecursiveWatchStatus(nonRecursiveWatcher); + lines.push(`\n[Non-Recursive Watchers (${watchers.length}, active: ${active}, failed: ${failed}, reusing: ${reusing})]:`); + + for (const watcher of watchers) { + const decorations = []; + if (watcher.instance.failed) { + decorations.push('[FAILED]'); + } + if (watcher.instance.isReusingRecursiveWatcher) { + decorations.push('[REUSING]'); + } + lines.push(`${watcher.request.path}\t${decorations.length > 0 ? decorations.join(' ') + ' ' : ''}(${requestDetailsToString(watcher.request)})`); + } +} diff --git a/src/vs/platform/files/test/node/nodejsWatcher.integrationTest.ts b/src/vs/platform/files/test/node/nodejsWatcher.integrationTest.ts index 74dbb343c970c..678faf1194163 100644 --- a/src/vs/platform/files/test/node/nodejsWatcher.integrationTest.ts +++ b/src/vs/platform/files/test/node/nodejsWatcher.integrationTest.ts @@ -3,23 +3,26 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import * as assert from 'assert'; import { tmpdir } from 'os'; import { basename, dirname, join } from 'vs/base/common/path'; import { Promises, RimRafMode } from 'vs/base/node/pfs'; import { flakySuite, getRandomTestPath } from 'vs/base/test/node/testUtils'; import { FileChangeType } from 'vs/platform/files/common/files'; -import { INonRecursiveWatchRequest } from 'vs/platform/files/common/watcher'; -import { NodeJSFileWatcherLibrary, watchFileContents } from 'vs/platform/files/node/watcher/nodejs/nodejsWatcherLib'; +import { INonRecursiveWatchRequest, IRecursiveWatcherWithSubscribe } from 'vs/platform/files/common/watcher'; +import { watchFileContents } from 'vs/platform/files/node/watcher/nodejs/nodejsWatcherLib'; import { isLinux, isMacintosh, isWindows } from 'vs/base/common/platform'; import { getDriveLetter } from 'vs/base/common/extpath'; import { ltrim } from 'vs/base/common/strings'; -import { DeferredPromise } from 'vs/base/common/async'; +import { DeferredPromise, timeout } from 'vs/base/common/async'; import { CancellationTokenSource } from 'vs/base/common/cancellation'; import { NodeJSWatcher } from 'vs/platform/files/node/watcher/nodejs/nodejsWatcher'; import { FileAccess } from 'vs/base/common/network'; import { extUriBiasedIgnorePathCase } from 'vs/base/common/resources'; import { URI } from 'vs/base/common/uri'; import { addUNCHostToAllowlist } from 'vs/base/node/unc'; +import { Emitter, Event } from 'vs/base/common/event'; +import { TestParcelWatcher } from 'vs/platform/files/test/node/parcelWatcher.integrationTest'; // this suite has shown flaky runs in Azure pipelines where // tasks would just hang and timeout after a while (not in @@ -30,27 +33,20 @@ import { addUNCHostToAllowlist } from 'vs/base/node/unc'; class TestNodeJSWatcher extends NodeJSWatcher { - override async watch(requests: INonRecursiveWatchRequest[]): Promise { - await super.watch(requests); - await this.whenReady(); - } - - async whenReady(): Promise { - for (const [, watcher] of this.watchers) { - await watcher.instance.ready; - } - } - } + protected override readonly suspendedWatchRequestPollingInterval = 100; - class TestNodeJSFileWatcherLibrary extends NodeJSFileWatcherLibrary { + private readonly _onDidWatch = this._register(new Emitter()); + readonly onDidWatch = this._onDidWatch.event; - private readonly _whenDisposed = new DeferredPromise(); - readonly whenDisposed = this._whenDisposed.p; + readonly onWatchFail = this._onDidWatchFail.event; - override dispose(): void { - super.dispose(); + protected override async doWatch(requests: INonRecursiveWatchRequest[]): Promise { + await super.doWatch(requests); + for (const watcher of this.watchers) { + await watcher.instance.ready; + } - this._whenDisposed.complete(); + this._onDidWatch.fire(); } } @@ -67,7 +63,20 @@ import { addUNCHostToAllowlist } from 'vs/base/node/unc'; enableLogging(false); setup(async () => { - watcher = new TestNodeJSWatcher(); + await createWatcher(undefined); + + testDir = URI.file(getRandomTestPath(tmpdir(), 'vsctests', 'filewatcher')).fsPath; + + const sourceDir = FileAccess.asFileUri('vs/platform/files/test/node/fixtures/service').fsPath; + + await Promises.copy(sourceDir, testDir, { preserveSymlinks: false }); + }); + + async function createWatcher(accessor: IRecursiveWatcherWithSubscribe | undefined) { + await watcher?.stop(); + watcher?.dispose(); + + watcher = new TestNodeJSWatcher(accessor); watcher?.setVerboseLogging(loggingEnabled); watcher.onDidLogMessage(e => { @@ -81,13 +90,7 @@ import { addUNCHostToAllowlist } from 'vs/base/node/unc'; console.log(`[non-recursive watcher test error] ${e}`); } }); - - testDir = getRandomTestPath(tmpdir(), 'vsctests', 'filewatcher'); - - const sourceDir = FileAccess.asFileUri('vs/platform/files/test/node/fixtures/service').fsPath; - - await Promises.copy(sourceDir, testDir, { preserveSymlinks: false }); - }); + } teardown(async () => { await watcher.stop(); @@ -134,7 +137,13 @@ import { addUNCHostToAllowlist } from 'vs/base/node/unc'; } test('basics (folder watch)', async function () { - await watcher.watch([{ path: testDir, excludes: [], recursive: false }]); + const request = { path: testDir, excludes: [], recursive: false }; + await watcher.watch([request]); + assert.strictEqual(watcher.isSuspended(request), false); + + const instance = Array.from(watcher.watchers)[0].instance; + assert.strictEqual(instance.isReusingRecursiveWatcher, false); + assert.strictEqual(instance.failed, false); // New file const newFilePath = join(testDir, 'newFile.txt'); @@ -242,7 +251,13 @@ import { addUNCHostToAllowlist } from 'vs/base/node/unc'; test('basics (file watch)', async function () { const filePath = join(testDir, 'lorem.txt'); - await watcher.watch([{ path: filePath, excludes: [], recursive: false }]); + const request = { path: filePath, excludes: [], recursive: false }; + await watcher.watch([request]); + assert.strictEqual(watcher.isSuspended(request), false); + + const instance = Array.from(watcher.watchers)[0].instance; + assert.strictEqual(instance.isReusingRecursiveWatcher, false); + assert.strictEqual(instance.failed, false); // Change file let changeFuture = awaitEvent(watcher, filePath, FileChangeType.UPDATED); @@ -432,7 +447,7 @@ import { addUNCHostToAllowlist } from 'vs/base/node/unc'; return basicCrudTest(join(link, 'newFile.txt')); }); - async function basicCrudTest(filePath: string, skipAdd?: boolean, correlationId?: number | null, expectedCount?: number): Promise { + async function basicCrudTest(filePath: string, skipAdd?: boolean, correlationId?: number | null, expectedCount?: number, awaitWatchAfterAdd?: boolean): Promise { let changeFuture: Promise; // New file @@ -440,6 +455,9 @@ import { addUNCHostToAllowlist } from 'vs/base/node/unc'; changeFuture = awaitEvent(watcher, filePath, FileChangeType.ADDED, correlationId, expectedCount); await Promises.writeFile(filePath, 'Hello World'); await changeFuture; + if (awaitWatchAfterAdd) { + await Event.toPromise(watcher.onDidWatch); + } } // Change file @@ -506,27 +524,6 @@ import { addUNCHostToAllowlist } from 'vs/base/node/unc'; await watcher.watch([{ path: invalidPath, excludes: [], recursive: false }]); }); - (isMacintosh /* macOS: does not seem to report this */ ? test.skip : test)('deleting watched path is handled properly (folder watch)', async function () { - const watchedPath = join(testDir, 'deep'); - - const watcher = new TestNodeJSFileWatcherLibrary({ path: watchedPath, excludes: [], recursive: false }, changes => { }); - await watcher.ready; - - // Delete watched path and ensure watcher is now disposed - Promises.rm(watchedPath, RimRafMode.UNLINK); - await watcher.whenDisposed; - }); - - test('deleting watched path is handled properly (file watch)', async function () { - const watchedPath = join(testDir, 'lorem.txt'); - const watcher = new TestNodeJSFileWatcherLibrary({ path: watchedPath, excludes: [], recursive: false }, changes => { }); - await watcher.ready; - - // Delete watched path and ensure watcher is now disposed - Promises.unlink(watchedPath); - await watcher.whenDisposed; - }); - test('watchFileContents', async function () { const watchedPath = join(testDir, 'lorem.txt'); @@ -547,16 +544,220 @@ import { addUNCHostToAllowlist } from 'vs/base/node/unc'; return watchPromise; }); - test('watching same or overlapping paths supported when correlation is applied', async () => { + test('watching same or overlapping paths supported when correlation is applied', async function () { + await watcher.watch([ + { path: testDir, excludes: [], recursive: false, correlationId: 1 } + ]); + + await basicCrudTest(join(testDir, 'newFile_1.txt'), undefined, null, 1); - // same path, same options await watcher.watch([ { path: testDir, excludes: [], recursive: false, correlationId: 1 }, { path: testDir, excludes: [], recursive: false, correlationId: 2, }, { path: testDir, excludes: [], recursive: false, correlationId: undefined } ]); - await basicCrudTest(join(testDir, 'newFile.txt'), undefined, null, 3); + await basicCrudTest(join(testDir, 'newFile_2.txt'), undefined, null, 3); await basicCrudTest(join(testDir, 'otherNewFile.txt'), undefined, null, 3); }); + + test('watching missing path emits watcher fail event', async function () { + const onDidWatchFail = Event.toPromise(watcher.onWatchFail); + + const folderPath = join(testDir, 'missing'); + watcher.watch([{ path: folderPath, excludes: [], recursive: true }]); + + await onDidWatchFail; + }); + + test('deleting watched path emits watcher fail and delete event when correlated (file watch)', async function () { + const filePath = join(testDir, 'lorem.txt'); + + await watcher.watch([{ path: filePath, excludes: [], recursive: false, correlationId: 1 }]); + + const instance = Array.from(watcher.watchers)[0].instance; + + const onDidWatchFail = Event.toPromise(watcher.onWatchFail); + const changeFuture = awaitEvent(watcher, filePath, FileChangeType.DELETED, 1); + Promises.unlink(filePath); + await onDidWatchFail; + await changeFuture; + assert.strictEqual(instance.failed, true); + }); + + (isMacintosh || isWindows /* macOS: does not seem to report deletes on folders | Windows: reports on('error') event only */ ? test.skip : test)('deleting watched path emits watcher fail and delete event when correlated (folder watch)', async function () { + const folderPath = join(testDir, 'deep'); + + await watcher.watch([{ path: folderPath, excludes: [], recursive: false, correlationId: 1 }]); + + const onDidWatchFail = Event.toPromise(watcher.onWatchFail); + const changeFuture = awaitEvent(watcher, folderPath, FileChangeType.DELETED, 1); + Promises.rm(folderPath, RimRafMode.UNLINK); + await onDidWatchFail; + await changeFuture; + }); + + test('correlated watch requests support suspend/resume (file, does not exist in beginning)', async function () { + const filePath = join(testDir, 'not-found.txt'); + + const onDidWatchFail = Event.toPromise(watcher.onWatchFail); + const request = { path: filePath, excludes: [], recursive: false, correlationId: 1 }; + await watcher.watch([request]); + await onDidWatchFail; + assert.strictEqual(watcher.isSuspended(request), 'polling'); + + await basicCrudTest(filePath, undefined, 1, undefined, true); + await basicCrudTest(filePath, undefined, 1, undefined, true); + }); + + test('correlated watch requests support suspend/resume (file, exists in beginning)', async function () { + const filePath = join(testDir, 'lorem.txt'); + const request = { path: filePath, excludes: [], recursive: false, correlationId: 1 }; + await watcher.watch([request]); + + const onDidWatchFail = Event.toPromise(watcher.onWatchFail); + await basicCrudTest(filePath, true, 1); + await onDidWatchFail; + assert.strictEqual(watcher.isSuspended(request), 'polling'); + + await basicCrudTest(filePath, undefined, 1, undefined, true); + }); + + test('correlated watch requests support suspend/resume (folder, does not exist in beginning)', async function () { + let onDidWatchFail = Event.toPromise(watcher.onWatchFail); + + const folderPath = join(testDir, 'not-found'); + const request = { path: folderPath, excludes: [], recursive: false, correlationId: 1 }; + await watcher.watch([request]); + await onDidWatchFail; + assert.strictEqual(watcher.isSuspended(request), 'polling'); + + let changeFuture = awaitEvent(watcher, folderPath, FileChangeType.ADDED, 1); + let onDidWatch = Event.toPromise(watcher.onDidWatch); + await Promises.mkdir(folderPath); + await changeFuture; + await onDidWatch; + + assert.strictEqual(watcher.isSuspended(request), false); + + const filePath = join(folderPath, 'newFile.txt'); + await basicCrudTest(filePath, undefined, 1); + + if (!isMacintosh) { // macOS does not report DELETE events for folders + onDidWatchFail = Event.toPromise(watcher.onWatchFail); + await Promises.rmdir(folderPath); + await onDidWatchFail; + + changeFuture = awaitEvent(watcher, folderPath, FileChangeType.ADDED, 1); + onDidWatch = Event.toPromise(watcher.onDidWatch); + await Promises.mkdir(folderPath); + await changeFuture; + await onDidWatch; + + await timeout(500); // somehow needed on Linux + + await basicCrudTest(filePath, undefined, 1); + } + }); + + (isMacintosh /* macOS: does not seem to report this */ ? test.skip : test)('correlated watch requests support suspend/resume (folder, exists in beginning)', async function () { + const folderPath = join(testDir, 'deep'); + await watcher.watch([{ path: folderPath, excludes: [], recursive: false, correlationId: 1 }]); + + const filePath = join(folderPath, 'newFile.txt'); + await basicCrudTest(filePath, undefined, 1); + + const onDidWatchFail = Event.toPromise(watcher.onWatchFail); + await Promises.rm(folderPath); + await onDidWatchFail; + + const changeFuture = awaitEvent(watcher, folderPath, FileChangeType.ADDED, 1); + const onDidWatch = Event.toPromise(watcher.onDidWatch); + await Promises.mkdir(folderPath); + await changeFuture; + await onDidWatch; + + await timeout(500); // somehow needed on Linux + + await basicCrudTest(filePath, undefined, 1); + }); + + test('parcel watcher reused when present for non-recursive file watching (uncorrelated)', function () { + return testParcelWatcherReused(undefined); + }); + + test('parcel watcher reused when present for non-recursive file watching (correlated)', function () { + return testParcelWatcherReused(2); + }); + + function createParcelWatcher() { + const recursiveWatcher = new TestParcelWatcher(); + recursiveWatcher.setVerboseLogging(loggingEnabled); + recursiveWatcher.onDidLogMessage(e => { + if (loggingEnabled) { + console.log(`[recursive watcher test message] ${e.message}`); + } + }); + + recursiveWatcher.onDidError(e => { + if (loggingEnabled) { + console.log(`[recursive watcher test error] ${e}`); + } + }); + + return recursiveWatcher; + } + + async function testParcelWatcherReused(correlationId: number | undefined) { + const recursiveWatcher = createParcelWatcher(); + await recursiveWatcher.watch([{ path: testDir, excludes: [], recursive: true, correlationId: 1 }]); + + const recursiveInstance = Array.from(recursiveWatcher.watchers)[0]; + assert.strictEqual(recursiveInstance.subscriptionsCount, 0); + + await createWatcher(recursiveWatcher); + + const filePath = join(testDir, 'deep', 'conway.js'); + await watcher.watch([{ path: filePath, excludes: [], recursive: false, correlationId }]); + + const { instance } = Array.from(watcher.watchers)[0]; + assert.strictEqual(instance.isReusingRecursiveWatcher, true); + assert.strictEqual(recursiveInstance.subscriptionsCount, 1); + + let changeFuture = awaitEvent(watcher, filePath, isMacintosh /* somehow fsevents seems to report still on the initial create from test setup */ ? FileChangeType.ADDED : FileChangeType.UPDATED, correlationId); + await Promises.writeFile(filePath, 'Hello World'); + await changeFuture; + + await recursiveWatcher.stop(); + recursiveWatcher.dispose(); + + await timeout(500); // give the watcher some time to restart + + changeFuture = awaitEvent(watcher, filePath, FileChangeType.UPDATED, correlationId); + await Promises.writeFile(filePath, 'Hello World'); + await changeFuture; + + assert.strictEqual(instance.isReusingRecursiveWatcher, false); + } + + test('correlated watch requests support suspend/resume (file, does not exist in beginning, parcel watcher reused)', async function () { + const recursiveWatcher = createParcelWatcher(); + await recursiveWatcher.watch([{ path: testDir, excludes: [], recursive: true }]); + + await createWatcher(recursiveWatcher); + + const filePath = join(testDir, 'not-found-2.txt'); + + const onDidWatchFail = Event.toPromise(watcher.onWatchFail); + const request = { path: filePath, excludes: [], recursive: false, correlationId: 1 }; + await watcher.watch([request]); + await onDidWatchFail; + assert.strictEqual(watcher.isSuspended(request), true); + + const changeFuture = awaitEvent(watcher, filePath, FileChangeType.ADDED, 1); + await Promises.writeFile(filePath, 'Hello World'); + await changeFuture; + + assert.strictEqual(watcher.isSuspended(request), false); + }); }); diff --git a/src/vs/platform/files/test/node/parcelWatcher.integrationTest.ts b/src/vs/platform/files/test/node/parcelWatcher.integrationTest.ts index 42e8b4ad73008..749b6392c64d8 100644 --- a/src/vs/platform/files/test/node/parcelWatcher.integrationTest.ts +++ b/src/vs/platform/files/test/node/parcelWatcher.integrationTest.ts @@ -20,37 +20,48 @@ import { FileAccess } from 'vs/base/common/network'; import { extUriBiasedIgnorePathCase } from 'vs/base/common/resources'; import { URI } from 'vs/base/common/uri'; import { addUNCHostToAllowlist } from 'vs/base/node/unc'; +import { Emitter, Event } from 'vs/base/common/event'; +import { DisposableStore } from 'vs/base/common/lifecycle'; -// this suite has shown flaky runs in Azure pipelines where -// tasks would just hang and timeout after a while (not in -// mocha but generally). as such they will run only on demand -// whenever we update the watcher library. +export class TestParcelWatcher extends ParcelWatcher { -((process.env['BUILD_SOURCEVERSION'] || process.env['CI']) ? suite.skip : flakySuite)('File Watcher (parcel)', () => { + protected override readonly suspendedWatchRequestPollingInterval = 100; - class TestParcelWatcher extends ParcelWatcher { + private readonly _onDidWatch = this._register(new Emitter()); + readonly onDidWatch = this._onDidWatch.event; - testNormalizePaths(paths: string[], excludes: string[] = []): string[] { + readonly onWatchFail = this._onDidWatchFail.event; - // Work with strings as paths to simplify testing - const requests: IRecursiveWatchRequest[] = paths.map(path => { - return { path, excludes, recursive: true }; - }); + testRemoveDuplicateRequests(paths: string[], excludes: string[] = []): string[] { - return this.normalizeRequests(requests, false /* validate paths skipped for tests */).map(request => request.path); - } + // Work with strings as paths to simplify testing + const requests: IRecursiveWatchRequest[] = paths.map(path => { + return { path, excludes, recursive: true }; + }); - override async watch(requests: IRecursiveWatchRequest[]): Promise { - await super.watch(requests); - await this.whenReady(); - } + return this.removeDuplicateRequests(requests, false /* validate paths skipped for tests */).map(request => request.path); + } - async whenReady(): Promise { - for (const [, watcher] of this.watchers) { - await watcher.ready; - } + protected override async doWatch(requests: IRecursiveWatchRequest[]): Promise { + await super.doWatch(requests); + await this.whenReady(); + + this._onDidWatch.fire(); + } + + async whenReady(): Promise { + for (const watcher of this.watchers) { + await watcher.ready; } } +} + +// this suite has shown flaky runs in Azure pipelines where +// tasks would just hang and timeout after a while (not in +// mocha but generally). as such they will run only on demand +// whenever we update the watcher library. + +((process.env['BUILD_SOURCEVERSION'] || process.env['CI']) ? suite.skip : flakySuite)('File Watcher (parcel)', () => { let testDir: string; let watcher: TestParcelWatcher; @@ -80,7 +91,7 @@ import { addUNCHostToAllowlist } from 'vs/base/node/unc'; } }); - testDir = getRandomTestPath(tmpdir(), 'vsctests', 'filewatcher'); + testDir = URI.file(getRandomTestPath(tmpdir(), 'vsctests', 'filewatcher')).fsPath; const sourceDir = FileAccess.asFileUri('vs/platform/files/test/node/fixtures/service').fsPath; @@ -88,7 +99,18 @@ import { addUNCHostToAllowlist } from 'vs/base/node/unc'; }); teardown(async () => { + const watchers = watcher.watchers.size; + let stoppedInstances = 0; + for (const instance of watcher.watchers) { + Event.once(instance.onDidStop)(() => { + if (instance.stopped) { + stoppedInstances++; + } + }); + } + await watcher.stop(); + assert.strictEqual(stoppedInstances, watchers, 'All watchers must be stopped before the test ends'); watcher.dispose(); // Possible that the file watcher is still holding @@ -160,37 +182,68 @@ import { addUNCHostToAllowlist } from 'vs/base/node/unc'; } test('basics', async function () { - await watcher.watch([{ path: testDir, excludes: [], recursive: true }]); + const request = { path: testDir, excludes: [], recursive: true }; + await watcher.watch([request]); + assert.strictEqual(watcher.watchers.size, watcher.watchers.size); + + const instance = Array.from(watcher.watchers)[0]; + assert.strictEqual(request, instance.request); + assert.strictEqual(instance.failed, false); + assert.strictEqual(instance.stopped, false); + + const disposables = new DisposableStore(); + + const subscriptions1 = new Map(); + const subscriptions2 = new Map(); // New file const newFilePath = join(testDir, 'deep', 'newFile.txt'); + disposables.add(instance.subscribe(newFilePath, change => subscriptions1.set(change.resource.fsPath, change.type))); + disposables.add(instance.subscribe(newFilePath, change => subscriptions2.set(change.resource.fsPath, change.type))); // can subscribe multiple times + assert.strictEqual(instance.include(newFilePath), true); + assert.strictEqual(instance.exclude(newFilePath), false); let changeFuture: Promise = awaitEvent(watcher, newFilePath, FileChangeType.ADDED); await Promises.writeFile(newFilePath, 'Hello World'); await changeFuture; + assert.strictEqual(subscriptions1.get(newFilePath), FileChangeType.ADDED); + assert.strictEqual(subscriptions2.get(newFilePath), FileChangeType.ADDED); // New folder const newFolderPath = join(testDir, 'deep', 'New Folder'); + disposables.add(instance.subscribe(newFolderPath, change => subscriptions1.set(change.resource.fsPath, change.type))); + const disposable = instance.subscribe(newFolderPath, change => subscriptions2.set(change.resource.fsPath, change.type)); + disposable.dispose(); + assert.strictEqual(instance.include(newFolderPath), true); + assert.strictEqual(instance.exclude(newFolderPath), false); changeFuture = awaitEvent(watcher, newFolderPath, FileChangeType.ADDED); await Promises.mkdir(newFolderPath); await changeFuture; + assert.strictEqual(subscriptions1.get(newFolderPath), FileChangeType.ADDED); + assert.strictEqual(subscriptions2.has(newFolderPath), false /* subscription was disposed before the event */); // Rename file let renamedFilePath = join(testDir, 'deep', 'renamedFile.txt'); + disposables.add(instance.subscribe(renamedFilePath, change => subscriptions1.set(change.resource.fsPath, change.type))); changeFuture = Promise.all([ awaitEvent(watcher, newFilePath, FileChangeType.DELETED), awaitEvent(watcher, renamedFilePath, FileChangeType.ADDED) ]); await Promises.rename(newFilePath, renamedFilePath); await changeFuture; + assert.strictEqual(subscriptions1.get(newFilePath), FileChangeType.DELETED); + assert.strictEqual(subscriptions1.get(renamedFilePath), FileChangeType.ADDED); // Rename folder let renamedFolderPath = join(testDir, 'deep', 'Renamed Folder'); + disposables.add(instance.subscribe(renamedFolderPath, change => subscriptions1.set(change.resource.fsPath, change.type))); changeFuture = Promise.all([ awaitEvent(watcher, newFolderPath, FileChangeType.DELETED), awaitEvent(watcher, renamedFolderPath, FileChangeType.ADDED) ]); await Promises.rename(newFolderPath, renamedFolderPath); await changeFuture; + assert.strictEqual(subscriptions1.get(newFolderPath), FileChangeType.DELETED); + assert.strictEqual(subscriptions1.get(renamedFolderPath), FileChangeType.ADDED); // Rename file (same name, different case) const caseRenamedFilePath = join(testDir, 'deep', 'RenamedFile.txt'); @@ -270,13 +323,19 @@ import { addUNCHostToAllowlist } from 'vs/base/node/unc'; // Delete file changeFuture = awaitEvent(watcher, copiedFilepath, FileChangeType.DELETED); + disposables.add(instance.subscribe(copiedFilepath, change => subscriptions1.set(change.resource.fsPath, change.type))); await Promises.unlink(copiedFilepath); await changeFuture; + assert.strictEqual(subscriptions1.get(copiedFilepath), FileChangeType.DELETED); // Delete folder changeFuture = awaitEvent(watcher, copiedFolderpath, FileChangeType.DELETED); + disposables.add(instance.subscribe(copiedFolderpath, change => subscriptions1.set(change.resource.fsPath, change.type))); await Promises.rmdir(copiedFolderpath); await changeFuture; + assert.strictEqual(subscriptions1.get(copiedFolderpath), FileChangeType.DELETED); + + disposables.dispose(); }); (isMacintosh /* this test seems not possible with fsevents backend */ ? test.skip : test)('basics (atomic writes)', async function () { @@ -542,7 +601,7 @@ import { addUNCHostToAllowlist } from 'vs/base/node/unc'; await watcher.watch([{ path: invalidPath, excludes: [], recursive: true }]); }); - (isWindows /* flaky on windows */ ? test.skip : test)('deleting watched path is handled properly', async function () { + (isWindows /* flaky on windows */ ? test.skip : test)('deleting watched path without correlation restarts watching', async function () { const watchedPath = join(testDir, 'deep'); await watcher.watch([{ path: watchedPath, excludes: [], recursive: true }]); @@ -574,35 +633,40 @@ import { addUNCHostToAllowlist } from 'vs/base/node/unc'; test('should not exclude roots that do not overlap', () => { if (isWindows) { - assert.deepStrictEqual(watcher.testNormalizePaths(['C:\\a']), ['C:\\a']); - assert.deepStrictEqual(watcher.testNormalizePaths(['C:\\a', 'C:\\b']), ['C:\\a', 'C:\\b']); - assert.deepStrictEqual(watcher.testNormalizePaths(['C:\\a', 'C:\\b', 'C:\\c\\d\\e']), ['C:\\a', 'C:\\b', 'C:\\c\\d\\e']); + assert.deepStrictEqual(watcher.testRemoveDuplicateRequests(['C:\\a']), ['C:\\a']); + assert.deepStrictEqual(watcher.testRemoveDuplicateRequests(['C:\\a', 'C:\\b']), ['C:\\a', 'C:\\b']); + assert.deepStrictEqual(watcher.testRemoveDuplicateRequests(['C:\\a', 'C:\\b', 'C:\\c\\d\\e']), ['C:\\a', 'C:\\b', 'C:\\c\\d\\e']); } else { - assert.deepStrictEqual(watcher.testNormalizePaths(['/a']), ['/a']); - assert.deepStrictEqual(watcher.testNormalizePaths(['/a', '/b']), ['/a', '/b']); - assert.deepStrictEqual(watcher.testNormalizePaths(['/a', '/b', '/c/d/e']), ['/a', '/b', '/c/d/e']); + assert.deepStrictEqual(watcher.testRemoveDuplicateRequests(['/a']), ['/a']); + assert.deepStrictEqual(watcher.testRemoveDuplicateRequests(['/a', '/b']), ['/a', '/b']); + assert.deepStrictEqual(watcher.testRemoveDuplicateRequests(['/a', '/b', '/c/d/e']), ['/a', '/b', '/c/d/e']); } }); test('should remove sub-folders of other paths', () => { if (isWindows) { - assert.deepStrictEqual(watcher.testNormalizePaths(['C:\\a', 'C:\\a\\b']), ['C:\\a']); - assert.deepStrictEqual(watcher.testNormalizePaths(['C:\\a', 'C:\\b', 'C:\\a\\b']), ['C:\\a', 'C:\\b']); - assert.deepStrictEqual(watcher.testNormalizePaths(['C:\\b\\a', 'C:\\a', 'C:\\b', 'C:\\a\\b']), ['C:\\a', 'C:\\b']); - assert.deepStrictEqual(watcher.testNormalizePaths(['C:\\a', 'C:\\a\\b', 'C:\\a\\c\\d']), ['C:\\a']); + assert.deepStrictEqual(watcher.testRemoveDuplicateRequests(['C:\\a', 'C:\\a\\b']), ['C:\\a']); + assert.deepStrictEqual(watcher.testRemoveDuplicateRequests(['C:\\a', 'C:\\b', 'C:\\a\\b']), ['C:\\a', 'C:\\b']); + assert.deepStrictEqual(watcher.testRemoveDuplicateRequests(['C:\\b\\a', 'C:\\a', 'C:\\b', 'C:\\a\\b']), ['C:\\a', 'C:\\b']); + assert.deepStrictEqual(watcher.testRemoveDuplicateRequests(['C:\\a', 'C:\\a\\b', 'C:\\a\\c\\d']), ['C:\\a']); } else { - assert.deepStrictEqual(watcher.testNormalizePaths(['/a', '/a/b']), ['/a']); - assert.deepStrictEqual(watcher.testNormalizePaths(['/a', '/b', '/a/b']), ['/a', '/b']); - assert.deepStrictEqual(watcher.testNormalizePaths(['/b/a', '/a', '/b', '/a/b']), ['/a', '/b']); - assert.deepStrictEqual(watcher.testNormalizePaths(['/a', '/a/b', '/a/c/d']), ['/a']); + assert.deepStrictEqual(watcher.testRemoveDuplicateRequests(['/a', '/a/b']), ['/a']); + assert.deepStrictEqual(watcher.testRemoveDuplicateRequests(['/a', '/b', '/a/b']), ['/a', '/b']); + assert.deepStrictEqual(watcher.testRemoveDuplicateRequests(['/b/a', '/a', '/b', '/a/b']), ['/a', '/b']); + assert.deepStrictEqual(watcher.testRemoveDuplicateRequests(['/a', '/a/b', '/a/c/d']), ['/a']); } }); test('should ignore when everything excluded', () => { - assert.deepStrictEqual(watcher.testNormalizePaths(['/foo/bar', '/bar'], ['**', 'something']), []); + assert.deepStrictEqual(watcher.testRemoveDuplicateRequests(['/foo/bar', '/bar'], ['**', 'something']), []); }); test('watching same or overlapping paths supported when correlation is applied', async () => { + await watcher.watch([ + { path: testDir, excludes: [], recursive: true, correlationId: 1 } + ]); + + await basicCrudTest(join(testDir, 'newFile.txt'), null, 1); // same path, same options await watcher.watch([ @@ -646,4 +710,141 @@ import { addUNCHostToAllowlist } from 'vs/base/node/unc'; await basicCrudTest(join(testDir, 'deep', 'newFile.txt'), null, 3); await basicCrudTest(join(testDir, 'deep', 'otherNewFile.txt'), null, 3); }); + + test('watching missing path emits watcher fail event', async function () { + const onDidWatchFail = Event.toPromise(watcher.onWatchFail); + + const folderPath = join(testDir, 'missing'); + watcher.watch([{ path: folderPath, excludes: [], recursive: true }]); + + await onDidWatchFail; + }); + + test('deleting watched path emits watcher fail and delete event if correlated', async function () { + const folderPath = join(testDir, 'deep'); + + await watcher.watch([{ path: folderPath, excludes: [], recursive: true, correlationId: 1 }]); + + let failed = false; + const instance = Array.from(watcher.watchers)[0]; + assert.strictEqual(instance.include(folderPath), true); + instance.onDidFail(() => failed = true); + + const onDidWatchFail = Event.toPromise(watcher.onWatchFail); + const changeFuture = awaitEvent(watcher, folderPath, FileChangeType.DELETED, undefined, 1); + Promises.rm(folderPath, RimRafMode.UNLINK); + await onDidWatchFail; + await changeFuture; + assert.strictEqual(failed, true); + assert.strictEqual(instance.failed, true); + }); + + test('correlated watch requests support suspend/resume (folder, does not exist in beginning, not reusing watcher)', async () => { + await testCorrelatedWatchFolderDoesNotExist(false); + }); + + test('correlated watch requests support suspend/resume (folder, does not exist in beginning, reusing watcher)', async () => { + await testCorrelatedWatchFolderDoesNotExist(true); + }); + + async function testCorrelatedWatchFolderDoesNotExist(reuseExistingWatcher: boolean) { + let onDidWatchFail = Event.toPromise(watcher.onWatchFail); + + const folderPath = join(testDir, 'not-found'); + + const requests: IRecursiveWatchRequest[] = []; + if (reuseExistingWatcher) { + requests.push({ path: testDir, excludes: [], recursive: true }); + await watcher.watch(requests); + } + + const request: IRecursiveWatchRequest = { path: folderPath, excludes: [], recursive: true, correlationId: 1 }; + requests.push(request); + + await watcher.watch(requests); + await onDidWatchFail; + + if (reuseExistingWatcher) { + assert.strictEqual(watcher.isSuspended(request), true); + } else { + assert.strictEqual(watcher.isSuspended(request), 'polling'); + } + + let changeFuture = awaitEvent(watcher, folderPath, FileChangeType.ADDED, undefined, 1); + let onDidWatch = Event.toPromise(watcher.onDidWatch); + await Promises.mkdir(folderPath); + await changeFuture; + await onDidWatch; + + assert.strictEqual(watcher.isSuspended(request), false); + + const filePath = join(folderPath, 'newFile.txt'); + await basicCrudTest(filePath, 1); + + onDidWatchFail = Event.toPromise(watcher.onWatchFail); + await Promises.rm(folderPath); + await onDidWatchFail; + + changeFuture = awaitEvent(watcher, folderPath, FileChangeType.ADDED, undefined, 1); + onDidWatch = Event.toPromise(watcher.onDidWatch); + await Promises.mkdir(folderPath); + await changeFuture; + await onDidWatch; + + await basicCrudTest(filePath, 1); + } + + test('correlated watch requests support suspend/resume (folder, exist in beginning, not reusing watcher)', async () => { + await testCorrelatedWatchFolderExists(false); + }); + + test('correlated watch requests support suspend/resume (folder, exist in beginning, reusing watcher)', async () => { + await testCorrelatedWatchFolderExists(true); + }); + + async function testCorrelatedWatchFolderExists(reuseExistingWatcher: boolean) { + const folderPath = join(testDir, 'deep'); + + const requests: IRecursiveWatchRequest[] = [{ path: folderPath, excludes: [], recursive: true, correlationId: 1 }]; + if (reuseExistingWatcher) { + requests.push({ path: testDir, excludes: [], recursive: true }); + } + + await watcher.watch(requests); + + const filePath = join(folderPath, 'newFile.txt'); + await basicCrudTest(filePath, 1); + + const onDidWatchFail = Event.toPromise(watcher.onWatchFail); + await Promises.rm(folderPath); + await onDidWatchFail; + + const changeFuture = awaitEvent(watcher, folderPath, FileChangeType.ADDED, undefined, 1); + const onDidWatch = Event.toPromise(watcher.onDidWatch); + await Promises.mkdir(folderPath); + await changeFuture; + await onDidWatch; + + await basicCrudTest(filePath, 1); + } + + test('watch request reuses another recursive watcher even when requests are coming in at the same time', async function () { + const folderPath1 = join(testDir, 'deep', 'not-existing1'); + const folderPath2 = join(testDir, 'deep', 'not-existing2'); + const folderPath3 = join(testDir, 'not-existing3'); + + const requests: IRecursiveWatchRequest[] = [ + { path: folderPath1, excludes: [], recursive: true, correlationId: 1 }, + { path: folderPath2, excludes: [], recursive: true, correlationId: 2 }, + { path: folderPath3, excludes: [], recursive: true, correlationId: 3 }, + { path: join(testDir, 'deep'), excludes: [], recursive: true } + ]; + + await watcher.watch(requests); + + assert.strictEqual(watcher.isSuspended(requests[0]), true); + assert.strictEqual(watcher.isSuspended(requests[1]), true); + assert.strictEqual(watcher.isSuspended(requests[2]), 'polling'); + assert.strictEqual(watcher.isSuspended(requests[3]), false); + }); }); diff --git a/src/vs/platform/hover/browser/hover.ts b/src/vs/platform/hover/browser/hover.ts index 5f3bb1e331712..b856f528c5b52 100644 --- a/src/vs/platform/hover/browser/hover.ts +++ b/src/vs/platform/hover/browser/hover.ts @@ -4,228 +4,108 @@ *--------------------------------------------------------------------------------------------*/ import { createDecorator } from 'vs/platform/instantiation/common/instantiation'; -import { IDisposable } from 'vs/base/common/lifecycle'; -import { IMarkdownString } from 'vs/base/common/htmlContent'; -import { HoverPosition } from 'vs/base/browser/ui/hover/hoverWidget'; -import { IHoverWidget } from 'vs/base/browser/ui/iconLabel/iconHoverDelegate'; +import { Disposable, DisposableStore } from 'vs/base/common/lifecycle'; +import { IHoverDelegate, IHoverDelegateOptions } from 'vs/base/browser/ui/hover/hoverDelegate'; +import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; +import { addStandardDisposableListener } from 'vs/base/browser/dom'; +import { KeyCode } from 'vs/base/common/keyCodes'; +import type { IHoverDelegate2, IHoverOptions, IHoverWidget } from 'vs/base/browser/ui/hover/hover'; export const IHoverService = createDecorator('hoverService'); -/** - * Enables the convenient display of rich markdown-based hovers in the workbench. - */ -export interface IHoverService { +export interface IHoverService extends IHoverDelegate2 { readonly _serviceBrand: undefined; - - /** - * Shows a hover, provided a hover with the same options object is not already visible. - * @param options A set of options defining the characteristics of the hover. - * @param focus Whether to focus the hover (useful for keyboard accessibility). - * - * **Example:** A simple usage with a single element target. - * - * ```typescript - * showHover({ - * text: new MarkdownString('Hello world'), - * target: someElement - * }); - * ``` - */ - showHover(options: IHoverOptions, focus?: boolean): IHoverWidget | undefined; - - /** - * Hides the hover if it was visible. This call will be ignored if the the hover is currently - * "locked" via the alt/option key. - */ - hideHover(): void; - - /** - * This should only be used until we have the ability to show multiple context views - * simultaneously. #188822 - */ - showAndFocusLastHover(): void; -} - -export interface IHoverOptions { - /** - * The content to display in the primary section of the hover. The type of text determines the - * default `hideOnHover` behavior. - */ - content: IMarkdownString | string | HTMLElement; - - /** - * The target for the hover. This determines the position of the hover and it will only be - * hidden when the mouse leaves both the hover and the target. A HTMLElement can be used for - * simple cases and a IHoverTarget for more complex cases where multiple elements and/or a - * dispose method is required. - */ - target: IHoverTarget | HTMLElement; - - /* - * The container to pass to {@link IContextViewProvider.showContextView} which renders the hover - * in. This is particularly useful for more natural tab focusing behavior, where the hover is - * created as the next tab index after the element being hovered and/or to workaround the - * element's container hiding on `focusout`. - */ - container?: HTMLElement; - - /** - * An ID to associate with the hover to be used as an equality check. Normally when calling - * {@link IHoverService.showHover} the options object itself is used to determine if the hover - * is the same one that is already showing, when this is set, the ID will be used instead. - */ - id?: number | string; - - /** - * A set of actions for the hover's "status bar". - */ - actions?: IHoverAction[]; - - /** - * An optional array of classes to add to the hover element. - */ - additionalClasses?: string[]; - - /** - * An optional link handler for markdown links, if this is not provided the IOpenerService will - * be used to open the links using its default options. - */ - linkHandler?(url: string): void; - - /** - * Whether to trap focus in the following ways: - * - When the hover closes, focus goes to the element that had focus before the hover opened - * - If there are elements in the hover to focus, focus stays inside of the hover when tabbing - * Note that this is overridden to true when in screen reader optimized mode. - */ - trapFocus?: boolean; - - /** - * Options that defines where the hover is positioned. - */ - position?: IHoverPositionOptions; - - /** - * Options that defines how long the hover is shown and when it hides. - */ - persistence?: IHoverPersistenceOptions; - - /** - * Options that define how the hover looks. - */ - appearance?: IHoverAppearanceOptions; -} - -export interface IHoverPositionOptions { - /** - * Position of the hover. The default is to show above the target. This option will be ignored - * if there is not enough room to layout the hover in the specified position, unless the - * forcePosition option is set. - */ - hoverPosition?: HoverPosition; - - /** - * Force the hover position, reducing the size of the hover instead of adjusting the hover - * position. - */ - forcePosition?: boolean; } -export interface IHoverPersistenceOptions { - /** - * Whether to hide the hover when the mouse leaves the `target` and enters the actual hover. - * This is false by default when text is an `IMarkdownString` and true when `text` is a - * `string`. Note that this will be ignored if any `actions` are provided as hovering is - * required to make them accessible. - * - * In general hiding on hover is desired for: - * - Regular text where selection is not important - * - Markdown that contains no links where selection is not important - */ - hideOnHover?: boolean; - - /** - * Whether to hide the hover when a key is pressed. - */ - hideOnKeyDown?: boolean; - - /** - * Whether to make the hover sticky, this means it will not be hidden when the mouse leaves the - * hover. - */ - sticky?: boolean; +export class WorkbenchHoverDelegate extends Disposable implements IHoverDelegate { + + private lastHoverHideTime = 0; + private timeLimit = 200; + + private _delay: number; + get delay(): number { + if (this.isInstantlyHovering()) { + return 0; // show instantly when a hover was recently shown + } + return this._delay; + } + + private readonly hoverDisposables = this._register(new DisposableStore()); + + constructor( + public readonly placement: 'mouse' | 'element', + private readonly instantHover: boolean, + private overrideOptions: Partial | ((options: IHoverDelegateOptions, focus?: boolean) => Partial) = {}, + @IConfigurationService private readonly configurationService: IConfigurationService, + @IHoverService private readonly hoverService: IHoverService, + ) { + super(); + + this._delay = this.configurationService.getValue('workbench.hover.delay'); + this._register(this.configurationService.onDidChangeConfiguration(e => { + if (e.affectsConfiguration('workbench.hover.delay')) { + this._delay = this.configurationService.getValue('workbench.hover.delay'); + } + })); + } + + showHover(options: IHoverDelegateOptions, focus?: boolean): IHoverWidget | undefined { + const overrideOptions = typeof this.overrideOptions === 'function' ? this.overrideOptions(options, focus) : this.overrideOptions; + + // close hover on escape + this.hoverDisposables.clear(); + const targets = options.target instanceof HTMLElement ? [options.target] : options.target.targetElements; + for (const target of targets) { + this.hoverDisposables.add(addStandardDisposableListener(target, 'keydown', (e) => { + if (e.equals(KeyCode.Escape)) { + this.hoverService.hideHover(); + } + })); + } + + const id = options.content instanceof HTMLElement ? undefined : options.content.toString(); + + return this.hoverService.showHover({ + ...options, + ...overrideOptions, + persistence: { + hideOnKeyDown: true, + ...overrideOptions.persistence + }, + id, + appearance: { + ...options.appearance, + compact: true, + skipFadeInAnimation: this.isInstantlyHovering(), + ...overrideOptions.appearance + } + }, focus); + } + + private isInstantlyHovering(): boolean { + return this.instantHover && Date.now() - this.lastHoverHideTime < this.timeLimit; + } + + setInstantHoverTimeLimit(timeLimit: number): void { + if (!this.instantHover) { + throw new Error('Instant hover is not enabled'); + } + this.timeLimit = timeLimit; + } + + onDidHideHover(): void { + this.hoverDisposables.clear(); + if (this.instantHover) { + this.lastHoverHideTime = Date.now(); + } + } } -export interface IHoverAppearanceOptions { - /** - * Whether to show the hover pointer, a little arrow that connects the target and the hover. - */ - showPointer?: boolean; - - /** - * Whether to show a compact hover, reducing the font size and padding of the hover. - */ - compact?: boolean; - - /** - * When {@link hideOnHover} is explicitly true or undefined and its auto value is detected to - * hide, show a hint at the bottom of the hover explaining how to mouse over the widget. This - * should be used in the cases where despite the hover having no interactive content, it's - * likely the user may want to interact with it somehow. - */ - showHoverHint?: boolean; - - /** - * Whether to skip the fade in animation, this should be used when hovering from one hover to - * another in the same group so it looks like the hover is moving from one element to the other. - */ - skipFadeInAnimation?: boolean; -} - -export interface IHoverAction { - /** - * The label to use in the hover's status bar. - */ - label: string; - - /** - * The command ID of the action, this is used to resolve the keybinding to display after the - * action label. - */ - commandId: string; - - /** - * An optional class of an icon that will be displayed before the label. - */ - iconClass?: string; - - /** - * The callback to run the action. - * @param target The action element that was activated. - */ - run(target: HTMLElement): void; -} - -/** - * A target for a hover. - */ -export interface IHoverTarget extends IDisposable { - /** - * A set of target elements used to position the hover. If multiple elements are used the hover - * will try to not overlap any target element. An example use case for this is show a hover for - * wrapped text. - */ - readonly targetElements: readonly HTMLElement[]; - - /** - * An optional absolute x coordinate to position the hover with, for example to position the - * hover using `MouseEvent.pageX`. - */ - x?: number; - - /** - * An optional absolute y coordinate to position the hover with, for example to position the - * hover using `MouseEvent.pageY`. - */ - y?: number; -} +// TODO@benibenj remove this, only temp fix for contextviews +export const nativeHoverDelegate: IHoverDelegate = { + showHover: function (): IHoverWidget | undefined { + throw new Error('Native hover function not implemented.'); + }, + delay: 0, + showNativeHover: true +}; diff --git a/src/vs/editor/test/common/modes/supports/javascriptIndentationRules.ts b/src/vs/platform/hover/test/browser/nullHoverService.ts similarity index 50% rename from src/vs/editor/test/common/modes/supports/javascriptIndentationRules.ts rename to src/vs/platform/hover/test/browser/nullHoverService.ts index 12fb83c4925fc..1040ba4d772b7 100644 --- a/src/vs/editor/test/common/modes/supports/javascriptIndentationRules.ts +++ b/src/vs/platform/hover/test/browser/nullHoverService.ts @@ -3,9 +3,13 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -export const javascriptIndentationRules = { - decreaseIndentPattern: /^((?!.*?\/\*).*\*\/)?\s*[\}\]].*$/, - increaseIndentPattern: /^((?!\/\/).)*(\{([^}"'`]*|(\t|[ ])*\/\/.*)|\([^)"'`]*|\[[^\]"'`]*)$/, - // e.g. * ...| or */| or *-----*/| - unIndentedLinePattern: /^(\t|[ ])*[ ]\*[^/]*\*\/\s*$|^(\t|[ ])*[ ]\*\/\s*$|^(\t|[ ])*[ ]\*([ ]([^\*]|\*(?!\/))*)?$/ +import { Disposable } from 'vs/base/common/lifecycle'; +import type { IHoverService } from 'vs/platform/hover/browser/hover'; + +export const NullHoverService: IHoverService = { + _serviceBrand: undefined, + hideHover: () => undefined, + showHover: () => undefined, + setupUpdatableHover: () => Disposable.None as any, + showAndFocusLastHover: () => undefined, }; diff --git a/src/vs/platform/instantiation/common/instantiation.ts b/src/vs/platform/instantiation/common/instantiation.ts index f6113a5ecd173..86766a4a6c6be 100644 --- a/src/vs/platform/instantiation/common/instantiation.ts +++ b/src/vs/platform/instantiation/common/instantiation.ts @@ -63,6 +63,16 @@ export interface IInstantiationService { * and adds/overwrites the given services. */ createChild(services: ServiceCollection): IInstantiationService; + + /** + * Disposes this instantiation service. + * + * - Will dispose all services that this instantiation service has created. + * - Will dispose all its children but not its parent. + * - Will NOT dispose services-instances that this service has been created with + * - Will NOT dispose consumer-instances this service has created + */ + dispose(): void; } diff --git a/src/vs/platform/instantiation/common/instantiationService.ts b/src/vs/platform/instantiation/common/instantiationService.ts index ef9ae6601ce54..fd95305865a51 100644 --- a/src/vs/platform/instantiation/common/instantiationService.ts +++ b/src/vs/platform/instantiation/common/instantiationService.ts @@ -6,7 +6,7 @@ import { GlobalIdleValue } from 'vs/base/common/async'; import { Event } from 'vs/base/common/event'; import { illegalState } from 'vs/base/common/errors'; -import { IDisposable, toDisposable } from 'vs/base/common/lifecycle'; +import { dispose, IDisposable, isDisposable, toDisposable } from 'vs/base/common/lifecycle'; import { SyncDescriptor, SyncDescriptor0 } from 'vs/platform/instantiation/common/descriptors'; import { Graph } from 'vs/platform/instantiation/common/graph'; import { GetLeadingNonServiceArgs, IInstantiationService, ServiceIdentifier, ServicesAccessor, _util } from 'vs/platform/instantiation/common/instantiation'; @@ -32,6 +32,10 @@ export class InstantiationService implements IInstantiationService { readonly _globalGraph?: Graph; private _globalGraphImplicitDependency?: string; + private _isDisposed = false; + private readonly _servicesToMaybeDispose = new Set(); + private readonly _children = new Set(); + constructor( private readonly _services: ServiceCollection = new ServiceCollection(), private readonly _strict: boolean = false, @@ -43,11 +47,45 @@ export class InstantiationService implements IInstantiationService { this._globalGraph = _enableTracing ? _parent?._globalGraph ?? new Graph(e => e) : undefined; } + dispose(): void { + if (!this._isDisposed) { + this._isDisposed = true; + // dispose all child services + dispose(this._children); + this._children.clear(); + + // dispose all services created by this service + for (const candidate of this._servicesToMaybeDispose) { + if (isDisposable(candidate)) { + candidate.dispose(); + } + } + this._servicesToMaybeDispose.clear(); + } + } + + private _throwIfDisposed(): void { + if (this._isDisposed) { + throw new Error('InstantiationService has been disposed'); + } + } + createChild(services: ServiceCollection): IInstantiationService { - return new InstantiationService(services, this._strict, this, this._enableTracing); + this._throwIfDisposed(); + + const result = new class extends InstantiationService { + override dispose(): void { + this._children.delete(result); + super.dispose(); + } + }(services, this._strict, this, this._enableTracing); + this._children.add(result); + return result; } invokeFunction(fn: (accessor: ServicesAccessor, ...args: TS) => R, ...args: TS): R { + this._throwIfDisposed(); + const _trace = Trace.traceInvocation(this._enableTracing, fn); let _done = false; try { @@ -75,6 +113,8 @@ export class InstantiationService implements IInstantiationService { createInstance(descriptor: SyncDescriptor0): T; createInstance any, R extends InstanceType>(ctor: Ctor, ...args: GetLeadingNonServiceArgs>): R; createInstance(ctorOrDescriptor: any | SyncDescriptor, ...rest: any[]): any { + this._throwIfDisposed(); + let _trace: Trace; let result: any; if (ctorOrDescriptor instanceof SyncDescriptor) { @@ -119,11 +159,11 @@ export class InstantiationService implements IInstantiationService { return Reflect.construct(ctor, args.concat(serviceArgs)); } - private _setServiceInstance(id: ServiceIdentifier, instance: T): void { + private _setCreatedServiceInstance(id: ServiceIdentifier, instance: T): void { if (this._services.get(id) instanceof SyncDescriptor) { this._services.set(id, instance); } else if (this._parent) { - this._parent._setServiceInstance(id, instance); + this._parent._setCreatedServiceInstance(id, instance); } else { throw new Error('illegalState - setting UNKNOWN service instance'); } @@ -221,7 +261,7 @@ export class InstantiationService implements IInstantiationService { if (instanceOrDesc instanceof SyncDescriptor) { // create instance and overwrite the service collections const instance = this._createServiceInstanceWithOwner(data.id, data.desc.ctor, data.desc.staticArguments, data.desc.supportsDelayedInstantiation, data._trace); - this._setServiceInstance(data.id, instance); + this._setCreatedServiceInstance(data.id, instance); } graph.removeNode(data); } @@ -231,7 +271,7 @@ export class InstantiationService implements IInstantiationService { private _createServiceInstanceWithOwner(id: ServiceIdentifier, ctor: any, args: any[] = [], supportsDelayedInstantiation: boolean, _trace: Trace): T { if (this._services.get(id) instanceof SyncDescriptor) { - return this._createServiceInstance(id, ctor, args, supportsDelayedInstantiation, _trace); + return this._createServiceInstance(id, ctor, args, supportsDelayedInstantiation, _trace, this._servicesToMaybeDispose); } else if (this._parent) { return this._parent._createServiceInstanceWithOwner(id, ctor, args, supportsDelayedInstantiation, _trace); } else { @@ -239,10 +279,12 @@ export class InstantiationService implements IInstantiationService { } } - private _createServiceInstance(id: ServiceIdentifier, ctor: any, args: any[] = [], supportsDelayedInstantiation: boolean, _trace: Trace): T { + private _createServiceInstance(id: ServiceIdentifier, ctor: any, args: any[] = [], supportsDelayedInstantiation: boolean, _trace: Trace, disposeBucket: Set): T { if (!supportsDelayedInstantiation) { // eager instantiation - return this._createInstance(ctor, args, _trace); + const result = this._createInstance(ctor, args, _trace); + disposeBucket.add(result); + return result; } else { const child = new InstantiationService(undefined, this._strict, this, this._enableTracing); @@ -274,7 +316,7 @@ export class InstantiationService implements IInstantiationService { } } earlyListeners.clear(); - + disposeBucket.add(result); return result; }); return new Proxy(Object.create(null), { diff --git a/src/vs/platform/instantiation/test/common/instantiationService.test.ts b/src/vs/platform/instantiation/test/common/instantiationService.test.ts index d3fa8924baa19..fd5bb77d2f72a 100644 --- a/src/vs/platform/instantiation/test/common/instantiationService.test.ts +++ b/src/vs/platform/instantiation/test/common/instantiationService.test.ts @@ -655,5 +655,163 @@ suite('Instantiation Service', () => { assert.strictEqual(eventCount, 1); }); + + test('Dispose services it created', function () { + let disposedA = false; + let disposedB = false; + + const A = createDecorator('A'); + interface A { + _serviceBrand: undefined; + value: 1; + } + class AImpl implements A { + _serviceBrand: undefined; + value: 1 = 1; + dispose() { + disposedA = true; + } + } + + const B = createDecorator('B'); + interface B { + _serviceBrand: undefined; + value: 1; + } + class BImpl implements B { + _serviceBrand: undefined; + value: 1 = 1; + dispose() { + disposedB = true; + } + } + + const insta = new InstantiationService(new ServiceCollection( + [A, new SyncDescriptor(AImpl, undefined, true)], + [B, new BImpl()], + ), true, undefined, true); + + class Consumer { + constructor( + @A public readonly a: A, + @B public readonly b: B + ) { + assert.strictEqual(a.value, b.value); + } + } + + const c: Consumer = insta.createInstance(Consumer); + + insta.dispose(); + assert.ok(c); + assert.strictEqual(disposedA, true); + assert.strictEqual(disposedB, false); + }); + + test('Disposed service cannot be used anymore', function () { + + + const B = createDecorator('B'); + interface B { + _serviceBrand: undefined; + value: 1; + } + class BImpl implements B { + _serviceBrand: undefined; + value: 1 = 1; + } + + const insta = new InstantiationService(new ServiceCollection( + [B, new BImpl()], + ), true, undefined, true); + + class Consumer { + constructor( + @B public readonly b: B + ) { + assert.strictEqual(b.value, 1); + } + } + + const c: Consumer = insta.createInstance(Consumer); + assert.ok(c); + + insta.dispose(); + + assert.throws(() => insta.createInstance(Consumer)); + assert.throws(() => insta.invokeFunction(accessor => { })); + assert.throws(() => insta.createChild(new ServiceCollection())); + }); + + test('Child does not dispose parent', function () { + + const B = createDecorator('B'); + interface B { + _serviceBrand: undefined; + value: 1; + } + class BImpl implements B { + _serviceBrand: undefined; + value: 1 = 1; + } + + const insta1 = new InstantiationService(new ServiceCollection( + [B, new BImpl()], + ), true, undefined, true); + + const insta2 = insta1.createChild(new ServiceCollection()); + + class Consumer { + constructor( + @B public readonly b: B + ) { + assert.strictEqual(b.value, 1); + } + } + + assert.ok(insta1.createInstance(Consumer)); + assert.ok(insta2.createInstance(Consumer)); + + insta2.dispose(); + + assert.ok(insta1.createInstance(Consumer)); // parent NOT disposed by child + assert.throws(() => insta2.createInstance(Consumer)); + }); + + test('Parent does dispose children', function () { + + const B = createDecorator('B'); + interface B { + _serviceBrand: undefined; + value: 1; + } + class BImpl implements B { + _serviceBrand: undefined; + value: 1 = 1; + } + + const insta1 = new InstantiationService(new ServiceCollection( + [B, new BImpl()], + ), true, undefined, true); + + const insta2 = insta1.createChild(new ServiceCollection()); + + class Consumer { + constructor( + @B public readonly b: B + ) { + assert.strictEqual(b.value, 1); + } + } + + assert.ok(insta1.createInstance(Consumer)); + assert.ok(insta2.createInstance(Consumer)); + + insta1.dispose(); + + assert.throws(() => insta2.createInstance(Consumer)); // child is disposed by parent + assert.throws(() => insta1.createInstance(Consumer)); + }); + ensureNoDisposablesAreLeakedInTestSuite(); }); diff --git a/src/vs/platform/instantiation/test/common/instantiationServiceMock.ts b/src/vs/platform/instantiation/test/common/instantiationServiceMock.ts index a639165802d40..a7c52175e9d84 100644 --- a/src/vs/platform/instantiation/test/common/instantiationServiceMock.ts +++ b/src/vs/platform/instantiation/test/common/instantiationServiceMock.ts @@ -21,7 +21,7 @@ export class TestInstantiationService extends InstantiationService implements ID private _servciesMap: Map, any>; - constructor(private _serviceCollection: ServiceCollection = new ServiceCollection(), strict: boolean = false, parent?: TestInstantiationService) { + constructor(private _serviceCollection: ServiceCollection = new ServiceCollection(), strict: boolean = false, parent?: TestInstantiationService, private _properDispose?: boolean) { super(_serviceCollection, strict, parent); this._servciesMap = new Map, any>(); @@ -130,8 +130,11 @@ export class TestInstantiationService extends InstantiationService implements ID return new TestInstantiationService(services, false, this); } - dispose() { + override dispose() { sinon.restore(); + if (this._properDispose) { + super.dispose(); + } } } diff --git a/src/vs/platform/issue/common/issue.ts b/src/vs/platform/issue/common/issue.ts index 76da6c0fee438..81f3a8083caad 100644 --- a/src/vs/platform/issue/common/issue.ts +++ b/src/vs/platform/issue/common/issue.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { URI, UriComponents } from 'vs/base/common/uri'; +import { UriComponents } from 'vs/base/common/uri'; import { ISandboxConfiguration } from 'vs/base/parts/sandbox/common/sandboxTypes'; import { PerformanceInfo, SystemInfo } from 'vs/platform/diagnostics/common/diagnostics'; import { createDecorator } from 'vs/platform/instantiation/common/instantiation'; @@ -25,6 +25,12 @@ export const enum IssueType { FeatureRequest } +export enum IssueSource { + VSCode = 'vscode', + Extension = 'extension', + Marketplace = 'marketplace' +} + export interface IssueReporterStyles extends WindowStyles { textLinkColor?: string; textLinkActiveForeground?: string; @@ -55,8 +61,6 @@ export interface IssueReporterExtensionData { bugsUrl: string | undefined; extensionData?: string; extensionTemplate?: string; - hasIssueUriRequestHandler?: boolean; - hasIssueDataProviders?: boolean; data?: string; uri?: UriComponents; } @@ -65,13 +69,14 @@ export interface IssueReporterData extends WindowData { styles: IssueReporterStyles; enabledExtensions: IssueReporterExtensionData[]; issueType?: IssueType; + issueSource?: IssueSource; extensionId?: string; experiments?: string; restrictedMode: boolean; isUnsupported: boolean; githubAccessToken: string; - readonly issueTitle?: string; - readonly issueBody?: string; + issueTitle?: string; + issueBody?: string; data?: string; uri?: UriComponents; } @@ -134,9 +139,6 @@ export interface IIssueMainService { $reloadWithExtensionsDisabled(): Promise; $showConfirmCloseDialog(): Promise; $showClipboardDialog(): Promise; - $getIssueReporterUri(extensionId: string): Promise; - $getIssueReporterData(extensionId: string): Promise; - $getIssueReporterTemplate(extensionId: string): Promise; - $getReporterStatus(extensionId: string, extensionName: string): Promise; + $sendReporterMenu(extensionId: string, extensionName: string): Promise; $closeReporter(): Promise; } diff --git a/src/vs/platform/issue/electron-main/issueMainService.ts b/src/vs/platform/issue/electron-main/issueMainService.ts index 3998b23ead912..ee123897dd148 100644 --- a/src/vs/platform/issue/electron-main/issueMainService.ts +++ b/src/vs/platform/issue/electron-main/issueMainService.ts @@ -5,13 +5,12 @@ import { BrowserWindow, BrowserWindowConstructorOptions, contentTracing, Display, IpcMainEvent, screen } from 'electron'; import { arch, release, type } from 'os'; -import { Promises, raceTimeout, timeout } from 'vs/base/common/async'; +import { raceTimeout } from 'vs/base/common/async'; import { CancellationTokenSource } from 'vs/base/common/cancellation'; import { randomPath } from 'vs/base/common/extpath'; import { DisposableStore } from 'vs/base/common/lifecycle'; import { FileAccess } from 'vs/base/common/network'; import { IProcessEnvironment, isMacintosh } from 'vs/base/common/platform'; -import { URI } from 'vs/base/common/uri'; import { listProcesses } from 'vs/base/node/ps'; import { validatedIpcMain } from 'vs/base/parts/ipc/electron-main/ipcMain'; import { localize } from 'vs/nls'; @@ -179,7 +178,6 @@ export class IssueMainService implements IIssueMainService { this.issueReporterWindow.on('close', () => { this.issueReporterWindow = null; - issueReporterDisposables.dispose(); }); @@ -187,14 +185,13 @@ export class IssueMainService implements IIssueMainService { if (this.issueReporterWindow) { this.issueReporterWindow.close(); this.issueReporterWindow = null; - issueReporterDisposables.dispose(); } }); } } - if (this.issueReporterWindow) { + else if (this.issueReporterWindow) { this.focusWindow(this.issueReporterWindow); } } @@ -376,83 +373,16 @@ export class IssueMainService implements IIssueMainService { return window; } - async $getIssueReporterUri(extensionId: string): Promise { - const window = this.issueReporterWindowCheck(); - const replyChannel = `vscode:triggerIssueUriRequestHandlerResponse${window.id}`; - return Promises.withAsyncBody(async (resolve, reject) => { - - const cts = new CancellationTokenSource(); - window.sendWhenReady('vscode:triggerIssueUriRequestHandler', cts.token, { replyChannel, extensionId }); - - validatedIpcMain.once(replyChannel, (_: unknown, data: string) => { - resolve(URI.parse(data)); - }); - - try { - await timeout(5000); - cts.cancel(); - reject(new Error('Timed out waiting for issue reporter URI')); - } finally { - validatedIpcMain.removeHandler(replyChannel); - } - }); - } - - async $getIssueReporterData(extensionId: string): Promise { - const window = this.issueReporterWindowCheck(); - const replyChannel = `vscode:triggerIssueDataProviderResponse${window.id}`; - return Promises.withAsyncBody(async (resolve) => { - - const cts = new CancellationTokenSource(); - window.sendWhenReady('vscode:triggerIssueDataProvider', cts.token, { replyChannel, extensionId }); - - validatedIpcMain.once(replyChannel, (_: unknown, data: string) => { - resolve(data); - }); - - try { - await timeout(5000); - cts.cancel(); - resolve('Error: Extension timed out waiting for issue reporter data'); - } finally { - validatedIpcMain.removeHandler(replyChannel); - } - }); - } - - async $getIssueReporterTemplate(extensionId: string): Promise { - const window = this.issueReporterWindowCheck(); - const replyChannel = `vscode:triggerIssueDataTemplateResponse${window.id}`; - return Promises.withAsyncBody(async (resolve) => { - - const cts = new CancellationTokenSource(); - window.sendWhenReady('vscode:triggerIssueDataTemplate', cts.token, { replyChannel, extensionId }); - - validatedIpcMain.once(replyChannel, (_: unknown, data: string) => { - resolve(data); - }); - - try { - await timeout(5000); - cts.cancel(); - resolve('Error: Extension timed out waiting for issue reporter template'); - } finally { - validatedIpcMain.removeHandler(replyChannel); - } - }); - } - - async $getReporterStatus(extensionId: string, extensionName: string): Promise { - const defaultResult = [false, false]; + async $sendReporterMenu(extensionId: string, extensionName: string): Promise { const window = this.issueReporterWindowCheck(); - const replyChannel = `vscode:triggerReporterStatus`; + const replyChannel = `vscode:triggerReporterMenu`; const cts = new CancellationTokenSource(); window.sendWhenReady(replyChannel, cts.token, { replyChannel, extensionId, extensionName }); - const result = await raceTimeout(new Promise(resolve => validatedIpcMain.once('vscode:triggerReporterStatusResponse', (_: unknown, data: boolean[]) => resolve(data))), 2000, () => { - this.logService.error('Error: Extension timed out waiting for reporter status'); + const result = await raceTimeout(new Promise(resolve => validatedIpcMain.once(`vscode:triggerReporterMenuResponse:${extensionId}`, (_: unknown, data: IssueReporterData | undefined) => resolve(data))), 5000, () => { + this.logService.error(`Error: Extension ${extensionId} timed out waiting for menu response`); cts.cancel(); }); - return (result ?? defaultResult) as boolean[]; + return result as IssueReporterData | undefined; } async $closeReporter(): Promise { diff --git a/src/vs/platform/launch/electron-main/launchMainService.ts b/src/vs/platform/launch/electron-main/launchMainService.ts index 8cc5076eea2a6..1fcb947305dd8 100644 --- a/src/vs/platform/launch/electron-main/launchMainService.ts +++ b/src/vs/platform/launch/electron-main/launchMainService.ts @@ -147,7 +147,7 @@ export class LaunchMainService implements ILaunchMainService { let openNewWindow = false; // Force new window - if (args['new-window'] || args['unity-launch'] || baseConfig.forceProfile || baseConfig.forceTempProfile) { + if (args['new-window'] || baseConfig.forceProfile || baseConfig.forceTempProfile) { openNewWindow = true; } diff --git a/src/vs/platform/layout/browser/layoutService.ts b/src/vs/platform/layout/browser/layoutService.ts index 6fad206c73eec..65de031245134 100644 --- a/src/vs/platform/layout/browser/layoutService.ts +++ b/src/vs/platform/layout/browser/layoutService.ts @@ -84,6 +84,15 @@ export interface ILayoutService { */ getContainer(window: Window): HTMLElement; + /** + * Ensures that the styles for the container associated + * to the window have loaded. For the main window, this + * will resolve instantly, but for floating windows, this + * will resolve once the styles have been loaded and helps + * for when certain layout assumptions are made. + */ + whenContainerStylesLoaded(window: Window): Promise | undefined; + /** * An offset to use for positioning elements inside the main container. */ @@ -94,13 +103,6 @@ export interface ILayoutService { */ readonly activeContainerOffset: ILayoutOffsetInfo; - /** - * A promise resolved when the stylesheets for the active container have been - * loaded. Aux windows load their styles asynchronously, so there may be - * an initial delay before resolution happens. - */ - readonly whenActiveContainerStylesLoaded: Promise; - /** * Focus the primary component of the active container. */ diff --git a/src/vs/platform/log/common/log.ts b/src/vs/platform/log/common/log.ts index e2d69c0fe3074..28fc419bbaf6b 100644 --- a/src/vs/platform/log/common/log.ts +++ b/src/vs/platform/log/common/log.ts @@ -3,6 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import * as nls from 'vs/nls'; import { toErrorMessage } from 'vs/base/common/errorMessage'; import { Emitter, Event } from 'vs/base/common/event'; import { hash } from 'vs/base/common/hash'; @@ -12,6 +13,7 @@ import { isWindows } from 'vs/base/common/platform'; import { joinPath } from 'vs/base/common/resources'; import { Mutable, isNumber, isString } from 'vs/base/common/types'; import { URI } from 'vs/base/common/uri'; +import { ILocalizedString } from 'vs/platform/action/common/action'; import { RawContextKey } from 'vs/platform/contextkey/common/contextkey'; import { IEnvironmentService } from 'vs/platform/environment/common/environment'; import { createDecorator } from 'vs/platform/instantiation/common/instantiation'; @@ -745,6 +747,17 @@ export function LogLevelToString(logLevel: LogLevel): string { } } +export function LogLevelToLocalizedString(logLevel: LogLevel): ILocalizedString { + switch (logLevel) { + case LogLevel.Trace: return { original: 'Trace', value: nls.localize('trace', "Trace") }; + case LogLevel.Debug: return { original: 'Debug', value: nls.localize('debug', "Debug") }; + case LogLevel.Info: return { original: 'Info', value: nls.localize('info', "Info") }; + case LogLevel.Warning: return { original: 'Warning', value: nls.localize('warn', "Warning") }; + case LogLevel.Error: return { original: 'Error', value: nls.localize('error', "Error") }; + case LogLevel.Off: return { original: 'Off', value: nls.localize('off', "Off") }; + } +} + export function parseLogLevel(logLevel: string): LogLevel | undefined { switch (logLevel) { case 'trace': diff --git a/src/vs/platform/markers/common/markerService.ts b/src/vs/platform/markers/common/markerService.ts index 0f2ef2566fc94..91120aab5b6b0 100644 --- a/src/vs/platform/markers/common/markerService.ts +++ b/src/vs/platform/markers/common/markerService.ts @@ -12,7 +12,13 @@ import { Schemas } from 'vs/base/common/network'; import { URI } from 'vs/base/common/uri'; import { IMarker, IMarkerData, IMarkerService, IResourceMarker, MarkerSeverity, MarkerStatistics } from './markers'; -export const unsupportedSchemas = new Set([Schemas.inMemory, Schemas.vscodeSourceControl, Schemas.walkThrough, Schemas.walkThroughSnippet]); +export const unsupportedSchemas = new Set([ + Schemas.inMemory, + Schemas.vscodeSourceControl, + Schemas.walkThrough, + Schemas.walkThroughSnippet, + Schemas.vscodeChatCodeBlock, +]); class DoubleResourceMap { diff --git a/src/vs/platform/menubar/electron-main/menubar.ts b/src/vs/platform/menubar/electron-main/menubar.ts index 2ab5bcecbfaba..f11b38cb19d42 100644 --- a/src/vs/platform/menubar/electron-main/menubar.ts +++ b/src/vs/platform/menubar/electron-main/menubar.ts @@ -647,7 +647,7 @@ export class Menubar { return [new MenuItem({ label: nls.localize('miDownloadingUpdate', "Downloading Update..."), enabled: false })]; case StateType.Downloaded: - return [new MenuItem({ + return isMacintosh ? [] : [new MenuItem({ label: this.mnemonicLabel(nls.localize('miInstallUpdate', "Install &&Update...")), click: () => { this.reportMenuActionTelemetry('InstallUpdate'); this.updateService.applyUpdate(); diff --git a/src/vs/platform/native/electron-main/nativeHostMainService.ts b/src/vs/platform/native/electron-main/nativeHostMainService.ts index fa4087a411fb5..a1d2830161112 100644 --- a/src/vs/platform/native/electron-main/nativeHostMainService.ts +++ b/src/vs/platform/native/electron-main/nativeHostMainService.ts @@ -40,7 +40,7 @@ import { VSBuffer } from 'vs/base/common/buffer'; import { hasWSLFeatureInstalled } from 'vs/platform/remote/node/wsl'; import { WindowProfiler } from 'vs/platform/profiling/electron-main/windowProfiling'; import { IV8Profile } from 'vs/platform/profiling/common/profiling'; -import { IAuxiliaryWindowsMainService, isAuxiliaryWindow } from 'vs/platform/auxiliaryWindow/electron-main/auxiliaryWindows'; +import { IAuxiliaryWindowsMainService } from 'vs/platform/auxiliaryWindow/electron-main/auxiliaryWindows'; import { IAuxiliaryWindow } from 'vs/platform/auxiliaryWindow/electron-main/auxiliaryWindow'; import { CancellationError } from 'vs/base/common/errors'; @@ -105,11 +105,11 @@ export class NativeHostMainService extends Disposable implements INativeHostMain readonly onDidBlurMainOrAuxiliaryWindow = Event.any( this.onDidBlurMainWindow, - Event.map(Event.filter(Event.fromNodeEventEmitter(app, 'browser-window-blur', (event, window: BrowserWindow) => window), window => isAuxiliaryWindow(window.webContents)), window => window.id) + Event.filter(Event.fromNodeEventEmitter(app, 'browser-window-blur', (event, window: BrowserWindow) => window.id), windowId => !!this.auxiliaryWindowsMainService.getWindowById(windowId)) ); readonly onDidFocusMainOrAuxiliaryWindow = Event.any( this.onDidFocusMainWindow, - Event.map(Event.filter(Event.fromNodeEventEmitter(app, 'browser-window-focus', (event, window: BrowserWindow) => window), window => isAuxiliaryWindow(window.webContents)), window => window.id) + Event.filter(Event.fromNodeEventEmitter(app, 'browser-window-focus', (event, window: BrowserWindow) => window.id), windowId => !!this.auxiliaryWindowsMainService.getWindowById(windowId)) ); readonly onDidResumeOS = Event.fromNodeEventEmitter(powerMonitor, 'resume'); diff --git a/src/vs/platform/opener/browser/link.ts b/src/vs/platform/opener/browser/link.ts index 2b455fa8dc6db..710292ef17de5 100644 --- a/src/vs/platform/opener/browser/link.ts +++ b/src/vs/platform/opener/browser/link.ts @@ -12,6 +12,10 @@ import { KeyCode } from 'vs/base/common/keyCodes'; import { Disposable } from 'vs/base/common/lifecycle'; import { IOpenerService } from 'vs/platform/opener/common/opener'; import 'vs/css!./link'; +import { getDefaultHoverDelegate } from 'vs/base/browser/ui/hover/hoverDelegateFactory'; +import { IHoverDelegate } from 'vs/base/browser/ui/hover/hoverDelegate'; +import type { IUpdatableHover } from 'vs/base/browser/ui/hover/hover'; +import { IHoverService } from 'vs/platform/hover/browser/hover'; export interface ILinkDescriptor { readonly label: string | HTMLElement; @@ -22,12 +26,16 @@ export interface ILinkDescriptor { export interface ILinkOptions { readonly opener?: (href: string) => void; + readonly hoverDelegate?: IHoverDelegate; readonly textLinkForeground?: string; } export class Link extends Disposable { private el: HTMLAnchorElement; + private hover?: IUpdatableHover; + private hoverDelegate: IHoverDelegate; + private _enabled: boolean = true; get enabled(): boolean { @@ -68,9 +76,7 @@ export class Link extends Disposable { this.el.tabIndex = link.tabIndex; } - if (typeof link.title !== 'undefined') { - this.el.title = link.title; - } + this.setTooltip(link.title); this._link = link; } @@ -79,6 +85,7 @@ export class Link extends Disposable { container: HTMLElement, private _link: ILinkDescriptor, options: ILinkOptions = {}, + @IHoverService private readonly _hoverService: IHoverService, @IOpenerService openerService: IOpenerService ) { super(); @@ -86,9 +93,11 @@ export class Link extends Disposable { this.el = append(container, $('a.monaco-link', { tabIndex: _link.tabIndex ?? 0, href: _link.href, - title: _link.title }, _link.label)); + this.hoverDelegate = options.hoverDelegate ?? getDefaultHoverDelegate('mouse'); + this.setTooltip(_link.title); + this.el.setAttribute('role', 'button'); const onClickEmitter = this._register(new DomEmitter(this.el, 'click')); @@ -117,4 +126,14 @@ export class Link extends Disposable { this.enabled = true; } + + private setTooltip(title: string | undefined): void { + if (this.hoverDelegate.showNativeHover) { + this.el.title = title ?? ''; + } else if (!this.hover && title) { + this.hover = this._register(this._hoverService.setupUpdatableHover(this.hoverDelegate, this.el, title)); + } else if (this.hover) { + this.hover.update(title); + } + } } diff --git a/src/vs/platform/policy/node/nativePolicyService.ts b/src/vs/platform/policy/node/nativePolicyService.ts index d6e3f60cdd1e6..747177ae7e999 100644 --- a/src/vs/platform/policy/node/nativePolicyService.ts +++ b/src/vs/platform/policy/node/nativePolicyService.ts @@ -13,7 +13,7 @@ import { ILogService } from 'vs/platform/log/common/log'; export class NativePolicyService extends AbstractPolicyService implements IPolicyService { private throttler = new Throttler(); - private watcher = this._register(new MutableDisposable()); + private readonly watcher = this._register(new MutableDisposable()); constructor( @ILogService private readonly logService: ILogService, diff --git a/src/vs/platform/profiling/common/profiling.ts b/src/vs/platform/profiling/common/profiling.ts index c4bfe9ddd24d1..f4b4a302750dc 100644 --- a/src/vs/platform/profiling/common/profiling.ts +++ b/src/vs/platform/profiling/common/profiling.ts @@ -37,7 +37,7 @@ export interface IV8InspectProfilingService { _serviceBrand: undefined; - startProfiling(options: { port: number }): Promise; + startProfiling(options: { host: string; port: number }): Promise; stopProfiling(sessionId: string): Promise; } diff --git a/src/vs/platform/profiling/common/profilingTelemetrySpec.ts b/src/vs/platform/profiling/common/profilingTelemetrySpec.ts index cd85a0d7385bc..316b6321f34d1 100644 --- a/src/vs/platform/profiling/common/profilingTelemetrySpec.ts +++ b/src/vs/platform/profiling/common/profilingTelemetrySpec.ts @@ -22,10 +22,10 @@ type TelemetrySampleData = { type TelemetrySampleDataClassification = { owner: 'jrieken'; comment: 'A callstack that took a long time to execute'; - selfTime: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Self time of the sample' }; - totalTime: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Total time of the sample' }; - percentage: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Relative time (percentage) of the sample' }; - perfBaseline: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Performance baseline for the machine' }; + selfTime: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Self time of the sample' }; + totalTime: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Total time of the sample' }; + percentage: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Relative time (percentage) of the sample' }; + perfBaseline: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Performance baseline for the machine' }; functionName: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The name of the sample' }; callers: { classification: 'CallstackOrException'; purpose: 'PerformanceAndHealth'; comment: 'The heaviest call trace into this sample' }; callersAnnotated: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The heaviest call trace into this sample annotated with respective costs' }; diff --git a/src/vs/platform/profiling/node/profilingService.ts b/src/vs/platform/profiling/node/profilingService.ts index 6187edd76c3c6..c3f218d047ce3 100644 --- a/src/vs/platform/profiling/node/profilingService.ts +++ b/src/vs/platform/profiling/node/profilingService.ts @@ -13,9 +13,9 @@ export class InspectProfilingService implements IV8InspectProfilingService { private readonly _sessions = new Map(); - async startProfiling(options: { port: number }): Promise { + async startProfiling(options: { host: string; port: number }): Promise { const prof = await import('v8-inspect-profiler'); - const session = await prof.startProfiling({ port: options.port, checkForPaused: true }); + const session = await prof.startProfiling({ host: options.host, port: options.port, checkForPaused: true }); const id = generateUuid(); this._sessions.set(id, session); return id; diff --git a/src/vs/platform/quickinput/browser/commandsQuickAccess.ts b/src/vs/platform/quickinput/browser/commandsQuickAccess.ts index 87a211bcfbf17..f74b013ddc25f 100644 --- a/src/vs/platform/quickinput/browser/commandsQuickAccess.ts +++ b/src/vs/platform/quickinput/browser/commandsQuickAccess.ts @@ -19,6 +19,7 @@ import { IConfigurationChangeEvent, IConfigurationService } from 'vs/platform/co import { IDialogService } from 'vs/platform/dialogs/common/dialogs'; import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; import { IKeybindingService } from 'vs/platform/keybinding/common/keybinding'; +import { ILogService } from 'vs/platform/log/common/log'; import { FastAndSlowPicks, IPickerQuickAccessItem, IPickerQuickAccessProviderOptions, PickerQuickAccessProvider, Picks } from 'vs/platform/quickinput/browser/pickerQuickAccess'; import { IQuickAccessProviderRunOptions } from 'vs/platform/quickinput/common/quickAccess'; import { IQuickPickSeparator } from 'vs/platform/quickinput/common/quickInput'; @@ -27,6 +28,7 @@ import { ITelemetryService } from 'vs/platform/telemetry/common/telemetry'; export interface ICommandQuickPick extends IPickerQuickAccessItem { readonly commandId: string; + readonly commandWhen?: string; readonly commandAlias?: string; readonly commandDescription?: ILocalizedString; tfIdfScore?: number; @@ -332,6 +334,7 @@ export class CommandsHistory extends Disposable { constructor( @IStorageService private readonly storageService: IStorageService, @IConfigurationService private readonly configurationService: IConfigurationService, + @ILogService private readonly logService: ILogService ) { super(); @@ -373,7 +376,7 @@ export class CommandsHistory extends Disposable { try { serializedCache = JSON.parse(raw); } catch (error) { - // invalid data + this.logService.error(`[CommandsHistory] invalid data: ${error}`); } } diff --git a/src/vs/platform/quickinput/browser/media/quickInput.css b/src/vs/platform/quickinput/browser/media/quickInput.css index 8756dc7c60a2c..3afa06e58881f 100644 --- a/src/vs/platform/quickinput/browser/media/quickInput.css +++ b/src/vs/platform/quickinput/browser/media/quickInput.css @@ -16,8 +16,7 @@ .quick-input-titlebar { display: flex; align-items: center; - border-top-left-radius: 5px; /* match border radius of quick input widget */ - border-top-right-radius: 5px; + border-radius: inherit; } .quick-input-left-action-bar { @@ -60,7 +59,7 @@ .quick-input-header { display: flex; - padding: 8px 6px 6px 6px; + padding: 8px 6px 2px 6px; } .quick-input-widget.hidden-input .quick-input-header { @@ -264,8 +263,16 @@ overflow: hidden; } -.quick-input-list .monaco-highlighted-label .highlight { +/* preserve list-like styling instead of tree-like styling */ +.quick-input-list .monaco-list .monaco-list-row .monaco-highlighted-label .highlight { font-weight: bold; + background-color: unset; + color: var(--vscode-list-highlightForeground) !important; +} + +/* preserve list-like styling instead of tree-like styling */ +.quick-input-list .monaco-list .monaco-list-row.focused .monaco-highlighted-label .highlight { + color: var(--vscode-list-focusHighlightForeground) !important; } .quick-input-list .quick-input-list-entry .quick-input-list-separator { @@ -301,7 +308,9 @@ .quick-input-list .quick-input-list-entry .quick-input-list-entry-action-bar .action-label.always-visible, .quick-input-list .quick-input-list-entry:hover .quick-input-list-entry-action-bar .action-label, -.quick-input-list .monaco-list-row.focused .quick-input-list-entry-action-bar .action-label { +.quick-input-list .quick-input-list-entry.focus-inside .quick-input-list-entry-action-bar .action-label, +.quick-input-list .monaco-list-row.focused .quick-input-list-entry-action-bar .action-label, +.quick-input-list .monaco-list-row.passive-focused .quick-input-list-entry-action-bar .action-label { display: flex; } @@ -314,8 +323,32 @@ background: none; } -/* Quick input separators as full-row item */ .quick-input-list .quick-input-list-separator-as-item { - font-weight: 600; + padding: 4px 6px; font-size: 12px; } + +/* Quick input separators as full-row item */ +.quick-input-list .quick-input-list-separator-as-item .label-name { + font-weight: 600; +} + +.quick-input-list .quick-input-list-separator-as-item .label-description { + /* Override default description opacity so we don't have a contrast ratio issue. */ + opacity: 1 !important; +} + +/* Hide border when the item becomes the sticky one */ +.quick-input-list .monaco-tree-sticky-row .quick-input-list-entry.quick-input-list-separator-as-item.quick-input-list-separator-border { + border-top-style: none; +} + +/* Give sticky row the same padding as the scrollable list */ +.quick-input-list .monaco-tree-sticky-row { + padding: 0 5px; +} + +/* Hide the twistie containers so that there isn't blank indent */ +.quick-input-list .monaco-tl-twistie { + display: none !important; +} diff --git a/src/vs/platform/quickinput/browser/pickerQuickAccess.ts b/src/vs/platform/quickinput/browser/pickerQuickAccess.ts index acde1e461d17c..86160c9e26aa9 100644 --- a/src/vs/platform/quickinput/browser/pickerQuickAccess.ts +++ b/src/vs/platform/quickinput/browser/pickerQuickAccess.ts @@ -6,7 +6,7 @@ import { timeout } from 'vs/base/common/async'; import { CancellationToken, CancellationTokenSource } from 'vs/base/common/cancellation'; import { Disposable, DisposableStore, IDisposable, MutableDisposable } from 'vs/base/common/lifecycle'; -import { IKeyMods, IQuickPickDidAcceptEvent, IQuickPickSeparator, IQuickPick, IQuickPickItem } from 'vs/platform/quickinput/common/quickInput'; +import { IKeyMods, IQuickPickDidAcceptEvent, IQuickPickSeparator, IQuickPick, IQuickPickItem, IQuickInputButton } from 'vs/platform/quickinput/common/quickInput'; import { IQuickAccessProvider, IQuickAccessProviderRunOptions } from 'vs/platform/quickinput/common/quickAccess'; import { isFunction } from 'vs/base/common/types'; @@ -59,6 +59,22 @@ export interface IPickerQuickAccessItem extends IQuickPickItem { trigger?(buttonIndex: number, keyMods: IKeyMods): TriggerAction | Promise; } +export interface IPickerQuickAccessSeparator extends IQuickPickSeparator { + /** + * A method that will be executed when a button of the pick item was + * clicked on. + * + * @param buttonIndex index of the button of the item that + * was clicked. + * + * @param the state of modifier keys when the button was triggered. + * + * @returns a value that indicates what should happen after the trigger + * which can be a `Promise` for long running operations. + */ + trigger?(buttonIndex: number, keyMods: IKeyMods): TriggerAction | Promise; +} + export interface IPickerQuickAccessProviderOptions { /** @@ -320,47 +336,52 @@ export abstract class PickerQuickAccessProvider { - if (typeof item.trigger === 'function') { - const buttonIndex = item.buttons?.indexOf(button) ?? -1; - if (buttonIndex >= 0) { - const result = item.trigger(buttonIndex, picker.keyMods); - const action = (typeof result === 'number') ? result : await result; - - if (token.isCancellationRequested) { - return; - } + const buttonTrigger = async (button: IQuickInputButton, item: T | IPickerQuickAccessSeparator) => { + if (typeof item.trigger !== 'function') { + return; + } - switch (action) { - case TriggerAction.NO_ACTION: - break; - case TriggerAction.CLOSE_PICKER: - picker.hide(); - break; - case TriggerAction.REFRESH_PICKER: - updatePickerItems(); - break; - case TriggerAction.REMOVE_ITEM: { - const index = picker.items.indexOf(item); - if (index !== -1) { - const items = picker.items.slice(); - const removed = items.splice(index, 1); - const activeItems = picker.activeItems.filter(activeItem => activeItem !== removed[0]); - const keepScrollPositionBefore = picker.keepScrollPosition; - picker.keepScrollPosition = true; - picker.items = items; - if (activeItems) { - picker.activeItems = activeItems; - } - picker.keepScrollPosition = keepScrollPositionBefore; + const buttonIndex = item.buttons?.indexOf(button) ?? -1; + if (buttonIndex >= 0) { + const result = item.trigger(buttonIndex, picker.keyMods); + const action = (typeof result === 'number') ? result : await result; + + if (token.isCancellationRequested) { + return; + } + + switch (action) { + case TriggerAction.NO_ACTION: + break; + case TriggerAction.CLOSE_PICKER: + picker.hide(); + break; + case TriggerAction.REFRESH_PICKER: + updatePickerItems(); + break; + case TriggerAction.REMOVE_ITEM: { + const index = picker.items.indexOf(item); + if (index !== -1) { + const items = picker.items.slice(); + const removed = items.splice(index, 1); + const activeItems = picker.activeItems.filter(activeItem => activeItem !== removed[0]); + const keepScrollPositionBefore = picker.keepScrollPosition; + picker.keepScrollPosition = true; + picker.items = items; + if (activeItems) { + picker.activeItems = activeItems; } - break; + picker.keepScrollPosition = keepScrollPositionBefore; } + break; } } } - })); + }; + + // Trigger the pick with button index if button triggered + disposables.add(picker.onDidTriggerItemButton(({ button, item }) => buttonTrigger(button, item))); + disposables.add(picker.onDidTriggerSeparatorButton(({ button, separator }) => buttonTrigger(button, separator))); return disposables; } diff --git a/src/vs/platform/quickinput/browser/quickAccess.ts b/src/vs/platform/quickinput/browser/quickAccess.ts index cb35e451aa151..88169c32ed52b 100644 --- a/src/vs/platform/quickinput/browser/quickAccess.ts +++ b/src/vs/platform/quickinput/browser/quickAccess.ts @@ -92,6 +92,10 @@ export class QuickAccessController extends Disposable implements IQuickAccessCon } } + // Store the existing selection if there was one. + const visibleSelection = visibleQuickAccess?.picker?.valueSelection; + const visibleValue = visibleQuickAccess?.picker?.value; + // Create a picker for the provider to use with the initial value // and adjust the filtering to exclude the prefix from filtering const disposables = new DisposableStore(); @@ -148,6 +152,11 @@ export class QuickAccessController extends Disposable implements IQuickAccessCon // on the onDidHide event. picker.show(); + // If the previous picker had a selection and the value is unchanged, we should set that in the new picker. + if (visibleSelection && visibleValue === value) { + picker.valueSelection = visibleSelection; + } + // Pick mode: return with promise if (pick) { return pickPromise?.p; diff --git a/src/vs/platform/quickinput/browser/quickInput.ts b/src/vs/platform/quickinput/browser/quickInput.ts index 16b52782c26d3..c44157fe7c4bb 100644 --- a/src/vs/platform/quickinput/browser/quickInput.ts +++ b/src/vs/platform/quickinput/browser/quickInput.ts @@ -8,11 +8,10 @@ import { StandardKeyboardEvent } from 'vs/base/browser/keyboardEvent'; import { ActionBar } from 'vs/base/browser/ui/actionbar/actionbar'; import { Button, IButtonStyles } from 'vs/base/browser/ui/button/button'; import { CountBadge, ICountBadgeStyles } from 'vs/base/browser/ui/countBadge/countBadge'; -import { IHoverDelegate, IHoverDelegateOptions, IHoverWidget } from 'vs/base/browser/ui/iconLabel/iconHoverDelegate'; +import { IHoverDelegate, IHoverDelegateOptions } from 'vs/base/browser/ui/hover/hoverDelegate'; import { IInputBoxStyles } from 'vs/base/browser/ui/inputbox/inputBox'; import { IKeybindingLabelStyles } from 'vs/base/browser/ui/keybindingLabel/keybindingLabel'; -import { IListRenderer, IListVirtualDelegate } from 'vs/base/browser/ui/list/list'; -import { IListOptions, IListStyles, List } from 'vs/base/browser/ui/list/listWidget'; +import { IListStyles } from 'vs/base/browser/ui/list/listWidget'; import { IProgressBarStyles, ProgressBar } from 'vs/base/browser/ui/progressbar/progressbar'; import { IToggleStyles, Toggle } from 'vs/base/browser/ui/toggle/toggle'; import { equals } from 'vs/base/common/arrays'; @@ -21,17 +20,18 @@ import { Codicon } from 'vs/base/common/codicons'; import { Emitter, Event } from 'vs/base/common/event'; import { KeyCode } from 'vs/base/common/keyCodes'; import { Disposable, DisposableStore } from 'vs/base/common/lifecycle'; -import { isIOS } from 'vs/base/common/platform'; +import { isIOS, isMacintosh } from 'vs/base/common/platform'; import Severity from 'vs/base/common/severity'; import { ThemeIcon } from 'vs/base/common/themables'; import 'vs/css!./media/quickInput'; import { localize } from 'vs/nls'; import { IInputBox, IKeyMods, IQuickInput, IQuickInputButton, IQuickInputHideEvent, IQuickInputToggle, IQuickNavigateConfiguration, IQuickPick, IQuickPickDidAcceptEvent, IQuickPickItem, IQuickPickItemButtonEvent, IQuickPickSeparator, IQuickPickSeparatorButtonEvent, IQuickPickWillAcceptEvent, IQuickWidget, ItemActivation, NO_KEY_MODS, QuickInputHideReason } from 'vs/platform/quickinput/common/quickInput'; import { QuickInputBox } from './quickInputBox'; -import { QuickInputList, QuickInputListFocus } from './quickInputList'; import { quickInputButtonToAction, renderQuickInputDescription } from './quickInputUtils'; import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; -import { IHoverService } from 'vs/platform/hover/browser/hover'; +import { IHoverService, WorkbenchHoverDelegate } from 'vs/platform/hover/browser/hover'; +import { QuickInputListFocus, QuickInputTree } from 'vs/platform/quickinput/browser/quickInputTree'; +import type { IHoverOptions } from 'vs/base/browser/ui/hover/hover'; export interface IQuickInputOptions { idPrefix: string; @@ -41,13 +41,6 @@ export interface IQuickInputOptions { setContextKey(id?: string): void; linkOpenerDelegate(content: string): void; returnFocus(): void; - createList( - user: string, - container: HTMLElement, - delegate: IListVirtualDelegate, - renderers: IListRenderer[], - options: IListOptions, - ): List; /** * @todo With IHover in vs/editor, can we depend on the service directly * instead of passing it through a hover delegate? @@ -108,7 +101,7 @@ export interface QuickInputUI { customButtonContainer: HTMLElement; customButton: Button; progressBar: ProgressBar; - list: QuickInputList; + list: QuickInputTree; onDidAccept: Event; onDidCustom: Event; onDidTriggerButton: Event; @@ -162,6 +155,7 @@ class QuickInput extends Disposable implements IQuickInput { private _lastSeverity: Severity | undefined; private readonly onDidTriggerButtonEmitter = this._register(new Emitter()); private readonly onDidHideEmitter = this._register(new Emitter()); + private readonly onWillHideEmitter = this._register(new Emitter()); private readonly onDisposeEmitter = this._register(new Emitter()); protected readonly visibleDisposables = this._register(new DisposableStore()); @@ -352,6 +346,11 @@ class QuickInput extends Disposable implements IQuickInput { readonly onDidHide = this.onDidHideEmitter.event; + willHide(reason = QuickInputHideReason.Other): void { + this.onWillHideEmitter.fire({ reason }); + } + readonly onWillHide = this.onWillHideEmitter.event; + protected update() { if (!this.visible) { return; @@ -725,7 +724,15 @@ export class QuickPick extends QuickInput implements I return this.ui.keyMods; } - set valueSelection(valueSelection: Readonly<[number, number]>) { + get valueSelection() { + const selection = this.ui.inputBox.getSelection(); + if (!selection) { + return undefined; + } + return [selection.start, selection.end]; + } + + set valueSelection(valueSelection: Readonly<[number, number]> | undefined) { this._valueSelection = valueSelection; this.valueSelectionUpdated = true; this.update(); @@ -820,20 +827,25 @@ export class QuickPick extends QuickInput implements I this.ui.inputBox.onDidChange(value => { this.doSetValue(value, true /* skip update since this originates from the UI */); })); + // Keybindings for the input box or list if there is no input box this.visibleDisposables.add((this._hideInput ? this.ui.list : this.ui.inputBox).onKeyDown((event: KeyboardEvent | StandardKeyboardEvent) => { switch (event.keyCode) { case KeyCode.DownArrow: - this.ui.list.focus(QuickInputListFocus.Next); + if (isMacintosh ? event.metaKey : event.altKey) { + this.ui.list.focus(QuickInputListFocus.NextSeparator); + } else { + this.ui.list.focus(QuickInputListFocus.Next); + } if (this.canSelectMany) { this.ui.list.domFocus(); } dom.EventHelper.stop(event, true); break; case KeyCode.UpArrow: - if (this.ui.list.getFocusedElements().length) { - this.ui.list.focus(QuickInputListFocus.Previous); + if (isMacintosh ? event.metaKey : event.altKey) { + this.ui.list.focus(QuickInputListFocus.PreviousSeparator); } else { - this.ui.list.focus(QuickInputListFocus.Last); + this.ui.list.focus(QuickInputListFocus.Previous); } if (this.canSelectMany) { this.ui.list.domFocus(); @@ -1066,6 +1078,7 @@ export class QuickPick extends QuickInput implements I this.ui.list.sortByLabel = this.sortByLabel; if (this.itemsUpdated) { this.itemsUpdated = false; + const currentActiveItems = this._activeItems; this.ui.list.setElements(this.items); this.ui.list.filter(this.filterValue(this.ui.inputBox.value)); this.ui.checkAll.checked = this.ui.list.getAllVisibleChecked(); @@ -1073,6 +1086,15 @@ export class QuickPick extends QuickInput implements I this.ui.count.setCount(this.ui.list.getCheckedCount()); switch (this._itemActivation) { case ItemActivation.NONE: + // Handle the case where we had active items (i.e. someone chose an item) + // but the initial item activation is set to none. Calling clearFocus will + // not trigger the onDidFocus event because when the tree receives new elements, + // it sets the focus to no elements. So we need to set & fire the active items + // accordingly to reflect the state change after setting the items. + if (currentActiveItems.length > 0) { + this._activeItems = []; + this.onDidChangeActiveEmitter.fire(this._activeItems); + } this._itemActivation = ItemActivation.FIRST; // only valid once, then unset break; case ItemActivation.SECOND: @@ -1154,7 +1176,15 @@ export class InputBox extends QuickInput implements IInputBox { this.update(); } - set valueSelection(valueSelection: Readonly<[number, number]>) { + get valueSelection() { + const selection = this.ui.inputBox.getSelection(); + if (!selection) { + return undefined; + } + return [selection.start, selection.end]; + } + + set valueSelection(valueSelection: Readonly<[number, number]> | undefined) { this._valueSelection = valueSelection; this.valueSelectionUpdated = true; this.update(); @@ -1258,24 +1288,16 @@ export class QuickWidget extends QuickInput implements IQuickWidget { } } -export class QuickInputHoverDelegate implements IHoverDelegate { - private lastHoverHideTime = 0; - readonly placement = 'element'; - - get delay() { - if (Date.now() - this.lastHoverHideTime < 200) { - return 0; // show instantly when a hover was recently shown - } - - return this.configurationService.getValue('workbench.hover.delay'); - } +export class QuickInputHoverDelegate extends WorkbenchHoverDelegate { constructor( - private readonly configurationService: IConfigurationService, - private readonly hoverService: IHoverService - ) { } + @IConfigurationService configurationService: IConfigurationService, + @IHoverService hoverService: IHoverService + ) { + super('element', false, (options) => this.getOverrideOptions(options), configurationService, hoverService); + } - showHover(options: IHoverDelegateOptions, focus?: boolean): IHoverWidget | undefined { + private getOverrideOptions(options: IHoverDelegateOptions): Partial { // Only show the hover hint if the content is of a decent size const showHoverHint = ( options.content instanceof HTMLElement @@ -1283,9 +1305,9 @@ export class QuickInputHoverDelegate implements IHoverDelegate { : typeof options.content === 'string' ? options.content : options.content.value - ).length > 20; - return this.hoverService.showHover({ - ...options, + ).includes('\n'); + + return { persistence: { hideOnKeyDown: false, }, @@ -1293,10 +1315,6 @@ export class QuickInputHoverDelegate implements IHoverDelegate { showHoverHint, skipFadeInAnimation: true, }, - }, focus); - } - - onDidHideHover(): void { - this.lastHoverHideTime = Date.now(); + }; } } diff --git a/src/vs/platform/quickinput/browser/quickInputBox.ts b/src/vs/platform/quickinput/browser/quickInputBox.ts index b3c6694387c85..9ee1440a91a63 100644 --- a/src/vs/platform/quickinput/browser/quickInputBox.ts +++ b/src/vs/platform/quickinput/browser/quickInputBox.ts @@ -59,6 +59,10 @@ export class QuickInputBox extends Disposable { this.findInput.inputBox.select(range); } + getSelection(): IRange | null { + return this.findInput.inputBox.getSelection(); + } + isSelectionAtEnd(): boolean { return this.findInput.inputBox.isSelectionAtEnd(); } diff --git a/src/vs/platform/quickinput/browser/quickInputController.ts b/src/vs/platform/quickinput/browser/quickInputController.ts index d5a23ae66e885..e64440ba2a778 100644 --- a/src/vs/platform/quickinput/browser/quickInputController.ts +++ b/src/vs/platform/quickinput/browser/quickInputController.ts @@ -18,11 +18,11 @@ import { isString } from 'vs/base/common/types'; import { localize } from 'vs/nls'; import { IInputBox, IInputOptions, IKeyMods, IPickOptions, IQuickInput, IQuickInputButton, IQuickNavigateConfiguration, IQuickPick, IQuickPickItem, IQuickWidget, QuickInputHideReason, QuickPickInput } from 'vs/platform/quickinput/common/quickInput'; import { QuickInputBox } from 'vs/platform/quickinput/browser/quickInputBox'; -import { QuickInputList, QuickInputListFocus } from 'vs/platform/quickinput/browser/quickInputList'; import { QuickInputUI, Writeable, IQuickInputStyles, IQuickInputOptions, QuickPick, backButton, InputBox, Visibilities, QuickWidget } from 'vs/platform/quickinput/browser/quickInput'; import { ILayoutService } from 'vs/platform/layout/browser/layoutService'; -import { IThemeService } from 'vs/platform/theme/common/themeService'; import { mainWindow } from 'vs/base/browser/window'; +import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; +import { QuickInputListFocus, QuickInputTree } from 'vs/platform/quickinput/browser/quickInputTree'; const $ = dom.$; @@ -54,9 +54,10 @@ export class QuickInputController extends Disposable { private previousFocusElement?: HTMLElement; - constructor(private options: IQuickInputOptions, - private readonly themeService: IThemeService, - private readonly layoutService: ILayoutService + constructor( + private options: IQuickInputOptions, + @ILayoutService private readonly layoutService: ILayoutService, + @IInstantiationService private readonly instantiationService: IInstantiationService, ) { super(); this.idPrefix = options.idPrefix; @@ -172,7 +173,7 @@ export class QuickInputController extends Disposable { const description1 = dom.append(container, $('.quick-input-description')); const listId = this.idPrefix + 'list'; - const list = this._register(new QuickInputList(container, listId, this.options, this.themeService)); + const list = this._register(this.instantiationService.createInstance(QuickInputTree, container, this.options.hoverDelegate, this.options.linkOpenerDelegate, listId)); inputBox.setAttribute('aria-controls', listId); this._register(list.onDidChangeFocus(() => { inputBox.setAttribute('aria-activedescendant', list.getActiveDescendant() ?? ''); @@ -219,6 +220,7 @@ export class QuickInputController extends Disposable { inputBox.setFocus(); })); // TODO: Turn into commands instead of handling KEY_DOWN + // Keybindings for the quickinput widget as a whole this._register(dom.addStandardDisposableListener(container, dom.EventType.KEY_DOWN, (event) => { if (dom.isAncestor(event.target, widget)) { return; // Ignore event if target is inside widget to allow the widget to handle the event. @@ -614,6 +616,7 @@ export class QuickInputController extends Disposable { if (!controller) { return; } + controller.willHide(reason); const container = this.ui?.container; const focusChanged = container && !dom.isAncestorOfActiveElement(container); diff --git a/src/vs/platform/quickinput/browser/quickInputList.ts b/src/vs/platform/quickinput/browser/quickInputList.ts deleted file mode 100644 index d8871456aee56..0000000000000 --- a/src/vs/platform/quickinput/browser/quickInputList.ts +++ /dev/null @@ -1,1121 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * 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 'vs/base/browser/dom'; -import { StandardKeyboardEvent } from 'vs/base/browser/keyboardEvent'; -import { ActionBar } from 'vs/base/browser/ui/actionbar/actionbar'; -import { AriaRole } from 'vs/base/browser/ui/aria/aria'; -import { HoverPosition } from 'vs/base/browser/ui/hover/hoverWidget'; -import { IHoverDelegate, IHoverWidget } from 'vs/base/browser/ui/iconLabel/iconHoverDelegate'; -import { IconLabel, IIconLabelValueOptions } from 'vs/base/browser/ui/iconLabel/iconLabel'; -import { KeybindingLabel } from 'vs/base/browser/ui/keybindingLabel/keybindingLabel'; -import { IListRenderer, IListVirtualDelegate } from 'vs/base/browser/ui/list/list'; -import { IListAccessibilityProvider, IListOptions, IListStyles, List } from 'vs/base/browser/ui/list/listWidget'; -import { range } from 'vs/base/common/arrays'; -import { ThrottledDelayer } from 'vs/base/common/async'; -import { compareAnything } from 'vs/base/common/comparers'; -import { memoize } from 'vs/base/common/decorators'; -import { isCancellationError } from 'vs/base/common/errors'; -import { Emitter, Event } from 'vs/base/common/event'; -import { IMatch } from 'vs/base/common/filters'; -import { IMarkdownString } from 'vs/base/common/htmlContent'; -import { getCodiconAriaLabel, IParsedLabelWithIcons, matchesFuzzyIconAware, parseLabelWithIcons } from 'vs/base/common/iconLabels'; -import { KeyCode } from 'vs/base/common/keyCodes'; -import { DisposableStore, dispose, IDisposable } from 'vs/base/common/lifecycle'; -import * as platform from 'vs/base/common/platform'; -import { ltrim } from 'vs/base/common/strings'; -import 'vs/css!./media/quickInput'; -import { localize } from 'vs/nls'; -import { IQuickInputOptions } from 'vs/platform/quickinput/browser/quickInput'; -import { quickInputButtonToAction } from 'vs/platform/quickinput/browser/quickInputUtils'; -import { IQuickPickItem, IQuickPickItemButtonEvent, IQuickPickSeparator, IQuickPickSeparatorButtonEvent, QuickPickItem } from 'vs/platform/quickinput/common/quickInput'; -import { Lazy } from 'vs/base/common/lazy'; -import { URI } from 'vs/base/common/uri'; -import { isDark } from 'vs/platform/theme/common/theme'; -import { IThemeService } from 'vs/platform/theme/common/themeService'; - -const $ = dom.$; - -interface IListElementLazyParts { - readonly saneLabel: string; - readonly saneSortLabel: string; - readonly saneAriaLabel: string; -} - -interface IListElement extends IListElementLazyParts { - readonly hasCheckbox: boolean; - readonly index: number; - readonly item?: IQuickPickItem; - readonly saneDescription?: string; - readonly saneDetail?: string; - readonly saneTooltip?: string | IMarkdownString | HTMLElement; - readonly fireButtonTriggered: (event: IQuickPickItemButtonEvent) => void; - readonly fireSeparatorButtonTriggered: (event: IQuickPickSeparatorButtonEvent) => void; - readonly onChecked: Event; - checked: boolean; - hidden: boolean; - element?: HTMLElement; - labelHighlights?: IMatch[]; - descriptionHighlights?: IMatch[]; - detailHighlights?: IMatch[]; - separator?: IQuickPickSeparator; -} - -class ListElement implements IListElement { - private readonly _init: Lazy; - - readonly hasCheckbox: boolean; - readonly index: number; - readonly item?: IQuickPickItem; - readonly saneDescription?: string; - readonly saneDetail?: string; - readonly saneTooltip?: string | IMarkdownString | HTMLElement; - readonly fireButtonTriggered: (event: IQuickPickItemButtonEvent) => void; - readonly fireSeparatorButtonTriggered: (event: IQuickPickSeparatorButtonEvent) => void; - - // state will get updated later - private _checked: boolean = false; - private _hidden: boolean = false; - private _element?: HTMLElement; - private _labelHighlights?: IMatch[]; - private _descriptionHighlights?: IMatch[]; - private _detailHighlights?: IMatch[]; - private _separator?: IQuickPickSeparator; - - private readonly _onChecked: Emitter<{ listElement: IListElement; checked: boolean }>; - onChecked: Event; - - constructor( - mainItem: QuickPickItem, - previous: QuickPickItem | undefined, - index: number, - hasCheckbox: boolean, - fireButtonTriggered: (event: IQuickPickItemButtonEvent) => void, - fireSeparatorButtonTriggered: (event: IQuickPickSeparatorButtonEvent) => void, - onCheckedEmitter: Emitter<{ listElement: IListElement; checked: boolean }> - ) { - this.hasCheckbox = hasCheckbox; - this.index = index; - this.fireButtonTriggered = fireButtonTriggered; - this.fireSeparatorButtonTriggered = fireSeparatorButtonTriggered; - this._onChecked = onCheckedEmitter; - this.onChecked = hasCheckbox - ? Event.map(Event.filter<{ listElement: IListElement; checked: boolean }>(this._onChecked.event, e => e.listElement === this), e => e.checked) - : Event.None; - - if (mainItem.type === 'separator') { - this._separator = mainItem; - } else { - this.item = mainItem; - if (previous && previous.type === 'separator' && !previous.buttons) { - this._separator = previous; - } - this.saneDescription = this.item.description; - this.saneDetail = this.item.detail; - this._labelHighlights = this.item.highlights?.label; - this._descriptionHighlights = this.item.highlights?.description; - this._detailHighlights = this.item.highlights?.detail; - this.saneTooltip = this.item.tooltip; - } - this._init = new Lazy(() => { - const saneLabel = mainItem.label ?? ''; - const saneSortLabel = parseLabelWithIcons(saneLabel).text.trim(); - - const saneAriaLabel = mainItem.ariaLabel || [saneLabel, this.saneDescription, this.saneDetail] - .map(s => getCodiconAriaLabel(s)) - .filter(s => !!s) - .join(', '); - - return { - saneLabel, - saneSortLabel, - saneAriaLabel - }; - }); - } - - // #region Lazy Getters - - get saneLabel() { - return this._init.value.saneLabel; - } - - get saneSortLabel() { - return this._init.value.saneSortLabel; - } - - get saneAriaLabel() { - return this._init.value.saneAriaLabel; - } - - // #endregion - - // #region Getters and Setters - - get element() { - return this._element; - } - - set element(value: HTMLElement | undefined) { - this._element = value; - } - - get hidden() { - return this._hidden; - } - - set hidden(value: boolean) { - this._hidden = value; - } - - get checked() { - return this._checked; - } - - set checked(value: boolean) { - if (value !== this._checked) { - this._checked = value; - this._onChecked.fire({ listElement: this, checked: value }); - } - } - - get separator() { - return this._separator; - } - - set separator(value: IQuickPickSeparator | undefined) { - this._separator = value; - } - - get labelHighlights() { - return this._labelHighlights; - } - - set labelHighlights(value: IMatch[] | undefined) { - this._labelHighlights = value; - } - - get descriptionHighlights() { - return this._descriptionHighlights; - } - - set descriptionHighlights(value: IMatch[] | undefined) { - this._descriptionHighlights = value; - } - - get detailHighlights() { - return this._detailHighlights; - } - - set detailHighlights(value: IMatch[] | undefined) { - this._detailHighlights = value; - } - - // #endregion -} - -interface IListElementTemplateData { - entry: HTMLDivElement; - checkbox: HTMLInputElement; - icon: HTMLDivElement; - label: IconLabel; - keybinding: KeybindingLabel; - detail: IconLabel; - separator: HTMLDivElement; - actionBar: ActionBar; - element: IListElement; - toDisposeElement: IDisposable[]; - toDisposeTemplate: IDisposable[]; -} - -class ListElementRenderer implements IListRenderer { - - static readonly ID = 'listelement'; - - constructor( - private readonly themeService: IThemeService, - private readonly hoverDelegate: IHoverDelegate | undefined, - ) { } - - get templateId() { - return ListElementRenderer.ID; - } - - renderTemplate(container: HTMLElement): IListElementTemplateData { - const data: IListElementTemplateData = Object.create(null); - data.toDisposeElement = []; - data.toDisposeTemplate = []; - - data.entry = dom.append(container, $('.quick-input-list-entry')); - - // Checkbox - const label = dom.append(data.entry, $('label.quick-input-list-label')); - data.toDisposeTemplate.push(dom.addStandardDisposableListener(label, dom.EventType.CLICK, e => { - if (!data.checkbox.offsetParent) { // If checkbox not visible: - e.preventDefault(); // Prevent toggle of checkbox when it is immediately shown afterwards. #91740 - } - })); - data.checkbox = dom.append(label, $('input.quick-input-list-checkbox')); - data.checkbox.type = 'checkbox'; - data.toDisposeTemplate.push(dom.addStandardDisposableListener(data.checkbox, dom.EventType.CHANGE, e => { - data.element.checked = data.checkbox.checked; - })); - - // Rows - const rows = dom.append(label, $('.quick-input-list-rows')); - const row1 = dom.append(rows, $('.quick-input-list-row')); - const row2 = dom.append(rows, $('.quick-input-list-row')); - - // Label - data.label = new IconLabel(row1, { supportHighlights: true, supportDescriptionHighlights: true, supportIcons: true, hoverDelegate: this.hoverDelegate }); - data.toDisposeTemplate.push(data.label); - data.icon = dom.prepend(data.label.element, $('.quick-input-list-icon')); - - // Keybinding - const keybindingContainer = dom.append(row1, $('.quick-input-list-entry-keybinding')); - data.keybinding = new KeybindingLabel(keybindingContainer, platform.OS); - - // Detail - const detailContainer = dom.append(row2, $('.quick-input-list-label-meta')); - data.detail = new IconLabel(detailContainer, { supportHighlights: true, supportIcons: true, hoverDelegate: this.hoverDelegate }); - data.toDisposeTemplate.push(data.detail); - - // Separator - data.separator = dom.append(data.entry, $('.quick-input-list-separator')); - - // Actions - data.actionBar = new ActionBar(data.entry, this.hoverDelegate ? { hoverDelegate: this.hoverDelegate } : undefined); - data.actionBar.domNode.classList.add('quick-input-list-entry-action-bar'); - data.toDisposeTemplate.push(data.actionBar); - - return data; - } - - renderElement(element: IListElement, index: number, data: IListElementTemplateData): void { - data.element = element; - element.element = data.entry ?? undefined; - const mainItem: QuickPickItem = element.item ? element.item : element.separator!; - - data.checkbox.checked = element.checked; - data.toDisposeElement.push(element.onChecked(checked => data.checkbox.checked = checked)); - - const { labelHighlights, descriptionHighlights, detailHighlights } = element; - - if (element.item?.iconPath) { - const icon = isDark(this.themeService.getColorTheme().type) ? element.item.iconPath.dark : (element.item.iconPath.light ?? element.item.iconPath.dark); - const iconUrl = URI.revive(icon); - data.icon.className = 'quick-input-list-icon'; - data.icon.style.backgroundImage = dom.asCSSUrl(iconUrl); - } else { - data.icon.style.backgroundImage = ''; - data.icon.className = element.item?.iconClass ? `quick-input-list-icon ${element.item.iconClass}` : ''; - } - - // Label - const options: IIconLabelValueOptions = { - matches: labelHighlights || [], - // If we have a tooltip, we want that to be shown and not any other hover - descriptionTitle: element.saneTooltip ? undefined : element.saneDescription, - descriptionMatches: descriptionHighlights || [], - labelEscapeNewLines: true - }; - if (mainItem.type !== 'separator') { - options.extraClasses = mainItem.iconClasses; - options.italic = mainItem.italic; - options.strikethrough = mainItem.strikethrough; - data.entry.classList.remove('quick-input-list-separator-as-item'); - } else { - data.entry.classList.add('quick-input-list-separator-as-item'); - } - data.label.setLabel(element.saneLabel, element.saneDescription, options); - - // Keybinding - data.keybinding.set(mainItem.type === 'separator' ? undefined : mainItem.keybinding); - - // Detail - if (element.saneDetail) { - data.detail.element.style.display = ''; - data.detail.setLabel(element.saneDetail, undefined, { - matches: detailHighlights, - // If we have a tooltip, we want that to be shown and not any other hover - title: element.saneTooltip ? undefined : element.saneDetail, - labelEscapeNewLines: true - }); - } else { - data.detail.element.style.display = 'none'; - } - - // Separator - if (element.item && element.separator && element.separator.label) { - data.separator.textContent = element.separator.label; - data.separator.style.display = ''; - } else { - data.separator.style.display = 'none'; - } - data.entry.classList.toggle('quick-input-list-separator-border', !!element.separator); - - // Actions - const buttons = mainItem.buttons; - if (buttons && buttons.length) { - data.actionBar.push(buttons.map((button, index) => quickInputButtonToAction( - button, - `id-${index}`, - () => mainItem.type !== 'separator' - ? element.fireButtonTriggered({ button, item: mainItem }) - : element.fireSeparatorButtonTriggered({ button, separator: mainItem }) - )), { icon: true, label: false }); - data.entry.classList.add('has-actions'); - } else { - data.entry.classList.remove('has-actions'); - } - } - - disposeElement(element: IListElement, index: number, data: IListElementTemplateData): void { - data.toDisposeElement = dispose(data.toDisposeElement); - data.actionBar.clear(); - } - - disposeTemplate(data: IListElementTemplateData): void { - data.toDisposeElement = dispose(data.toDisposeElement); - data.toDisposeTemplate = dispose(data.toDisposeTemplate); - } -} - -class ListElementDelegate implements IListVirtualDelegate { - - getHeight(element: IListElement): number { - if (!element.item) { - // must be a separator - return 24; - } - return element.saneDetail ? 44 : 22; - } - - getTemplateId(element: IListElement): string { - return ListElementRenderer.ID; - } -} - -export enum QuickInputListFocus { - First = 1, - Second, - Last, - Next, - Previous, - NextPage, - PreviousPage -} - -export class QuickInputList { - - readonly id: string; - private container: HTMLElement; - private list: List; - private inputElements: Array = []; - private elements: IListElement[] = []; - private elementsToIndexes = new Map(); - matchOnDescription = false; - matchOnDetail = false; - matchOnLabel = true; - matchOnLabelMode: 'fuzzy' | 'contiguous' = 'fuzzy'; - matchOnMeta = true; - sortByLabel = true; - private readonly _onChangedAllVisibleChecked = new Emitter(); - onChangedAllVisibleChecked: Event = this._onChangedAllVisibleChecked.event; - private readonly _onChangedCheckedCount = new Emitter(); - onChangedCheckedCount: Event = this._onChangedCheckedCount.event; - private readonly _onChangedVisibleCount = new Emitter(); - onChangedVisibleCount: Event = this._onChangedVisibleCount.event; - private readonly _onChangedCheckedElements = new Emitter(); - onChangedCheckedElements: Event = this._onChangedCheckedElements.event; - private readonly _onButtonTriggered = new Emitter>(); - onButtonTriggered = this._onButtonTriggered.event; - private readonly _onSeparatorButtonTriggered = new Emitter(); - onSeparatorButtonTriggered = this._onSeparatorButtonTriggered.event; - private readonly _onKeyDown = new Emitter(); - onKeyDown: Event = this._onKeyDown.event; - private readonly _onLeave = new Emitter(); - onLeave: Event = this._onLeave.event; - private readonly _listElementChecked = new Emitter<{ listElement: IListElement; checked: boolean }>(); - private _fireCheckedEvents = true; - private elementDisposables: IDisposable[] = []; - private disposables: IDisposable[] = []; - private _lastHover: IHoverWidget | undefined; - private _toggleHover: IDisposable | undefined; - - constructor( - private parent: HTMLElement, - id: string, - private options: IQuickInputOptions, - themeService: IThemeService - ) { - this.id = id; - this.container = dom.append(this.parent, $('.quick-input-list')); - const delegate = new ListElementDelegate(); - const accessibilityProvider = new QuickInputAccessibilityProvider(); - this.list = options.createList('QuickInput', this.container, delegate, [new ListElementRenderer(themeService, options.hoverDelegate)], { - identityProvider: { - getId: element => { - // always prefer item over separator because if item is defined, it must be the main item type - // always prefer a defined id if one was specified and use label as a fallback - return element.item?.id - ?? element.item?.label - ?? element.separator?.id - ?? element.separator?.label - ?? ''; - } - }, - setRowLineHeight: false, - multipleSelectionSupport: false, - horizontalScrolling: false, - accessibilityProvider - } as IListOptions); - this.list.getHTMLElement().id = id; - this.disposables.push(this.list); - this.disposables.push(this.list.onKeyDown(e => { - const event = new StandardKeyboardEvent(e); - switch (event.keyCode) { - case KeyCode.Space: - this.toggleCheckbox(); - break; - case KeyCode.KeyA: - if (platform.isMacintosh ? e.metaKey : e.ctrlKey) { - this.list.setFocus(range(this.list.length)); - } - break; - case KeyCode.UpArrow: { - const focus1 = this.list.getFocus(); - if (focus1.length === 1 && focus1[0] === 0) { - this._onLeave.fire(); - } - break; - } - case KeyCode.DownArrow: { - const focus2 = this.list.getFocus(); - if (focus2.length === 1 && focus2[0] === this.list.length - 1) { - this._onLeave.fire(); - } - break; - } - } - - this._onKeyDown.fire(event); - })); - this.disposables.push(this.list.onMouseDown(e => { - if (e.browserEvent.button !== 2) { - // Works around / fixes #64350. - e.browserEvent.preventDefault(); - } - })); - this.disposables.push(dom.addDisposableListener(this.container, dom.EventType.CLICK, e => { - if (e.x || e.y) { // Avoid 'click' triggered by 'space' on checkbox. - this._onLeave.fire(); - } - })); - this.disposables.push(this.list.onMouseMiddleClick(e => { - this._onLeave.fire(); - })); - this.disposables.push(this.list.onContextMenu(e => { - if (typeof e.index === 'number') { - e.browserEvent.preventDefault(); - - // we want to treat a context menu event as - // a gesture to open the item at the index - // since we do not have any context menu - // this enables for example macOS to Ctrl- - // click on an item to open it. - this.list.setSelection([e.index]); - } - })); - - const delayer = new ThrottledDelayer(options.hoverDelegate.delay); - // onMouseOver triggers every time a new element has been moused over - // even if it's on the same list item. - this.disposables.push(this.list.onMouseOver(async e => { - // If we hover over an anchor element, we don't want to show the hover because - // the anchor may have a tooltip that we want to show instead. - if (e.browserEvent.target instanceof HTMLAnchorElement) { - delayer.cancel(); - return; - } - if ( - // anchors are an exception as called out above so we skip them here - !(e.browserEvent.relatedTarget instanceof HTMLAnchorElement) && - // check if the mouse is still over the same element - dom.isAncestor(e.browserEvent.relatedTarget as Node, e.element?.element as Node) - ) { - return; - } - try { - await delayer.trigger(async () => { - if (e.element) { - this.showHover(e.element); - } - }); - } catch (e) { - // Ignore cancellation errors due to mouse out - if (!isCancellationError(e)) { - throw e; - } - } - })); - this.disposables.push(this.list.onMouseOut(e => { - // onMouseOut triggers every time a new element has been moused over - // even if it's on the same list item. We only want one event, so we - // check if the mouse is still over the same element. - if (dom.isAncestor(e.browserEvent.relatedTarget as Node, e.element?.element as Node)) { - return; - } - delayer.cancel(); - })); - this.disposables.push(delayer); - this.disposables.push(this._listElementChecked.event(_ => this.fireCheckedEvents())); - this.disposables.push( - this._onChangedAllVisibleChecked, - this._onChangedCheckedCount, - this._onChangedVisibleCount, - this._onChangedCheckedElements, - this._onButtonTriggered, - this._onSeparatorButtonTriggered, - this._onLeave, - this._onKeyDown - ); - } - - @memoize - get onDidChangeFocus() { - return Event.map(this.list.onDidChangeFocus, e => e.elements.map(e => e.item)); - } - - @memoize - get onDidChangeSelection() { - return Event.map(this.list.onDidChangeSelection, e => ({ items: e.elements.map(e => e.item), event: e.browserEvent })); - } - - get scrollTop() { - return this.list.scrollTop; - } - - set scrollTop(scrollTop: number) { - this.list.scrollTop = scrollTop; - } - - get ariaLabel() { - return this.list.getHTMLElement().ariaLabel; - } - - set ariaLabel(label: string | null) { - this.list.getHTMLElement().ariaLabel = label; - } - - getAllVisibleChecked() { - return this.allVisibleChecked(this.elements, false); - } - - private allVisibleChecked(elements: IListElement[], whenNoneVisible = true) { - for (let i = 0, n = elements.length; i < n; i++) { - const element = elements[i]; - if (!element.hidden) { - if (!element.checked) { - return false; - } else { - whenNoneVisible = true; - } - } - } - return whenNoneVisible; - } - - getCheckedCount() { - let count = 0; - const elements = this.elements; - for (let i = 0, n = elements.length; i < n; i++) { - if (elements[i].checked) { - count++; - } - } - return count; - } - - getVisibleCount() { - let count = 0; - const elements = this.elements; - for (let i = 0, n = elements.length; i < n; i++) { - if (!elements[i].hidden) { - count++; - } - } - return count; - } - - setAllVisibleChecked(checked: boolean) { - try { - this._fireCheckedEvents = false; - this.elements.forEach(element => { - if (!element.hidden) { - element.checked = checked; - } - }); - } finally { - this._fireCheckedEvents = true; - this.fireCheckedEvents(); - } - } - - setElements(inputElements: Array): void { - this.elementDisposables = dispose(this.elementDisposables); - const fireButtonTriggered = (event: IQuickPickItemButtonEvent) => this.fireButtonTriggered(event); - const fireSeparatorButtonTriggered = (event: IQuickPickSeparatorButtonEvent) => this.fireSeparatorButtonTriggered(event); - this.inputElements = inputElements; - const elementsToIndexes = new Map(); - const hasCheckbox = this.parent.classList.contains('show-checkboxes'); - this.elements = inputElements.reduce((result, item, index) => { - const previous = index > 0 ? inputElements[index - 1] : undefined; - if (item.type === 'separator') { - if (!item.buttons) { - // This separator will be rendered as a part of the list item - return result; - } - } - - const element = new ListElement( - item, - previous, - index, - hasCheckbox, - fireButtonTriggered, - fireSeparatorButtonTriggered, - this._listElementChecked - ); - - const resultIndex = result.length; - result.push(element); - elementsToIndexes.set(element.item ?? element.separator!, resultIndex); - return result; - }, [] as IListElement[]); - this.elementsToIndexes = elementsToIndexes; - this.list.splice(0, this.list.length); // Clear focus and selection first, sending the events when the list is empty. - this.list.splice(0, this.list.length, this.elements); - this._onChangedVisibleCount.fire(this.elements.length); - } - - getElementsCount(): number { - return this.inputElements.length; - } - - getFocusedElements() { - return this.list.getFocusedElements() - .map(e => e.item); - } - - setFocusedElements(items: IQuickPickItem[]) { - this.list.setFocus(items - .filter(item => this.elementsToIndexes.has(item)) - .map(item => this.elementsToIndexes.get(item)!)); - if (items.length > 0) { - const focused = this.list.getFocus()[0]; - if (typeof focused === 'number') { - this.list.reveal(focused); - } - } - } - - getActiveDescendant() { - return this.list.getHTMLElement().getAttribute('aria-activedescendant'); - } - - getSelectedElements() { - return this.list.getSelectedElements() - .map(e => e.item); - } - - setSelectedElements(items: IQuickPickItem[]) { - this.list.setSelection(items - .filter(item => this.elementsToIndexes.has(item)) - .map(item => this.elementsToIndexes.get(item)!)); - } - - getCheckedElements() { - return this.elements.filter(e => e.checked) - .map(e => e.item) - .filter(e => !!e) as IQuickPickItem[]; - } - - setCheckedElements(items: IQuickPickItem[]) { - try { - this._fireCheckedEvents = false; - const checked = new Set(); - for (const item of items) { - checked.add(item); - } - for (const element of this.elements) { - element.checked = checked.has(element.item); - } - } finally { - this._fireCheckedEvents = true; - this.fireCheckedEvents(); - } - } - - set enabled(value: boolean) { - this.list.getHTMLElement().style.pointerEvents = value ? '' : 'none'; - } - - focus(what: QuickInputListFocus): void { - if (!this.list.length) { - return; - } - - if (what === QuickInputListFocus.Second && this.list.length < 2) { - what = QuickInputListFocus.First; - } - - switch (what) { - case QuickInputListFocus.First: - this.list.scrollTop = 0; - this.list.focusFirst(undefined, (e) => !!e.item); - break; - case QuickInputListFocus.Second: - this.list.scrollTop = 0; - this.list.focusNth(1, undefined, (e) => !!e.item); - break; - case QuickInputListFocus.Last: - this.list.scrollTop = this.list.scrollHeight; - this.list.focusLast(undefined, (e) => !!e.item); - break; - case QuickInputListFocus.Next: { - this.list.focusNext(undefined, true, undefined, (e) => !!e.item); - const index = this.list.getFocus()[0]; - if (index !== 0 && !this.elements[index - 1].item && this.list.firstVisibleIndex > index - 1) { - this.list.reveal(index - 1); - } - break; - } - case QuickInputListFocus.Previous: { - this.list.focusPrevious(undefined, true, undefined, (e) => !!e.item); - const index = this.list.getFocus()[0]; - if (index !== 0 && !this.elements[index - 1].item && this.list.firstVisibleIndex > index - 1) { - this.list.reveal(index - 1); - } - break; - } - case QuickInputListFocus.NextPage: - this.list.focusNextPage(undefined, (e) => !!e.item); - break; - case QuickInputListFocus.PreviousPage: - this.list.focusPreviousPage(undefined, (e) => !!e.item); - break; - } - - const focused = this.list.getFocus()[0]; - if (typeof focused === 'number') { - this.list.reveal(focused); - } - } - - clearFocus() { - this.list.setFocus([]); - } - - domFocus() { - this.list.domFocus(); - } - - /** - * Disposes of the hover and shows a new one for the given index if it has a tooltip. - * @param element The element to show the hover for - */ - private showHover(element: IListElement): void { - if (this._lastHover && !this._lastHover.isDisposed) { - this.options.hoverDelegate.onDidHideHover?.(); - this._lastHover?.dispose(); - } - - if (!element.element || !element.saneTooltip) { - return; - } - this._lastHover = this.options.hoverDelegate.showHover({ - content: element.saneTooltip, - target: element.element, - linkHandler: (url) => { - this.options.linkOpenerDelegate(url); - }, - appearance: { - showPointer: true, - }, - container: this.container, - position: { - hoverPosition: HoverPosition.RIGHT - } - }, false); - } - - layout(maxHeight?: number): void { - this.list.getHTMLElement().style.maxHeight = maxHeight ? `${ - // Make sure height aligns with list item heights - Math.floor(maxHeight / 44) * 44 - // Add some extra height so that it's clear there's more to scroll - + 6 - }px` : ''; - this.list.layout(); - } - - filter(query: string): boolean { - if (!(this.sortByLabel || this.matchOnLabel || this.matchOnDescription || this.matchOnDetail)) { - this.list.layout(); - return false; - } - - const queryWithWhitespace = query; - query = query.trim(); - - // Reset filtering - if (!query || !(this.matchOnLabel || this.matchOnDescription || this.matchOnDetail)) { - this.elements.forEach(element => { - element.labelHighlights = undefined; - element.descriptionHighlights = undefined; - element.detailHighlights = undefined; - element.hidden = false; - const previous = element.index && this.inputElements[element.index - 1]; - if (element.item) { - element.separator = previous && previous.type === 'separator' && !previous.buttons ? previous : undefined; - } - }); - } - - // Filter by value (since we support icons in labels, use $(..) aware fuzzy matching) - else { - let currentSeparator: IQuickPickSeparator | undefined; - this.elements.forEach(element => { - let labelHighlights: IMatch[] | undefined; - if (this.matchOnLabelMode === 'fuzzy') { - labelHighlights = this.matchOnLabel ? matchesFuzzyIconAware(query, parseLabelWithIcons(element.saneLabel)) ?? undefined : undefined; - } else { - labelHighlights = this.matchOnLabel ? matchesContiguousIconAware(queryWithWhitespace, parseLabelWithIcons(element.saneLabel)) ?? undefined : undefined; - } - const descriptionHighlights = this.matchOnDescription ? matchesFuzzyIconAware(query, parseLabelWithIcons(element.saneDescription || '')) ?? undefined : undefined; - const detailHighlights = this.matchOnDetail ? matchesFuzzyIconAware(query, parseLabelWithIcons(element.saneDetail || '')) ?? undefined : undefined; - - if (labelHighlights || descriptionHighlights || detailHighlights) { - element.labelHighlights = labelHighlights; - element.descriptionHighlights = descriptionHighlights; - element.detailHighlights = detailHighlights; - element.hidden = false; - } else { - element.labelHighlights = undefined; - element.descriptionHighlights = undefined; - element.detailHighlights = undefined; - element.hidden = element.item ? !element.item.alwaysShow : true; - } - - // Ensure separators are filtered out first before deciding if we need to bring them back - if (element.item) { - element.separator = undefined; - } else if (element.separator) { - element.hidden = true; - } - - // we can show the separator unless the list gets sorted by match - if (!this.sortByLabel) { - const previous = element.index && this.inputElements[element.index - 1]; - currentSeparator = previous && previous.type === 'separator' ? previous : currentSeparator; - if (currentSeparator && !element.hidden) { - element.separator = currentSeparator; - currentSeparator = undefined; - } - } - }); - } - - const shownElements = this.elements.filter(element => !element.hidden); - - // Sort by value - if (this.sortByLabel && query) { - const normalizedSearchValue = query.toLowerCase(); - shownElements.sort((a, b) => { - return compareEntries(a, b, normalizedSearchValue); - }); - } - - this.elementsToIndexes = shownElements.reduce((map, element, index) => { - map.set(element.item ?? element.separator!, index); - return map; - }, new Map()); - this.list.splice(0, this.list.length, shownElements); - this.list.setFocus([]); - this.list.layout(); - - this._onChangedAllVisibleChecked.fire(this.getAllVisibleChecked()); - this._onChangedVisibleCount.fire(shownElements.length); - - return true; - } - - toggleCheckbox() { - try { - this._fireCheckedEvents = false; - const elements = this.list.getFocusedElements(); - const allChecked = this.allVisibleChecked(elements); - for (const element of elements) { - element.checked = !allChecked; - } - } finally { - this._fireCheckedEvents = true; - this.fireCheckedEvents(); - } - } - - display(display: boolean) { - this.container.style.display = display ? '' : 'none'; - } - - isDisplayed() { - return this.container.style.display !== 'none'; - } - - dispose() { - this.elementDisposables = dispose(this.elementDisposables); - this.disposables = dispose(this.disposables); - } - - private fireCheckedEvents() { - if (this._fireCheckedEvents) { - this._onChangedAllVisibleChecked.fire(this.getAllVisibleChecked()); - this._onChangedCheckedCount.fire(this.getCheckedCount()); - this._onChangedCheckedElements.fire(this.getCheckedElements()); - } - } - - private fireButtonTriggered(event: IQuickPickItemButtonEvent) { - this._onButtonTriggered.fire(event); - } - - private fireSeparatorButtonTriggered(event: IQuickPickSeparatorButtonEvent) { - this._onSeparatorButtonTriggered.fire(event); - } - - style(styles: IListStyles) { - this.list.style(styles); - } - - toggleHover() { - const element: IListElement | undefined = this.list.getFocusedElements()[0]; - if (!element?.saneTooltip) { - return; - } - - // if there's a hover already, hide it (toggle off) - if (this._lastHover && !this._lastHover.isDisposed) { - this._lastHover.dispose(); - return; - } - - // If there is no hover, show it (toggle on) - const focused = this.list.getFocusedElements()[0]; - if (!focused) { - return; - } - this.showHover(focused); - const store = new DisposableStore(); - store.add(this.list.onDidChangeFocus(e => { - if (e.indexes.length) { - this.showHover(e.elements[0]); - } - })); - if (this._lastHover) { - store.add(this._lastHover); - } - this._toggleHover = store; - this.elementDisposables.push(this._toggleHover); - } -} - -function matchesContiguousIconAware(query: string, target: IParsedLabelWithIcons): IMatch[] | null { - - const { text, iconOffsets } = target; - - // Return early if there are no icon markers in the word to match against - if (!iconOffsets || iconOffsets.length === 0) { - return matchesContiguous(query, text); - } - - // Trim the word to match against because it could have leading - // whitespace now if the word started with an icon - const wordToMatchAgainstWithoutIconsTrimmed = ltrim(text, ' '); - const leadingWhitespaceOffset = text.length - wordToMatchAgainstWithoutIconsTrimmed.length; - - // match on value without icon - const matches = matchesContiguous(query, wordToMatchAgainstWithoutIconsTrimmed); - - // Map matches back to offsets with icon and trimming - if (matches) { - for (const match of matches) { - const iconOffset = iconOffsets[match.start + leadingWhitespaceOffset] /* icon offsets at index */ + leadingWhitespaceOffset /* overall leading whitespace offset */; - match.start += iconOffset; - match.end += iconOffset; - } - } - - return matches; -} - -function matchesContiguous(word: string, wordToMatchAgainst: string): IMatch[] | null { - const matchIndex = wordToMatchAgainst.toLowerCase().indexOf(word.toLowerCase()); - if (matchIndex !== -1) { - return [{ start: matchIndex, end: matchIndex + word.length }]; - } - return null; -} - -function compareEntries(elementA: IListElement, elementB: IListElement, lookFor: string): number { - - const labelHighlightsA = elementA.labelHighlights || []; - const labelHighlightsB = elementB.labelHighlights || []; - if (labelHighlightsA.length && !labelHighlightsB.length) { - return -1; - } - - if (!labelHighlightsA.length && labelHighlightsB.length) { - return 1; - } - - if (labelHighlightsA.length === 0 && labelHighlightsB.length === 0) { - return 0; - } - - return compareAnything(elementA.saneSortLabel, elementB.saneSortLabel, lookFor); -} - -class QuickInputAccessibilityProvider implements IListAccessibilityProvider { - - getWidgetAriaLabel(): string { - return localize('quickInput', "Quick Input"); - } - - getAriaLabel(element: IListElement): string | null { - return element.separator?.label - ? `${element.saneAriaLabel}, ${element.separator.label}` - : element.saneAriaLabel; - } - - getWidgetRole(): AriaRole { - return 'listbox'; - } - - getRole(element: IListElement) { - return element.hasCheckbox ? 'checkbox' : 'option'; - } - - isChecked(element: IListElement) { - if (!element.hasCheckbox) { - return undefined; - } - - return { - value: element.checked, - onDidChange: element.onChecked - }; - } -} diff --git a/src/vs/platform/quickinput/browser/quickInputService.ts b/src/vs/platform/quickinput/browser/quickInputService.ts index c36470d2a95e4..e23c1158e68b7 100644 --- a/src/vs/platform/quickinput/browser/quickInputService.ts +++ b/src/vs/platform/quickinput/browser/quickInputService.ts @@ -3,14 +3,11 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { IListRenderer, IListVirtualDelegate } from 'vs/base/browser/ui/list/list'; -import { List } from 'vs/base/browser/ui/list/listWidget'; import { CancellationToken } from 'vs/base/common/cancellation'; import { Emitter } from 'vs/base/common/event'; import { IContextKey, IContextKeyService, RawContextKey } from 'vs/platform/contextkey/common/contextkey'; import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; import { ILayoutService } from 'vs/platform/layout/browser/layoutService'; -import { IWorkbenchListOptions, WorkbenchList } from 'vs/platform/list/browser/listService'; import { IOpenerService } from 'vs/platform/opener/common/opener'; import { QuickAccessController } from 'vs/platform/quickinput/browser/quickAccess'; import { IQuickAccessController } from 'vs/platform/quickinput/common/quickAccess'; @@ -21,7 +18,6 @@ import { IThemeService, Themable } from 'vs/platform/theme/common/themeService'; import { IQuickInputOptions, IQuickInputStyles, QuickInputHoverDelegate } from './quickInput'; import { QuickInputController, IQuickInputControllerHost } from 'vs/platform/quickinput/browser/quickInputController'; import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; -import { IHoverService } from 'vs/platform/hover/browser/hover'; import { getWindow } from 'vs/base/browser/dom'; export class QuickInputService extends Themable implements IQuickInputService { @@ -64,7 +60,6 @@ export class QuickInputService extends Themable implements IQuickInputService { @IThemeService themeService: IThemeService, @ILayoutService protected readonly layoutService: ILayoutService, @IConfigurationService protected readonly configurationService: IConfigurationService, - @IHoverService private readonly hoverService: IHoverService ) { super(themeService); } @@ -84,23 +79,16 @@ export class QuickInputService extends Themable implements IQuickInputService { }); }, returnFocus: () => host.focus(), - createList: ( - user: string, - container: HTMLElement, - delegate: IListVirtualDelegate, - renderers: IListRenderer[], - options: IWorkbenchListOptions - ) => this.instantiationService.createInstance(WorkbenchList, user, container, delegate, renderers, options) as List, styles: this.computeStyles(), - hoverDelegate: new QuickInputHoverDelegate(this.configurationService, this.hoverService) + hoverDelegate: this._register(this.instantiationService.createInstance(QuickInputHoverDelegate)) }; - const controller = this._register(new QuickInputController({ - ...defaultOptions, - ...options - }, - this.themeService, - this.layoutService + const controller = this._register(this.instantiationService.createInstance( + QuickInputController, + { + ...defaultOptions, + ...options + } )); controller.layout(host.activeContainerDimension, host.activeContainerOffset.quickPickTop); diff --git a/src/vs/platform/quickinput/browser/quickInputTree.ts b/src/vs/platform/quickinput/browser/quickInputTree.ts new file mode 100644 index 0000000000000..2782b842d8a49 --- /dev/null +++ b/src/vs/platform/quickinput/browser/quickInputTree.ts @@ -0,0 +1,1700 @@ +/*--------------------------------------------------------------------------------------------- + * 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 'vs/base/browser/dom'; +import { Emitter, Event } from 'vs/base/common/event'; +import { IHoverDelegate } from 'vs/base/browser/ui/hover/hoverDelegate'; +import { IListVirtualDelegate } from 'vs/base/browser/ui/list/list'; +import { IObjectTreeElement, ITreeEvent, ITreeNode, ITreeRenderer } from 'vs/base/browser/ui/tree/tree'; +import { localize } from 'vs/nls'; +import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; +import { WorkbenchObjectTree } from 'vs/platform/list/browser/listService'; +import { IThemeService } from 'vs/platform/theme/common/themeService'; +import { Disposable, DisposableStore } from 'vs/base/common/lifecycle'; +import { IQuickPickItem, IQuickPickItemButtonEvent, IQuickPickSeparator, IQuickPickSeparatorButtonEvent, QuickPickItem } from 'vs/platform/quickinput/common/quickInput'; +import { IMarkdownString } from 'vs/base/common/htmlContent'; +import { IMatch } from 'vs/base/common/filters'; +import { IListAccessibilityProvider, IListStyles } from 'vs/base/browser/ui/list/listWidget'; +import { AriaRole } from 'vs/base/browser/ui/aria/aria'; +import { StandardKeyboardEvent } from 'vs/base/browser/keyboardEvent'; +import { KeyCode } from 'vs/base/common/keyCodes'; +import { OS, isMacintosh } from 'vs/base/common/platform'; +import { memoize } from 'vs/base/common/decorators'; +import { IIconLabelValueOptions, IconLabel } from 'vs/base/browser/ui/iconLabel/iconLabel'; +import { KeybindingLabel } from 'vs/base/browser/ui/keybindingLabel/keybindingLabel'; +import { ActionBar } from 'vs/base/browser/ui/actionbar/actionbar'; +import { isDark } from 'vs/platform/theme/common/theme'; +import { URI } from 'vs/base/common/uri'; +import { quickInputButtonToAction } from 'vs/platform/quickinput/browser/quickInputUtils'; +import { Lazy } from 'vs/base/common/lazy'; +import { IParsedLabelWithIcons, getCodiconAriaLabel, matchesFuzzyIconAware, parseLabelWithIcons } from 'vs/base/common/iconLabels'; +import { HoverPosition } from 'vs/base/browser/ui/hover/hoverWidget'; +import { compareAnything } from 'vs/base/common/comparers'; +import { ltrim } from 'vs/base/common/strings'; +import { RenderIndentGuides } from 'vs/base/browser/ui/tree/abstractTree'; +import { ThrottledDelayer } from 'vs/base/common/async'; +import { isCancellationError } from 'vs/base/common/errors'; +import type { IHoverWidget, IUpdatableHoverTooltipMarkdownString } from 'vs/base/browser/ui/hover/hover'; + +const $ = dom.$; + +export enum QuickInputListFocus { + First = 1, + Second, + Last, + Next, + Previous, + NextPage, + PreviousPage, + NextSeparator, + PreviousSeparator +} + +interface IQuickInputItemLazyParts { + readonly saneLabel: string; + readonly saneSortLabel: string; + readonly saneAriaLabel: string; +} + +interface IQuickPickElement extends IQuickInputItemLazyParts { + readonly hasCheckbox: boolean; + readonly index: number; + readonly item?: IQuickPickItem; + readonly saneDescription?: string; + readonly saneDetail?: string; + readonly saneTooltip?: string | IMarkdownString | HTMLElement; + hidden: boolean; + element?: HTMLElement; + labelHighlights?: IMatch[]; + descriptionHighlights?: IMatch[]; + detailHighlights?: IMatch[]; + separator?: IQuickPickSeparator; +} + +interface IQuickInputItemTemplateData { + entry: HTMLDivElement; + checkbox: HTMLInputElement; + icon: HTMLDivElement; + label: IconLabel; + keybinding: KeybindingLabel; + detail: IconLabel; + separator: HTMLDivElement; + actionBar: ActionBar; + element: IQuickPickElement; + toDisposeElement: DisposableStore; + toDisposeTemplate: DisposableStore; +} + +class BaseQuickPickItemElement implements IQuickPickElement { + private readonly _init: Lazy; + + constructor( + readonly index: number, + readonly hasCheckbox: boolean, + mainItem: QuickPickItem + ) { + this._init = new Lazy(() => { + const saneLabel = mainItem.label ?? ''; + const saneSortLabel = parseLabelWithIcons(saneLabel).text.trim(); + + const saneAriaLabel = mainItem.ariaLabel || [saneLabel, this.saneDescription, this.saneDetail] + .map(s => getCodiconAriaLabel(s)) + .filter(s => !!s) + .join(', '); + + return { + saneLabel, + saneSortLabel, + saneAriaLabel + }; + }); + this._saneDescription = mainItem.description; + this._saneTooltip = mainItem.tooltip; + } + + // #region Lazy Getters + + get saneLabel() { + return this._init.value.saneLabel; + } + get saneSortLabel() { + return this._init.value.saneSortLabel; + } + get saneAriaLabel() { + return this._init.value.saneAriaLabel; + } + + // #endregion + + // #region Getters and Setters + + private _element?: HTMLElement; + get element() { + return this._element; + } + set element(value: HTMLElement | undefined) { + this._element = value; + } + + private _hidden = false; + get hidden() { + return this._hidden; + } + set hidden(value: boolean) { + this._hidden = value; + } + + private _saneDescription?: string; + get saneDescription() { + return this._saneDescription; + } + set saneDescription(value: string | undefined) { + this._saneDescription = value; + } + + protected _saneDetail?: string; + get saneDetail() { + return this._saneDetail; + } + set saneDetail(value: string | undefined) { + this._saneDetail = value; + } + + private _saneTooltip?: string | IMarkdownString | HTMLElement; + get saneTooltip() { + return this._saneTooltip; + } + set saneTooltip(value: string | IMarkdownString | HTMLElement | undefined) { + this._saneTooltip = value; + } + + protected _labelHighlights?: IMatch[]; + get labelHighlights() { + return this._labelHighlights; + } + set labelHighlights(value: IMatch[] | undefined) { + this._labelHighlights = value; + } + + protected _descriptionHighlights?: IMatch[]; + get descriptionHighlights() { + return this._descriptionHighlights; + } + set descriptionHighlights(value: IMatch[] | undefined) { + this._descriptionHighlights = value; + } + + protected _detailHighlights?: IMatch[]; + get detailHighlights() { + return this._detailHighlights; + } + set detailHighlights(value: IMatch[] | undefined) { + this._detailHighlights = value; + } +} + +class QuickPickItemElement extends BaseQuickPickItemElement { + readonly onChecked: Event; + + constructor( + index: number, + hasCheckbox: boolean, + readonly fireButtonTriggered: (event: IQuickPickItemButtonEvent) => void, + private _onChecked: Emitter<{ element: IQuickPickElement; checked: boolean }>, + readonly item: IQuickPickItem, + private _separator: IQuickPickSeparator | undefined, + ) { + super(index, hasCheckbox, item); + + this.onChecked = hasCheckbox + ? Event.map(Event.filter<{ element: IQuickPickElement; checked: boolean }>(this._onChecked.event, e => e.element === this), e => e.checked) + : Event.None; + + this._saneDetail = item.detail; + this._labelHighlights = item.highlights?.label; + this._descriptionHighlights = item.highlights?.description; + this._detailHighlights = item.highlights?.detail; + } + + get separator() { + return this._separator; + } + set separator(value: IQuickPickSeparator | undefined) { + this._separator = value; + } + + private _checked = false; + get checked() { + return this._checked; + } + set checked(value: boolean) { + if (value !== this._checked) { + this._checked = value; + this._onChecked.fire({ element: this, checked: value }); + } + } + + get checkboxDisabled() { + return !!this.item.disabled; + } +} + +enum QuickPickSeparatorFocusReason { + /** + * No item is hovered or active + */ + NONE = 0, + /** + * Some item within this section is hovered + */ + MOUSE_HOVER = 1, + /** + * Some item within this section is active + */ + ACTIVE_ITEM = 2 +} + +class QuickPickSeparatorElement extends BaseQuickPickItemElement { + children = new Array(); + /** + * If this item is >0, it means that there is some item in the list that is either: + * * hovered over + * * active + */ + focusInsideSeparator = QuickPickSeparatorFocusReason.NONE; + + constructor( + index: number, + readonly fireSeparatorButtonTriggered: (event: IQuickPickSeparatorButtonEvent) => void, + readonly separator: IQuickPickSeparator, + ) { + super(index, false, separator); + } +} + +class QuickInputItemDelegate implements IListVirtualDelegate { + getHeight(element: IQuickPickElement): number { + + if (element instanceof QuickPickSeparatorElement) { + return 30; + } + return element.saneDetail ? 44 : 22; + } + + getTemplateId(element: IQuickPickElement): string { + if (element instanceof QuickPickItemElement) { + return QuickPickItemElementRenderer.ID; + } else { + return QuickPickSeparatorElementRenderer.ID; + } + } +} + +class QuickInputAccessibilityProvider implements IListAccessibilityProvider { + + getWidgetAriaLabel(): string { + return localize('quickInput', "Quick Input"); + } + + getAriaLabel(element: IQuickPickElement): string | null { + return element.separator?.label + ? `${element.saneAriaLabel}, ${element.separator.label}` + : element.saneAriaLabel; + } + + getWidgetRole(): AriaRole { + return 'listbox'; + } + + getRole(element: IQuickPickElement) { + return element.hasCheckbox ? 'checkbox' : 'option'; + } + + isChecked(element: IQuickPickElement) { + if (!element.hasCheckbox || !(element instanceof QuickPickItemElement)) { + return undefined; + } + + return { + value: element.checked, + onDidChange: element.onChecked + }; + } +} + +abstract class BaseQuickInputListRenderer implements ITreeRenderer { + abstract templateId: string; + + constructor( + private readonly hoverDelegate: IHoverDelegate | undefined + ) { } + + // TODO: only do the common stuff here and have a subclass handle their specific stuff + renderTemplate(container: HTMLElement): IQuickInputItemTemplateData { + const data: IQuickInputItemTemplateData = Object.create(null); + data.toDisposeElement = new DisposableStore(); + data.toDisposeTemplate = new DisposableStore(); + data.entry = dom.append(container, $('.quick-input-list-entry')); + + // Checkbox + const label = dom.append(data.entry, $('label.quick-input-list-label')); + data.toDisposeTemplate.add(dom.addStandardDisposableListener(label, dom.EventType.CLICK, e => { + if (!data.checkbox.offsetParent) { // If checkbox not visible: + e.preventDefault(); // Prevent toggle of checkbox when it is immediately shown afterwards. #91740 + } + })); + data.checkbox = dom.append(label, $('input.quick-input-list-checkbox')); + data.checkbox.type = 'checkbox'; + + // Rows + const rows = dom.append(label, $('.quick-input-list-rows')); + const row1 = dom.append(rows, $('.quick-input-list-row')); + const row2 = dom.append(rows, $('.quick-input-list-row')); + + // Label + data.label = new IconLabel(row1, { supportHighlights: true, supportDescriptionHighlights: true, supportIcons: true, hoverDelegate: this.hoverDelegate }); + data.toDisposeTemplate.add(data.label); + data.icon = dom.prepend(data.label.element, $('.quick-input-list-icon')); + + // Keybinding + const keybindingContainer = dom.append(row1, $('.quick-input-list-entry-keybinding')); + data.keybinding = new KeybindingLabel(keybindingContainer, OS); + data.toDisposeTemplate.add(data.keybinding); + + // Detail + const detailContainer = dom.append(row2, $('.quick-input-list-label-meta')); + data.detail = new IconLabel(detailContainer, { supportHighlights: true, supportIcons: true, hoverDelegate: this.hoverDelegate }); + data.toDisposeTemplate.add(data.detail); + + // Separator + data.separator = dom.append(data.entry, $('.quick-input-list-separator')); + + // Actions + data.actionBar = new ActionBar(data.entry, this.hoverDelegate ? { hoverDelegate: this.hoverDelegate } : undefined); + data.actionBar.domNode.classList.add('quick-input-list-entry-action-bar'); + data.toDisposeTemplate.add(data.actionBar); + + return data; + } + + disposeTemplate(data: IQuickInputItemTemplateData): void { + data.toDisposeElement.dispose(); + data.toDisposeTemplate.dispose(); + } + + disposeElement(_element: ITreeNode, _index: number, data: IQuickInputItemTemplateData): void { + data.toDisposeElement.clear(); + data.actionBar.clear(); + } + + // TODO: only do the common stuff here and have a subclass handle their specific stuff + abstract renderElement(node: ITreeNode, index: number, data: IQuickInputItemTemplateData): void; +} + +class QuickPickItemElementRenderer extends BaseQuickInputListRenderer { + static readonly ID = 'quickpickitem'; + + // Follow what we do in the separator renderer + private readonly _itemsWithSeparatorsFrequency = new Map(); + + constructor( + hoverDelegate: IHoverDelegate | undefined, + @IThemeService private readonly themeService: IThemeService, + ) { + super(hoverDelegate); + } + + get templateId() { + return QuickPickItemElementRenderer.ID; + } + + override renderTemplate(container: HTMLElement): IQuickInputItemTemplateData { + const data = super.renderTemplate(container); + + data.toDisposeTemplate.add(dom.addStandardDisposableListener(data.checkbox, dom.EventType.CHANGE, e => { + (data.element as QuickPickItemElement).checked = data.checkbox.checked; + })); + + return data; + } + + renderElement(node: ITreeNode, index: number, data: IQuickInputItemTemplateData): void { + const element = node.element; + data.element = element; + element.element = data.entry ?? undefined; + const mainItem: IQuickPickItem = element.item; + + data.checkbox.checked = element.checked; + data.toDisposeElement.add(element.onChecked(checked => data.checkbox.checked = checked)); + data.checkbox.disabled = element.checkboxDisabled; + + const { labelHighlights, descriptionHighlights, detailHighlights } = element; + + // Icon + if (mainItem.iconPath) { + const icon = isDark(this.themeService.getColorTheme().type) ? mainItem.iconPath.dark : (mainItem.iconPath.light ?? mainItem.iconPath.dark); + const iconUrl = URI.revive(icon); + data.icon.className = 'quick-input-list-icon'; + data.icon.style.backgroundImage = dom.asCSSUrl(iconUrl); + } else { + data.icon.style.backgroundImage = ''; + data.icon.className = mainItem.iconClass ? `quick-input-list-icon ${mainItem.iconClass}` : ''; + } + + // Label + let descriptionTitle: IUpdatableHoverTooltipMarkdownString | undefined; + // if we have a tooltip, that will be the hover, + // with the saneDescription as fallback if it + // is defined + if (!element.saneTooltip && element.saneDescription) { + descriptionTitle = { + markdown: { + value: element.saneDescription, + supportThemeIcons: true + }, + markdownNotSupportedFallback: element.saneDescription + }; + } + const options: IIconLabelValueOptions = { + matches: labelHighlights || [], + // If we have a tooltip, we want that to be shown and not any other hover + descriptionTitle, + descriptionMatches: descriptionHighlights || [], + labelEscapeNewLines: true + }; + options.extraClasses = mainItem.iconClasses; + options.italic = mainItem.italic; + options.strikethrough = mainItem.strikethrough; + data.entry.classList.remove('quick-input-list-separator-as-item'); + data.label.setLabel(element.saneLabel, element.saneDescription, options); + + // Keybinding + data.keybinding.set(mainItem.keybinding); + + // Detail + if (element.saneDetail) { + let title: IUpdatableHoverTooltipMarkdownString | undefined; + // If we have a tooltip, we want that to be shown and not any other hover + if (!element.saneTooltip) { + title = { + markdown: { + value: element.saneDetail, + supportThemeIcons: true + }, + markdownNotSupportedFallback: element.saneDetail + }; + } + data.detail.element.style.display = ''; + data.detail.setLabel(element.saneDetail, undefined, { + matches: detailHighlights, + title, + labelEscapeNewLines: true + }); + } else { + data.detail.element.style.display = 'none'; + } + + // Separator + if (element.separator?.label) { + data.separator.textContent = element.separator.label; + data.separator.style.display = ''; + this.addItemWithSeparator(element); + } else { + data.separator.style.display = 'none'; + } + data.entry.classList.toggle('quick-input-list-separator-border', !!element.separator); + + // Actions + const buttons = mainItem.buttons; + if (buttons && buttons.length) { + data.actionBar.push(buttons.map((button, index) => quickInputButtonToAction( + button, + `id-${index}`, + () => element.fireButtonTriggered({ button, item: element.item }) + )), { icon: true, label: false }); + data.entry.classList.add('has-actions'); + } else { + data.entry.classList.remove('has-actions'); + } + } + + override disposeElement(element: ITreeNode, _index: number, data: IQuickInputItemTemplateData): void { + this.removeItemWithSeparator(element.element); + super.disposeElement(element, _index, data); + } + + isItemWithSeparatorVisible(item: QuickPickItemElement): boolean { + return this._itemsWithSeparatorsFrequency.has(item); + } + + private addItemWithSeparator(item: QuickPickItemElement): void { + this._itemsWithSeparatorsFrequency.set(item, (this._itemsWithSeparatorsFrequency.get(item) || 0) + 1); + } + + private removeItemWithSeparator(item: QuickPickItemElement): void { + const frequency = this._itemsWithSeparatorsFrequency.get(item) || 0; + if (frequency > 1) { + this._itemsWithSeparatorsFrequency.set(item, frequency - 1); + } else { + this._itemsWithSeparatorsFrequency.delete(item); + } + } +} + +class QuickPickSeparatorElementRenderer extends BaseQuickInputListRenderer { + static readonly ID = 'quickpickseparator'; + + // This is a frequency map because sticky scroll re-uses the same renderer to render a second + // instance of the same separator. + private readonly _visibleSeparatorsFrequency = new Map(); + + get templateId() { + return QuickPickSeparatorElementRenderer.ID; + } + + get visibleSeparators(): QuickPickSeparatorElement[] { + return [...this._visibleSeparatorsFrequency.keys()]; + } + + isSeparatorVisible(separator: QuickPickSeparatorElement): boolean { + return this._visibleSeparatorsFrequency.has(separator); + } + + override renderElement(node: ITreeNode, index: number, data: IQuickInputItemTemplateData): void { + const element = node.element; + data.element = element; + element.element = data.entry ?? undefined; + element.element.classList.toggle('focus-inside', !!element.focusInsideSeparator); + const mainItem: IQuickPickSeparator = element.separator; + + const { labelHighlights, descriptionHighlights, detailHighlights } = element; + + // Icon + data.icon.style.backgroundImage = ''; + data.icon.className = ''; + + // Label + let descriptionTitle: IUpdatableHoverTooltipMarkdownString | undefined; + // if we have a tooltip, that will be the hover, + // with the saneDescription as fallback if it + // is defined + if (!element.saneTooltip && element.saneDescription) { + descriptionTitle = { + markdown: { + value: element.saneDescription, + supportThemeIcons: true + }, + markdownNotSupportedFallback: element.saneDescription + }; + } + const options: IIconLabelValueOptions = { + matches: labelHighlights || [], + // If we have a tooltip, we want that to be shown and not any other hover + descriptionTitle, + descriptionMatches: descriptionHighlights || [], + labelEscapeNewLines: true + }; + data.entry.classList.add('quick-input-list-separator-as-item'); + data.label.setLabel(element.saneLabel, element.saneDescription, options); + + // Detail + if (element.saneDetail) { + let title: IUpdatableHoverTooltipMarkdownString | undefined; + // If we have a tooltip, we want that to be shown and not any other hover + if (!element.saneTooltip) { + title = { + markdown: { + value: element.saneDetail, + supportThemeIcons: true + }, + markdownNotSupportedFallback: element.saneDetail + }; + } + data.detail.element.style.display = ''; + data.detail.setLabel(element.saneDetail, undefined, { + matches: detailHighlights, + title, + labelEscapeNewLines: true + }); + } else { + data.detail.element.style.display = 'none'; + } + + // Separator + data.separator.style.display = 'none'; + data.entry.classList.add('quick-input-list-separator-border'); + + // Actions + const buttons = mainItem.buttons; + if (buttons && buttons.length) { + data.actionBar.push(buttons.map((button, index) => quickInputButtonToAction( + button, + `id-${index}`, + () => element.fireSeparatorButtonTriggered({ button, separator: element.separator }) + )), { icon: true, label: false }); + data.entry.classList.add('has-actions'); + } else { + data.entry.classList.remove('has-actions'); + } + + this.addSeparator(element); + } + + override disposeElement(element: ITreeNode, _index: number, data: IQuickInputItemTemplateData): void { + this.removeSeparator(element.element); + if (!this.isSeparatorVisible(element.element)) { + element.element.element?.classList.remove('focus-inside'); + } + super.disposeElement(element, _index, data); + } + + private addSeparator(separator: QuickPickSeparatorElement): void { + this._visibleSeparatorsFrequency.set(separator, (this._visibleSeparatorsFrequency.get(separator) || 0) + 1); + } + + private removeSeparator(separator: QuickPickSeparatorElement): void { + const frequency = this._visibleSeparatorsFrequency.get(separator) || 0; + if (frequency > 1) { + this._visibleSeparatorsFrequency.set(separator, frequency - 1); + } else { + this._visibleSeparatorsFrequency.delete(separator); + } + } +} + +export class QuickInputTree extends Disposable { + + private readonly _onKeyDown = new Emitter(); + /** + * Event that is fired when the tree receives a keydown. + */ + readonly onKeyDown: Event = this._onKeyDown.event; + + private readonly _onLeave = new Emitter(); + /** + * Event that is fired when the tree would no longer have focus. + */ + readonly onLeave: Event = this._onLeave.event; + + private readonly _onChangedAllVisibleChecked = new Emitter(); + onChangedAllVisibleChecked: Event = this._onChangedAllVisibleChecked.event; + + private readonly _onChangedCheckedCount = new Emitter(); + onChangedCheckedCount: Event = this._onChangedCheckedCount.event; + + private readonly _onChangedVisibleCount = new Emitter(); + onChangedVisibleCount: Event = this._onChangedVisibleCount.event; + + private readonly _onChangedCheckedElements = new Emitter(); + onChangedCheckedElements: Event = this._onChangedCheckedElements.event; + + private readonly _onButtonTriggered = new Emitter>(); + onButtonTriggered = this._onButtonTriggered.event; + + private readonly _onSeparatorButtonTriggered = new Emitter(); + onSeparatorButtonTriggered = this._onSeparatorButtonTriggered.event; + + private readonly _onTriggerEmptySelectionOrFocus = new Emitter>(); + + private readonly _container: HTMLElement; + private readonly _tree: WorkbenchObjectTree; + private readonly _separatorRenderer: QuickPickSeparatorElementRenderer; + private readonly _itemRenderer: QuickPickItemElementRenderer; + private readonly _elementChecked = new Emitter<{ element: IQuickPickElement; checked: boolean }>(); + private _inputElements = new Array(); + private _elementTree = new Array(); + private _itemElements = new Array(); + // Elements that apply to the current set of elements + private readonly _elementDisposable = this._register(new DisposableStore()); + private _lastHover: IHoverWidget | undefined; + // This is used to prevent setting the checked state of a single element from firing the checked events + // so that we can batch them together. This can probably be improved by handling events differently, + // but this works for now. An observable would probably be ideal for this. + private _shouldFireCheckedEvents = true; + + constructor( + private parent: HTMLElement, + private hoverDelegate: IHoverDelegate, + private linkOpenerDelegate: (content: string) => void, + id: string, + @IInstantiationService instantiationService: IInstantiationService + ) { + super(); + this._container = dom.append(this.parent, $('.quick-input-list')); + this._separatorRenderer = new QuickPickSeparatorElementRenderer(hoverDelegate); + this._itemRenderer = instantiationService.createInstance(QuickPickItemElementRenderer, hoverDelegate); + this._tree = this._register(instantiationService.createInstance( + WorkbenchObjectTree, + 'QuickInput', + this._container, + new QuickInputItemDelegate(), + [this._itemRenderer, this._separatorRenderer], + { + accessibilityProvider: new QuickInputAccessibilityProvider(), + setRowLineHeight: false, + multipleSelectionSupport: false, + hideTwistiesOfChildlessElements: true, + renderIndentGuides: RenderIndentGuides.None, + findWidgetEnabled: false, + indent: 0, + horizontalScrolling: false, + allowNonCollapsibleParents: true, + identityProvider: { + getId: element => { + // always prefer item over separator because if item is defined, it must be the main item type + // always prefer a defined id if one was specified and use label as a fallback + return element.item?.id + ?? element.item?.label + ?? element.separator?.id + ?? element.separator?.label + ?? ''; + }, + }, + alwaysConsumeMouseWheel: true + } + )); + this._tree.getHTMLElement().id = id; + this._registerListeners(); + } + + //#region public getters/setters + + @memoize + get onDidChangeFocus() { + return Event.map( + Event.any(this._tree.onDidChangeFocus, this._onTriggerEmptySelectionOrFocus.event), + e => e.elements.filter((e): e is QuickPickItemElement => e instanceof QuickPickItemElement).map(e => e.item) + ); + } + + @memoize + get onDidChangeSelection() { + return Event.map( + Event.any(this._tree.onDidChangeSelection, this._onTriggerEmptySelectionOrFocus.event), + e => ({ + items: e.elements.filter((e): e is QuickPickItemElement => e instanceof QuickPickItemElement).map(e => e.item), + event: e.browserEvent + })); + } + + get scrollTop() { + return this._tree.scrollTop; + } + + set scrollTop(scrollTop: number) { + this._tree.scrollTop = scrollTop; + } + + get ariaLabel() { + return this._tree.ariaLabel; + } + + set ariaLabel(label: string | null) { + this._tree.ariaLabel = label ?? ''; + } + + set enabled(value: boolean) { + this._tree.getHTMLElement().style.pointerEvents = value ? '' : 'none'; + } + + private _matchOnDescription = false; + get matchOnDescription() { + return this._matchOnDescription; + } + set matchOnDescription(value: boolean) { + this._matchOnDescription = value; + } + + private _matchOnDetail = false; + get matchOnDetail() { + return this._matchOnDetail; + } + set matchOnDetail(value: boolean) { + this._matchOnDetail = value; + } + + private _matchOnLabel = true; + get matchOnLabel() { + return this._matchOnLabel; + } + set matchOnLabel(value: boolean) { + this._matchOnLabel = value; + } + + private _matchOnLabelMode: 'fuzzy' | 'contiguous' = 'fuzzy'; + get matchOnLabelMode() { + return this._matchOnLabelMode; + } + set matchOnLabelMode(value: 'fuzzy' | 'contiguous') { + this._matchOnLabelMode = value; + } + + private _matchOnMeta = true; + get matchOnMeta() { + return this._matchOnMeta; + } + set matchOnMeta(value: boolean) { + this._matchOnMeta = value; + } + + private _sortByLabel = true; + get sortByLabel() { + return this._sortByLabel; + } + set sortByLabel(value: boolean) { + this._sortByLabel = value; + } + + //#endregion + + //#region register listeners + + private _registerListeners() { + this._registerOnKeyDown(); + this._registerOnContainerClick(); + this._registerOnMouseMiddleClick(); + this._registerOnElementChecked(); + this._registerOnContextMenu(); + this._registerHoverListeners(); + this._registerSelectionChangeListener(); + this._registerSeparatorActionShowingListeners(); + } + + private _registerOnKeyDown() { + // TODO: Should this be added at a higher level? + this._register(this._tree.onKeyDown(e => { + const event = new StandardKeyboardEvent(e); + switch (event.keyCode) { + case KeyCode.Space: + this.toggleCheckbox(); + break; + case KeyCode.KeyA: + if (isMacintosh ? e.metaKey : e.ctrlKey) { + this._tree.setFocus(this._itemElements); + } + break; + // When we hit the top of the tree, we fire the onLeave event. + case KeyCode.UpArrow: { + const focus1 = this._tree.getFocus(); + if (focus1.length === 1 && focus1[0] === this._itemElements[0]) { + this._onLeave.fire(); + } + break; + } + // When we hit the bottom of the tree, we fire the onLeave event. + case KeyCode.DownArrow: { + const focus2 = this._tree.getFocus(); + if (focus2.length === 1 && focus2[0] === this._itemElements[this._itemElements.length - 1]) { + this._onLeave.fire(); + } + break; + } + } + + this._onKeyDown.fire(event); + })); + } + + private _registerOnContainerClick() { + this._register(dom.addDisposableListener(this._container, dom.EventType.CLICK, e => { + if (e.x || e.y) { // Avoid 'click' triggered by 'space' on checkbox. + this._onLeave.fire(); + } + })); + } + + private _registerOnMouseMiddleClick() { + this._register(dom.addDisposableListener(this._container, dom.EventType.AUXCLICK, e => { + if (e.button === 1) { + this._onLeave.fire(); + } + })); + } + + private _registerOnElementChecked() { + this._register(this._elementChecked.event(_ => this._fireCheckedEvents())); + } + + private _registerOnContextMenu() { + this._register(this._tree.onContextMenu(e => { + if (e.element) { + e.browserEvent.preventDefault(); + + // we want to treat a context menu event as + // a gesture to open the item at the index + // since we do not have any context menu + // this enables for example macOS to Ctrl- + // click on an item to open it. + this._tree.setSelection([e.element]); + } + })); + } + + private _registerHoverListeners() { + const delayer = this._register(new ThrottledDelayer(this.hoverDelegate.delay)); + this._register(this._tree.onMouseOver(async e => { + // If we hover over an anchor element, we don't want to show the hover because + // the anchor may have a tooltip that we want to show instead. + if (e.browserEvent.target instanceof HTMLAnchorElement) { + delayer.cancel(); + return; + } + if ( + // anchors are an exception as called out above so we skip them here + !(e.browserEvent.relatedTarget instanceof HTMLAnchorElement) && + // check if the mouse is still over the same element + dom.isAncestor(e.browserEvent.relatedTarget as Node, e.element?.element as Node) + ) { + return; + } + try { + await delayer.trigger(async () => { + if (e.element instanceof QuickPickItemElement) { + this.showHover(e.element); + } + }); + } catch (e) { + // Ignore cancellation errors due to mouse out + if (!isCancellationError(e)) { + throw e; + } + } + })); + this._register(this._tree.onMouseOut(e => { + // onMouseOut triggers every time a new element has been moused over + // even if it's on the same list item. We only want one event, so we + // check if the mouse is still over the same element. + if (dom.isAncestor(e.browserEvent.relatedTarget as Node, e.element?.element as Node)) { + return; + } + delayer.cancel(); + })); + } + + /** + * Register's focus change and mouse events so that we can track when items inside of a + * separator's section are focused or hovered so that we can display the separator's actions + */ + private _registerSeparatorActionShowingListeners() { + this._register(this._tree.onDidChangeFocus(e => { + const parent = e.elements[0] + ? this._tree.getParentElement(e.elements[0]) as QuickPickSeparatorElement + // treat null as focus lost and when we have no separators + : null; + for (const separator of this._separatorRenderer.visibleSeparators) { + const value = separator === parent; + // get bitness of ACTIVE_ITEM and check if it changed + const currentActive = !!(separator.focusInsideSeparator & QuickPickSeparatorFocusReason.ACTIVE_ITEM); + if (currentActive !== value) { + if (value) { + separator.focusInsideSeparator |= QuickPickSeparatorFocusReason.ACTIVE_ITEM; + } else { + separator.focusInsideSeparator &= ~QuickPickSeparatorFocusReason.ACTIVE_ITEM; + } + + this._tree.rerender(separator); + } + } + })); + this._register(this._tree.onMouseOver(e => { + const parent = e.element + ? this._tree.getParentElement(e.element) as QuickPickSeparatorElement + : null; + for (const separator of this._separatorRenderer.visibleSeparators) { + if (separator !== parent) { + continue; + } + const currentMouse = !!(separator.focusInsideSeparator & QuickPickSeparatorFocusReason.MOUSE_HOVER); + if (!currentMouse) { + separator.focusInsideSeparator |= QuickPickSeparatorFocusReason.MOUSE_HOVER; + this._tree.rerender(separator); + } + } + })); + this._register(this._tree.onMouseOut(e => { + const parent = e.element + ? this._tree.getParentElement(e.element) as QuickPickSeparatorElement + : null; + for (const separator of this._separatorRenderer.visibleSeparators) { + if (separator !== parent) { + continue; + } + const currentMouse = !!(separator.focusInsideSeparator & QuickPickSeparatorFocusReason.MOUSE_HOVER); + if (currentMouse) { + separator.focusInsideSeparator &= ~QuickPickSeparatorFocusReason.MOUSE_HOVER; + this._tree.rerender(separator); + } + } + })); + } + + private _registerSelectionChangeListener() { + // When the user selects a separator, the separator will move to the top and focus will be + // set to the first element after the separator. + this._register(this._tree.onDidChangeSelection(e => { + const elementsWithoutSeparators = e.elements.filter((e): e is QuickPickItemElement => e instanceof QuickPickItemElement); + if (elementsWithoutSeparators.length !== e.elements.length) { + if (e.elements.length === 1 && e.elements[0] instanceof QuickPickSeparatorElement) { + this._tree.setFocus([e.elements[0].children[0]]); + this._tree.reveal(e.elements[0], 0); + } + this._tree.setSelection(elementsWithoutSeparators); + } + })); + } + + //#endregion + + //#region public methods + + getAllVisibleChecked() { + return this._allVisibleChecked(this._itemElements, false); + } + + getCheckedCount() { + return this._itemElements.filter(element => element.checked).length; + } + + getVisibleCount() { + return this._itemElements.filter(e => !e.hidden).length; + } + + setAllVisibleChecked(checked: boolean) { + try { + this._shouldFireCheckedEvents = false; + this._itemElements.forEach(element => { + if (!element.hidden && !element.checkboxDisabled) { + // Would fire an event if we didn't have the flag set + element.checked = checked; + } + }); + } finally { + this._shouldFireCheckedEvents = true; + this._fireCheckedEvents(); + } + } + + setElements(inputElements: QuickPickItem[]): void { + this._elementDisposable.clear(); + this._inputElements = inputElements; + const hasCheckbox = this.parent.classList.contains('show-checkboxes'); + let currentSeparatorElement: QuickPickSeparatorElement | undefined; + this._itemElements = new Array(); + this._elementTree = inputElements.reduce((result, item, index) => { + let element: IQuickPickElement; + if (item.type === 'separator') { + if (!item.buttons) { + // This separator will be rendered as a part of the list item + return result; + } + currentSeparatorElement = new QuickPickSeparatorElement( + index, + (event: IQuickPickSeparatorButtonEvent) => this.fireSeparatorButtonTriggered(event), + item + ); + element = currentSeparatorElement; + } else { + const previous = index > 0 ? inputElements[index - 1] : undefined; + let separator: IQuickPickSeparator | undefined; + if (previous && previous.type === 'separator' && !previous.buttons) { + // Found an inline separator so we clear out the current separator element + currentSeparatorElement = undefined; + separator = previous; + } + const qpi = new QuickPickItemElement( + index, + hasCheckbox, + (event: IQuickPickItemButtonEvent) => this.fireButtonTriggered(event), + this._elementChecked, + item, + separator, + ); + this._itemElements.push(qpi); + + if (currentSeparatorElement) { + currentSeparatorElement.children.push(qpi); + return result; + } + element = qpi; + } + + result.push(element); + return result; + }, new Array()); + + const elements = new Array>(); + let visibleCount = 0; + for (const element of this._elementTree) { + if (element instanceof QuickPickSeparatorElement) { + elements.push({ + element, + collapsible: false, + collapsed: false, + children: element.children.map(e => ({ + element: e, + collapsible: false, + collapsed: false, + })), + }); + visibleCount += element.children.length + 1; // +1 for the separator itself; + } else { + elements.push({ + element, + collapsible: false, + collapsed: false, + }); + visibleCount++; + } + } + this._tree.setChildren(null, elements); + this._onChangedVisibleCount.fire(visibleCount); + } + + getElementsCount(): number { + return this._inputElements.length; + } + + getFocusedElements() { + return this._tree.getFocus() + .filter((e): e is IQuickPickElement => !!e) + .map(e => e.item) + .filter((e): e is IQuickPickItem => !!e); + } + + setFocusedElements(items: IQuickPickItem[]) { + const elements = items.map(item => this._itemElements.find(e => e.item === item)) + .filter((e): e is QuickPickItemElement => !!e); + this._tree.setFocus(elements); + if (items.length > 0) { + const focused = this._tree.getFocus()[0]; + if (focused) { + this._tree.reveal(focused); + } + } + } + + getActiveDescendant() { + return this._tree.getHTMLElement().getAttribute('aria-activedescendant'); + } + + getSelectedElements() { + return this._tree.getSelection() + .filter((e): e is IQuickPickElement => !!e && !!(e as QuickPickItemElement).item) + .map(e => e.item); + } + + setSelectedElements(items: IQuickPickItem[]) { + const elements = items.map(item => this._itemElements.find(e => e.item === item)) + .filter((e): e is QuickPickItemElement => !!e); + this._tree.setSelection(elements); + } + + getCheckedElements() { + return this._itemElements.filter(e => e.checked) + .map(e => e.item); + } + + setCheckedElements(items: IQuickPickItem[]) { + try { + this._shouldFireCheckedEvents = false; + const checked = new Set(); + for (const item of items) { + checked.add(item); + } + for (const element of this._itemElements) { + // Would fire an event if we didn't have the flag set + element.checked = checked.has(element.item); + } + } finally { + this._shouldFireCheckedEvents = true; + this._fireCheckedEvents(); + } + } + + focus(what: QuickInputListFocus): void { + if (!this._itemElements.length) { + return; + } + + if (what === QuickInputListFocus.Second && this._itemElements.length < 2) { + what = QuickInputListFocus.First; + } + + switch (what) { + case QuickInputListFocus.First: + this._tree.scrollTop = 0; + this._tree.focusFirst(undefined, (e) => e.element instanceof QuickPickItemElement); + break; + case QuickInputListFocus.Second: + this._tree.scrollTop = 0; + this._tree.setFocus([this._itemElements[1]]); + break; + case QuickInputListFocus.Last: + this._tree.scrollTop = this._tree.scrollHeight; + this._tree.setFocus([this._itemElements[this._itemElements.length - 1]]); + break; + case QuickInputListFocus.Next: + this._tree.focusNext(undefined, true, undefined, (e) => { + if (!(e.element instanceof QuickPickItemElement)) { + return false; + } + this._tree.reveal(e.element); + return true; + }); + break; + case QuickInputListFocus.Previous: + this._tree.focusPrevious(undefined, true, undefined, (e) => { + if (!(e.element instanceof QuickPickItemElement)) { + return false; + } + const parent = this._tree.getParentElement(e.element); + if (parent === null || (parent as QuickPickSeparatorElement).children[0] !== e.element) { + this._tree.reveal(e.element); + } else { + // Only if we are the first child of a separator do we reveal the separator + this._tree.reveal(parent); + } + return true; + }); + break; + case QuickInputListFocus.NextPage: + this._tree.focusNextPage(undefined, (e) => { + if (!(e.element instanceof QuickPickItemElement)) { + return false; + } + this._tree.reveal(e.element); + return true; + }); + break; + case QuickInputListFocus.PreviousPage: + this._tree.focusPreviousPage(undefined, (e) => { + if (!(e.element instanceof QuickPickItemElement)) { + return false; + } + const parent = this._tree.getParentElement(e.element); + if (parent === null || (parent as QuickPickSeparatorElement).children[0] !== e.element) { + this._tree.reveal(e.element); + } else { + this._tree.reveal(parent); + } + return true; + }); + break; + case QuickInputListFocus.NextSeparator: { + let foundSeparatorAsItem = false; + const before = this._tree.getFocus()[0]; + this._tree.focusNext(undefined, true, undefined, (e) => { + if (foundSeparatorAsItem) { + // This should be the index right after the separator so it + // is the item we want to focus. + return true; + } + + if (e.element instanceof QuickPickSeparatorElement) { + foundSeparatorAsItem = true; + // If the separator is visible, then we should just reveal its first child so it's not as jarring. + if (this._separatorRenderer.isSeparatorVisible(e.element)) { + this._tree.reveal(e.element.children[0]); + } else { + // If the separator is not visible, then we should + // push it up to the top of the list. + this._tree.reveal(e.element, 0); + } + } else if (e.element instanceof QuickPickItemElement) { + if (e.element.separator) { + if (this._itemRenderer.isItemWithSeparatorVisible(e.element)) { + this._tree.reveal(e.element); + } else { + this._tree.reveal(e.element, 0); + } + return true; + } else if (e.element === this._elementTree[0]) { + // We should stop at the first item in the list if it's a regular item. + this._tree.reveal(e.element, 0); + return true; + } + } + return false; + }); + const after = this._tree.getFocus()[0]; + if (before === after) { + // If we didn't move, then we should just move to the end + // of the list. + this._tree.scrollTop = this._tree.scrollHeight; + this._tree.setFocus([this._itemElements[this._itemElements.length - 1]]); + } + break; + } + case QuickInputListFocus.PreviousSeparator: { + let focusElement: IQuickPickElement | undefined; + // If we are already sitting on an inline separator, then we + // have already found the _current_ separator and need to + // move to the previous one. + let foundSeparator = !!this._tree.getFocus()[0]?.separator; + this._tree.focusPrevious(undefined, true, undefined, (e) => { + if (e.element instanceof QuickPickSeparatorElement) { + if (foundSeparator) { + if (!focusElement) { + if (this._separatorRenderer.isSeparatorVisible(e.element)) { + this._tree.reveal(e.element); + } else { + this._tree.reveal(e.element, 0); + } + focusElement = e.element.children[0]; + } + } else { + foundSeparator = true; + } + } else if (e.element instanceof QuickPickItemElement) { + if (!focusElement) { + if (e.element.separator) { + if (this._itemRenderer.isItemWithSeparatorVisible(e.element)) { + this._tree.reveal(e.element); + } else { + this._tree.reveal(e.element, 0); + } + + focusElement = e.element; + } else if (e.element === this._elementTree[0]) { + // We should stop at the first item in the list if it's a regular item. + this._tree.reveal(e.element, 0); + return true; + } + } + } + return false; + }); + if (focusElement) { + this._tree.setFocus([focusElement]); + } + break; + } + } + } + + clearFocus() { + this._tree.setFocus([]); + } + + domFocus() { + this._tree.domFocus(); + } + + layout(maxHeight?: number): void { + this._tree.getHTMLElement().style.maxHeight = maxHeight ? `${ + // Make sure height aligns with list item heights + Math.floor(maxHeight / 44) * 44 + // Add some extra height so that it's clear there's more to scroll + + 6 + }px` : ''; + this._tree.layout(); + } + + filter(query: string): boolean { + if (!(this._sortByLabel || this._matchOnLabel || this._matchOnDescription || this._matchOnDetail)) { + this._tree.layout(); + return false; + } + + const queryWithWhitespace = query; + query = query.trim(); + + // Reset filtering + if (!query || !(this.matchOnLabel || this.matchOnDescription || this.matchOnDetail)) { + this._itemElements.forEach(element => { + element.labelHighlights = undefined; + element.descriptionHighlights = undefined; + element.detailHighlights = undefined; + element.hidden = false; + const previous = element.index && this._inputElements[element.index - 1]; + if (element.item) { + element.separator = previous && previous.type === 'separator' && !previous.buttons ? previous : undefined; + } + }); + } + + // Filter by value (since we support icons in labels, use $(..) aware fuzzy matching) + else { + let currentSeparator: IQuickPickSeparator | undefined; + this._elementTree.forEach(element => { + let labelHighlights: IMatch[] | undefined; + if (this.matchOnLabelMode === 'fuzzy') { + labelHighlights = this.matchOnLabel ? matchesFuzzyIconAware(query, parseLabelWithIcons(element.saneLabel)) ?? undefined : undefined; + } else { + labelHighlights = this.matchOnLabel ? matchesContiguousIconAware(queryWithWhitespace, parseLabelWithIcons(element.saneLabel)) ?? undefined : undefined; + } + const descriptionHighlights = this.matchOnDescription ? matchesFuzzyIconAware(query, parseLabelWithIcons(element.saneDescription || '')) ?? undefined : undefined; + const detailHighlights = this.matchOnDetail ? matchesFuzzyIconAware(query, parseLabelWithIcons(element.saneDetail || '')) ?? undefined : undefined; + + if (labelHighlights || descriptionHighlights || detailHighlights) { + element.labelHighlights = labelHighlights; + element.descriptionHighlights = descriptionHighlights; + element.detailHighlights = detailHighlights; + element.hidden = false; + } else { + element.labelHighlights = undefined; + element.descriptionHighlights = undefined; + element.detailHighlights = undefined; + element.hidden = element.item ? !element.item.alwaysShow : true; + } + + // Ensure separators are filtered out first before deciding if we need to bring them back + if (element.item) { + element.separator = undefined; + } else if (element.separator) { + element.hidden = true; + } + + // we can show the separator unless the list gets sorted by match + if (!this.sortByLabel) { + const previous = element.index && this._inputElements[element.index - 1]; + currentSeparator = previous && previous.type === 'separator' ? previous : currentSeparator; + if (currentSeparator && !element.hidden) { + element.separator = currentSeparator; + currentSeparator = undefined; + } + } + }); + } + + const shownElements = this._elementTree.filter(element => !element.hidden); + + // Sort by value + if (this.sortByLabel && query) { + const normalizedSearchValue = query.toLowerCase(); + shownElements.sort((a, b) => { + return compareEntries(a, b, normalizedSearchValue); + }); + } + + let currentSeparator: QuickPickSeparatorElement | undefined; + const finalElements = shownElements.reduce((result, element, index) => { + if (element instanceof QuickPickItemElement) { + if (currentSeparator) { + currentSeparator.children.push(element); + } else { + result.push(element); + } + } else if (element instanceof QuickPickSeparatorElement) { + element.children = []; + currentSeparator = element; + result.push(element); + } + return result; + }, new Array()); + + const elements = new Array>(); + for (const element of finalElements) { + if (element instanceof QuickPickSeparatorElement) { + elements.push({ + element, + collapsible: false, + collapsed: false, + children: element.children.map(e => ({ + element: e, + collapsible: false, + collapsed: false, + })), + }); + } else { + elements.push({ + element, + collapsible: false, + collapsed: false, + }); + } + } + const before = this._tree.getFocus().length; + this._tree.setChildren(null, elements); + // Temporary fix until we figure out why the tree doesn't fire an event when focus & selection + // get changed to empty arrays. + if (before > 0 && elements.length === 0) { + this._onTriggerEmptySelectionOrFocus.fire({ + elements: [] + }); + } + this._tree.layout(); + + this._onChangedAllVisibleChecked.fire(this.getAllVisibleChecked()); + this._onChangedVisibleCount.fire(shownElements.length); + + return true; + } + + toggleCheckbox() { + try { + this._shouldFireCheckedEvents = false; + const elements = this._tree.getFocus().filter((e): e is QuickPickItemElement => e instanceof QuickPickItemElement); + const allChecked = this._allVisibleChecked(elements); + for (const element of elements) { + if (!element.checkboxDisabled) { + // Would fire an event if we didn't have the flag set + element.checked = !allChecked; + } + } + } finally { + this._shouldFireCheckedEvents = true; + this._fireCheckedEvents(); + } + } + + display(display: boolean) { + this._container.style.display = display ? '' : 'none'; + } + + isDisplayed() { + return this._container.style.display !== 'none'; + } + + style(styles: IListStyles) { + this._tree.style(styles); + } + + toggleHover() { + const focused: IQuickPickElement | null = this._tree.getFocus()[0]; + if (!focused?.saneTooltip || !(focused instanceof QuickPickItemElement)) { + return; + } + + // if there's a hover already, hide it (toggle off) + if (this._lastHover && !this._lastHover.isDisposed) { + this._lastHover.dispose(); + return; + } + + // If there is no hover, show it (toggle on) + this.showHover(focused); + const store = new DisposableStore(); + store.add(this._tree.onDidChangeFocus(e => { + if (e.elements[0] instanceof QuickPickItemElement) { + this.showHover(e.elements[0]); + } + })); + if (this._lastHover) { + store.add(this._lastHover); + } + this._elementDisposable.add(store); + } + + //#endregion + + //#region private methods + + private _allVisibleChecked(elements: QuickPickItemElement[], whenNoneVisible = true) { + for (let i = 0, n = elements.length; i < n; i++) { + const element = elements[i]; + if (!element.hidden) { + if (!element.checked) { + return false; + } else { + whenNoneVisible = true; + } + } + } + return whenNoneVisible; + } + + private _fireCheckedEvents() { + if (!this._shouldFireCheckedEvents) { + return; + } + this._onChangedAllVisibleChecked.fire(this.getAllVisibleChecked()); + this._onChangedCheckedCount.fire(this.getCheckedCount()); + this._onChangedCheckedElements.fire(this.getCheckedElements()); + } + + private fireButtonTriggered(event: IQuickPickItemButtonEvent) { + this._onButtonTriggered.fire(event); + } + + private fireSeparatorButtonTriggered(event: IQuickPickSeparatorButtonEvent) { + this._onSeparatorButtonTriggered.fire(event); + } + + /** + * Disposes of the hover and shows a new one for the given index if it has a tooltip. + * @param element The element to show the hover for + */ + private showHover(element: QuickPickItemElement): void { + if (this._lastHover && !this._lastHover.isDisposed) { + this.hoverDelegate.onDidHideHover?.(); + this._lastHover?.dispose(); + } + + if (!element.element || !element.saneTooltip) { + return; + } + this._lastHover = this.hoverDelegate.showHover({ + content: element.saneTooltip, + target: element.element, + linkHandler: (url) => { + this.linkOpenerDelegate(url); + }, + appearance: { + showPointer: true, + }, + container: this._container, + position: { + hoverPosition: HoverPosition.RIGHT + } + }, false); + } +} + +function matchesContiguousIconAware(query: string, target: IParsedLabelWithIcons): IMatch[] | null { + + const { text, iconOffsets } = target; + + // Return early if there are no icon markers in the word to match against + if (!iconOffsets || iconOffsets.length === 0) { + return matchesContiguous(query, text); + } + + // Trim the word to match against because it could have leading + // whitespace now if the word started with an icon + const wordToMatchAgainstWithoutIconsTrimmed = ltrim(text, ' '); + const leadingWhitespaceOffset = text.length - wordToMatchAgainstWithoutIconsTrimmed.length; + + // match on value without icon + const matches = matchesContiguous(query, wordToMatchAgainstWithoutIconsTrimmed); + + // Map matches back to offsets with icon and trimming + if (matches) { + for (const match of matches) { + const iconOffset = iconOffsets[match.start + leadingWhitespaceOffset] /* icon offsets at index */ + leadingWhitespaceOffset /* overall leading whitespace offset */; + match.start += iconOffset; + match.end += iconOffset; + } + } + + return matches; +} + +function matchesContiguous(word: string, wordToMatchAgainst: string): IMatch[] | null { + const matchIndex = wordToMatchAgainst.toLowerCase().indexOf(word.toLowerCase()); + if (matchIndex !== -1) { + return [{ start: matchIndex, end: matchIndex + word.length }]; + } + return null; +} + +function compareEntries(elementA: IQuickPickElement, elementB: IQuickPickElement, lookFor: string): number { + + const labelHighlightsA = elementA.labelHighlights || []; + const labelHighlightsB = elementB.labelHighlights || []; + if (labelHighlightsA.length && !labelHighlightsB.length) { + return -1; + } + + if (!labelHighlightsA.length && labelHighlightsB.length) { + return 1; + } + + if (labelHighlightsA.length === 0 && labelHighlightsB.length === 0) { + return 0; + } + + return compareAnything(elementA.saneSortLabel, elementB.saneSortLabel, lookFor); +} diff --git a/src/vs/platform/quickinput/common/quickAccess.ts b/src/vs/platform/quickinput/common/quickAccess.ts index 47dc660daca30..c160bb1fb93b6 100644 --- a/src/vs/platform/quickinput/common/quickAccess.ts +++ b/src/vs/platform/quickinput/common/quickAccess.ts @@ -6,7 +6,7 @@ import { coalesce } from 'vs/base/common/arrays'; import { CancellationToken } from 'vs/base/common/cancellation'; import { IDisposable, toDisposable } from 'vs/base/common/lifecycle'; -import { ItemActivation, IQuickNavigateConfiguration, IQuickPick, IQuickPickItem } from 'vs/platform/quickinput/common/quickInput'; +import { ItemActivation, IQuickNavigateConfiguration, IQuickPick, IQuickPickItem, QuickPickItem } from 'vs/platform/quickinput/common/quickInput'; import { Registry } from 'vs/platform/registry/common/platform'; /** @@ -22,6 +22,11 @@ export interface IQuickAccessProviderRunOptions { */ export interface AnythingQuickAccessProviderRunOptions extends IQuickAccessProviderRunOptions { readonly includeHelp?: boolean; + /** + * @deprecated - temporary for Dynamic Chat Variables (see usage) until it has built-in UX for file picking + * Useful for adding items to the top of the list that might contain actions. + */ + readonly additionPicks?: QuickPickItem[]; } export interface IQuickAccessOptions { diff --git a/src/vs/platform/quickinput/common/quickInput.ts b/src/vs/platform/quickinput/common/quickInput.ts index 4ccce2c7120f2..820544bcd69c5 100644 --- a/src/vs/platform/quickinput/common/quickInput.ts +++ b/src/vs/platform/quickinput/common/quickInput.ts @@ -46,6 +46,10 @@ export interface IQuickPickItem { highlights?: IQuickPickItemHighlights; buttons?: readonly IQuickInputButton[]; picked?: boolean; + /** + * Used when we're in multi-select mode. Renders a disabled checkbox. + */ + disabled?: boolean; alwaysShow?: boolean; } @@ -53,6 +57,7 @@ export interface IQuickPickSeparator { type: 'separator'; id?: string; label?: string; + description?: string; ariaLabel?: string; buttons?: readonly IQuickInputButton[]; tooltip?: string | IMarkdownString; @@ -209,6 +214,11 @@ export interface IQuickInput extends IDisposable { */ readonly onDidHide: Event; + /** + * An event that is fired when the quick input will be hidden. + */ + readonly onWillHide: Event; + /** * An event that is fired when the quick input is disposed. */ @@ -285,6 +295,12 @@ export interface IQuickInput extends IDisposable { * @param reason The reason why the quick input was hidden. */ didHide(reason?: QuickInputHideReason): void; + + /** + * Notifies that the quick input will be hidden. + * @param reason The reason why the quick input will be hidden. + */ + willHide(reason?: QuickInputHideReason): void; } export interface IQuickWidget extends IQuickInput { diff --git a/src/vs/platform/quickinput/test/browser/quickinput.test.ts b/src/vs/platform/quickinput/test/browser/quickinput.test.ts index f2a71af555338..216aa3a7f54ac 100644 --- a/src/vs/platform/quickinput/test/browser/quickinput.test.ts +++ b/src/vs/platform/quickinput/test/browser/quickinput.test.ts @@ -6,9 +6,9 @@ import * as assert from 'assert'; import { unthemedInboxStyles } from 'vs/base/browser/ui/inputbox/inputBox'; import { unthemedButtonStyles } from 'vs/base/browser/ui/button/button'; -import { IListRenderer, IListVirtualDelegate } from 'vs/base/browser/ui/list/list'; -import { IListOptions, List, unthemedListStyles } from 'vs/base/browser/ui/list/listWidget'; +import { unthemedListStyles } from 'vs/base/browser/ui/list/listWidget'; import { unthemedToggleStyles } from 'vs/base/browser/ui/toggle/toggle'; +import { Event } from 'vs/base/common/event'; import { raceTimeout } from 'vs/base/common/async'; import { unthemedCountStyles } from 'vs/base/browser/ui/countBadge/countBadge'; import { unthemedKeybindingLabelOptions } from 'vs/base/browser/ui/keybindingLabel/keybindingLabel'; @@ -19,7 +19,19 @@ import { ensureNoDisposablesAreLeakedInTestSuite } from 'vs/base/test/common/uti import { toDisposable } from 'vs/base/common/lifecycle'; import { mainWindow } from 'vs/base/browser/window'; import { QuickPick } from 'vs/platform/quickinput/browser/quickInput'; -import { IQuickPickItem } from 'vs/platform/quickinput/common/quickInput'; +import { IQuickPickItem, ItemActivation } from 'vs/platform/quickinput/common/quickInput'; +import { TestInstantiationService } from 'vs/platform/instantiation/test/common/instantiationServiceMock'; +import { IThemeService } from 'vs/platform/theme/common/themeService'; +import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; +import { TestConfigurationService } from 'vs/platform/configuration/test/common/testConfigurationService'; +import { ILayoutService } from 'vs/platform/layout/browser/layoutService'; +import { IContextViewService } from 'vs/platform/contextview/browser/contextView'; +import { IListService, ListService } from 'vs/platform/list/browser/listService'; +import { IContextKeyService } from 'vs/platform/contextkey/common/contextkey'; +import { ContextKeyService } from 'vs/platform/contextkey/browser/contextKeyService'; +import { NoMatchingKb } from 'vs/platform/keybinding/common/keybindingResolver'; +import { IKeybindingService } from 'vs/platform/keybinding/common/keybinding'; +import { ContextViewService } from 'vs/platform/contextview/browser/contextViewService'; // Sets up an `onShow` listener to allow us to wait until the quick pick is shown (useful when triggering an `accept()` right after launching a quick pick) // kick this off before you launch the picker and then await the promise returned after you launch the picker. @@ -45,50 +57,58 @@ suite('QuickInput', () => { // https://github.com/microsoft/vscode/issues/147543 mainWindow.document.body.appendChild(fixture); store.add(toDisposable(() => mainWindow.document.body.removeChild(fixture))); - controller = store.add(new QuickInputController({ - container: fixture, - idPrefix: 'testQuickInput', - ignoreFocusOut() { return true; }, - returnFocus() { }, - backKeybindingLabel() { return undefined; }, - setContextKey() { return undefined; }, - linkOpenerDelegate(content) { }, - createList: ( - user: string, - container: HTMLElement, - delegate: IListVirtualDelegate, - renderers: IListRenderer[], - options: IListOptions, - ) => new List(user, container, delegate, renderers, options), - hoverDelegate: { - showHover(options, focus) { - return undefined; - }, - delay: 200 - }, - styles: { - button: unthemedButtonStyles, - countBadge: unthemedCountStyles, - inputBox: unthemedInboxStyles, - toggle: unthemedToggleStyles, - keybindingLabel: unthemedKeybindingLabelOptions, - list: unthemedListStyles, - progressBar: unthemedProgressBarOptions, - widget: { - quickInputBackground: undefined, - quickInputForeground: undefined, - quickInputTitleBackground: undefined, - widgetBorder: undefined, - widgetShadow: undefined, + const instantiationService = new TestInstantiationService(); + + // Stub the services the quick input controller needs to function + instantiationService.stub(IThemeService, new TestThemeService()); + instantiationService.stub(IConfigurationService, new TestConfigurationService()); + instantiationService.stub(IListService, store.add(new ListService())); + instantiationService.stub(ILayoutService, { activeContainer: fixture, onDidLayoutContainer: Event.None } as any); + instantiationService.stub(IContextViewService, store.add(instantiationService.createInstance(ContextViewService))); + instantiationService.stub(IContextKeyService, store.add(instantiationService.createInstance(ContextKeyService))); + instantiationService.stub(IKeybindingService, { + mightProducePrintableCharacter() { return false; }, + softDispatch() { return NoMatchingKb; }, + }); + + controller = store.add(instantiationService.createInstance( + QuickInputController, + { + container: fixture, + idPrefix: 'testQuickInput', + ignoreFocusOut() { return true; }, + returnFocus() { }, + backKeybindingLabel() { return undefined; }, + setContextKey() { return undefined; }, + linkOpenerDelegate(content) { }, + hoverDelegate: { + showHover(options, focus) { + return undefined; + }, + delay: 200 }, - pickerGroup: { - pickerGroupBorder: undefined, - pickerGroupForeground: undefined, + styles: { + button: unthemedButtonStyles, + countBadge: unthemedCountStyles, + inputBox: unthemedInboxStyles, + toggle: unthemedToggleStyles, + keybindingLabel: unthemedKeybindingLabelOptions, + list: unthemedListStyles, + progressBar: unthemedProgressBarOptions, + widget: { + quickInputBackground: undefined, + quickInputForeground: undefined, + quickInputTitleBackground: undefined, + widgetBorder: undefined, + widgetShadow: undefined, + }, + pickerGroup: { + pickerGroupBorder: undefined, + pickerGroupForeground: undefined, + } } } - }, - new TestThemeService(), - { activeContainer: fixture } as any)); + )); // initial layout controller.layout({ height: 20, width: 40 }, 0); @@ -218,4 +238,41 @@ suite('QuickInput', () => { // https://github.com/microsoft/vscode/issues/147543 // Since we don't select any items, the selected items should be empty assert.strictEqual(quickpick.selectedItems.length, 0); }); + + test('activeItems - verify onDidChangeActive is triggered after setting items', async () => { + const quickpick = store.add(controller.createQuickPick()); + + // Setup listener for verification + const activeItemsFromEvent: IQuickPickItem[] = []; + store.add(quickpick.onDidChangeActive(items => activeItemsFromEvent.push(...items))); + + quickpick.show(); + + const item = { label: 'step 1' }; + quickpick.items = [item]; + + assert.strictEqual(activeItemsFromEvent.length, 1); + assert.strictEqual(activeItemsFromEvent[0], item); + assert.strictEqual(quickpick.activeItems.length, 1); + assert.strictEqual(quickpick.activeItems[0], item); + }); + + test('activeItems - verify setting itemActivation to None still triggers onDidChangeActive after selection #207832', async () => { + const quickpick = store.add(controller.createQuickPick()); + const item = { label: 'step 1' }; + quickpick.items = [item]; + quickpick.show(); + assert.strictEqual(quickpick.activeItems[0], item); + + // Setup listener for verification + const activeItemsFromEvent: IQuickPickItem[] = []; + store.add(quickpick.onDidChangeActive(items => activeItemsFromEvent.push(...items))); + + // Trigger a change + quickpick.itemActivation = ItemActivation.NONE; + quickpick.items = [item]; + + assert.strictEqual(activeItemsFromEvent.length, 0); + assert.strictEqual(quickpick.activeItems.length, 0); + }); }); diff --git a/src/vs/platform/remote/browser/remoteAuthorityResolverService.ts b/src/vs/platform/remote/browser/remoteAuthorityResolverService.ts index 529d9d74999ec..8b85c237151ca 100644 --- a/src/vs/platform/remote/browser/remoteAuthorityResolverService.ts +++ b/src/vs/platform/remote/browser/remoteAuthorityResolverService.ts @@ -15,7 +15,7 @@ import { URI } from 'vs/base/common/uri'; import { ILogService } from 'vs/platform/log/common/log'; import { IProductService } from 'vs/platform/product/common/productService'; import { IRemoteAuthorityResolverService, IRemoteConnectionData, RemoteConnectionType, ResolvedAuthority, ResolvedOptions, ResolverResult, WebSocketRemoteConnection, getRemoteAuthorityPrefix } from 'vs/platform/remote/common/remoteAuthorityResolver'; -import { getRemoteServerRootPath, parseAuthorityWithOptionalPort } from 'vs/platform/remote/common/remoteHosts'; +import { parseAuthorityWithOptionalPort } from 'vs/platform/remote/common/remoteHosts'; export class RemoteAuthorityResolverService extends Disposable implements IRemoteAuthorityResolverService { @@ -34,6 +34,7 @@ export class RemoteAuthorityResolverService extends Disposable implements IRemot isWorkbenchOptionsBasedResolution: boolean, connectionToken: Promise | string | undefined, resourceUriProvider: ((uri: URI) => URI) | undefined, + serverBasePath: string | undefined, @IProductService productService: IProductService, @ILogService private readonly _logService: ILogService, ) { @@ -44,7 +45,7 @@ export class RemoteAuthorityResolverService extends Disposable implements IRemot if (resourceUriProvider) { RemoteAuthorities.setDelegate(resourceUriProvider); } - RemoteAuthorities.setServerRootPath(getRemoteServerRootPath(productService)); + RemoteAuthorities.setServerRootPath(productService, serverBasePath); } async resolveAuthority(authority: string): Promise { diff --git a/src/vs/platform/remote/common/remoteAgentConnection.ts b/src/vs/platform/remote/common/remoteAgentConnection.ts index 9539650dec0e4..45ebbe8df04ad 100644 --- a/src/vs/platform/remote/common/remoteAgentConnection.ts +++ b/src/vs/platform/remote/common/remoteAgentConnection.ts @@ -9,6 +9,7 @@ import { CancellationToken, CancellationTokenSource } from 'vs/base/common/cance import { isCancellationError, onUnexpectedError } from 'vs/base/common/errors'; import { Emitter } from 'vs/base/common/event'; import { Disposable, DisposableStore, IDisposable, toDisposable } from 'vs/base/common/lifecycle'; +import { RemoteAuthorities } from 'vs/base/common/network'; import * as performance from 'vs/base/common/performance'; import { StopWatch } from 'vs/base/common/stopwatch'; import { generateUuid } from 'vs/base/common/uuid'; @@ -17,7 +18,6 @@ import { Client, ISocket, PersistentProtocol, SocketCloseEventType } from 'vs/ba import { ILogService } from 'vs/platform/log/common/log'; import { RemoteAgentConnectionContext } from 'vs/platform/remote/common/remoteAgentEnvironment'; import { RemoteAuthorityResolverError, RemoteConnection } from 'vs/platform/remote/common/remoteAuthorityResolver'; -import { getRemoteServerRootPath } from 'vs/platform/remote/common/remoteHosts'; import { IRemoteSocketFactoryService } from 'vs/platform/remote/common/remoteSocketFactoryService'; import { ISignService } from 'vs/platform/sign/common/sign'; @@ -232,7 +232,7 @@ async function connectToRemoteExtensionHostAgent(opt let socket: ISocket; try { - socket = await createSocket(options.logService, options.remoteSocketFactoryService, options.connectTo, getRemoteServerRootPath(options), `reconnectionToken=${options.reconnectionToken}&reconnection=${options.reconnectionProtocol ? 'true' : 'false'}`, connectionTypeToString(connectionType), `renderer-${connectionTypeToString(connectionType)}-${options.reconnectionToken}`, timeoutCancellationToken); + socket = await createSocket(options.logService, options.remoteSocketFactoryService, options.connectTo, RemoteAuthorities.getServerRootPath(), `reconnectionToken=${options.reconnectionToken}&reconnection=${options.reconnectionProtocol ? 'true' : 'false'}`, connectionTypeToString(connectionType), `renderer-${connectionTypeToString(connectionType)}-${options.reconnectionToken}`, timeoutCancellationToken); } catch (error) { options.logService.error(`${logPrefix} socketFactory.connect() failed or timed out. Error:`); options.logService.error(error); diff --git a/src/vs/platform/remote/common/remoteHosts.ts b/src/vs/platform/remote/common/remoteHosts.ts index ccc99953c8d48..ccf58f9accbad 100644 --- a/src/vs/platform/remote/common/remoteHosts.ts +++ b/src/vs/platform/remote/common/remoteHosts.ts @@ -25,15 +25,6 @@ export function getRemoteName(authority: string | undefined): string | undefined return authority.substr(0, pos); } -/** - * The root path to use when accessing the remote server. The path contains the quality and commit of the current build. - * @param product - * @returns - */ -export function getRemoteServerRootPath(product: { quality?: string; commit?: string }): string { - return `/${product.quality ?? 'oss'}-${product.commit ?? 'dev'}`; -} - export function parseAuthorityWithPort(authority: string): { host: string; port: number } { const { host, port } = parseAuthority(authority); if (typeof port === 'undefined') { diff --git a/src/vs/platform/remote/electron-sandbox/remoteAuthorityResolverService.ts b/src/vs/platform/remote/electron-sandbox/remoteAuthorityResolverService.ts index debbe333ae8bd..9948495f89862 100644 --- a/src/vs/platform/remote/electron-sandbox/remoteAuthorityResolverService.ts +++ b/src/vs/platform/remote/electron-sandbox/remoteAuthorityResolverService.ts @@ -11,7 +11,6 @@ import { RemoteAuthorities } from 'vs/base/common/network'; import { URI } from 'vs/base/common/uri'; import { IProductService } from 'vs/platform/product/common/productService'; import { IRemoteAuthorityResolverService, IRemoteConnectionData, RemoteConnectionType, ResolvedAuthority, ResolvedOptions, ResolverResult } from 'vs/platform/remote/common/remoteAuthorityResolver'; -import { getRemoteServerRootPath } from 'vs/platform/remote/common/remoteHosts'; import { ElectronRemoteResourceLoader } from 'vs/platform/remote/electron-sandbox/electronRemoteResourceLoader'; export class RemoteAuthorityResolverService extends Disposable implements IRemoteAuthorityResolverService { @@ -33,7 +32,7 @@ export class RemoteAuthorityResolverService extends Disposable implements IRemot this._canonicalURIRequests = new Map(); this._canonicalURIProvider = null; - RemoteAuthorities.setServerRootPath(getRemoteServerRootPath(productService)); + RemoteAuthorities.setServerRootPath(productService, undefined); // on the desktop we don't support custom server base paths } resolveAuthority(authority: string): Promise { diff --git a/src/vs/platform/remoteTunnel/node/remoteTunnelService.ts b/src/vs/platform/remoteTunnel/node/remoteTunnelService.ts index 22c7d83535afa..30dc15f3db553 100644 --- a/src/vs/platform/remoteTunnel/node/remoteTunnelService.ts +++ b/src/vs/platform/remoteTunnel/node/remoteTunnelService.ts @@ -26,8 +26,8 @@ import { joinPath } from 'vs/base/common/resources'; type RemoteTunnelEnablementClassification = { owner: 'aeschli'; comment: 'Reporting when Remote Tunnel access is turned on or off'; - enabled?: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Flag indicating if Remote Tunnel Access is enabled or not' }; - service?: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Flag indicating if Remote Tunnel Access is installed as a service' }; + enabled?: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Flag indicating if Remote Tunnel Access is enabled or not' }; + service?: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Flag indicating if Remote Tunnel Access is installed as a service' }; }; type RemoteTunnelEnablementEvent = { diff --git a/src/vs/platform/sharedProcess/electron-main/sharedProcess.ts b/src/vs/platform/sharedProcess/electron-main/sharedProcess.ts index 66defd005d19d..087f5858b4831 100644 --- a/src/vs/platform/sharedProcess/electron-main/sharedProcess.ts +++ b/src/vs/platform/sharedProcess/electron-main/sharedProcess.ts @@ -6,7 +6,7 @@ import { IpcMainEvent, MessagePortMain } from 'electron'; import { validatedIpcMain } from 'vs/base/parts/ipc/electron-main/ipcMain'; import { Barrier, DeferredPromise } from 'vs/base/common/async'; -import { Disposable } from 'vs/base/common/lifecycle'; +import { Disposable, IDisposable } from 'vs/base/common/lifecycle'; import { IEnvironmentMainService } from 'vs/platform/environment/electron-main/environmentMainService'; import { ILifecycleMainService } from 'vs/platform/lifecycle/electron-main/lifecycleMainService'; import { ILogService } from 'vs/platform/log/common/log'; @@ -25,6 +25,7 @@ export class SharedProcess extends Disposable { private readonly firstWindowConnectionBarrier = new Barrier(); private utilityProcess: UtilityProcess | undefined = undefined; + private utilityProcessLogListener: IDisposable | undefined = undefined; constructor( private readonly machineId: string, @@ -104,13 +105,10 @@ export class SharedProcess extends Disposable { // all services within have been created. const whenReady = new DeferredPromise(); - if (this.utilityProcess) { - this.utilityProcess.once(SharedProcessLifecycle.initDone, () => whenReady.complete()); - } else { - validatedIpcMain.once(SharedProcessLifecycle.initDone, () => whenReady.complete()); - } + this.utilityProcess?.once(SharedProcessLifecycle.initDone, () => whenReady.complete()); await whenReady.p; + this.utilityProcessLogListener?.dispose(); this.logService.trace('[SharedProcess] Overall ready'); })(); } @@ -131,11 +129,7 @@ export class SharedProcess extends Disposable { // Wait for shared process indicating that IPC connections are accepted const sharedProcessIpcReady = new DeferredPromise(); - if (this.utilityProcess) { - this.utilityProcess.once(SharedProcessLifecycle.ipcReady, () => sharedProcessIpcReady.complete()); - } else { - validatedIpcMain.once(SharedProcessLifecycle.ipcReady, () => sharedProcessIpcReady.complete()); - } + this.utilityProcess?.once(SharedProcessLifecycle.ipcReady, () => sharedProcessIpcReady.complete()); await sharedProcessIpcReady.p; this.logService.trace('[SharedProcess] IPC ready'); @@ -148,6 +142,15 @@ export class SharedProcess extends Disposable { private createUtilityProcess(): void { this.utilityProcess = this._register(new UtilityProcess(this.logService, NullTelemetryService, this.lifecycleMainService)); + // Install a log listener for very early shared process warnings and errors + this.utilityProcessLogListener = this.utilityProcess.onMessage((e: any) => { + if (typeof e.warning === 'string') { + this.logService.warn(e.warning); + } else if (typeof e.error === 'string') { + this.logService.error(e.error); + } + }); + const inspectParams = parseSharedProcessDebugPort(this.environmentMainService.args, this.environmentMainService.isBuilt); let execArgv: string[] | undefined = undefined; if (inspectParams.port) { diff --git a/src/vs/platform/sign/browser/signService.ts b/src/vs/platform/sign/browser/signService.ts index dadf7a0f418e4..a9d699bf3b297 100644 --- a/src/vs/platform/sign/browser/signService.ts +++ b/src/vs/platform/sign/browser/signService.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { WindowIntervalTimer } from 'vs/base/browser/dom'; -import { $window } from 'vs/base/browser/window'; +import { mainWindow } from 'vs/base/browser/window'; import { memoize } from 'vs/base/common/decorators'; import { FileAccess } from 'vs/base/common/network'; import { IProductService } from 'vs/platform/product/common/productService'; @@ -70,7 +70,7 @@ export class SignService extends AbstractSignService implements ISignService { if (typeof vsda_web !== 'undefined') { resolve(); } - }, 50, $window); + }, 50, mainWindow); }).finally(() => checkInterval.dispose()), ]); diff --git a/src/vs/platform/telemetry/common/errorTelemetry.ts b/src/vs/platform/telemetry/common/errorTelemetry.ts index 22c005fe2a644..a872cbb4dee59 100644 --- a/src/vs/platform/telemetry/common/errorTelemetry.ts +++ b/src/vs/platform/telemetry/common/errorTelemetry.ts @@ -16,11 +16,11 @@ type ErrorEventFragment = { callstack: { classification: 'CallstackOrException'; purpose: 'PerformanceAndHealth'; comment: 'The callstack of the error.' }; msg?: { classification: 'CallstackOrException'; purpose: 'PerformanceAndHealth'; comment: 'The message of the error. Normally the first line int the callstack.' }; file?: { classification: 'CallstackOrException'; purpose: 'PerformanceAndHealth'; comment: 'The file the error originated from.' }; - line?: { classification: 'CallstackOrException'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'The line the error originate on.' }; - column?: { classification: 'CallstackOrException'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'The column of the line which the error orginated on.' }; + line?: { classification: 'CallstackOrException'; purpose: 'PerformanceAndHealth'; comment: 'The line the error originate on.' }; + column?: { classification: 'CallstackOrException'; purpose: 'PerformanceAndHealth'; comment: 'The column of the line which the error orginated on.' }; uncaught_error_name?: { classification: 'CallstackOrException'; purpose: 'PerformanceAndHealth'; comment: 'If the error is uncaught what is the error type' }; uncaught_error_msg?: { classification: 'CallstackOrException'; purpose: 'PerformanceAndHealth'; comment: 'If the error is uncaught this is just msg but for uncaught errors.' }; - count?: { classification: 'CallstackOrException'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'How many times this error has been thrown' }; + count?: { classification: 'CallstackOrException'; purpose: 'PerformanceAndHealth'; comment: 'How many times this error has been thrown' }; }; export interface ErrorEvent { callstack: string; diff --git a/src/vs/platform/telemetry/common/gdprTypings.ts b/src/vs/platform/telemetry/common/gdprTypings.ts index 903c0e698f77a..20638f84273f3 100644 --- a/src/vs/platform/telemetry/common/gdprTypings.ts +++ b/src/vs/platform/telemetry/common/gdprTypings.ts @@ -8,7 +8,6 @@ export interface IPropertyData { comment: string; expiration?: string; endpoint?: string; - isMeasurement?: boolean; } export interface IGDPRProperty { diff --git a/src/vs/platform/telemetry/common/telemetryUtils.ts b/src/vs/platform/telemetry/common/telemetryUtils.ts index 5deb494c3b469..c26c33886cce0 100644 --- a/src/vs/platform/telemetry/common/telemetryUtils.ts +++ b/src/vs/platform/telemetry/common/telemetryUtils.ts @@ -333,6 +333,7 @@ function removePropertiesWithPossibleUserInfo(property: string): string { { label: 'Slack Token', regex: /xox[pbar]\-[A-Za-z0-9]/ }, { label: 'GitHub Token', regex: /(gh[psuro]_[a-zA-Z0-9]{36}|github_pat_[a-zA-Z0-9]{22}_[a-zA-Z0-9]{59})/ }, { label: 'Generic Secret', regex: /(key|token|sig|secret|signature|password|passwd|pwd|android:value)[^a-zA-Z0-9]/i }, + { label: 'CLI Credentials', regex: /((login|psexec|(certutil|psexec)\.exe).{1,50}(\s-u(ser(name)?)?\s+.{3,100})?\s-(admin|user|vm|root)?p(ass(word)?)?\s+["']?[^$\-\/\s]|(^|[\s\r\n\\])net(\.exe)?.{1,5}(user\s+|share\s+\/user:| user -? secrets ? set) \s + [^ $\s \/])/ }, { label: 'Email', regex: /@[a-zA-Z0-9-]+\.[a-zA-Z0-9-]+/ } // Regex which matches @*.site ]; diff --git a/src/vs/platform/telemetry/test/browser/1dsAppender.test.ts b/src/vs/platform/telemetry/test/browser/1dsAppender.test.ts index 8c16031dede9c..2ee6f9bc99b6c 100644 --- a/src/vs/platform/telemetry/test/browser/1dsAppender.test.ts +++ b/src/vs/platform/telemetry/test/browser/1dsAppender.test.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import type { ITelemetryItem, ITelemetryUnloadState } from '@microsoft/1ds-core-js'; import * as assert from 'assert'; +import { ensureNoDisposablesAreLeakedInTestSuite } from 'vs/base/test/common/utils'; import { OneDataSystemWebAppender } from 'vs/platform/telemetry/browser/1dsAppender'; import { IAppInsightsCore } from 'vs/platform/telemetry/common/1dsAppender'; @@ -28,14 +29,17 @@ suite('AIAdapter', () => { const prefix = 'prefix'; + teardown(() => { + adapter.flush(); + }); + + ensureNoDisposablesAreLeakedInTestSuite(); + setup(() => { appInsightsMock = new AppInsightsCoreMock(); adapter = new OneDataSystemWebAppender(false, prefix, undefined!, () => appInsightsMock); }); - teardown(() => { - adapter.flush(); - }); test('Simple event', () => { adapter.log('testEvent'); diff --git a/src/vs/platform/terminal/common/capabilities/capabilities.ts b/src/vs/platform/terminal/common/capabilities/capabilities.ts index debdaf7a9266c..8c6575f872ee2 100644 --- a/src/vs/platform/terminal/common/capabilities/capabilities.ts +++ b/src/vs/platform/terminal/common/capabilities/capabilities.ts @@ -175,7 +175,7 @@ export interface ICommandDetectionCapability { readonly currentCommand: ICurrentPartialCommand | undefined; readonly onCommandStarted: Event; readonly onCommandFinished: Event; - readonly onCommandExecuted: Event; + readonly onCommandExecuted: Event; readonly onCommandInvalidated: Event; readonly onCurrentCommandInvalidated: Event; setCwd(value: string): void; diff --git a/src/vs/platform/terminal/common/capabilities/commandDetection/terminalCommand.ts b/src/vs/platform/terminal/common/capabilities/commandDetection/terminalCommand.ts index 1fffd61bfe02c..afcae0058190f 100644 --- a/src/vs/platform/terminal/common/capabilities/commandDetection/terminalCommand.ts +++ b/src/vs/platform/terminal/common/capabilities/commandDetection/terminalCommand.ts @@ -263,6 +263,7 @@ export class PartialTerminalCommand implements ICurrentPartialCommand { currentContinuationMarker?: IMarker; continuations?: { marker: IMarker; end: number }[]; + cwd?: string; command?: string; isTrusted?: boolean; diff --git a/src/vs/platform/terminal/common/capabilities/commandDetectionCapability.ts b/src/vs/platform/terminal/common/capabilities/commandDetectionCapability.ts index df3cb4ce9ee7c..7a00fd0a2eda7 100644 --- a/src/vs/platform/terminal/common/capabilities/commandDetectionCapability.ts +++ b/src/vs/platform/terminal/common/capabilities/commandDetectionCapability.ts @@ -35,7 +35,7 @@ export class CommandDetectionCapability extends Disposable implements ICommandDe private _commitCommandFinished?: RunOnceScheduler; private _ptyHeuristicsHooks: ICommandDetectionHeuristicsHooks; - private _ptyHeuristics: MandatoryMutableDisposable; + private readonly _ptyHeuristics: MandatoryMutableDisposable; get commands(): readonly TerminalCommand[] { return this._commands; } get executingCommand(): string | undefined { return this._currentCommand.command; } @@ -74,7 +74,7 @@ export class CommandDetectionCapability extends Disposable implements ICommandDe readonly onBeforeCommandFinished = this._onBeforeCommandFinished.event; private readonly _onCommandFinished = this._register(new Emitter()); readonly onCommandFinished = this._onCommandFinished.event; - private readonly _onCommandExecuted = this._register(new Emitter()); + private readonly _onCommandExecuted = this._register(new Emitter()); readonly onCommandExecuted = this._onCommandExecuted.event; private readonly _onCommandInvalidated = this._register(new Emitter()); readonly onCommandInvalidated = this._onCommandInvalidated.event; @@ -281,6 +281,7 @@ export class CommandDetectionCapability extends Disposable implements ICommandDe handleCommandStart(options?: IHandleCommandOptions): void { this._handleCommandStartOptions = options; + this._currentCommand.cwd = this._cwd; // Only update the column if the line has already been set this._currentCommand.commandStartMarker = options?.marker || this._currentCommand.commandStartMarker; if (this._currentCommand.commandStartMarker?.line === this._terminal.buffer.active.cursorY) { @@ -408,7 +409,7 @@ export class CommandDetectionCapability extends Disposable implements ICommandDe interface ICommandDetectionHeuristicsHooks { readonly onCurrentCommandInvalidatedEmitter: Emitter; readonly onCommandStartedEmitter: Emitter; - readonly onCommandExecutedEmitter: Emitter; + readonly onCommandExecutedEmitter: Emitter; readonly dimensions: ITerminalDimensions; readonly isCommandStorageDisabled: boolean; @@ -495,12 +496,12 @@ class UnixPtyHeuristics extends Disposable { if (y === commandExecutedLine) { currentCommand.command += this._terminal.buffer.active.getLine(commandExecutedLine)?.translateToString(true, undefined, currentCommand.commandExecutedX) || ''; } - this._hooks.onCommandExecutedEmitter.fire(); + this._hooks.onCommandExecutedEmitter.fire(currentCommand as ITerminalCommand); } } const enum AdjustCommandStartMarkerConstants { - MaxCheckLineCount = 5, + MaxCheckLineCount = 10, Interval = 20, MaximumPollCount = 50, } @@ -513,9 +514,7 @@ const enum AdjustCommandStartMarkerConstants { */ class WindowsPtyHeuristics extends Disposable { - private _onCursorMoveListener = this._register(new MutableDisposable()); - - private _recentlyPerformedCsiJ = false; + private readonly _onCursorMoveListener = this._register(new MutableDisposable()); private _tryAdjustCommandStartMarkerScheduler?: RunOnceScheduler; private _tryAdjustCommandStartMarkerScannedLineCount: number = 0; @@ -530,8 +529,8 @@ class WindowsPtyHeuristics extends Disposable { super(); this._register(_terminal.parser.registerCsiHandler({ final: 'J' }, params => { + // Clear commands when the viewport is cleared if (params.length >= 1 && (params[0] === 2 || params[0] === 3)) { - this._recentlyPerformedCsiJ = true; this._hooks.clearCommandsInViewport(); } // We don't want to override xterm.js' default behavior, just augment it @@ -539,11 +538,6 @@ class WindowsPtyHeuristics extends Disposable { })); this._register(this._capability.onBeforeCommandFinished(command => { - if (this._recentlyPerformedCsiJ) { - this._recentlyPerformedCsiJ = false; - return; - } - // For older Windows backends we cannot listen to CSI J, instead we assume running clear // or cls will clear all commands in the viewport. This is not perfect but it's right // most of the time. @@ -740,7 +734,7 @@ class WindowsPtyHeuristics extends Disposable { this._onCursorMoveListener.clear(); this._evaluateCommandMarkers(); this._capability.currentCommand.commandExecutedX = this._terminal.buffer.active.cursorX; - this._hooks.onCommandExecutedEmitter.fire(); + this._hooks.onCommandExecutedEmitter.fire(this._capability.currentCommand as ITerminalCommand); this._logService.debug('CommandDetectionCapability#handleCommandExecuted', this._capability.currentCommand.commandExecutedX, this._capability.currentCommand.commandExecutedMarker?.line); } @@ -834,7 +828,7 @@ class WindowsPtyHeuristics extends Disposable { } this._capability.currentCommand.commandExecutedMarker = this._hooks.commandMarkers[this._hooks.commandMarkers.length - 1]; // Fire this now to prevent issues like #197409 - this._hooks.onCommandExecutedEmitter.fire(); + this._hooks.onCommandExecutedEmitter.fire(this._capability.currentCommand as ITerminalCommand); } private _cursorOnNextLine(): boolean { @@ -903,6 +897,15 @@ class WindowsPtyHeuristics extends Disposable { } } + // Bash Prompt + const bashPrompt = lineText.match(/^(?.*\$)/)?.groups?.prompt; + if (bashPrompt) { + const adjustedPrompt = this._adjustPrompt(bashPrompt, lineText, '$'); + if (adjustedPrompt) { + return adjustedPrompt; + } + } + // Python Prompt const pythonPrompt = lineText.match(/^(?>>> )/g)?.groups?.prompt; if (pythonPrompt) { diff --git a/src/vs/platform/terminal/common/terminal.ts b/src/vs/platform/terminal/common/terminal.ts index ffe0e56a1890a..fd8e698e99111 100644 --- a/src/vs/platform/terminal/common/terminal.ts +++ b/src/vs/platform/terminal/common/terminal.ts @@ -75,10 +75,12 @@ export const enum TerminalSettingId { TerminalTitle = 'terminal.integrated.tabs.title', TerminalDescription = 'terminal.integrated.tabs.description', RightClickBehavior = 'terminal.integrated.rightClickBehavior', + MiddleClickBehavior = 'terminal.integrated.middleClickBehavior', Cwd = 'terminal.integrated.cwd', ConfirmOnExit = 'terminal.integrated.confirmOnExit', ConfirmOnKill = 'terminal.integrated.confirmOnKill', EnableBell = 'terminal.integrated.enableBell', + EnableVisualBell = 'terminal.integrated.enableVisualBell', CommandsToSkipShell = 'terminal.integrated.commandsToSkipShell', AllowChords = 'terminal.integrated.allowChords', AllowMnemonics = 'terminal.integrated.allowMnemonics', @@ -93,6 +95,7 @@ export const enum TerminalSettingId { WindowsEnableConpty = 'terminal.integrated.windowsEnableConpty', WordSeparators = 'terminal.integrated.wordSeparators', EnableFileLinks = 'terminal.integrated.enableFileLinks', + AllowedLinkSchemes = 'terminal.integrated.allowedLinkSchemes', UnicodeVersion = 'terminal.integrated.unicodeVersion', LocalEchoLatencyThreshold = 'terminal.integrated.localEchoLatencyThreshold', LocalEchoEnabled = 'terminal.integrated.localEchoEnabled', @@ -102,6 +105,7 @@ export const enum TerminalSettingId { PersistentSessionReviveProcess = 'terminal.integrated.persistentSessionReviveProcess', HideOnStartup = 'terminal.integrated.hideOnStartup', CustomGlyphs = 'terminal.integrated.customGlyphs', + RescaleOverlappingGlyphs = 'terminal.integrated.rescaleOverlappingGlyphs', PersistentSessionScrollback = 'terminal.integrated.persistentSessionScrollback', InheritEnv = 'terminal.integrated.inheritEnv', ShowLinkHover = 'terminal.integrated.showLinkHover', @@ -121,6 +125,7 @@ export const enum TerminalSettingId { StickyScrollEnabled = 'terminal.integrated.stickyScroll.enabled', StickyScrollMaxLineCount = 'terminal.integrated.stickyScroll.maxLineCount', MouseWheelZoom = 'terminal.integrated.mouseWheelZoom', + ExperimentalInlineChat = 'terminal.integrated.experimentalInlineChat', // Debug settings that are hidden from user @@ -140,12 +145,14 @@ export const enum PosixShellType { Csh = 'csh', Ksh = 'ksh', Zsh = 'zsh', + Python = 'python' } export const enum WindowsShellType { CommandPrompt = 'cmd', PowerShell = 'pwsh', Wsl = 'wsl', - GitBash = 'gitbash' + GitBash = 'gitbash', + Python = 'python' } export type TerminalShellType = PosixShellType | WindowsShellType; @@ -565,7 +572,7 @@ export interface IShellLaunchConfig { * until `Terminal.show` is called. The typical usage for this is when you need to run * something that may need interactivity but only want to tell the user about it when * interaction is needed. Note that the terminals will still be exposed to all extensions - * as normal and they will remain hidden when the workspace is reloaded. + * as normal. The hidden terminals will not be restored when the workspace is next opened. */ hideFromUser?: boolean; @@ -608,6 +615,12 @@ export interface IShellLaunchConfig { */ isTransient?: boolean; + /** + * Attempt to force shell integration to be enabled by bypassing the {@link isFeatureTerminal} + * equals false requirement. + */ + forceShellIntegration?: boolean; + /** * Create a terminal without shell integration even when it's enabled */ diff --git a/src/vs/platform/terminal/common/terminalEnvironment.ts b/src/vs/platform/terminal/common/terminalEnvironment.ts index 38e8fa2c66968..5ddf2aa71d641 100644 --- a/src/vs/platform/terminal/common/terminalEnvironment.ts +++ b/src/vs/platform/terminal/common/terminalEnvironment.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { OperatingSystem, OS } from 'vs/base/common/platform'; +import type { IShellLaunchConfig } from 'vs/platform/terminal/common/terminal'; /** * Aggressively escape non-windows paths to prepare for being sent to a shell. This will do some @@ -59,3 +60,11 @@ export function sanitizeCwd(cwd: string): string { } return cwd; } + +/** + * Determines whether the given shell launch config should use the environment variable collection. + * @param slc The shell launch config to check. + */ +export function shouldUseEnvironmentVariableCollection(slc: IShellLaunchConfig): boolean { + return !slc.strictEnv; +} diff --git a/src/vs/platform/terminal/common/xterm/shellIntegrationAddon.ts b/src/vs/platform/terminal/common/xterm/shellIntegrationAddon.ts index 5f5dc956aa545..798fc50f934cc 100644 --- a/src/vs/platform/terminal/common/xterm/shellIntegrationAddon.ts +++ b/src/vs/platform/terminal/common/xterm/shellIntegrationAddon.ts @@ -97,7 +97,10 @@ const enum VSCodeOscPt { /** * Explicitly set the command line. This helps workaround performance and reliability problems * with parsing out the command, such as conpty not guaranteeing the position of the sequence or - * the shell not guaranteeing that the entire command is even visible. + * the shell not guaranteeing that the entire command is even visible. Ideally this is called + * immediately before {@link CommandExecuted}, immediately before {@link CommandFinished} will + * also work but that means terminal will only know the accurate command line when the command is + * finished. * * The command line can escape ascii characters using the `\xAB` format, where AB are the * hexadecimal representation of the character code (case insensitive), and escape the `\` diff --git a/src/vs/platform/terminal/node/terminalEnvironment.ts b/src/vs/platform/terminal/node/terminalEnvironment.ts index 066ca8f6bc131..813fbe5c43251 100644 --- a/src/vs/platform/terminal/node/terminalEnvironment.ts +++ b/src/vs/platform/terminal/node/terminalEnvironment.ts @@ -112,12 +112,23 @@ export function getShellIntegrationInjection( logService: ILogService, productService: IProductService ): IShellIntegrationConfigInjection | undefined { - // Shell integration arg injection is disabled when: + // Conditionally disable shell integration arg injection // - The global setting is disabled // - There is no executable (not sure what script to run) // - The terminal is used by a feature like tasks or debugging const useWinpty = isWindows && (!options.windowsEnableConpty || getWindowsBuildNumber() < 18309); - if (!options.shellIntegration.enabled || !shellLaunchConfig.executable || shellLaunchConfig.isFeatureTerminal || shellLaunchConfig.hideFromUser || shellLaunchConfig.ignoreShellIntegration || useWinpty) { + if ( + // The global setting is disabled + !options.shellIntegration.enabled || + // There is no executable (so there's no way to determine how to inject) + !shellLaunchConfig.executable || + // It's a feature terminal (tasks, debug), unless it's explicitly being forced + (shellLaunchConfig.isFeatureTerminal && !shellLaunchConfig.forceShellIntegration) || + // The ignoreShellIntegration flag is passed (eg. relaunching without shell integration) + shellLaunchConfig.ignoreShellIntegration || + // Winpty is unsupported + useWinpty + ) { return undefined; } @@ -150,6 +161,20 @@ export function getShellIntegrationInjection( envMixin['VSCODE_SUGGEST'] = '1'; } return { newArgs, envMixin }; + } else if (shell === 'bash.exe') { + if (!originalArgs || originalArgs.length === 0) { + newArgs = shellIntegrationArgs.get(ShellIntegrationExecutable.Bash); + } else if (areZshBashLoginArgs(originalArgs)) { + envMixin['VSCODE_SHELL_LOGIN'] = '1'; + addEnvMixinPathPrefix(options, envMixin); + newArgs = shellIntegrationArgs.get(ShellIntegrationExecutable.Bash); + } + if (!newArgs) { + return undefined; + } + newArgs = [...newArgs]; // Shallow clone the array to avoid setting the default array + newArgs[newArgs.length - 1] = format(newArgs[newArgs.length - 1], appRoot); + return { newArgs, envMixin }; } logService.warn(`Shell integration cannot be enabled for executable "${shellLaunchConfig.executable}" and args`, shellLaunchConfig.args); return undefined; @@ -299,16 +324,18 @@ shellIntegrationArgs.set(ShellIntegrationExecutable.PwshLogin, ['-l', '-noexit', shellIntegrationArgs.set(ShellIntegrationExecutable.Zsh, ['-i']); shellIntegrationArgs.set(ShellIntegrationExecutable.ZshLogin, ['-il']); shellIntegrationArgs.set(ShellIntegrationExecutable.Bash, ['--init-file', '{0}/out/vs/workbench/contrib/terminal/browser/media/shellIntegration-bash.sh']); -const loginArgs = ['-login', '-l']; +const pwshLoginArgs = ['-login', '-l']; +const shLoginArgs = ['--login', '-l']; +const shInteractiveArgs = ['-i', '--interactive']; const pwshImpliedArgs = ['-nol', '-nologo']; function arePwshLoginArgs(originalArgs: string | string[]): boolean { if (typeof originalArgs === 'string') { - return loginArgs.includes(originalArgs.toLowerCase()); + return pwshLoginArgs.includes(originalArgs.toLowerCase()); } else { - return originalArgs.length === 1 && loginArgs.includes(originalArgs[0].toLowerCase()) || + return originalArgs.length === 1 && pwshLoginArgs.includes(originalArgs[0].toLowerCase()) || (originalArgs.length === 2 && - (((loginArgs.includes(originalArgs[0].toLowerCase())) || loginArgs.includes(originalArgs[1].toLowerCase()))) + (((pwshLoginArgs.includes(originalArgs[0].toLowerCase())) || pwshLoginArgs.includes(originalArgs[1].toLowerCase()))) && ((pwshImpliedArgs.includes(originalArgs[0].toLowerCase())) || pwshImpliedArgs.includes(originalArgs[1].toLowerCase()))); } } @@ -322,6 +349,9 @@ function arePwshImpliedArgs(originalArgs: string | string[]): boolean { } function areZshBashLoginArgs(originalArgs: string | string[]): boolean { - return originalArgs === 'string' && loginArgs.includes(originalArgs.toLowerCase()) - || typeof originalArgs !== 'string' && originalArgs.length === 1 && loginArgs.includes(originalArgs[0].toLowerCase()); + if (typeof originalArgs !== 'string') { + originalArgs = originalArgs.filter(arg => !shInteractiveArgs.includes(arg.toLowerCase())); + } + return originalArgs === 'string' && shLoginArgs.includes(originalArgs.toLowerCase()) + || typeof originalArgs !== 'string' && originalArgs.length === 1 && shLoginArgs.includes(originalArgs[0].toLowerCase()); } diff --git a/src/vs/platform/terminal/node/terminalProcess.ts b/src/vs/platform/terminal/node/terminalProcess.ts index 023b6dc66e05d..79be9013c8115 100644 --- a/src/vs/platform/terminal/node/terminalProcess.ts +++ b/src/vs/platform/terminal/node/terminalProcess.ts @@ -73,6 +73,7 @@ const posixShellTypeMap = new Map([ ['ksh', PosixShellType.Ksh], ['sh', PosixShellType.Sh], ['pwsh', PosixShellType.PowerShell], + ['python', PosixShellType.Python], ['zsh', PosixShellType.Zsh] ]); @@ -404,7 +405,12 @@ export class TerminalProcess extends Disposable implements ITerminalChildProcess this._onDidChangeProperty.fire({ type: ProcessPropertyType.Title, value: this._currentTitle }); // If fig is installed it may change the title of the process const sanitizedTitle = this.currentTitle.replace(/ \(figterm\)$/g, ''); - this._onDidChangeProperty.fire({ type: ProcessPropertyType.ShellType, value: posixShellTypeMap.get(sanitizedTitle) }); + + if (sanitizedTitle.toLowerCase().startsWith('python')) { + this._onDidChangeProperty.fire({ type: ProcessPropertyType.ShellType, value: PosixShellType.Python }); + } else { + this._onDidChangeProperty.fire({ type: ProcessPropertyType.ShellType, value: posixShellTypeMap.get(sanitizedTitle) }); + } } shutdown(immediate: boolean): void { diff --git a/src/vs/platform/terminal/node/windowsShellHelper.ts b/src/vs/platform/terminal/node/windowsShellHelper.ts index 1def6545d6908..e9fcb6f8130d7 100644 --- a/src/vs/platform/terminal/node/windowsShellHelper.ts +++ b/src/vs/platform/terminal/node/windowsShellHelper.ts @@ -152,6 +152,9 @@ export class WindowsShellHelper extends Disposable implements IWindowsShellHelpe case 'sles-12.exe': return WindowsShellType.Wsl; default: + if (executable.match(/python(\d(\.\d{0,2})?)?\.exe/)) { + return WindowsShellType.Python; + } return undefined; } } diff --git a/src/vs/platform/theme/browser/defaultStyles.ts b/src/vs/platform/theme/browser/defaultStyles.ts index a455c0ec61418..7549840266a45 100644 --- a/src/vs/platform/theme/browser/defaultStyles.ts +++ b/src/vs/platform/theme/browser/defaultStyles.ts @@ -174,6 +174,9 @@ export const defaultListStyles: IListStyles = { listHoverOutline: asCssVariable(activeContrastBorder), treeIndentGuidesStroke: asCssVariable(treeIndentGuidesStroke), treeInactiveIndentGuidesStroke: asCssVariable(treeInactiveIndentGuidesStroke), + treeStickyScrollBackground: undefined, + treeStickyScrollBorder: undefined, + treeStickyScrollShadow: undefined, tableColumnsBorder: asCssVariable(tableColumnsBorder), tableOddRowsBackgroundColor: asCssVariable(tableOddRowsBackgroundColor), }; @@ -216,6 +219,9 @@ export const defaultSelectBoxStyles: ISelectBoxStyles = { tableOddRowsBackgroundColor: undefined, treeIndentGuidesStroke: undefined, treeInactiveIndentGuidesStroke: undefined, + treeStickyScrollBackground: undefined, + treeStickyScrollBorder: undefined, + treeStickyScrollShadow: undefined }; export function getSelectBoxStyles(override: IStyleOverride): ISelectBoxStyles { diff --git a/src/vs/platform/theme/common/colorRegistry.ts b/src/vs/platform/theme/common/colorRegistry.ts index 3702702f5428b..82b65f7a795c3 100644 --- a/src/vs/platform/theme/common/colorRegistry.ts +++ b/src/vs/platform/theme/common/colorRegistry.ts @@ -3,698 +3,16 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { assertNever } from 'vs/base/common/assert'; -import { RunOnceScheduler } from 'vs/base/common/async'; -import { Color, RGBA } from 'vs/base/common/color'; -import { Emitter, Event } from 'vs/base/common/event'; -import { IJSONSchema, IJSONSchemaMap } from 'vs/base/common/jsonSchema'; -import * as nls from 'vs/nls'; -import { IJSONContributionRegistry, Extensions as JSONExtensions } from 'vs/platform/jsonschemas/common/jsonContributionRegistry'; -import * as platform from 'vs/platform/registry/common/platform'; -import { IColorTheme } from 'vs/platform/theme/common/themeService'; - -// ------ API types - -export type ColorIdentifier = string; - -export interface ColorContribution { - readonly id: ColorIdentifier; - readonly description: string; - readonly defaults: ColorDefaults | null; - readonly needsTransparency: boolean; - readonly deprecationMessage: string | undefined; -} - -/** - * Returns the css variable name for the given color identifier. Dots (`.`) are replaced with hyphens (`-`) and - * everything is prefixed with `--vscode-`. - * - * @sample `editorSuggestWidget.background` is `--vscode-editorSuggestWidget-background`. - */ -export function asCssVariableName(colorIdent: ColorIdentifier): string { - return `--vscode-${colorIdent.replace(/\./g, '-')}`; -} - -export function asCssVariable(color: ColorIdentifier): string { - return `var(${asCssVariableName(color)})`; -} - -export function asCssVariableWithDefault(color: ColorIdentifier, defaultCssValue: string): string { - return `var(${asCssVariableName(color)}, ${defaultCssValue})`; -} - -export const enum ColorTransformType { - Darken, - Lighten, - Transparent, - Opaque, - OneOf, - LessProminent, - IfDefinedThenElse -} - -export type ColorTransform = - | { op: ColorTransformType.Darken; value: ColorValue; factor: number } - | { op: ColorTransformType.Lighten; value: ColorValue; factor: number } - | { op: ColorTransformType.Transparent; value: ColorValue; factor: number } - | { op: ColorTransformType.Opaque; value: ColorValue; background: ColorValue } - | { op: ColorTransformType.OneOf; values: readonly ColorValue[] } - | { op: ColorTransformType.LessProminent; value: ColorValue; background: ColorValue; factor: number; transparency: number } - | { op: ColorTransformType.IfDefinedThenElse; if: ColorIdentifier; then: ColorValue; else: ColorValue }; - -export interface ColorDefaults { - light: ColorValue | null; - dark: ColorValue | null; - hcDark: ColorValue | null; - hcLight: ColorValue | null; -} - - -/** - * A Color Value is either a color literal, a reference to an other color or a derived color - */ -export type ColorValue = Color | string | ColorIdentifier | ColorTransform; - -// color registry -export const Extensions = { - ColorContribution: 'base.contributions.colors' -}; - -export interface IColorRegistry { - - readonly onDidChangeSchema: Event; - - /** - * Register a color to the registry. - * @param id The color id as used in theme description files - * @param defaults The default values - * @param needsTransparency Whether the color requires transparency - * @description the description - */ - registerColor(id: string, defaults: ColorDefaults, description: string, needsTransparency?: boolean): ColorIdentifier; - - /** - * Register a color to the registry. - */ - deregisterColor(id: string): void; - - /** - * Get all color contributions - */ - getColors(): ColorContribution[]; - - /** - * Gets the default color of the given id - */ - resolveDefaultColor(id: ColorIdentifier, theme: IColorTheme): Color | undefined; - - /** - * JSON schema for an object to assign color values to one of the color contributions. - */ - getColorSchema(): IJSONSchema; - - /** - * JSON schema to for a reference to a color contribution. - */ - getColorReferenceSchema(): IJSONSchema; - -} - -class ColorRegistry implements IColorRegistry { - - private readonly _onDidChangeSchema = new Emitter(); - readonly onDidChangeSchema: Event = this._onDidChangeSchema.event; - - private colorsById: { [key: string]: ColorContribution }; - private colorSchema: IJSONSchema & { properties: IJSONSchemaMap } = { type: 'object', properties: {} }; - private colorReferenceSchema: IJSONSchema & { enum: string[]; enumDescriptions: string[] } = { type: 'string', enum: [], enumDescriptions: [] }; - - constructor() { - this.colorsById = {}; - } - - public registerColor(id: string, defaults: ColorDefaults | null, description: string, needsTransparency = false, deprecationMessage?: string): ColorIdentifier { - const colorContribution: ColorContribution = { id, description, defaults, needsTransparency, deprecationMessage }; - this.colorsById[id] = colorContribution; - const propertySchema: IJSONSchema = { type: 'string', description, format: 'color-hex', defaultSnippets: [{ body: '${1:#ff0000}' }] }; - if (deprecationMessage) { - propertySchema.deprecationMessage = deprecationMessage; - } - if (needsTransparency) { - propertySchema.pattern = '^#(?:(?[0-9a-fA-f]{3}[0-9a-eA-E])|(?:[0-9a-fA-F]{6}(?:(?![fF]{2})(?:[0-9a-fA-F]{2}))))?$'; - propertySchema.patternErrorMessage = 'This color must be transparent or it will obscure content'; - } - this.colorSchema.properties[id] = propertySchema; - this.colorReferenceSchema.enum.push(id); - this.colorReferenceSchema.enumDescriptions.push(description); - - this._onDidChangeSchema.fire(); - return id; - } - - - public deregisterColor(id: string): void { - delete this.colorsById[id]; - delete this.colorSchema.properties[id]; - const index = this.colorReferenceSchema.enum.indexOf(id); - if (index !== -1) { - this.colorReferenceSchema.enum.splice(index, 1); - this.colorReferenceSchema.enumDescriptions.splice(index, 1); - } - this._onDidChangeSchema.fire(); - } - - public getColors(): ColorContribution[] { - return Object.keys(this.colorsById).map(id => this.colorsById[id]); - } - - public resolveDefaultColor(id: ColorIdentifier, theme: IColorTheme): Color | undefined { - const colorDesc = this.colorsById[id]; - if (colorDesc && colorDesc.defaults) { - const colorValue = colorDesc.defaults[theme.type]; - return resolveColorValue(colorValue, theme); - } - return undefined; - } - - public getColorSchema(): IJSONSchema { - return this.colorSchema; - } - - public getColorReferenceSchema(): IJSONSchema { - return this.colorReferenceSchema; - } - - public toString() { - const sorter = (a: string, b: string) => { - const cat1 = a.indexOf('.') === -1 ? 0 : 1; - const cat2 = b.indexOf('.') === -1 ? 0 : 1; - if (cat1 !== cat2) { - return cat1 - cat2; - } - return a.localeCompare(b); - }; - - return Object.keys(this.colorsById).sort(sorter).map(k => `- \`${k}\`: ${this.colorsById[k].description}`).join('\n'); - } - -} - -const colorRegistry = new ColorRegistry(); -platform.Registry.add(Extensions.ColorContribution, colorRegistry); - - -export function registerColor(id: string, defaults: ColorDefaults | null, description: string, needsTransparency?: boolean, deprecationMessage?: string): ColorIdentifier { - return colorRegistry.registerColor(id, defaults, description, needsTransparency, deprecationMessage); -} - -export function getColorRegistry(): IColorRegistry { - return colorRegistry; -} - -// ----- base colors - -export const foreground = registerColor('foreground', { dark: '#CCCCCC', light: '#616161', hcDark: '#FFFFFF', hcLight: '#292929' }, nls.localize('foreground', "Overall foreground color. This color is only used if not overridden by a component.")); -export const disabledForeground = registerColor('disabledForeground', { dark: '#CCCCCC80', light: '#61616180', hcDark: '#A5A5A5', hcLight: '#7F7F7F' }, nls.localize('disabledForeground', "Overall foreground for disabled elements. This color is only used if not overridden by a component.")); -export const errorForeground = registerColor('errorForeground', { dark: '#F48771', light: '#A1260D', hcDark: '#F48771', hcLight: '#B5200D' }, nls.localize('errorForeground', "Overall foreground color for error messages. This color is only used if not overridden by a component.")); -export const descriptionForeground = registerColor('descriptionForeground', { light: '#717171', dark: transparent(foreground, 0.7), hcDark: transparent(foreground, 0.7), hcLight: transparent(foreground, 0.7) }, nls.localize('descriptionForeground', "Foreground color for description text providing additional information, for example for a label.")); -export const iconForeground = registerColor('icon.foreground', { dark: '#C5C5C5', light: '#424242', hcDark: '#FFFFFF', hcLight: '#292929' }, nls.localize('iconForeground', "The default color for icons in the workbench.")); - -export const focusBorder = registerColor('focusBorder', { dark: '#007FD4', light: '#0090F1', hcDark: '#F38518', hcLight: '#006BBD' }, nls.localize('focusBorder', "Overall border color for focused elements. This color is only used if not overridden by a component.")); - -export const contrastBorder = registerColor('contrastBorder', { light: null, dark: null, hcDark: '#6FC3DF', hcLight: '#0F4A85' }, nls.localize('contrastBorder', "An extra border around elements to separate them from others for greater contrast.")); -export const activeContrastBorder = registerColor('contrastActiveBorder', { light: null, dark: null, hcDark: focusBorder, hcLight: focusBorder }, nls.localize('activeContrastBorder', "An extra border around active elements to separate them from others for greater contrast.")); - -export const selectionBackground = registerColor('selection.background', { light: null, dark: null, hcDark: null, hcLight: null }, nls.localize('selectionBackground', "The background color of text selections in the workbench (e.g. for input fields or text areas). Note that this does not apply to selections within the editor.")); - -// ------ text colors - -export const textSeparatorForeground = registerColor('textSeparator.foreground', { light: '#0000002e', dark: '#ffffff2e', hcDark: Color.black, hcLight: '#292929' }, nls.localize('textSeparatorForeground', "Color for text separators.")); -export const textLinkForeground = registerColor('textLink.foreground', { light: '#006AB1', dark: '#3794FF', hcDark: '#3794FF', hcLight: '#0F4A85' }, nls.localize('textLinkForeground', "Foreground color for links in text.")); -export const textLinkActiveForeground = registerColor('textLink.activeForeground', { light: '#006AB1', dark: '#3794FF', hcDark: '#3794FF', hcLight: '#0F4A85' }, nls.localize('textLinkActiveForeground', "Foreground color for links in text when clicked on and on mouse hover.")); -export const textPreformatForeground = registerColor('textPreformat.foreground', { light: '#A31515', dark: '#D7BA7D', hcDark: '#000000', hcLight: '#FFFFFF' }, nls.localize('textPreformatForeground', "Foreground color for preformatted text segments.")); -export const textPreformatBackground = registerColor('textPreformat.background', { light: '#0000001A', dark: '#FFFFFF1A', hcDark: '#FFFFFF', hcLight: '#09345f' }, nls.localize('textPreformatBackground', "Background color for preformatted text segments.")); -export const textBlockQuoteBackground = registerColor('textBlockQuote.background', { light: '#f2f2f2', dark: '#222222', hcDark: null, hcLight: '#F2F2F2' }, nls.localize('textBlockQuoteBackground', "Background color for block quotes in text.")); -export const textBlockQuoteBorder = registerColor('textBlockQuote.border', { light: '#007acc80', dark: '#007acc80', hcDark: Color.white, hcLight: '#292929' }, nls.localize('textBlockQuoteBorder', "Border color for block quotes in text.")); -export const textCodeBlockBackground = registerColor('textCodeBlock.background', { light: '#dcdcdc66', dark: '#0a0a0a66', hcDark: Color.black, hcLight: '#F2F2F2' }, nls.localize('textCodeBlockBackground', "Background color for code blocks in text.")); - -// ----- widgets -export const widgetShadow = registerColor('widget.shadow', { dark: transparent(Color.black, .36), light: transparent(Color.black, .16), hcDark: null, hcLight: null }, nls.localize('widgetShadow', 'Shadow color of widgets such as find/replace inside the editor.')); -export const widgetBorder = registerColor('widget.border', { dark: null, light: null, hcDark: contrastBorder, hcLight: contrastBorder }, nls.localize('widgetBorder', 'Border color of widgets such as find/replace inside the editor.')); - -export const inputBackground = registerColor('input.background', { dark: '#3C3C3C', light: Color.white, hcDark: Color.black, hcLight: Color.white }, nls.localize('inputBoxBackground', "Input box background.")); -export const inputForeground = registerColor('input.foreground', { dark: foreground, light: foreground, hcDark: foreground, hcLight: foreground }, nls.localize('inputBoxForeground', "Input box foreground.")); -export const inputBorder = registerColor('input.border', { dark: null, light: null, hcDark: contrastBorder, hcLight: contrastBorder }, nls.localize('inputBoxBorder', "Input box border.")); -export const inputActiveOptionBorder = registerColor('inputOption.activeBorder', { dark: '#007ACC', light: '#007ACC', hcDark: contrastBorder, hcLight: contrastBorder }, nls.localize('inputBoxActiveOptionBorder', "Border color of activated options in input fields.")); -export const inputActiveOptionHoverBackground = registerColor('inputOption.hoverBackground', { dark: '#5a5d5e80', light: '#b8b8b850', hcDark: null, hcLight: null }, nls.localize('inputOption.hoverBackground', "Background color of activated options in input fields.")); -export const inputActiveOptionBackground = registerColor('inputOption.activeBackground', { dark: transparent(focusBorder, 0.4), light: transparent(focusBorder, 0.2), hcDark: Color.transparent, hcLight: Color.transparent }, nls.localize('inputOption.activeBackground', "Background hover color of options in input fields.")); -export const inputActiveOptionForeground = registerColor('inputOption.activeForeground', { dark: Color.white, light: Color.black, hcDark: foreground, hcLight: foreground }, nls.localize('inputOption.activeForeground', "Foreground color of activated options in input fields.")); -export const inputPlaceholderForeground = registerColor('input.placeholderForeground', { light: transparent(foreground, 0.5), dark: transparent(foreground, 0.5), hcDark: transparent(foreground, 0.7), hcLight: transparent(foreground, 0.7) }, nls.localize('inputPlaceholderForeground', "Input box foreground color for placeholder text.")); - -export const inputValidationInfoBackground = registerColor('inputValidation.infoBackground', { dark: '#063B49', light: '#D6ECF2', hcDark: Color.black, hcLight: Color.white }, nls.localize('inputValidationInfoBackground', "Input validation background color for information severity.")); -export const inputValidationInfoForeground = registerColor('inputValidation.infoForeground', { dark: null, light: null, hcDark: null, hcLight: foreground }, nls.localize('inputValidationInfoForeground', "Input validation foreground color for information severity.")); -export const inputValidationInfoBorder = registerColor('inputValidation.infoBorder', { dark: '#007acc', light: '#007acc', hcDark: contrastBorder, hcLight: contrastBorder }, nls.localize('inputValidationInfoBorder', "Input validation border color for information severity.")); -export const inputValidationWarningBackground = registerColor('inputValidation.warningBackground', { dark: '#352A05', light: '#F6F5D2', hcDark: Color.black, hcLight: Color.white }, nls.localize('inputValidationWarningBackground', "Input validation background color for warning severity.")); -export const inputValidationWarningForeground = registerColor('inputValidation.warningForeground', { dark: null, light: null, hcDark: null, hcLight: foreground }, nls.localize('inputValidationWarningForeground', "Input validation foreground color for warning severity.")); -export const inputValidationWarningBorder = registerColor('inputValidation.warningBorder', { dark: '#B89500', light: '#B89500', hcDark: contrastBorder, hcLight: contrastBorder }, nls.localize('inputValidationWarningBorder', "Input validation border color for warning severity.")); -export const inputValidationErrorBackground = registerColor('inputValidation.errorBackground', { dark: '#5A1D1D', light: '#F2DEDE', hcDark: Color.black, hcLight: Color.white }, nls.localize('inputValidationErrorBackground', "Input validation background color for error severity.")); -export const inputValidationErrorForeground = registerColor('inputValidation.errorForeground', { dark: null, light: null, hcDark: null, hcLight: foreground }, nls.localize('inputValidationErrorForeground', "Input validation foreground color for error severity.")); -export const inputValidationErrorBorder = registerColor('inputValidation.errorBorder', { dark: '#BE1100', light: '#BE1100', hcDark: contrastBorder, hcLight: contrastBorder }, nls.localize('inputValidationErrorBorder', "Input validation border color for error severity.")); - -export const selectBackground = registerColor('dropdown.background', { dark: '#3C3C3C', light: Color.white, hcDark: Color.black, hcLight: Color.white }, nls.localize('dropdownBackground', "Dropdown background.")); -export const selectListBackground = registerColor('dropdown.listBackground', { dark: null, light: null, hcDark: Color.black, hcLight: Color.white }, nls.localize('dropdownListBackground', "Dropdown list background.")); -export const selectForeground = registerColor('dropdown.foreground', { dark: '#F0F0F0', light: foreground, hcDark: Color.white, hcLight: foreground }, nls.localize('dropdownForeground', "Dropdown foreground.")); -export const selectBorder = registerColor('dropdown.border', { dark: selectBackground, light: '#CECECE', hcDark: contrastBorder, hcLight: contrastBorder }, nls.localize('dropdownBorder', "Dropdown border.")); - -export const buttonForeground = registerColor('button.foreground', { dark: Color.white, light: Color.white, hcDark: Color.white, hcLight: Color.white }, nls.localize('buttonForeground', "Button foreground color.")); -export const buttonSeparator = registerColor('button.separator', { dark: transparent(buttonForeground, .4), light: transparent(buttonForeground, .4), hcDark: transparent(buttonForeground, .4), hcLight: transparent(buttonForeground, .4) }, nls.localize('buttonSeparator', "Button separator color.")); -export const buttonBackground = registerColor('button.background', { dark: '#0E639C', light: '#007ACC', hcDark: null, hcLight: '#0F4A85' }, nls.localize('buttonBackground', "Button background color.")); -export const buttonHoverBackground = registerColor('button.hoverBackground', { dark: lighten(buttonBackground, 0.2), light: darken(buttonBackground, 0.2), hcDark: buttonBackground, hcLight: buttonBackground }, nls.localize('buttonHoverBackground', "Button background color when hovering.")); -export const buttonBorder = registerColor('button.border', { dark: contrastBorder, light: contrastBorder, hcDark: contrastBorder, hcLight: contrastBorder }, nls.localize('buttonBorder', "Button border color.")); - -export const buttonSecondaryForeground = registerColor('button.secondaryForeground', { dark: Color.white, light: Color.white, hcDark: Color.white, hcLight: foreground }, nls.localize('buttonSecondaryForeground', "Secondary button foreground color.")); -export const buttonSecondaryBackground = registerColor('button.secondaryBackground', { dark: '#3A3D41', light: '#5F6A79', hcDark: null, hcLight: Color.white }, nls.localize('buttonSecondaryBackground', "Secondary button background color.")); -export const buttonSecondaryHoverBackground = registerColor('button.secondaryHoverBackground', { dark: lighten(buttonSecondaryBackground, 0.2), light: darken(buttonSecondaryBackground, 0.2), hcDark: null, hcLight: null }, nls.localize('buttonSecondaryHoverBackground', "Secondary button background color when hovering.")); - -export const badgeBackground = registerColor('badge.background', { dark: '#4D4D4D', light: '#C4C4C4', hcDark: Color.black, hcLight: '#0F4A85' }, nls.localize('badgeBackground', "Badge background color. Badges are small information labels, e.g. for search results count.")); -export const badgeForeground = registerColor('badge.foreground', { dark: Color.white, light: '#333', hcDark: Color.white, hcLight: Color.white }, nls.localize('badgeForeground', "Badge foreground color. Badges are small information labels, e.g. for search results count.")); - -export const scrollbarShadow = registerColor('scrollbar.shadow', { dark: '#000000', light: '#DDDDDD', hcDark: null, hcLight: null }, nls.localize('scrollbarShadow', "Scrollbar shadow to indicate that the view is scrolled.")); -export const scrollbarSliderBackground = registerColor('scrollbarSlider.background', { dark: Color.fromHex('#797979').transparent(0.4), light: Color.fromHex('#646464').transparent(0.4), hcDark: transparent(contrastBorder, 0.6), hcLight: transparent(contrastBorder, 0.4) }, nls.localize('scrollbarSliderBackground', "Scrollbar slider background color.")); -export const scrollbarSliderHoverBackground = registerColor('scrollbarSlider.hoverBackground', { dark: Color.fromHex('#646464').transparent(0.7), light: Color.fromHex('#646464').transparent(0.7), hcDark: transparent(contrastBorder, 0.8), hcLight: transparent(contrastBorder, 0.8) }, nls.localize('scrollbarSliderHoverBackground', "Scrollbar slider background color when hovering.")); -export const scrollbarSliderActiveBackground = registerColor('scrollbarSlider.activeBackground', { dark: Color.fromHex('#BFBFBF').transparent(0.4), light: Color.fromHex('#000000').transparent(0.6), hcDark: contrastBorder, hcLight: contrastBorder }, nls.localize('scrollbarSliderActiveBackground', "Scrollbar slider background color when clicked on.")); - -export const progressBarBackground = registerColor('progressBar.background', { dark: Color.fromHex('#0E70C0'), light: Color.fromHex('#0E70C0'), hcDark: contrastBorder, hcLight: contrastBorder }, nls.localize('progressBarBackground', "Background color of the progress bar that can show for long running operations.")); - -export const editorErrorBackground = registerColor('editorError.background', { dark: null, light: null, hcDark: null, hcLight: null }, nls.localize('editorError.background', 'Background color of error text in the editor. The color must not be opaque so as not to hide underlying decorations.'), true); -export const editorErrorForeground = registerColor('editorError.foreground', { dark: '#F14C4C', light: '#E51400', hcDark: '#F48771', hcLight: '#B5200D' }, nls.localize('editorError.foreground', 'Foreground color of error squigglies in the editor.')); -export const editorErrorBorder = registerColor('editorError.border', { dark: null, light: null, hcDark: Color.fromHex('#E47777').transparent(0.8), hcLight: '#B5200D' }, nls.localize('errorBorder', 'If set, color of double underlines for errors in the editor.')); - -export const editorWarningBackground = registerColor('editorWarning.background', { dark: null, light: null, hcDark: null, hcLight: null }, nls.localize('editorWarning.background', 'Background color of warning text in the editor. The color must not be opaque so as not to hide underlying decorations.'), true); -export const editorWarningForeground = registerColor('editorWarning.foreground', { dark: '#CCA700', light: '#BF8803', hcDark: '#FFD370', hcLight: '#895503' }, nls.localize('editorWarning.foreground', 'Foreground color of warning squigglies in the editor.')); -export const editorWarningBorder = registerColor('editorWarning.border', { dark: null, light: null, hcDark: Color.fromHex('#FFCC00').transparent(0.8), hcLight: Color.fromHex('#FFCC00').transparent(0.8) }, nls.localize('warningBorder', 'If set, color of double underlines for warnings in the editor.')); - -export const editorInfoBackground = registerColor('editorInfo.background', { dark: null, light: null, hcDark: null, hcLight: null }, nls.localize('editorInfo.background', 'Background color of info text in the editor. The color must not be opaque so as not to hide underlying decorations.'), true); -export const editorInfoForeground = registerColor('editorInfo.foreground', { dark: '#3794FF', light: '#1a85ff', hcDark: '#3794FF', hcLight: '#1a85ff' }, nls.localize('editorInfo.foreground', 'Foreground color of info squigglies in the editor.')); -export const editorInfoBorder = registerColor('editorInfo.border', { dark: null, light: null, hcDark: Color.fromHex('#3794FF').transparent(0.8), hcLight: '#292929' }, nls.localize('infoBorder', 'If set, color of double underlines for infos in the editor.')); - -export const editorHintForeground = registerColor('editorHint.foreground', { dark: Color.fromHex('#eeeeee').transparent(0.7), light: '#6c6c6c', hcDark: null, hcLight: null }, nls.localize('editorHint.foreground', 'Foreground color of hint squigglies in the editor.')); -export const editorHintBorder = registerColor('editorHint.border', { dark: null, light: null, hcDark: Color.fromHex('#eeeeee').transparent(0.8), hcLight: '#292929' }, nls.localize('hintBorder', 'If set, color of double underlines for hints in the editor.')); - -export const sashHoverBorder = registerColor('sash.hoverBorder', { dark: focusBorder, light: focusBorder, hcDark: focusBorder, hcLight: focusBorder }, nls.localize('sashActiveBorder', "Border color of active sashes.")); - -/** - * Editor background color. - */ -export const editorBackground = registerColor('editor.background', { light: '#ffffff', dark: '#1E1E1E', hcDark: Color.black, hcLight: Color.white }, nls.localize('editorBackground', "Editor background color.")); - -/** - * Editor foreground color. - */ -export const editorForeground = registerColor('editor.foreground', { light: '#333333', dark: '#BBBBBB', hcDark: Color.white, hcLight: foreground }, nls.localize('editorForeground', "Editor default foreground color.")); - -/** - * Sticky scroll - */ -export const editorStickyScrollBackground = registerColor('editorStickyScroll.background', { light: editorBackground, dark: editorBackground, hcDark: editorBackground, hcLight: editorBackground }, nls.localize('editorStickyScrollBackground', "Background color of sticky scroll in the editor")); -export const editorStickyScrollHoverBackground = registerColor('editorStickyScrollHover.background', { dark: '#2A2D2E', light: '#F0F0F0', hcDark: null, hcLight: Color.fromHex('#0F4A85').transparent(0.1) }, nls.localize('editorStickyScrollHoverBackground', "Background color of sticky scroll on hover in the editor")); -export const editorStickyScrollBorder = registerColor('editorStickyScroll.border', { dark: null, light: null, hcDark: contrastBorder, hcLight: contrastBorder }, nls.localize('editorStickyScrollBorder', "Border color of sticky scroll in the editor")); -export const editorStickyScrollShadow = registerColor('editorStickyScroll.shadow', { dark: scrollbarShadow, light: scrollbarShadow, hcDark: scrollbarShadow, hcLight: scrollbarShadow }, nls.localize('editorStickyScrollShadow', " Shadow color of sticky scroll in the editor")); - -/** - * Editor widgets - */ -export const editorWidgetBackground = registerColor('editorWidget.background', { dark: '#252526', light: '#F3F3F3', hcDark: '#0C141F', hcLight: Color.white }, nls.localize('editorWidgetBackground', 'Background color of editor widgets, such as find/replace.')); -export const editorWidgetForeground = registerColor('editorWidget.foreground', { dark: foreground, light: foreground, hcDark: foreground, hcLight: foreground }, nls.localize('editorWidgetForeground', 'Foreground color of editor widgets, such as find/replace.')); -export const editorWidgetBorder = registerColor('editorWidget.border', { dark: '#454545', light: '#C8C8C8', hcDark: contrastBorder, hcLight: contrastBorder }, nls.localize('editorWidgetBorder', 'Border color of editor widgets. The color is only used if the widget chooses to have a border and if the color is not overridden by a widget.')); -export const editorWidgetResizeBorder = registerColor('editorWidget.resizeBorder', { light: null, dark: null, hcDark: null, hcLight: null }, nls.localize('editorWidgetResizeBorder', "Border color of the resize bar of editor widgets. The color is only used if the widget chooses to have a resize border and if the color is not overridden by a widget.")); - -/** - * Quick pick widget - */ -export const quickInputBackground = registerColor('quickInput.background', { dark: editorWidgetBackground, light: editorWidgetBackground, hcDark: editorWidgetBackground, hcLight: editorWidgetBackground }, nls.localize('pickerBackground', "Quick picker background color. The quick picker widget is the container for pickers like the command palette.")); -export const quickInputForeground = registerColor('quickInput.foreground', { dark: editorWidgetForeground, light: editorWidgetForeground, hcDark: editorWidgetForeground, hcLight: editorWidgetForeground }, nls.localize('pickerForeground', "Quick picker foreground color. The quick picker widget is the container for pickers like the command palette.")); -export const quickInputTitleBackground = registerColor('quickInputTitle.background', { dark: new Color(new RGBA(255, 255, 255, 0.105)), light: new Color(new RGBA(0, 0, 0, 0.06)), hcDark: '#000000', hcLight: Color.white }, nls.localize('pickerTitleBackground', "Quick picker title background color. The quick picker widget is the container for pickers like the command palette.")); -export const pickerGroupForeground = registerColor('pickerGroup.foreground', { dark: '#3794FF', light: '#0066BF', hcDark: Color.white, hcLight: '#0F4A85' }, nls.localize('pickerGroupForeground', "Quick picker color for grouping labels.")); -export const pickerGroupBorder = registerColor('pickerGroup.border', { dark: '#3F3F46', light: '#CCCEDB', hcDark: Color.white, hcLight: '#0F4A85' }, nls.localize('pickerGroupBorder', "Quick picker color for grouping borders.")); - -/** - * Keybinding label - */ -export const keybindingLabelBackground = registerColor('keybindingLabel.background', { dark: new Color(new RGBA(128, 128, 128, 0.17)), light: new Color(new RGBA(221, 221, 221, 0.4)), hcDark: Color.transparent, hcLight: Color.transparent }, nls.localize('keybindingLabelBackground', "Keybinding label background color. The keybinding label is used to represent a keyboard shortcut.")); -export const keybindingLabelForeground = registerColor('keybindingLabel.foreground', { dark: Color.fromHex('#CCCCCC'), light: Color.fromHex('#555555'), hcDark: Color.white, hcLight: foreground }, nls.localize('keybindingLabelForeground', "Keybinding label foreground color. The keybinding label is used to represent a keyboard shortcut.")); -export const keybindingLabelBorder = registerColor('keybindingLabel.border', { dark: new Color(new RGBA(51, 51, 51, 0.6)), light: new Color(new RGBA(204, 204, 204, 0.4)), hcDark: new Color(new RGBA(111, 195, 223)), hcLight: contrastBorder }, nls.localize('keybindingLabelBorder', "Keybinding label border color. The keybinding label is used to represent a keyboard shortcut.")); -export const keybindingLabelBottomBorder = registerColor('keybindingLabel.bottomBorder', { dark: new Color(new RGBA(68, 68, 68, 0.6)), light: new Color(new RGBA(187, 187, 187, 0.4)), hcDark: new Color(new RGBA(111, 195, 223)), hcLight: foreground }, nls.localize('keybindingLabelBottomBorder', "Keybinding label border bottom color. The keybinding label is used to represent a keyboard shortcut.")); - -/** - * Editor selection colors. - */ -export const editorSelectionBackground = registerColor('editor.selectionBackground', { light: '#ADD6FF', dark: '#264F78', hcDark: '#f3f518', hcLight: '#0F4A85' }, nls.localize('editorSelectionBackground', "Color of the editor selection.")); -export const editorSelectionForeground = registerColor('editor.selectionForeground', { light: null, dark: null, hcDark: '#000000', hcLight: Color.white }, nls.localize('editorSelectionForeground', "Color of the selected text for high contrast.")); -export const editorInactiveSelection = registerColor('editor.inactiveSelectionBackground', { light: transparent(editorSelectionBackground, 0.5), dark: transparent(editorSelectionBackground, 0.5), hcDark: transparent(editorSelectionBackground, 0.7), hcLight: transparent(editorSelectionBackground, 0.5) }, nls.localize('editorInactiveSelection', "Color of the selection in an inactive editor. The color must not be opaque so as not to hide underlying decorations."), true); -export const editorSelectionHighlight = registerColor('editor.selectionHighlightBackground', { light: lessProminent(editorSelectionBackground, editorBackground, 0.3, 0.6), dark: lessProminent(editorSelectionBackground, editorBackground, 0.3, 0.6), hcDark: null, hcLight: null }, nls.localize('editorSelectionHighlight', 'Color for regions with the same content as the selection. The color must not be opaque so as not to hide underlying decorations.'), true); -export const editorSelectionHighlightBorder = registerColor('editor.selectionHighlightBorder', { light: null, dark: null, hcDark: activeContrastBorder, hcLight: activeContrastBorder }, nls.localize('editorSelectionHighlightBorder', "Border color for regions with the same content as the selection.")); - - -/** - * Editor find match colors. - */ -export const editorFindMatch = registerColor('editor.findMatchBackground', { light: '#A8AC94', dark: '#515C6A', hcDark: null, hcLight: null }, nls.localize('editorFindMatch', "Color of the current search match.")); -export const editorFindMatchHighlight = registerColor('editor.findMatchHighlightBackground', { light: '#EA5C0055', dark: '#EA5C0055', hcDark: null, hcLight: null }, nls.localize('findMatchHighlight', "Color of the other search matches. The color must not be opaque so as not to hide underlying decorations."), true); -export const editorFindRangeHighlight = registerColor('editor.findRangeHighlightBackground', { dark: '#3a3d4166', light: '#b4b4b44d', hcDark: null, hcLight: null }, nls.localize('findRangeHighlight', "Color of the range limiting the search. The color must not be opaque so as not to hide underlying decorations."), true); -export const editorFindMatchBorder = registerColor('editor.findMatchBorder', { light: null, dark: null, hcDark: activeContrastBorder, hcLight: activeContrastBorder }, nls.localize('editorFindMatchBorder', "Border color of the current search match.")); -export const editorFindMatchHighlightBorder = registerColor('editor.findMatchHighlightBorder', { light: null, dark: null, hcDark: activeContrastBorder, hcLight: activeContrastBorder }, nls.localize('findMatchHighlightBorder', "Border color of the other search matches.")); -export const editorFindRangeHighlightBorder = registerColor('editor.findRangeHighlightBorder', { dark: null, light: null, hcDark: transparent(activeContrastBorder, 0.4), hcLight: transparent(activeContrastBorder, 0.4) }, nls.localize('findRangeHighlightBorder', "Border color of the range limiting the search. The color must not be opaque so as not to hide underlying decorations."), true); - -/** - * Search Editor query match colors. - * - * Distinct from normal editor find match to allow for better differentiation - */ -export const searchEditorFindMatch = registerColor('searchEditor.findMatchBackground', { light: transparent(editorFindMatchHighlight, 0.66), dark: transparent(editorFindMatchHighlight, 0.66), hcDark: editorFindMatchHighlight, hcLight: editorFindMatchHighlight }, nls.localize('searchEditor.queryMatch', "Color of the Search Editor query matches.")); -export const searchEditorFindMatchBorder = registerColor('searchEditor.findMatchBorder', { light: transparent(editorFindMatchHighlightBorder, 0.66), dark: transparent(editorFindMatchHighlightBorder, 0.66), hcDark: editorFindMatchHighlightBorder, hcLight: editorFindMatchHighlightBorder }, nls.localize('searchEditor.editorFindMatchBorder', "Border color of the Search Editor query matches.")); - -/** - * Search Viewlet colors. - */ -export const searchResultsInfoForeground = registerColor('search.resultsInfoForeground', { light: foreground, dark: transparent(foreground, 0.65), hcDark: foreground, hcLight: foreground }, nls.localize('search.resultsInfoForeground', "Color of the text in the search viewlet's completion message.")); - -/** - * Editor hover - */ -export const editorHoverHighlight = registerColor('editor.hoverHighlightBackground', { light: '#ADD6FF26', dark: '#264f7840', hcDark: '#ADD6FF26', hcLight: null }, nls.localize('hoverHighlight', 'Highlight below the word for which a hover is shown. The color must not be opaque so as not to hide underlying decorations.'), true); -export const editorHoverBackground = registerColor('editorHoverWidget.background', { light: editorWidgetBackground, dark: editorWidgetBackground, hcDark: editorWidgetBackground, hcLight: editorWidgetBackground }, nls.localize('hoverBackground', 'Background color of the editor hover.')); -export const editorHoverForeground = registerColor('editorHoverWidget.foreground', { light: editorWidgetForeground, dark: editorWidgetForeground, hcDark: editorWidgetForeground, hcLight: editorWidgetForeground }, nls.localize('hoverForeground', 'Foreground color of the editor hover.')); -export const editorHoverBorder = registerColor('editorHoverWidget.border', { light: editorWidgetBorder, dark: editorWidgetBorder, hcDark: editorWidgetBorder, hcLight: editorWidgetBorder }, nls.localize('hoverBorder', 'Border color of the editor hover.')); -export const editorHoverStatusBarBackground = registerColor('editorHoverWidget.statusBarBackground', { dark: lighten(editorHoverBackground, 0.2), light: darken(editorHoverBackground, 0.05), hcDark: editorWidgetBackground, hcLight: editorWidgetBackground }, nls.localize('statusBarBackground', "Background color of the editor hover status bar.")); -/** - * Editor link colors - */ -export const editorActiveLinkForeground = registerColor('editorLink.activeForeground', { dark: '#4E94CE', light: Color.blue, hcDark: Color.cyan, hcLight: '#292929' }, nls.localize('activeLinkForeground', 'Color of active links.')); - -/** - * Inline hints - */ -export const editorInlayHintForeground = registerColor('editorInlayHint.foreground', { dark: '#969696', light: '#969696', hcDark: Color.white, hcLight: Color.black }, nls.localize('editorInlayHintForeground', 'Foreground color of inline hints')); -export const editorInlayHintBackground = registerColor('editorInlayHint.background', { dark: transparent(badgeBackground, .10), light: transparent(badgeBackground, .10), hcDark: transparent(Color.white, .10), hcLight: transparent(badgeBackground, .10) }, nls.localize('editorInlayHintBackground', 'Background color of inline hints')); -export const editorInlayHintTypeForeground = registerColor('editorInlayHint.typeForeground', { dark: editorInlayHintForeground, light: editorInlayHintForeground, hcDark: editorInlayHintForeground, hcLight: editorInlayHintForeground }, nls.localize('editorInlayHintForegroundTypes', 'Foreground color of inline hints for types')); -export const editorInlayHintTypeBackground = registerColor('editorInlayHint.typeBackground', { dark: editorInlayHintBackground, light: editorInlayHintBackground, hcDark: editorInlayHintBackground, hcLight: editorInlayHintBackground }, nls.localize('editorInlayHintBackgroundTypes', 'Background color of inline hints for types')); -export const editorInlayHintParameterForeground = registerColor('editorInlayHint.parameterForeground', { dark: editorInlayHintForeground, light: editorInlayHintForeground, hcDark: editorInlayHintForeground, hcLight: editorInlayHintForeground }, nls.localize('editorInlayHintForegroundParameter', 'Foreground color of inline hints for parameters')); -export const editorInlayHintParameterBackground = registerColor('editorInlayHint.parameterBackground', { dark: editorInlayHintBackground, light: editorInlayHintBackground, hcDark: editorInlayHintBackground, hcLight: editorInlayHintBackground }, nls.localize('editorInlayHintBackgroundParameter', 'Background color of inline hints for parameters')); - -/** - * Editor lightbulb icon colors - */ -export const editorLightBulbForeground = registerColor('editorLightBulb.foreground', { dark: '#FFCC00', light: '#DDB100', hcDark: '#FFCC00', hcLight: '#007ACC' }, nls.localize('editorLightBulbForeground', "The color used for the lightbulb actions icon.")); -export const editorLightBulbAutoFixForeground = registerColor('editorLightBulbAutoFix.foreground', { dark: '#75BEFF', light: '#007ACC', hcDark: '#75BEFF', hcLight: '#007ACC' }, nls.localize('editorLightBulbAutoFixForeground', "The color used for the lightbulb auto fix actions icon.")); -export const editorLightBulbAiForeground = registerColor('editorLightBulbAi.foreground', { dark: editorLightBulbForeground, light: editorLightBulbForeground, hcDark: editorLightBulbForeground, hcLight: editorLightBulbForeground }, nls.localize('editorLightBulbAiForeground', "The color used for the lightbulb AI icon.")); - -/** - * Diff Editor Colors - */ -export const defaultInsertColor = new Color(new RGBA(155, 185, 85, .2)); -export const defaultRemoveColor = new Color(new RGBA(255, 0, 0, .2)); - -export const diffInserted = registerColor('diffEditor.insertedTextBackground', { dark: '#9ccc2c33', light: '#9ccc2c40', hcDark: null, hcLight: null }, nls.localize('diffEditorInserted', 'Background color for text that got inserted. The color must not be opaque so as not to hide underlying decorations.'), true); -export const diffRemoved = registerColor('diffEditor.removedTextBackground', { dark: '#ff000033', light: '#ff000033', hcDark: null, hcLight: null }, nls.localize('diffEditorRemoved', 'Background color for text that got removed. The color must not be opaque so as not to hide underlying decorations.'), true); - -export const diffInsertedLine = registerColor('diffEditor.insertedLineBackground', { dark: defaultInsertColor, light: defaultInsertColor, hcDark: null, hcLight: null }, nls.localize('diffEditorInsertedLines', 'Background color for lines that got inserted. The color must not be opaque so as not to hide underlying decorations.'), true); -export const diffRemovedLine = registerColor('diffEditor.removedLineBackground', { dark: defaultRemoveColor, light: defaultRemoveColor, hcDark: null, hcLight: null }, nls.localize('diffEditorRemovedLines', 'Background color for lines that got removed. The color must not be opaque so as not to hide underlying decorations.'), true); - -export const diffInsertedLineGutter = registerColor('diffEditorGutter.insertedLineBackground', { dark: null, light: null, hcDark: null, hcLight: null }, nls.localize('diffEditorInsertedLineGutter', 'Background color for the margin where lines got inserted.')); -export const diffRemovedLineGutter = registerColor('diffEditorGutter.removedLineBackground', { dark: null, light: null, hcDark: null, hcLight: null }, nls.localize('diffEditorRemovedLineGutter', 'Background color for the margin where lines got removed.')); - -export const diffOverviewRulerInserted = registerColor('diffEditorOverview.insertedForeground', { dark: null, light: null, hcDark: null, hcLight: null }, nls.localize('diffEditorOverviewInserted', 'Diff overview ruler foreground for inserted content.')); -export const diffOverviewRulerRemoved = registerColor('diffEditorOverview.removedForeground', { dark: null, light: null, hcDark: null, hcLight: null }, nls.localize('diffEditorOverviewRemoved', 'Diff overview ruler foreground for removed content.')); - -export const diffInsertedOutline = registerColor('diffEditor.insertedTextBorder', { dark: null, light: null, hcDark: '#33ff2eff', hcLight: '#374E06' }, nls.localize('diffEditorInsertedOutline', 'Outline color for the text that got inserted.')); -export const diffRemovedOutline = registerColor('diffEditor.removedTextBorder', { dark: null, light: null, hcDark: '#FF008F', hcLight: '#AD0707' }, nls.localize('diffEditorRemovedOutline', 'Outline color for text that got removed.')); - -export const diffBorder = registerColor('diffEditor.border', { dark: null, light: null, hcDark: contrastBorder, hcLight: contrastBorder }, nls.localize('diffEditorBorder', 'Border color between the two text editors.')); -export const diffDiagonalFill = registerColor('diffEditor.diagonalFill', { dark: '#cccccc33', light: '#22222233', hcDark: null, hcLight: null }, nls.localize('diffDiagonalFill', "Color of the diff editor's diagonal fill. The diagonal fill is used in side-by-side diff views.")); - -export const diffUnchangedRegionBackground = registerColor('diffEditor.unchangedRegionBackground', { dark: 'sideBar.background', light: 'sideBar.background', hcDark: 'sideBar.background', hcLight: 'sideBar.background' }, nls.localize('diffEditor.unchangedRegionBackground', "The background color of unchanged blocks in the diff editor.")); -export const diffUnchangedRegionForeground = registerColor('diffEditor.unchangedRegionForeground', { dark: 'foreground', light: 'foreground', hcDark: 'foreground', hcLight: 'foreground' }, nls.localize('diffEditor.unchangedRegionForeground', "The foreground color of unchanged blocks in the diff editor.")); -export const diffUnchangedTextBackground = registerColor('diffEditor.unchangedCodeBackground', { dark: '#74747429', light: '#b8b8b829', hcDark: null, hcLight: null }, nls.localize('diffEditor.unchangedCodeBackground', "The background color of unchanged code in the diff editor.")); - -/** - * List and tree colors - */ -export const listFocusBackground = registerColor('list.focusBackground', { dark: null, light: null, hcDark: null, hcLight: null }, nls.localize('listFocusBackground', "List/Tree background color for the focused item when the list/tree is active. An active list/tree has keyboard focus, an inactive does not.")); -export const listFocusForeground = registerColor('list.focusForeground', { dark: null, light: null, hcDark: null, hcLight: null }, nls.localize('listFocusForeground', "List/Tree foreground color for the focused item when the list/tree is active. An active list/tree has keyboard focus, an inactive does not.")); -export const listFocusOutline = registerColor('list.focusOutline', { dark: focusBorder, light: focusBorder, hcDark: activeContrastBorder, hcLight: activeContrastBorder }, nls.localize('listFocusOutline', "List/Tree outline color for the focused item when the list/tree is active. An active list/tree has keyboard focus, an inactive does not.")); -export const listFocusAndSelectionOutline = registerColor('list.focusAndSelectionOutline', { dark: null, light: null, hcDark: null, hcLight: null }, nls.localize('listFocusAndSelectionOutline', "List/Tree outline color for the focused item when the list/tree is active and selected. An active list/tree has keyboard focus, an inactive does not.")); -export const listActiveSelectionBackground = registerColor('list.activeSelectionBackground', { dark: '#04395E', light: '#0060C0', hcDark: null, hcLight: Color.fromHex('#0F4A85').transparent(0.1) }, nls.localize('listActiveSelectionBackground', "List/Tree background color for the selected item when the list/tree is active. An active list/tree has keyboard focus, an inactive does not.")); -export const listActiveSelectionForeground = registerColor('list.activeSelectionForeground', { dark: Color.white, light: Color.white, hcDark: null, hcLight: null }, nls.localize('listActiveSelectionForeground', "List/Tree foreground color for the selected item when the list/tree is active. An active list/tree has keyboard focus, an inactive does not.")); -export const listActiveSelectionIconForeground = registerColor('list.activeSelectionIconForeground', { dark: null, light: null, hcDark: null, hcLight: null }, nls.localize('listActiveSelectionIconForeground', "List/Tree icon foreground color for the selected item when the list/tree is active. An active list/tree has keyboard focus, an inactive does not.")); -export const listInactiveSelectionBackground = registerColor('list.inactiveSelectionBackground', { dark: '#37373D', light: '#E4E6F1', hcDark: null, hcLight: Color.fromHex('#0F4A85').transparent(0.1) }, nls.localize('listInactiveSelectionBackground', "List/Tree background color for the selected item when the list/tree is inactive. An active list/tree has keyboard focus, an inactive does not.")); -export const listInactiveSelectionForeground = registerColor('list.inactiveSelectionForeground', { dark: null, light: null, hcDark: null, hcLight: null }, nls.localize('listInactiveSelectionForeground', "List/Tree foreground color for the selected item when the list/tree is inactive. An active list/tree has keyboard focus, an inactive does not.")); -export const listInactiveSelectionIconForeground = registerColor('list.inactiveSelectionIconForeground', { dark: null, light: null, hcDark: null, hcLight: null }, nls.localize('listInactiveSelectionIconForeground', "List/Tree icon foreground color for the selected item when the list/tree is inactive. An active list/tree has keyboard focus, an inactive does not.")); -export const listInactiveFocusBackground = registerColor('list.inactiveFocusBackground', { dark: null, light: null, hcDark: null, hcLight: null }, nls.localize('listInactiveFocusBackground', "List/Tree background color for the focused item when the list/tree is inactive. An active list/tree has keyboard focus, an inactive does not.")); -export const listInactiveFocusOutline = registerColor('list.inactiveFocusOutline', { dark: null, light: null, hcDark: null, hcLight: null }, nls.localize('listInactiveFocusOutline', "List/Tree outline color for the focused item when the list/tree is inactive. An active list/tree has keyboard focus, an inactive does not.")); -export const listHoverBackground = registerColor('list.hoverBackground', { dark: '#2A2D2E', light: '#F0F0F0', hcDark: Color.white.transparent(0.1), hcLight: Color.fromHex('#0F4A85').transparent(0.1) }, nls.localize('listHoverBackground', "List/Tree background when hovering over items using the mouse.")); -export const listHoverForeground = registerColor('list.hoverForeground', { dark: null, light: null, hcDark: null, hcLight: null }, nls.localize('listHoverForeground', "List/Tree foreground when hovering over items using the mouse.")); -export const listDropOverBackground = registerColor('list.dropBackground', { dark: '#062F4A', light: '#D6EBFF', hcDark: null, hcLight: null }, nls.localize('listDropBackground', "List/Tree drag and drop background when moving items over other items when using the mouse.")); -export const listDropBetweenBackground = registerColor('list.dropBetweenBackground', { dark: iconForeground, light: iconForeground, hcDark: null, hcLight: null }, nls.localize('listDropBetweenBackground', "List/Tree drag and drop border color when moving items between items when using the mouse.")); -export const listHighlightForeground = registerColor('list.highlightForeground', { dark: '#2AAAFF', light: '#0066BF', hcDark: focusBorder, hcLight: focusBorder }, nls.localize('highlight', 'List/Tree foreground color of the match highlights when searching inside the list/tree.')); -export const listFocusHighlightForeground = registerColor('list.focusHighlightForeground', { dark: listHighlightForeground, light: ifDefinedThenElse(listActiveSelectionBackground, listHighlightForeground, '#BBE7FF'), hcDark: listHighlightForeground, hcLight: listHighlightForeground }, nls.localize('listFocusHighlightForeground', 'List/Tree foreground color of the match highlights on actively focused items when searching inside the list/tree.')); -export const listInvalidItemForeground = registerColor('list.invalidItemForeground', { dark: '#B89500', light: '#B89500', hcDark: '#B89500', hcLight: '#B5200D' }, nls.localize('invalidItemForeground', 'List/Tree foreground color for invalid items, for example an unresolved root in explorer.')); -export const listErrorForeground = registerColor('list.errorForeground', { dark: '#F88070', light: '#B01011', hcDark: null, hcLight: null }, nls.localize('listErrorForeground', 'Foreground color of list items containing errors.')); -export const listWarningForeground = registerColor('list.warningForeground', { dark: '#CCA700', light: '#855F00', hcDark: null, hcLight: null }, nls.localize('listWarningForeground', 'Foreground color of list items containing warnings.')); -export const listFilterWidgetBackground = registerColor('listFilterWidget.background', { light: darken(editorWidgetBackground, 0), dark: lighten(editorWidgetBackground, 0), hcDark: editorWidgetBackground, hcLight: editorWidgetBackground }, nls.localize('listFilterWidgetBackground', 'Background color of the type filter widget in lists and trees.')); -export const listFilterWidgetOutline = registerColor('listFilterWidget.outline', { dark: Color.transparent, light: Color.transparent, hcDark: '#f38518', hcLight: '#007ACC' }, nls.localize('listFilterWidgetOutline', 'Outline color of the type filter widget in lists and trees.')); -export const listFilterWidgetNoMatchesOutline = registerColor('listFilterWidget.noMatchesOutline', { dark: '#BE1100', light: '#BE1100', hcDark: contrastBorder, hcLight: contrastBorder }, nls.localize('listFilterWidgetNoMatchesOutline', 'Outline color of the type filter widget in lists and trees, when there are no matches.')); -export const listFilterWidgetShadow = registerColor('listFilterWidget.shadow', { dark: widgetShadow, light: widgetShadow, hcDark: widgetShadow, hcLight: widgetShadow }, nls.localize('listFilterWidgetShadow', 'Shadow color of the type filter widget in lists and trees.')); -export const listFilterMatchHighlight = registerColor('list.filterMatchBackground', { dark: editorFindMatchHighlight, light: editorFindMatchHighlight, hcDark: null, hcLight: null }, nls.localize('listFilterMatchHighlight', 'Background color of the filtered match.')); -export const listFilterMatchHighlightBorder = registerColor('list.filterMatchBorder', { dark: editorFindMatchHighlightBorder, light: editorFindMatchHighlightBorder, hcDark: contrastBorder, hcLight: activeContrastBorder }, nls.localize('listFilterMatchHighlightBorder', 'Border color of the filtered match.')); -export const treeIndentGuidesStroke = registerColor('tree.indentGuidesStroke', { dark: '#585858', light: '#a9a9a9', hcDark: '#a9a9a9', hcLight: '#a5a5a5' }, nls.localize('treeIndentGuidesStroke', "Tree stroke color for the indentation guides.")); -export const treeInactiveIndentGuidesStroke = registerColor('tree.inactiveIndentGuidesStroke', { dark: transparent(treeIndentGuidesStroke, 0.4), light: transparent(treeIndentGuidesStroke, 0.4), hcDark: transparent(treeIndentGuidesStroke, 0.4), hcLight: transparent(treeIndentGuidesStroke, 0.4) }, nls.localize('treeInactiveIndentGuidesStroke', "Tree stroke color for the indentation guides that are not active.")); -export const tableColumnsBorder = registerColor('tree.tableColumnsBorder', { dark: '#CCCCCC20', light: '#61616120', hcDark: null, hcLight: null }, nls.localize('tableColumnsBorder', "Table border color between columns.")); -export const tableOddRowsBackgroundColor = registerColor('tree.tableOddRowsBackground', { dark: transparent(foreground, 0.04), light: transparent(foreground, 0.04), hcDark: null, hcLight: null }, nls.localize('tableOddRowsBackgroundColor', "Background color for odd table rows.")); -export const listDeemphasizedForeground = registerColor('list.deemphasizedForeground', { dark: '#8C8C8C', light: '#8E8E90', hcDark: '#A7A8A9', hcLight: '#666666' }, nls.localize('listDeemphasizedForeground', "List/Tree foreground color for items that are deemphasized. ")); - -/** - * Checkboxes - */ -export const checkboxBackground = registerColor('checkbox.background', { dark: selectBackground, light: selectBackground, hcDark: selectBackground, hcLight: selectBackground }, nls.localize('checkbox.background', "Background color of checkbox widget.")); -export const checkboxSelectBackground = registerColor('checkbox.selectBackground', { dark: editorWidgetBackground, light: editorWidgetBackground, hcDark: editorWidgetBackground, hcLight: editorWidgetBackground }, nls.localize('checkbox.select.background', "Background color of checkbox widget when the element it's in is selected.")); -export const checkboxForeground = registerColor('checkbox.foreground', { dark: selectForeground, light: selectForeground, hcDark: selectForeground, hcLight: selectForeground }, nls.localize('checkbox.foreground', "Foreground color of checkbox widget.")); -export const checkboxBorder = registerColor('checkbox.border', { dark: selectBorder, light: selectBorder, hcDark: selectBorder, hcLight: selectBorder }, nls.localize('checkbox.border', "Border color of checkbox widget.")); -export const checkboxSelectBorder = registerColor('checkbox.selectBorder', { dark: iconForeground, light: iconForeground, hcDark: iconForeground, hcLight: iconForeground }, nls.localize('checkbox.select.border', "Border color of checkbox widget when the element it's in is selected.")); - -/** - * Quick pick widget (dependent on List and tree colors) - */ -export const _deprecatedQuickInputListFocusBackground = registerColor('quickInput.list.focusBackground', { dark: null, light: null, hcDark: null, hcLight: null }, '', undefined, nls.localize('quickInput.list.focusBackground deprecation', "Please use quickInputList.focusBackground instead")); -export const quickInputListFocusForeground = registerColor('quickInputList.focusForeground', { dark: listActiveSelectionForeground, light: listActiveSelectionForeground, hcDark: listActiveSelectionForeground, hcLight: listActiveSelectionForeground }, nls.localize('quickInput.listFocusForeground', "Quick picker foreground color for the focused item.")); -export const quickInputListFocusIconForeground = registerColor('quickInputList.focusIconForeground', { dark: listActiveSelectionIconForeground, light: listActiveSelectionIconForeground, hcDark: listActiveSelectionIconForeground, hcLight: listActiveSelectionIconForeground }, nls.localize('quickInput.listFocusIconForeground', "Quick picker icon foreground color for the focused item.")); -export const quickInputListFocusBackground = registerColor('quickInputList.focusBackground', { dark: oneOf(_deprecatedQuickInputListFocusBackground, listActiveSelectionBackground), light: oneOf(_deprecatedQuickInputListFocusBackground, listActiveSelectionBackground), hcDark: null, hcLight: null }, nls.localize('quickInput.listFocusBackground', "Quick picker background color for the focused item.")); - -/** - * Menu colors - */ -export const menuBorder = registerColor('menu.border', { dark: null, light: null, hcDark: contrastBorder, hcLight: contrastBorder }, nls.localize('menuBorder', "Border color of menus.")); -export const menuForeground = registerColor('menu.foreground', { dark: selectForeground, light: selectForeground, hcDark: selectForeground, hcLight: selectForeground }, nls.localize('menuForeground', "Foreground color of menu items.")); -export const menuBackground = registerColor('menu.background', { dark: selectBackground, light: selectBackground, hcDark: selectBackground, hcLight: selectBackground }, nls.localize('menuBackground', "Background color of menu items.")); -export const menuSelectionForeground = registerColor('menu.selectionForeground', { dark: listActiveSelectionForeground, light: listActiveSelectionForeground, hcDark: listActiveSelectionForeground, hcLight: listActiveSelectionForeground }, nls.localize('menuSelectionForeground', "Foreground color of the selected menu item in menus.")); -export const menuSelectionBackground = registerColor('menu.selectionBackground', { dark: listActiveSelectionBackground, light: listActiveSelectionBackground, hcDark: listActiveSelectionBackground, hcLight: listActiveSelectionBackground }, nls.localize('menuSelectionBackground', "Background color of the selected menu item in menus.")); -export const menuSelectionBorder = registerColor('menu.selectionBorder', { dark: null, light: null, hcDark: activeContrastBorder, hcLight: activeContrastBorder }, nls.localize('menuSelectionBorder', "Border color of the selected menu item in menus.")); -export const menuSeparatorBackground = registerColor('menu.separatorBackground', { dark: '#606060', light: '#D4D4D4', hcDark: contrastBorder, hcLight: contrastBorder }, nls.localize('menuSeparatorBackground', "Color of a separator menu item in menus.")); - -/** - * Toolbar colors - */ -export const toolbarHoverBackground = registerColor('toolbar.hoverBackground', { dark: '#5a5d5e50', light: '#b8b8b850', hcDark: null, hcLight: null }, nls.localize('toolbarHoverBackground', "Toolbar background when hovering over actions using the mouse")); -export const toolbarHoverOutline = registerColor('toolbar.hoverOutline', { dark: null, light: null, hcDark: activeContrastBorder, hcLight: activeContrastBorder }, nls.localize('toolbarHoverOutline', "Toolbar outline when hovering over actions using the mouse")); -export const toolbarActiveBackground = registerColor('toolbar.activeBackground', { dark: lighten(toolbarHoverBackground, 0.1), light: darken(toolbarHoverBackground, 0.1), hcDark: null, hcLight: null }, nls.localize('toolbarActiveBackground', "Toolbar background when holding the mouse over actions")); - -/** - * Snippet placeholder colors - */ -export const snippetTabstopHighlightBackground = registerColor('editor.snippetTabstopHighlightBackground', { dark: new Color(new RGBA(124, 124, 124, 0.3)), light: new Color(new RGBA(10, 50, 100, 0.2)), hcDark: new Color(new RGBA(124, 124, 124, 0.3)), hcLight: new Color(new RGBA(10, 50, 100, 0.2)) }, nls.localize('snippetTabstopHighlightBackground', "Highlight background color of a snippet tabstop.")); -export const snippetTabstopHighlightBorder = registerColor('editor.snippetTabstopHighlightBorder', { dark: null, light: null, hcDark: null, hcLight: null }, nls.localize('snippetTabstopHighlightBorder', "Highlight border color of a snippet tabstop.")); -export const snippetFinalTabstopHighlightBackground = registerColor('editor.snippetFinalTabstopHighlightBackground', { dark: null, light: null, hcDark: null, hcLight: null }, nls.localize('snippetFinalTabstopHighlightBackground', "Highlight background color of the final tabstop of a snippet.")); -export const snippetFinalTabstopHighlightBorder = registerColor('editor.snippetFinalTabstopHighlightBorder', { dark: '#525252', light: new Color(new RGBA(10, 50, 100, 0.5)), hcDark: '#525252', hcLight: '#292929' }, nls.localize('snippetFinalTabstopHighlightBorder', "Highlight border color of the final tabstop of a snippet.")); - -/** - * Breadcrumb colors - */ -export const breadcrumbsForeground = registerColor('breadcrumb.foreground', { light: transparent(foreground, 0.8), dark: transparent(foreground, 0.8), hcDark: transparent(foreground, 0.8), hcLight: transparent(foreground, 0.8) }, nls.localize('breadcrumbsFocusForeground', "Color of focused breadcrumb items.")); -export const breadcrumbsBackground = registerColor('breadcrumb.background', { light: editorBackground, dark: editorBackground, hcDark: editorBackground, hcLight: editorBackground }, nls.localize('breadcrumbsBackground', "Background color of breadcrumb items.")); -export const breadcrumbsFocusForeground = registerColor('breadcrumb.focusForeground', { light: darken(foreground, 0.2), dark: lighten(foreground, 0.1), hcDark: lighten(foreground, 0.1), hcLight: lighten(foreground, 0.1) }, nls.localize('breadcrumbsFocusForeground', "Color of focused breadcrumb items.")); -export const breadcrumbsActiveSelectionForeground = registerColor('breadcrumb.activeSelectionForeground', { light: darken(foreground, 0.2), dark: lighten(foreground, 0.1), hcDark: lighten(foreground, 0.1), hcLight: lighten(foreground, 0.1) }, nls.localize('breadcrumbsSelectedForeground', "Color of selected breadcrumb items.")); -export const breadcrumbsPickerBackground = registerColor('breadcrumbPicker.background', { light: editorWidgetBackground, dark: editorWidgetBackground, hcDark: editorWidgetBackground, hcLight: editorWidgetBackground }, nls.localize('breadcrumbsSelectedBackground', "Background color of breadcrumb item picker.")); - -/** - * Merge-conflict colors - */ - -const headerTransparency = 0.5; -const currentBaseColor = Color.fromHex('#40C8AE').transparent(headerTransparency); -const incomingBaseColor = Color.fromHex('#40A6FF').transparent(headerTransparency); -const commonBaseColor = Color.fromHex('#606060').transparent(0.4); -const contentTransparency = 0.4; -const rulerTransparency = 1; - -export const mergeCurrentHeaderBackground = registerColor('merge.currentHeaderBackground', { dark: currentBaseColor, light: currentBaseColor, hcDark: null, hcLight: null }, nls.localize('mergeCurrentHeaderBackground', 'Current header background in inline merge-conflicts. The color must not be opaque so as not to hide underlying decorations.'), true); -export const mergeCurrentContentBackground = registerColor('merge.currentContentBackground', { dark: transparent(mergeCurrentHeaderBackground, contentTransparency), light: transparent(mergeCurrentHeaderBackground, contentTransparency), hcDark: transparent(mergeCurrentHeaderBackground, contentTransparency), hcLight: transparent(mergeCurrentHeaderBackground, contentTransparency) }, nls.localize('mergeCurrentContentBackground', 'Current content background in inline merge-conflicts. The color must not be opaque so as not to hide underlying decorations.'), true); -export const mergeIncomingHeaderBackground = registerColor('merge.incomingHeaderBackground', { dark: incomingBaseColor, light: incomingBaseColor, hcDark: null, hcLight: null }, nls.localize('mergeIncomingHeaderBackground', 'Incoming header background in inline merge-conflicts. The color must not be opaque so as not to hide underlying decorations.'), true); -export const mergeIncomingContentBackground = registerColor('merge.incomingContentBackground', { dark: transparent(mergeIncomingHeaderBackground, contentTransparency), light: transparent(mergeIncomingHeaderBackground, contentTransparency), hcDark: transparent(mergeIncomingHeaderBackground, contentTransparency), hcLight: transparent(mergeIncomingHeaderBackground, contentTransparency) }, nls.localize('mergeIncomingContentBackground', 'Incoming content background in inline merge-conflicts. The color must not be opaque so as not to hide underlying decorations.'), true); -export const mergeCommonHeaderBackground = registerColor('merge.commonHeaderBackground', { dark: commonBaseColor, light: commonBaseColor, hcDark: null, hcLight: null }, nls.localize('mergeCommonHeaderBackground', 'Common ancestor header background in inline merge-conflicts. The color must not be opaque so as not to hide underlying decorations.'), true); -export const mergeCommonContentBackground = registerColor('merge.commonContentBackground', { dark: transparent(mergeCommonHeaderBackground, contentTransparency), light: transparent(mergeCommonHeaderBackground, contentTransparency), hcDark: transparent(mergeCommonHeaderBackground, contentTransparency), hcLight: transparent(mergeCommonHeaderBackground, contentTransparency) }, nls.localize('mergeCommonContentBackground', 'Common ancestor content background in inline merge-conflicts. The color must not be opaque so as not to hide underlying decorations.'), true); - -export const mergeBorder = registerColor('merge.border', { dark: null, light: null, hcDark: '#C3DF6F', hcLight: '#007ACC' }, nls.localize('mergeBorder', 'Border color on headers and the splitter in inline merge-conflicts.')); - -export const overviewRulerCurrentContentForeground = registerColor('editorOverviewRuler.currentContentForeground', { dark: transparent(mergeCurrentHeaderBackground, rulerTransparency), light: transparent(mergeCurrentHeaderBackground, rulerTransparency), hcDark: mergeBorder, hcLight: mergeBorder }, nls.localize('overviewRulerCurrentContentForeground', 'Current overview ruler foreground for inline merge-conflicts.')); -export const overviewRulerIncomingContentForeground = registerColor('editorOverviewRuler.incomingContentForeground', { dark: transparent(mergeIncomingHeaderBackground, rulerTransparency), light: transparent(mergeIncomingHeaderBackground, rulerTransparency), hcDark: mergeBorder, hcLight: mergeBorder }, nls.localize('overviewRulerIncomingContentForeground', 'Incoming overview ruler foreground for inline merge-conflicts.')); -export const overviewRulerCommonContentForeground = registerColor('editorOverviewRuler.commonContentForeground', { dark: transparent(mergeCommonHeaderBackground, rulerTransparency), light: transparent(mergeCommonHeaderBackground, rulerTransparency), hcDark: mergeBorder, hcLight: mergeBorder }, nls.localize('overviewRulerCommonContentForeground', 'Common ancestor overview ruler foreground for inline merge-conflicts.')); - -export const overviewRulerFindMatchForeground = registerColor('editorOverviewRuler.findMatchForeground', { dark: '#d186167e', light: '#d186167e', hcDark: '#AB5A00', hcLight: '' }, nls.localize('overviewRulerFindMatchForeground', 'Overview ruler marker color for find matches. The color must not be opaque so as not to hide underlying decorations.'), true); - -export const overviewRulerSelectionHighlightForeground = registerColor('editorOverviewRuler.selectionHighlightForeground', { dark: '#A0A0A0CC', light: '#A0A0A0CC', hcDark: '#A0A0A0CC', hcLight: '#A0A0A0CC' }, nls.localize('overviewRulerSelectionHighlightForeground', 'Overview ruler marker color for selection highlights. The color must not be opaque so as not to hide underlying decorations.'), true); - - -export const minimapFindMatch = registerColor('minimap.findMatchHighlight', { light: '#d18616', dark: '#d18616', hcDark: '#AB5A00', hcLight: '#0F4A85' }, nls.localize('minimapFindMatchHighlight', 'Minimap marker color for find matches.'), true); -export const minimapSelectionOccurrenceHighlight = registerColor('minimap.selectionOccurrenceHighlight', { light: '#c9c9c9', dark: '#676767', hcDark: '#ffffff', hcLight: '#0F4A85' }, nls.localize('minimapSelectionOccurrenceHighlight', 'Minimap marker color for repeating editor selections.'), true); -export const minimapSelection = registerColor('minimap.selectionHighlight', { light: '#ADD6FF', dark: '#264F78', hcDark: '#ffffff', hcLight: '#0F4A85' }, nls.localize('minimapSelectionHighlight', 'Minimap marker color for the editor selection.'), true); -export const minimapInfo = registerColor('minimap.infoHighlight', { dark: editorInfoForeground, light: editorInfoForeground, hcDark: editorInfoBorder, hcLight: editorInfoBorder }, nls.localize('minimapInfo', 'Minimap marker color for infos.')); -export const minimapWarning = registerColor('minimap.warningHighlight', { dark: editorWarningForeground, light: editorWarningForeground, hcDark: editorWarningBorder, hcLight: editorWarningBorder }, nls.localize('overviewRuleWarning', 'Minimap marker color for warnings.')); -export const minimapError = registerColor('minimap.errorHighlight', { dark: new Color(new RGBA(255, 18, 18, 0.7)), light: new Color(new RGBA(255, 18, 18, 0.7)), hcDark: new Color(new RGBA(255, 50, 50, 1)), hcLight: '#B5200D' }, nls.localize('minimapError', 'Minimap marker color for errors.')); -export const minimapBackground = registerColor('minimap.background', { dark: null, light: null, hcDark: null, hcLight: null }, nls.localize('minimapBackground', "Minimap background color.")); -export const minimapForegroundOpacity = registerColor('minimap.foregroundOpacity', { dark: Color.fromHex('#000f'), light: Color.fromHex('#000f'), hcDark: Color.fromHex('#000f'), hcLight: Color.fromHex('#000f') }, nls.localize('minimapForegroundOpacity', 'Opacity of foreground elements rendered in the minimap. For example, "#000000c0" will render the elements with 75% opacity.')); - -export const minimapSliderBackground = registerColor('minimapSlider.background', { light: transparent(scrollbarSliderBackground, 0.5), dark: transparent(scrollbarSliderBackground, 0.5), hcDark: transparent(scrollbarSliderBackground, 0.5), hcLight: transparent(scrollbarSliderBackground, 0.5) }, nls.localize('minimapSliderBackground', "Minimap slider background color.")); -export const minimapSliderHoverBackground = registerColor('minimapSlider.hoverBackground', { light: transparent(scrollbarSliderHoverBackground, 0.5), dark: transparent(scrollbarSliderHoverBackground, 0.5), hcDark: transparent(scrollbarSliderHoverBackground, 0.5), hcLight: transparent(scrollbarSliderHoverBackground, 0.5) }, nls.localize('minimapSliderHoverBackground', "Minimap slider background color when hovering.")); -export const minimapSliderActiveBackground = registerColor('minimapSlider.activeBackground', { light: transparent(scrollbarSliderActiveBackground, 0.5), dark: transparent(scrollbarSliderActiveBackground, 0.5), hcDark: transparent(scrollbarSliderActiveBackground, 0.5), hcLight: transparent(scrollbarSliderActiveBackground, 0.5) }, nls.localize('minimapSliderActiveBackground', "Minimap slider background color when clicked on.")); - -export const problemsErrorIconForeground = registerColor('problemsErrorIcon.foreground', { dark: editorErrorForeground, light: editorErrorForeground, hcDark: editorErrorForeground, hcLight: editorErrorForeground }, nls.localize('problemsErrorIconForeground', "The color used for the problems error icon.")); -export const problemsWarningIconForeground = registerColor('problemsWarningIcon.foreground', { dark: editorWarningForeground, light: editorWarningForeground, hcDark: editorWarningForeground, hcLight: editorWarningForeground }, nls.localize('problemsWarningIconForeground', "The color used for the problems warning icon.")); -export const problemsInfoIconForeground = registerColor('problemsInfoIcon.foreground', { dark: editorInfoForeground, light: editorInfoForeground, hcDark: editorInfoForeground, hcLight: editorInfoForeground }, nls.localize('problemsInfoIconForeground', "The color used for the problems info icon.")); - -/** - * Chart colors - */ -export const chartsForeground = registerColor('charts.foreground', { dark: foreground, light: foreground, hcDark: foreground, hcLight: foreground }, nls.localize('chartsForeground', "The foreground color used in charts.")); -export const chartsLines = registerColor('charts.lines', { dark: transparent(foreground, .5), light: transparent(foreground, .5), hcDark: transparent(foreground, .5), hcLight: transparent(foreground, .5) }, nls.localize('chartsLines', "The color used for horizontal lines in charts.")); -export const chartsRed = registerColor('charts.red', { dark: editorErrorForeground, light: editorErrorForeground, hcDark: editorErrorForeground, hcLight: editorErrorForeground }, nls.localize('chartsRed', "The red color used in chart visualizations.")); -export const chartsBlue = registerColor('charts.blue', { dark: editorInfoForeground, light: editorInfoForeground, hcDark: editorInfoForeground, hcLight: editorInfoForeground }, nls.localize('chartsBlue', "The blue color used in chart visualizations.")); -export const chartsYellow = registerColor('charts.yellow', { dark: editorWarningForeground, light: editorWarningForeground, hcDark: editorWarningForeground, hcLight: editorWarningForeground }, nls.localize('chartsYellow', "The yellow color used in chart visualizations.")); -export const chartsOrange = registerColor('charts.orange', { dark: minimapFindMatch, light: minimapFindMatch, hcDark: minimapFindMatch, hcLight: minimapFindMatch }, nls.localize('chartsOrange', "The orange color used in chart visualizations.")); -export const chartsGreen = registerColor('charts.green', { dark: '#89D185', light: '#388A34', hcDark: '#89D185', hcLight: '#374e06' }, nls.localize('chartsGreen', "The green color used in chart visualizations.")); -export const chartsPurple = registerColor('charts.purple', { dark: '#B180D7', light: '#652D90', hcDark: '#B180D7', hcLight: '#652D90' }, nls.localize('chartsPurple', "The purple color used in chart visualizations.")); - -// ----- color functions - -export function executeTransform(transform: ColorTransform, theme: IColorTheme): Color | undefined { - switch (transform.op) { - case ColorTransformType.Darken: - return resolveColorValue(transform.value, theme)?.darken(transform.factor); - - case ColorTransformType.Lighten: - return resolveColorValue(transform.value, theme)?.lighten(transform.factor); - - case ColorTransformType.Transparent: - return resolveColorValue(transform.value, theme)?.transparent(transform.factor); - - case ColorTransformType.Opaque: { - const backgroundColor = resolveColorValue(transform.background, theme); - if (!backgroundColor) { - return resolveColorValue(transform.value, theme); - } - return resolveColorValue(transform.value, theme)?.makeOpaque(backgroundColor); - } - - case ColorTransformType.OneOf: - for (const candidate of transform.values) { - const color = resolveColorValue(candidate, theme); - if (color) { - return color; - } - } - return undefined; - - case ColorTransformType.IfDefinedThenElse: - return resolveColorValue(theme.defines(transform.if) ? transform.then : transform.else, theme); - - case ColorTransformType.LessProminent: { - const from = resolveColorValue(transform.value, theme); - if (!from) { - return undefined; - } - - const backgroundColor = resolveColorValue(transform.background, theme); - if (!backgroundColor) { - return from.transparent(transform.factor * transform.transparency); - } - - return from.isDarkerThan(backgroundColor) - ? Color.getLighterColor(from, backgroundColor, transform.factor).transparent(transform.transparency) - : Color.getDarkerColor(from, backgroundColor, transform.factor).transparent(transform.transparency); - } - default: - throw assertNever(transform); - } -} - -export function darken(colorValue: ColorValue, factor: number): ColorTransform { - return { op: ColorTransformType.Darken, value: colorValue, factor }; -} - -export function lighten(colorValue: ColorValue, factor: number): ColorTransform { - return { op: ColorTransformType.Lighten, value: colorValue, factor }; -} - -export function transparent(colorValue: ColorValue, factor: number): ColorTransform { - return { op: ColorTransformType.Transparent, value: colorValue, factor }; -} - -export function opaque(colorValue: ColorValue, background: ColorValue): ColorTransform { - return { op: ColorTransformType.Opaque, value: colorValue, background }; -} - -export function oneOf(...colorValues: ColorValue[]): ColorTransform { - return { op: ColorTransformType.OneOf, values: colorValues }; -} - -export function ifDefinedThenElse(ifArg: ColorIdentifier, thenArg: ColorValue, elseArg: ColorValue): ColorTransform { - return { op: ColorTransformType.IfDefinedThenElse, if: ifArg, then: thenArg, else: elseArg }; -} - -function lessProminent(colorValue: ColorValue, backgroundColorValue: ColorValue, factor: number, transparency: number): ColorTransform { - return { op: ColorTransformType.LessProminent, value: colorValue, background: backgroundColorValue, factor, transparency }; -} - -// ----- implementation - -/** - * @param colorValue Resolve a color value in the context of a theme - */ -export function resolveColorValue(colorValue: ColorValue | null, theme: IColorTheme): Color | undefined { - if (colorValue === null) { - return undefined; - } else if (typeof colorValue === 'string') { - if (colorValue[0] === '#') { - return Color.fromHex(colorValue); - } - return theme.getColor(colorValue); - } else if (colorValue instanceof Color) { - return colorValue; - } else if (typeof colorValue === 'object') { - return executeTransform(colorValue, theme); - } - return undefined; -} - -export const workbenchColorsSchemaId = 'vscode://schemas/workbench-colors'; - -const schemaRegistry = platform.Registry.as(JSONExtensions.JSONContribution); -schemaRegistry.registerSchema(workbenchColorsSchemaId, colorRegistry.getColorSchema()); - -const delayer = new RunOnceScheduler(() => schemaRegistry.notifySchemaChanged(workbenchColorsSchemaId), 200); -colorRegistry.onDidChangeSchema(() => { - if (!delayer.isScheduled()) { - delayer.schedule(); - } -}); - -// setTimeout(_ => console.log(colorRegistry.toString()), 5000); +export * from 'vs/platform/theme/common/colorUtils'; + +// Make sure all color files are exported +export * from 'vs/platform/theme/common/colors/baseColors'; +export * from 'vs/platform/theme/common/colors/chartsColors'; +export * from 'vs/platform/theme/common/colors/editorColors'; +export * from 'vs/platform/theme/common/colors/inputColors'; +export * from 'vs/platform/theme/common/colors/listColors'; +export * from 'vs/platform/theme/common/colors/menuColors'; +export * from 'vs/platform/theme/common/colors/minimapColors'; +export * from 'vs/platform/theme/common/colors/miscColors'; +export * from 'vs/platform/theme/common/colors/quickpickColors'; +export * from 'vs/platform/theme/common/colors/searchColors'; diff --git a/src/vs/platform/theme/common/colorUtils.ts b/src/vs/platform/theme/common/colorUtils.ts new file mode 100644 index 0000000000000..2388e7cb7024c --- /dev/null +++ b/src/vs/platform/theme/common/colorUtils.ts @@ -0,0 +1,328 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { assertNever } from 'vs/base/common/assert'; +import { RunOnceScheduler } from 'vs/base/common/async'; +import { Color } from 'vs/base/common/color'; +import { Emitter, Event } from 'vs/base/common/event'; +import { IJSONSchema, IJSONSchemaMap } from 'vs/base/common/jsonSchema'; +import { IJSONContributionRegistry, Extensions as JSONExtensions } from 'vs/platform/jsonschemas/common/jsonContributionRegistry'; +import * as platform from 'vs/platform/registry/common/platform'; +import { IColorTheme } from 'vs/platform/theme/common/themeService'; + +// ------ API types + +export type ColorIdentifier = string; + +export interface ColorContribution { + readonly id: ColorIdentifier; + readonly description: string; + readonly defaults: ColorDefaults | null; + readonly needsTransparency: boolean; + readonly deprecationMessage: string | undefined; +} + +/** + * Returns the css variable name for the given color identifier. Dots (`.`) are replaced with hyphens (`-`) and + * everything is prefixed with `--vscode-`. + * + * @sample `editorSuggestWidget.background` is `--vscode-editorSuggestWidget-background`. + */ +export function asCssVariableName(colorIdent: ColorIdentifier): string { + return `--vscode-${colorIdent.replace(/\./g, '-')}`; +} + +export function asCssVariable(color: ColorIdentifier): string { + return `var(${asCssVariableName(color)})`; +} + +export function asCssVariableWithDefault(color: ColorIdentifier, defaultCssValue: string): string { + return `var(${asCssVariableName(color)}, ${defaultCssValue})`; +} + +export const enum ColorTransformType { + Darken, + Lighten, + Transparent, + Opaque, + OneOf, + LessProminent, + IfDefinedThenElse +} + +export type ColorTransform = + | { op: ColorTransformType.Darken; value: ColorValue; factor: number } + | { op: ColorTransformType.Lighten; value: ColorValue; factor: number } + | { op: ColorTransformType.Transparent; value: ColorValue; factor: number } + | { op: ColorTransformType.Opaque; value: ColorValue; background: ColorValue } + | { op: ColorTransformType.OneOf; values: readonly ColorValue[] } + | { op: ColorTransformType.LessProminent; value: ColorValue; background: ColorValue; factor: number; transparency: number } + | { op: ColorTransformType.IfDefinedThenElse; if: ColorIdentifier; then: ColorValue; else: ColorValue }; + +export interface ColorDefaults { + light: ColorValue | null; + dark: ColorValue | null; + hcDark: ColorValue | null; + hcLight: ColorValue | null; +} + + +/** + * A Color Value is either a color literal, a reference to an other color or a derived color + */ +export type ColorValue = Color | string | ColorIdentifier | ColorTransform; + +// color registry +export const Extensions = { + ColorContribution: 'base.contributions.colors' +}; + +export interface IColorRegistry { + + readonly onDidChangeSchema: Event; + + /** + * Register a color to the registry. + * @param id The color id as used in theme description files + * @param defaults The default values + * @param needsTransparency Whether the color requires transparency + * @description the description + */ + registerColor(id: string, defaults: ColorDefaults, description: string, needsTransparency?: boolean): ColorIdentifier; + + /** + * Register a color to the registry. + */ + deregisterColor(id: string): void; + + /** + * Get all color contributions + */ + getColors(): ColorContribution[]; + + /** + * Gets the default color of the given id + */ + resolveDefaultColor(id: ColorIdentifier, theme: IColorTheme): Color | undefined; + + /** + * JSON schema for an object to assign color values to one of the color contributions. + */ + getColorSchema(): IJSONSchema; + + /** + * JSON schema to for a reference to a color contribution. + */ + getColorReferenceSchema(): IJSONSchema; + +} + +class ColorRegistry implements IColorRegistry { + + private readonly _onDidChangeSchema = new Emitter(); + readonly onDidChangeSchema: Event = this._onDidChangeSchema.event; + + private colorsById: { [key: string]: ColorContribution }; + private colorSchema: IJSONSchema & { properties: IJSONSchemaMap } = { type: 'object', properties: {} }; + private colorReferenceSchema: IJSONSchema & { enum: string[]; enumDescriptions: string[] } = { type: 'string', enum: [], enumDescriptions: [] }; + + constructor() { + this.colorsById = {}; + } + + public registerColor(id: string, defaults: ColorDefaults | null, description: string, needsTransparency = false, deprecationMessage?: string): ColorIdentifier { + const colorContribution: ColorContribution = { id, description, defaults, needsTransparency, deprecationMessage }; + this.colorsById[id] = colorContribution; + const propertySchema: IJSONSchema = { type: 'string', description, format: 'color-hex', defaultSnippets: [{ body: '${1:#ff0000}' }] }; + if (deprecationMessage) { + propertySchema.deprecationMessage = deprecationMessage; + } + if (needsTransparency) { + propertySchema.pattern = '^#(?:(?[0-9a-fA-f]{3}[0-9a-eA-E])|(?:[0-9a-fA-F]{6}(?:(?![fF]{2})(?:[0-9a-fA-F]{2}))))?$'; + propertySchema.patternErrorMessage = 'This color must be transparent or it will obscure content'; + } + this.colorSchema.properties[id] = propertySchema; + this.colorReferenceSchema.enum.push(id); + this.colorReferenceSchema.enumDescriptions.push(description); + + this._onDidChangeSchema.fire(); + return id; + } + + + public deregisterColor(id: string): void { + delete this.colorsById[id]; + delete this.colorSchema.properties[id]; + const index = this.colorReferenceSchema.enum.indexOf(id); + if (index !== -1) { + this.colorReferenceSchema.enum.splice(index, 1); + this.colorReferenceSchema.enumDescriptions.splice(index, 1); + } + this._onDidChangeSchema.fire(); + } + + public getColors(): ColorContribution[] { + return Object.keys(this.colorsById).map(id => this.colorsById[id]); + } + + public resolveDefaultColor(id: ColorIdentifier, theme: IColorTheme): Color | undefined { + const colorDesc = this.colorsById[id]; + if (colorDesc && colorDesc.defaults) { + const colorValue = colorDesc.defaults[theme.type]; + return resolveColorValue(colorValue, theme); + } + return undefined; + } + + public getColorSchema(): IJSONSchema { + return this.colorSchema; + } + + public getColorReferenceSchema(): IJSONSchema { + return this.colorReferenceSchema; + } + + public toString() { + const sorter = (a: string, b: string) => { + const cat1 = a.indexOf('.') === -1 ? 0 : 1; + const cat2 = b.indexOf('.') === -1 ? 0 : 1; + if (cat1 !== cat2) { + return cat1 - cat2; + } + return a.localeCompare(b); + }; + + return Object.keys(this.colorsById).sort(sorter).map(k => `- \`${k}\`: ${this.colorsById[k].description}`).join('\n'); + } + +} + +const colorRegistry = new ColorRegistry(); +platform.Registry.add(Extensions.ColorContribution, colorRegistry); + + +export function registerColor(id: string, defaults: ColorDefaults | null, description: string, needsTransparency?: boolean, deprecationMessage?: string): ColorIdentifier { + return colorRegistry.registerColor(id, defaults, description, needsTransparency, deprecationMessage); +} + +export function getColorRegistry(): IColorRegistry { + return colorRegistry; +} + +// ----- color functions + +export function executeTransform(transform: ColorTransform, theme: IColorTheme): Color | undefined { + switch (transform.op) { + case ColorTransformType.Darken: + return resolveColorValue(transform.value, theme)?.darken(transform.factor); + + case ColorTransformType.Lighten: + return resolveColorValue(transform.value, theme)?.lighten(transform.factor); + + case ColorTransformType.Transparent: + return resolveColorValue(transform.value, theme)?.transparent(transform.factor); + + case ColorTransformType.Opaque: { + const backgroundColor = resolveColorValue(transform.background, theme); + if (!backgroundColor) { + return resolveColorValue(transform.value, theme); + } + return resolveColorValue(transform.value, theme)?.makeOpaque(backgroundColor); + } + + case ColorTransformType.OneOf: + for (const candidate of transform.values) { + const color = resolveColorValue(candidate, theme); + if (color) { + return color; + } + } + return undefined; + + case ColorTransformType.IfDefinedThenElse: + return resolveColorValue(theme.defines(transform.if) ? transform.then : transform.else, theme); + + case ColorTransformType.LessProminent: { + const from = resolveColorValue(transform.value, theme); + if (!from) { + return undefined; + } + + const backgroundColor = resolveColorValue(transform.background, theme); + if (!backgroundColor) { + return from.transparent(transform.factor * transform.transparency); + } + + return from.isDarkerThan(backgroundColor) + ? Color.getLighterColor(from, backgroundColor, transform.factor).transparent(transform.transparency) + : Color.getDarkerColor(from, backgroundColor, transform.factor).transparent(transform.transparency); + } + default: + throw assertNever(transform); + } +} + +export function darken(colorValue: ColorValue, factor: number): ColorTransform { + return { op: ColorTransformType.Darken, value: colorValue, factor }; +} + +export function lighten(colorValue: ColorValue, factor: number): ColorTransform { + return { op: ColorTransformType.Lighten, value: colorValue, factor }; +} + +export function transparent(colorValue: ColorValue, factor: number): ColorTransform { + return { op: ColorTransformType.Transparent, value: colorValue, factor }; +} + +export function opaque(colorValue: ColorValue, background: ColorValue): ColorTransform { + return { op: ColorTransformType.Opaque, value: colorValue, background }; +} + +export function oneOf(...colorValues: ColorValue[]): ColorTransform { + return { op: ColorTransformType.OneOf, values: colorValues }; +} + +export function ifDefinedThenElse(ifArg: ColorIdentifier, thenArg: ColorValue, elseArg: ColorValue): ColorTransform { + return { op: ColorTransformType.IfDefinedThenElse, if: ifArg, then: thenArg, else: elseArg }; +} + +export function lessProminent(colorValue: ColorValue, backgroundColorValue: ColorValue, factor: number, transparency: number): ColorTransform { + return { op: ColorTransformType.LessProminent, value: colorValue, background: backgroundColorValue, factor, transparency }; +} + +// ----- implementation + +/** + * @param colorValue Resolve a color value in the context of a theme + */ +export function resolveColorValue(colorValue: ColorValue | null, theme: IColorTheme): Color | undefined { + if (colorValue === null) { + return undefined; + } else if (typeof colorValue === 'string') { + if (colorValue[0] === '#') { + return Color.fromHex(colorValue); + } + return theme.getColor(colorValue); + } else if (colorValue instanceof Color) { + return colorValue; + } else if (typeof colorValue === 'object') { + return executeTransform(colorValue, theme); + } + return undefined; +} + +export const workbenchColorsSchemaId = 'vscode://schemas/workbench-colors'; + +const schemaRegistry = platform.Registry.as(JSONExtensions.JSONContribution); +schemaRegistry.registerSchema(workbenchColorsSchemaId, colorRegistry.getColorSchema()); + +const delayer = new RunOnceScheduler(() => schemaRegistry.notifySchemaChanged(workbenchColorsSchemaId), 200); +colorRegistry.onDidChangeSchema(() => { + if (!delayer.isScheduled()) { + delayer.schedule(); + } +}); + +// setTimeout(_ => console.log(colorRegistry.toString()), 5000); diff --git a/src/vs/platform/theme/common/colors/baseColors.ts b/src/vs/platform/theme/common/colors/baseColors.ts new file mode 100644 index 0000000000000..1d19b3adc1fc4 --- /dev/null +++ b/src/vs/platform/theme/common/colors/baseColors.ts @@ -0,0 +1,91 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as nls from 'vs/nls'; + +// Import the effects we need +import { Color } from 'vs/base/common/color'; +import { registerColor, transparent } from 'vs/platform/theme/common/colorUtils'; + + +export const foreground = registerColor('foreground', + { dark: '#CCCCCC', light: '#616161', hcDark: '#FFFFFF', hcLight: '#292929' }, + nls.localize('foreground', "Overall foreground color. This color is only used if not overridden by a component.")); + +export const disabledForeground = registerColor('disabledForeground', + { dark: '#CCCCCC80', light: '#61616180', hcDark: '#A5A5A5', hcLight: '#7F7F7F' }, + nls.localize('disabledForeground', "Overall foreground for disabled elements. This color is only used if not overridden by a component.")); + +export const errorForeground = registerColor('errorForeground', + { dark: '#F48771', light: '#A1260D', hcDark: '#F48771', hcLight: '#B5200D' }, + nls.localize('errorForeground', "Overall foreground color for error messages. This color is only used if not overridden by a component.")); + +export const descriptionForeground = registerColor('descriptionForeground', + { light: '#717171', dark: transparent(foreground, 0.7), hcDark: transparent(foreground, 0.7), hcLight: transparent(foreground, 0.7) }, + nls.localize('descriptionForeground', "Foreground color for description text providing additional information, for example for a label.")); + +export const iconForeground = registerColor('icon.foreground', + { dark: '#C5C5C5', light: '#424242', hcDark: '#FFFFFF', hcLight: '#292929' }, + nls.localize('iconForeground', "The default color for icons in the workbench.")); + +export const focusBorder = registerColor('focusBorder', + { dark: '#007FD4', light: '#0090F1', hcDark: '#F38518', hcLight: '#006BBD' }, + nls.localize('focusBorder', "Overall border color for focused elements. This color is only used if not overridden by a component.")); + +export const contrastBorder = registerColor('contrastBorder', + { light: null, dark: null, hcDark: '#6FC3DF', hcLight: '#0F4A85' }, + nls.localize('contrastBorder', "An extra border around elements to separate them from others for greater contrast.")); + +export const activeContrastBorder = registerColor('contrastActiveBorder', + { light: null, dark: null, hcDark: focusBorder, hcLight: focusBorder }, + nls.localize('activeContrastBorder', "An extra border around active elements to separate them from others for greater contrast.")); + +export const selectionBackground = registerColor('selection.background', + { light: null, dark: null, hcDark: null, hcLight: null }, + nls.localize('selectionBackground', "The background color of text selections in the workbench (e.g. for input fields or text areas). Note that this does not apply to selections within the editor.")); + + +// ------ text link + +export const textLinkForeground = registerColor('textLink.foreground', + { light: '#006AB1', dark: '#3794FF', hcDark: '#21A6FF', hcLight: '#0F4A85' }, + nls.localize('textLinkForeground', "Foreground color for links in text.")); + +export const textLinkActiveForeground = registerColor('textLink.activeForeground', + { light: '#006AB1', dark: '#3794FF', hcDark: '#21A6FF', hcLight: '#0F4A85' }, + nls.localize('textLinkActiveForeground', "Foreground color for links in text when clicked on and on mouse hover.")); + +export const textSeparatorForeground = registerColor('textSeparator.foreground', + { light: '#0000002e', dark: '#ffffff2e', hcDark: Color.black, hcLight: '#292929' }, + nls.localize('textSeparatorForeground', "Color for text separators.")); + + +// ------ text preformat + +export const textPreformatForeground = registerColor('textPreformat.foreground', + { light: '#A31515', dark: '#D7BA7D', hcDark: '#000000', hcLight: '#FFFFFF' }, + nls.localize('textPreformatForeground', "Foreground color for preformatted text segments.")); + +export const textPreformatBackground = registerColor('textPreformat.background', + { light: '#0000001A', dark: '#FFFFFF1A', hcDark: '#FFFFFF', hcLight: '#09345f' }, + nls.localize('textPreformatBackground', "Background color for preformatted text segments.")); + + +// ------ text block quote + +export const textBlockQuoteBackground = registerColor('textBlockQuote.background', + { light: '#f2f2f2', dark: '#222222', hcDark: null, hcLight: '#F2F2F2' }, + nls.localize('textBlockQuoteBackground', "Background color for block quotes in text.")); + +export const textBlockQuoteBorder = registerColor('textBlockQuote.border', + { light: '#007acc80', dark: '#007acc80', hcDark: Color.white, hcLight: '#292929' }, + nls.localize('textBlockQuoteBorder', "Border color for block quotes in text.")); + + +// ------ text code block + +export const textCodeBlockBackground = registerColor('textCodeBlock.background', + { light: '#dcdcdc66', dark: '#0a0a0a66', hcDark: Color.black, hcLight: '#F2F2F2' }, + nls.localize('textCodeBlockBackground', "Background color for code blocks in text.")); diff --git a/src/vs/platform/theme/common/colors/chartsColors.ts b/src/vs/platform/theme/common/colors/chartsColors.ts new file mode 100644 index 0000000000000..eb63b6022347d --- /dev/null +++ b/src/vs/platform/theme/common/colors/chartsColors.ts @@ -0,0 +1,44 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as nls from 'vs/nls'; +import { registerColor, transparent } from 'vs/platform/theme/common/colorUtils'; + +import { foreground } from 'vs/platform/theme/common/colors/baseColors'; +import { editorErrorForeground, editorInfoForeground, editorWarningForeground } from 'vs/platform/theme/common/colors/editorColors'; +import { minimapFindMatch } from 'vs/platform/theme/common/colors/minimapColors'; + + +export const chartsForeground = registerColor('charts.foreground', + { dark: foreground, light: foreground, hcDark: foreground, hcLight: foreground }, + nls.localize('chartsForeground', "The foreground color used in charts.")); + +export const chartsLines = registerColor('charts.lines', + { dark: transparent(foreground, .5), light: transparent(foreground, .5), hcDark: transparent(foreground, .5), hcLight: transparent(foreground, .5) }, + nls.localize('chartsLines', "The color used for horizontal lines in charts.")); + +export const chartsRed = registerColor('charts.red', + { dark: editorErrorForeground, light: editorErrorForeground, hcDark: editorErrorForeground, hcLight: editorErrorForeground }, + nls.localize('chartsRed', "The red color used in chart visualizations.")); + +export const chartsBlue = registerColor('charts.blue', + { dark: editorInfoForeground, light: editorInfoForeground, hcDark: editorInfoForeground, hcLight: editorInfoForeground }, + nls.localize('chartsBlue', "The blue color used in chart visualizations.")); + +export const chartsYellow = registerColor('charts.yellow', + { dark: editorWarningForeground, light: editorWarningForeground, hcDark: editorWarningForeground, hcLight: editorWarningForeground }, + nls.localize('chartsYellow', "The yellow color used in chart visualizations.")); + +export const chartsOrange = registerColor('charts.orange', + { dark: minimapFindMatch, light: minimapFindMatch, hcDark: minimapFindMatch, hcLight: minimapFindMatch }, + nls.localize('chartsOrange', "The orange color used in chart visualizations.")); + +export const chartsGreen = registerColor('charts.green', + { dark: '#89D185', light: '#388A34', hcDark: '#89D185', hcLight: '#374e06' }, + nls.localize('chartsGreen', "The green color used in chart visualizations.")); + +export const chartsPurple = registerColor('charts.purple', + { dark: '#B180D7', light: '#652D90', hcDark: '#B180D7', hcLight: '#652D90' }, + nls.localize('chartsPurple', "The purple color used in chart visualizations.")); diff --git a/src/vs/platform/theme/common/colors/editorColors.ts b/src/vs/platform/theme/common/colors/editorColors.ts new file mode 100644 index 0000000000000..4116f5ec1410e --- /dev/null +++ b/src/vs/platform/theme/common/colors/editorColors.ts @@ -0,0 +1,441 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as nls from 'vs/nls'; + +// Import the effects we need +import { Color, RGBA } from 'vs/base/common/color'; +import { registerColor, transparent, lessProminent, darken, lighten } from 'vs/platform/theme/common/colorUtils'; + +// Import the colors we need +import { foreground, contrastBorder, activeContrastBorder } from 'vs/platform/theme/common/colors/baseColors'; +import { scrollbarShadow, badgeBackground } from 'vs/platform/theme/common/colors/miscColors'; + + +// ----- editor + +export const editorBackground = registerColor('editor.background', + { light: '#ffffff', dark: '#1E1E1E', hcDark: Color.black, hcLight: Color.white }, + nls.localize('editorBackground', "Editor background color.")); + +export const editorForeground = registerColor('editor.foreground', + { light: '#333333', dark: '#BBBBBB', hcDark: Color.white, hcLight: foreground }, + nls.localize('editorForeground', "Editor default foreground color.")); + + +export const editorStickyScrollBackground = registerColor('editorStickyScroll.background', + { light: editorBackground, dark: editorBackground, hcDark: editorBackground, hcLight: editorBackground }, + nls.localize('editorStickyScrollBackground', "Background color of sticky scroll in the editor")); + +export const editorStickyScrollHoverBackground = registerColor('editorStickyScrollHover.background', + { dark: '#2A2D2E', light: '#F0F0F0', hcDark: null, hcLight: Color.fromHex('#0F4A85').transparent(0.1) }, + nls.localize('editorStickyScrollHoverBackground', "Background color of sticky scroll on hover in the editor")); + +export const editorStickyScrollBorder = registerColor('editorStickyScroll.border', + { dark: null, light: null, hcDark: contrastBorder, hcLight: contrastBorder }, + nls.localize('editorStickyScrollBorder', "Border color of sticky scroll in the editor")); + +export const editorStickyScrollShadow = registerColor('editorStickyScroll.shadow', + { dark: scrollbarShadow, light: scrollbarShadow, hcDark: scrollbarShadow, hcLight: scrollbarShadow }, + nls.localize('editorStickyScrollShadow', " Shadow color of sticky scroll in the editor")); + + +export const editorWidgetBackground = registerColor('editorWidget.background', + { dark: '#252526', light: '#F3F3F3', hcDark: '#0C141F', hcLight: Color.white }, + nls.localize('editorWidgetBackground', 'Background color of editor widgets, such as find/replace.')); + +export const editorWidgetForeground = registerColor('editorWidget.foreground', + { dark: foreground, light: foreground, hcDark: foreground, hcLight: foreground }, + nls.localize('editorWidgetForeground', 'Foreground color of editor widgets, such as find/replace.')); + +export const editorWidgetBorder = registerColor('editorWidget.border', + { dark: '#454545', light: '#C8C8C8', hcDark: contrastBorder, hcLight: contrastBorder }, + nls.localize('editorWidgetBorder', 'Border color of editor widgets. The color is only used if the widget chooses to have a border and if the color is not overridden by a widget.')); + +export const editorWidgetResizeBorder = registerColor('editorWidget.resizeBorder', + { light: null, dark: null, hcDark: null, hcLight: null }, + nls.localize('editorWidgetResizeBorder', "Border color of the resize bar of editor widgets. The color is only used if the widget chooses to have a resize border and if the color is not overridden by a widget.")); + + +export const editorErrorBackground = registerColor('editorError.background', + { dark: null, light: null, hcDark: null, hcLight: null }, + nls.localize('editorError.background', 'Background color of error text in the editor. The color must not be opaque so as not to hide underlying decorations.'), true); + +export const editorErrorForeground = registerColor('editorError.foreground', + { dark: '#F14C4C', light: '#E51400', hcDark: '#F48771', hcLight: '#B5200D' }, + nls.localize('editorError.foreground', 'Foreground color of error squigglies in the editor.')); + +export const editorErrorBorder = registerColor('editorError.border', + { dark: null, light: null, hcDark: Color.fromHex('#E47777').transparent(0.8), hcLight: '#B5200D' }, + nls.localize('errorBorder', 'If set, color of double underlines for errors in the editor.')); + + +export const editorWarningBackground = registerColor('editorWarning.background', + { dark: null, light: null, hcDark: null, hcLight: null }, + nls.localize('editorWarning.background', 'Background color of warning text in the editor. The color must not be opaque so as not to hide underlying decorations.'), true); + +export const editorWarningForeground = registerColor('editorWarning.foreground', + { dark: '#CCA700', light: '#BF8803', hcDark: '#FFD370', hcLight: '#895503' }, + nls.localize('editorWarning.foreground', 'Foreground color of warning squigglies in the editor.')); + +export const editorWarningBorder = registerColor('editorWarning.border', + { dark: null, light: null, hcDark: Color.fromHex('#FFCC00').transparent(0.8), hcLight: Color.fromHex('#FFCC00').transparent(0.8) }, + nls.localize('warningBorder', 'If set, color of double underlines for warnings in the editor.')); + + +export const editorInfoBackground = registerColor('editorInfo.background', + { dark: null, light: null, hcDark: null, hcLight: null }, + nls.localize('editorInfo.background', 'Background color of info text in the editor. The color must not be opaque so as not to hide underlying decorations.'), true); + +export const editorInfoForeground = registerColor('editorInfo.foreground', + { dark: '#3794FF', light: '#1a85ff', hcDark: '#3794FF', hcLight: '#1a85ff' }, + nls.localize('editorInfo.foreground', 'Foreground color of info squigglies in the editor.')); + +export const editorInfoBorder = registerColor('editorInfo.border', + { dark: null, light: null, hcDark: Color.fromHex('#3794FF').transparent(0.8), hcLight: '#292929' }, + nls.localize('infoBorder', 'If set, color of double underlines for infos in the editor.')); + + +export const editorHintForeground = registerColor('editorHint.foreground', + { dark: Color.fromHex('#eeeeee').transparent(0.7), light: '#6c6c6c', hcDark: null, hcLight: null }, + nls.localize('editorHint.foreground', 'Foreground color of hint squigglies in the editor.')); + +export const editorHintBorder = registerColor('editorHint.border', + { dark: null, light: null, hcDark: Color.fromHex('#eeeeee').transparent(0.8), hcLight: '#292929' }, + nls.localize('hintBorder', 'If set, color of double underlines for hints in the editor.')); + + +export const editorActiveLinkForeground = registerColor('editorLink.activeForeground', + { dark: '#4E94CE', light: Color.blue, hcDark: Color.cyan, hcLight: '#292929' }, + nls.localize('activeLinkForeground', 'Color of active links.')); + + +// ----- editor selection + +export const editorSelectionBackground = registerColor('editor.selectionBackground', + { light: '#ADD6FF', dark: '#264F78', hcDark: '#f3f518', hcLight: '#0F4A85' }, + nls.localize('editorSelectionBackground', "Color of the editor selection.")); + +export const editorSelectionForeground = registerColor('editor.selectionForeground', + { light: null, dark: null, hcDark: '#000000', hcLight: Color.white }, + nls.localize('editorSelectionForeground', "Color of the selected text for high contrast.")); + +export const editorInactiveSelection = registerColor('editor.inactiveSelectionBackground', + { light: transparent(editorSelectionBackground, 0.5), dark: transparent(editorSelectionBackground, 0.5), hcDark: transparent(editorSelectionBackground, 0.7), hcLight: transparent(editorSelectionBackground, 0.5) }, + nls.localize('editorInactiveSelection', "Color of the selection in an inactive editor. The color must not be opaque so as not to hide underlying decorations."), true); + +export const editorSelectionHighlight = registerColor('editor.selectionHighlightBackground', + { light: lessProminent(editorSelectionBackground, editorBackground, 0.3, 0.6), dark: lessProminent(editorSelectionBackground, editorBackground, 0.3, 0.6), hcDark: null, hcLight: null }, + nls.localize('editorSelectionHighlight', 'Color for regions with the same content as the selection. The color must not be opaque so as not to hide underlying decorations.'), true); + +export const editorSelectionHighlightBorder = registerColor('editor.selectionHighlightBorder', + { light: null, dark: null, hcDark: activeContrastBorder, hcLight: activeContrastBorder }, + nls.localize('editorSelectionHighlightBorder', "Border color for regions with the same content as the selection.")); + + +// ----- editor find + +export const editorFindMatch = registerColor('editor.findMatchBackground', + { light: '#A8AC94', dark: '#515C6A', hcDark: null, hcLight: null }, + nls.localize('editorFindMatch', "Color of the current search match.")); + +export const editorFindMatchHighlight = registerColor('editor.findMatchHighlightBackground', + { light: '#EA5C0055', dark: '#EA5C0055', hcDark: null, hcLight: null }, + nls.localize('findMatchHighlight', "Color of the other search matches. The color must not be opaque so as not to hide underlying decorations."), true); + +export const editorFindRangeHighlight = registerColor('editor.findRangeHighlightBackground', + { dark: '#3a3d4166', light: '#b4b4b44d', hcDark: null, hcLight: null }, + nls.localize('findRangeHighlight', "Color of the range limiting the search. The color must not be opaque so as not to hide underlying decorations."), true); + +export const editorFindMatchBorder = registerColor('editor.findMatchBorder', + { light: null, dark: null, hcDark: activeContrastBorder, hcLight: activeContrastBorder }, + nls.localize('editorFindMatchBorder', "Border color of the current search match.")); + +export const editorFindMatchHighlightBorder = registerColor('editor.findMatchHighlightBorder', + { light: null, dark: null, hcDark: activeContrastBorder, hcLight: activeContrastBorder }, + nls.localize('findMatchHighlightBorder', "Border color of the other search matches.")); + +export const editorFindRangeHighlightBorder = registerColor('editor.findRangeHighlightBorder', + { dark: null, light: null, hcDark: transparent(activeContrastBorder, 0.4), hcLight: transparent(activeContrastBorder, 0.4) }, + nls.localize('findRangeHighlightBorder', "Border color of the range limiting the search. The color must not be opaque so as not to hide underlying decorations."), true); + + +// ----- editor hover + +export const editorHoverHighlight = registerColor('editor.hoverHighlightBackground', + { light: '#ADD6FF26', dark: '#264f7840', hcDark: '#ADD6FF26', hcLight: null }, + nls.localize('hoverHighlight', 'Highlight below the word for which a hover is shown. The color must not be opaque so as not to hide underlying decorations.'), true); + +export const editorHoverBackground = registerColor('editorHoverWidget.background', + { light: editorWidgetBackground, dark: editorWidgetBackground, hcDark: editorWidgetBackground, hcLight: editorWidgetBackground }, + nls.localize('hoverBackground', 'Background color of the editor hover.')); + +export const editorHoverForeground = registerColor('editorHoverWidget.foreground', + { light: editorWidgetForeground, dark: editorWidgetForeground, hcDark: editorWidgetForeground, hcLight: editorWidgetForeground }, + nls.localize('hoverForeground', 'Foreground color of the editor hover.')); + +export const editorHoverBorder = registerColor('editorHoverWidget.border', + { light: editorWidgetBorder, dark: editorWidgetBorder, hcDark: editorWidgetBorder, hcLight: editorWidgetBorder }, + nls.localize('hoverBorder', 'Border color of the editor hover.')); + +export const editorHoverStatusBarBackground = registerColor('editorHoverWidget.statusBarBackground', + { dark: lighten(editorHoverBackground, 0.2), light: darken(editorHoverBackground, 0.05), hcDark: editorWidgetBackground, hcLight: editorWidgetBackground }, + nls.localize('statusBarBackground', "Background color of the editor hover status bar.")); + + +// ----- editor inlay hint + +export const editorInlayHintForeground = registerColor('editorInlayHint.foreground', + { dark: '#969696', light: '#969696', hcDark: Color.white, hcLight: Color.black }, + nls.localize('editorInlayHintForeground', 'Foreground color of inline hints')); + +export const editorInlayHintBackground = registerColor('editorInlayHint.background', + { dark: transparent(badgeBackground, .10), light: transparent(badgeBackground, .10), hcDark: transparent(Color.white, .10), hcLight: transparent(badgeBackground, .10) }, + nls.localize('editorInlayHintBackground', 'Background color of inline hints')); + +export const editorInlayHintTypeForeground = registerColor('editorInlayHint.typeForeground', + { dark: editorInlayHintForeground, light: editorInlayHintForeground, hcDark: editorInlayHintForeground, hcLight: editorInlayHintForeground }, + nls.localize('editorInlayHintForegroundTypes', 'Foreground color of inline hints for types')); + +export const editorInlayHintTypeBackground = registerColor('editorInlayHint.typeBackground', + { dark: editorInlayHintBackground, light: editorInlayHintBackground, hcDark: editorInlayHintBackground, hcLight: editorInlayHintBackground }, + nls.localize('editorInlayHintBackgroundTypes', 'Background color of inline hints for types')); + +export const editorInlayHintParameterForeground = registerColor('editorInlayHint.parameterForeground', + { dark: editorInlayHintForeground, light: editorInlayHintForeground, hcDark: editorInlayHintForeground, hcLight: editorInlayHintForeground }, + nls.localize('editorInlayHintForegroundParameter', 'Foreground color of inline hints for parameters')); + +export const editorInlayHintParameterBackground = registerColor('editorInlayHint.parameterBackground', + { dark: editorInlayHintBackground, light: editorInlayHintBackground, hcDark: editorInlayHintBackground, hcLight: editorInlayHintBackground }, + nls.localize('editorInlayHintBackgroundParameter', 'Background color of inline hints for parameters')); + + +// ----- editor lightbulb + +export const editorLightBulbForeground = registerColor('editorLightBulb.foreground', + { dark: '#FFCC00', light: '#DDB100', hcDark: '#FFCC00', hcLight: '#007ACC' }, + nls.localize('editorLightBulbForeground', "The color used for the lightbulb actions icon.")); + +export const editorLightBulbAutoFixForeground = registerColor('editorLightBulbAutoFix.foreground', + { dark: '#75BEFF', light: '#007ACC', hcDark: '#75BEFF', hcLight: '#007ACC' }, + nls.localize('editorLightBulbAutoFixForeground', "The color used for the lightbulb auto fix actions icon.")); + +export const editorLightBulbAiForeground = registerColor('editorLightBulbAi.foreground', + { dark: editorLightBulbForeground, light: editorLightBulbForeground, hcDark: editorLightBulbForeground, hcLight: editorLightBulbForeground }, + nls.localize('editorLightBulbAiForeground', "The color used for the lightbulb AI icon.")); + + +// ----- editor snippet + +export const snippetTabstopHighlightBackground = registerColor('editor.snippetTabstopHighlightBackground', + { dark: new Color(new RGBA(124, 124, 124, 0.3)), light: new Color(new RGBA(10, 50, 100, 0.2)), hcDark: new Color(new RGBA(124, 124, 124, 0.3)), hcLight: new Color(new RGBA(10, 50, 100, 0.2)) }, + nls.localize('snippetTabstopHighlightBackground', "Highlight background color of a snippet tabstop.")); + +export const snippetTabstopHighlightBorder = registerColor('editor.snippetTabstopHighlightBorder', + { dark: null, light: null, hcDark: null, hcLight: null }, + nls.localize('snippetTabstopHighlightBorder', "Highlight border color of a snippet tabstop.")); + +export const snippetFinalTabstopHighlightBackground = registerColor('editor.snippetFinalTabstopHighlightBackground', + { dark: null, light: null, hcDark: null, hcLight: null }, + nls.localize('snippetFinalTabstopHighlightBackground', "Highlight background color of the final tabstop of a snippet.")); + +export const snippetFinalTabstopHighlightBorder = registerColor('editor.snippetFinalTabstopHighlightBorder', + { dark: '#525252', light: new Color(new RGBA(10, 50, 100, 0.5)), hcDark: '#525252', hcLight: '#292929' }, + nls.localize('snippetFinalTabstopHighlightBorder', "Highlight border color of the final tabstop of a snippet.")); + + +// ----- diff editor + +export const defaultInsertColor = new Color(new RGBA(155, 185, 85, .2)); +export const defaultRemoveColor = new Color(new RGBA(255, 0, 0, .2)); + +export const diffInserted = registerColor('diffEditor.insertedTextBackground', + { dark: '#9ccc2c33', light: '#9ccc2c40', hcDark: null, hcLight: null }, + nls.localize('diffEditorInserted', 'Background color for text that got inserted. The color must not be opaque so as not to hide underlying decorations.'), true); + +export const diffRemoved = registerColor('diffEditor.removedTextBackground', + { dark: '#ff000033', light: '#ff000033', hcDark: null, hcLight: null }, + nls.localize('diffEditorRemoved', 'Background color for text that got removed. The color must not be opaque so as not to hide underlying decorations.'), true); + + +export const diffInsertedLine = registerColor('diffEditor.insertedLineBackground', + { dark: defaultInsertColor, light: defaultInsertColor, hcDark: null, hcLight: null }, + nls.localize('diffEditorInsertedLines', 'Background color for lines that got inserted. The color must not be opaque so as not to hide underlying decorations.'), true); + +export const diffRemovedLine = registerColor('diffEditor.removedLineBackground', + { dark: defaultRemoveColor, light: defaultRemoveColor, hcDark: null, hcLight: null }, + nls.localize('diffEditorRemovedLines', 'Background color for lines that got removed. The color must not be opaque so as not to hide underlying decorations.'), true); + + +export const diffInsertedLineGutter = registerColor('diffEditorGutter.insertedLineBackground', + { dark: null, light: null, hcDark: null, hcLight: null }, + nls.localize('diffEditorInsertedLineGutter', 'Background color for the margin where lines got inserted.')); + +export const diffRemovedLineGutter = registerColor('diffEditorGutter.removedLineBackground', + { dark: null, light: null, hcDark: null, hcLight: null }, + nls.localize('diffEditorRemovedLineGutter', 'Background color for the margin where lines got removed.')); + + +export const diffOverviewRulerInserted = registerColor('diffEditorOverview.insertedForeground', + { dark: null, light: null, hcDark: null, hcLight: null }, + nls.localize('diffEditorOverviewInserted', 'Diff overview ruler foreground for inserted content.')); + +export const diffOverviewRulerRemoved = registerColor('diffEditorOverview.removedForeground', + { dark: null, light: null, hcDark: null, hcLight: null }, + nls.localize('diffEditorOverviewRemoved', 'Diff overview ruler foreground for removed content.')); + + +export const diffInsertedOutline = registerColor('diffEditor.insertedTextBorder', + { dark: null, light: null, hcDark: '#33ff2eff', hcLight: '#374E06' }, + nls.localize('diffEditorInsertedOutline', 'Outline color for the text that got inserted.')); + +export const diffRemovedOutline = registerColor('diffEditor.removedTextBorder', + { dark: null, light: null, hcDark: '#FF008F', hcLight: '#AD0707' }, + nls.localize('diffEditorRemovedOutline', 'Outline color for text that got removed.')); + + +export const diffBorder = registerColor('diffEditor.border', + { dark: null, light: null, hcDark: contrastBorder, hcLight: contrastBorder }, + nls.localize('diffEditorBorder', 'Border color between the two text editors.')); + +export const diffDiagonalFill = registerColor('diffEditor.diagonalFill', + { dark: '#cccccc33', light: '#22222233', hcDark: null, hcLight: null }, + nls.localize('diffDiagonalFill', "Color of the diff editor's diagonal fill. The diagonal fill is used in side-by-side diff views.")); + + +export const diffUnchangedRegionBackground = registerColor('diffEditor.unchangedRegionBackground', + { dark: 'sideBar.background', light: 'sideBar.background', hcDark: 'sideBar.background', hcLight: 'sideBar.background' }, + nls.localize('diffEditor.unchangedRegionBackground', "The background color of unchanged blocks in the diff editor.")); + +export const diffUnchangedRegionForeground = registerColor('diffEditor.unchangedRegionForeground', + { dark: 'foreground', light: 'foreground', hcDark: 'foreground', hcLight: 'foreground' }, + nls.localize('diffEditor.unchangedRegionForeground', "The foreground color of unchanged blocks in the diff editor.")); + +export const diffUnchangedTextBackground = registerColor('diffEditor.unchangedCodeBackground', + { dark: '#74747429', light: '#b8b8b829', hcDark: null, hcLight: null }, + nls.localize('diffEditor.unchangedCodeBackground', "The background color of unchanged code in the diff editor.")); + + +// ----- widget + +export const widgetShadow = registerColor('widget.shadow', + { dark: transparent(Color.black, .36), light: transparent(Color.black, .16), hcDark: null, hcLight: null }, + nls.localize('widgetShadow', 'Shadow color of widgets such as find/replace inside the editor.')); + +export const widgetBorder = registerColor('widget.border', + { dark: null, light: null, hcDark: contrastBorder, hcLight: contrastBorder }, + nls.localize('widgetBorder', 'Border color of widgets such as find/replace inside the editor.')); + + +// ----- toolbar + +export const toolbarHoverBackground = registerColor('toolbar.hoverBackground', + { dark: '#5a5d5e50', light: '#b8b8b850', hcDark: null, hcLight: null }, + nls.localize('toolbarHoverBackground', "Toolbar background when hovering over actions using the mouse")); + +export const toolbarHoverOutline = registerColor('toolbar.hoverOutline', + { dark: null, light: null, hcDark: activeContrastBorder, hcLight: activeContrastBorder }, + nls.localize('toolbarHoverOutline', "Toolbar outline when hovering over actions using the mouse")); + +export const toolbarActiveBackground = registerColor('toolbar.activeBackground', + { dark: lighten(toolbarHoverBackground, 0.1), light: darken(toolbarHoverBackground, 0.1), hcDark: null, hcLight: null }, + nls.localize('toolbarActiveBackground', "Toolbar background when holding the mouse over actions")); + + +// ----- breadcumbs + +export const breadcrumbsForeground = registerColor('breadcrumb.foreground', + { light: transparent(foreground, 0.8), dark: transparent(foreground, 0.8), hcDark: transparent(foreground, 0.8), hcLight: transparent(foreground, 0.8) }, + nls.localize('breadcrumbsFocusForeground', "Color of focused breadcrumb items.")); + +export const breadcrumbsBackground = registerColor('breadcrumb.background', + { light: editorBackground, dark: editorBackground, hcDark: editorBackground, hcLight: editorBackground }, + nls.localize('breadcrumbsBackground', "Background color of breadcrumb items.")); + +export const breadcrumbsFocusForeground = registerColor('breadcrumb.focusForeground', + { light: darken(foreground, 0.2), dark: lighten(foreground, 0.1), hcDark: lighten(foreground, 0.1), hcLight: lighten(foreground, 0.1) }, + nls.localize('breadcrumbsFocusForeground', "Color of focused breadcrumb items.")); + +export const breadcrumbsActiveSelectionForeground = registerColor('breadcrumb.activeSelectionForeground', + { light: darken(foreground, 0.2), dark: lighten(foreground, 0.1), hcDark: lighten(foreground, 0.1), hcLight: lighten(foreground, 0.1) }, + nls.localize('breadcrumbsSelectedForeground', "Color of selected breadcrumb items.")); + +export const breadcrumbsPickerBackground = registerColor('breadcrumbPicker.background', + { light: editorWidgetBackground, dark: editorWidgetBackground, hcDark: editorWidgetBackground, hcLight: editorWidgetBackground }, + nls.localize('breadcrumbsSelectedBackground', "Background color of breadcrumb item picker.")); + + +// ----- merge + +const headerTransparency = 0.5; +const currentBaseColor = Color.fromHex('#40C8AE').transparent(headerTransparency); +const incomingBaseColor = Color.fromHex('#40A6FF').transparent(headerTransparency); +const commonBaseColor = Color.fromHex('#606060').transparent(0.4); +const contentTransparency = 0.4; +const rulerTransparency = 1; + +export const mergeCurrentHeaderBackground = registerColor('merge.currentHeaderBackground', + { dark: currentBaseColor, light: currentBaseColor, hcDark: null, hcLight: null }, + nls.localize('mergeCurrentHeaderBackground', 'Current header background in inline merge-conflicts. The color must not be opaque so as not to hide underlying decorations.'), true); + +export const mergeCurrentContentBackground = registerColor('merge.currentContentBackground', + { dark: transparent(mergeCurrentHeaderBackground, contentTransparency), light: transparent(mergeCurrentHeaderBackground, contentTransparency), hcDark: transparent(mergeCurrentHeaderBackground, contentTransparency), hcLight: transparent(mergeCurrentHeaderBackground, contentTransparency) }, + nls.localize('mergeCurrentContentBackground', 'Current content background in inline merge-conflicts. The color must not be opaque so as not to hide underlying decorations.'), true); + +export const mergeIncomingHeaderBackground = registerColor('merge.incomingHeaderBackground', + { dark: incomingBaseColor, light: incomingBaseColor, hcDark: null, hcLight: null }, + nls.localize('mergeIncomingHeaderBackground', 'Incoming header background in inline merge-conflicts. The color must not be opaque so as not to hide underlying decorations.'), true); + +export const mergeIncomingContentBackground = registerColor('merge.incomingContentBackground', + { dark: transparent(mergeIncomingHeaderBackground, contentTransparency), light: transparent(mergeIncomingHeaderBackground, contentTransparency), hcDark: transparent(mergeIncomingHeaderBackground, contentTransparency), hcLight: transparent(mergeIncomingHeaderBackground, contentTransparency) }, + nls.localize('mergeIncomingContentBackground', 'Incoming content background in inline merge-conflicts. The color must not be opaque so as not to hide underlying decorations.'), true); + +export const mergeCommonHeaderBackground = registerColor('merge.commonHeaderBackground', + { dark: commonBaseColor, light: commonBaseColor, hcDark: null, hcLight: null }, + nls.localize('mergeCommonHeaderBackground', 'Common ancestor header background in inline merge-conflicts. The color must not be opaque so as not to hide underlying decorations.'), true); + +export const mergeCommonContentBackground = registerColor('merge.commonContentBackground', + { dark: transparent(mergeCommonHeaderBackground, contentTransparency), light: transparent(mergeCommonHeaderBackground, contentTransparency), hcDark: transparent(mergeCommonHeaderBackground, contentTransparency), hcLight: transparent(mergeCommonHeaderBackground, contentTransparency) }, + nls.localize('mergeCommonContentBackground', 'Common ancestor content background in inline merge-conflicts. The color must not be opaque so as not to hide underlying decorations.'), true); + +export const mergeBorder = registerColor('merge.border', + { dark: null, light: null, hcDark: '#C3DF6F', hcLight: '#007ACC' }, + nls.localize('mergeBorder', 'Border color on headers and the splitter in inline merge-conflicts.')); + + +export const overviewRulerCurrentContentForeground = registerColor('editorOverviewRuler.currentContentForeground', + { dark: transparent(mergeCurrentHeaderBackground, rulerTransparency), light: transparent(mergeCurrentHeaderBackground, rulerTransparency), hcDark: mergeBorder, hcLight: mergeBorder }, + nls.localize('overviewRulerCurrentContentForeground', 'Current overview ruler foreground for inline merge-conflicts.')); + +export const overviewRulerIncomingContentForeground = registerColor('editorOverviewRuler.incomingContentForeground', + { dark: transparent(mergeIncomingHeaderBackground, rulerTransparency), light: transparent(mergeIncomingHeaderBackground, rulerTransparency), hcDark: mergeBorder, hcLight: mergeBorder }, + nls.localize('overviewRulerIncomingContentForeground', 'Incoming overview ruler foreground for inline merge-conflicts.')); + +export const overviewRulerCommonContentForeground = registerColor('editorOverviewRuler.commonContentForeground', + { dark: transparent(mergeCommonHeaderBackground, rulerTransparency), light: transparent(mergeCommonHeaderBackground, rulerTransparency), hcDark: mergeBorder, hcLight: mergeBorder }, + nls.localize('overviewRulerCommonContentForeground', 'Common ancestor overview ruler foreground for inline merge-conflicts.')); + +export const overviewRulerFindMatchForeground = registerColor('editorOverviewRuler.findMatchForeground', + { dark: '#d186167e', light: '#d186167e', hcDark: '#AB5A00', hcLight: '' }, + nls.localize('overviewRulerFindMatchForeground', 'Overview ruler marker color for find matches. The color must not be opaque so as not to hide underlying decorations.'), true); + +export const overviewRulerSelectionHighlightForeground = registerColor('editorOverviewRuler.selectionHighlightForeground', + { dark: '#A0A0A0CC', light: '#A0A0A0CC', hcDark: '#A0A0A0CC', hcLight: '#A0A0A0CC' }, + nls.localize('overviewRulerSelectionHighlightForeground', 'Overview ruler marker color for selection highlights. The color must not be opaque so as not to hide underlying decorations.'), true); + + +// ----- problems + +export const problemsErrorIconForeground = registerColor('problemsErrorIcon.foreground', + { dark: editorErrorForeground, light: editorErrorForeground, hcDark: editorErrorForeground, hcLight: editorErrorForeground }, + nls.localize('problemsErrorIconForeground', "The color used for the problems error icon.")); + +export const problemsWarningIconForeground = registerColor('problemsWarningIcon.foreground', + { dark: editorWarningForeground, light: editorWarningForeground, hcDark: editorWarningForeground, hcLight: editorWarningForeground }, + nls.localize('problemsWarningIconForeground', "The color used for the problems warning icon.")); + +export const problemsInfoIconForeground = registerColor('problemsInfoIcon.foreground', + { dark: editorInfoForeground, light: editorInfoForeground, hcDark: editorInfoForeground, hcLight: editorInfoForeground }, + nls.localize('problemsInfoIconForeground', "The color used for the problems info icon.")); diff --git a/src/vs/platform/theme/common/colors/inputColors.ts b/src/vs/platform/theme/common/colors/inputColors.ts new file mode 100644 index 0000000000000..dc38222d402d0 --- /dev/null +++ b/src/vs/platform/theme/common/colors/inputColors.ts @@ -0,0 +1,184 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as nls from 'vs/nls'; + +// Import the effects we need +import { Color, RGBA } from 'vs/base/common/color'; +import { registerColor, transparent, lighten, darken } from 'vs/platform/theme/common/colorUtils'; + +// Import the colors we need +import { foreground, contrastBorder, focusBorder, iconForeground } from 'vs/platform/theme/common/colors/baseColors'; +import { editorWidgetBackground } from 'vs/platform/theme/common/colors/editorColors'; + + +// ----- input + +export const inputBackground = registerColor('input.background', + { dark: '#3C3C3C', light: Color.white, hcDark: Color.black, hcLight: Color.white }, + nls.localize('inputBoxBackground', "Input box background.")); + +export const inputForeground = registerColor('input.foreground', + { dark: foreground, light: foreground, hcDark: foreground, hcLight: foreground }, + nls.localize('inputBoxForeground', "Input box foreground.")); + +export const inputBorder = registerColor('input.border', + { dark: null, light: null, hcDark: contrastBorder, hcLight: contrastBorder }, + nls.localize('inputBoxBorder', "Input box border.")); + +export const inputActiveOptionBorder = registerColor('inputOption.activeBorder', + { dark: '#007ACC', light: '#007ACC', hcDark: contrastBorder, hcLight: contrastBorder }, + nls.localize('inputBoxActiveOptionBorder', "Border color of activated options in input fields.")); + +export const inputActiveOptionHoverBackground = registerColor('inputOption.hoverBackground', + { dark: '#5a5d5e80', light: '#b8b8b850', hcDark: null, hcLight: null }, + nls.localize('inputOption.hoverBackground', "Background color of activated options in input fields.")); + +export const inputActiveOptionBackground = registerColor('inputOption.activeBackground', + { dark: transparent(focusBorder, 0.4), light: transparent(focusBorder, 0.2), hcDark: Color.transparent, hcLight: Color.transparent }, + nls.localize('inputOption.activeBackground', "Background hover color of options in input fields.")); + +export const inputActiveOptionForeground = registerColor('inputOption.activeForeground', + { dark: Color.white, light: Color.black, hcDark: foreground, hcLight: foreground }, + nls.localize('inputOption.activeForeground', "Foreground color of activated options in input fields.")); + +export const inputPlaceholderForeground = registerColor('input.placeholderForeground', + { light: transparent(foreground, 0.5), dark: transparent(foreground, 0.5), hcDark: transparent(foreground, 0.7), hcLight: transparent(foreground, 0.7) }, + nls.localize('inputPlaceholderForeground', "Input box foreground color for placeholder text.")); + + +// ----- input validation + +export const inputValidationInfoBackground = registerColor('inputValidation.infoBackground', + { dark: '#063B49', light: '#D6ECF2', hcDark: Color.black, hcLight: Color.white }, + nls.localize('inputValidationInfoBackground', "Input validation background color for information severity.")); + +export const inputValidationInfoForeground = registerColor('inputValidation.infoForeground', + { dark: null, light: null, hcDark: null, hcLight: foreground }, + nls.localize('inputValidationInfoForeground', "Input validation foreground color for information severity.")); + +export const inputValidationInfoBorder = registerColor('inputValidation.infoBorder', + { dark: '#007acc', light: '#007acc', hcDark: contrastBorder, hcLight: contrastBorder }, + nls.localize('inputValidationInfoBorder', "Input validation border color for information severity.")); + +export const inputValidationWarningBackground = registerColor('inputValidation.warningBackground', + { dark: '#352A05', light: '#F6F5D2', hcDark: Color.black, hcLight: Color.white }, + nls.localize('inputValidationWarningBackground', "Input validation background color for warning severity.")); + +export const inputValidationWarningForeground = registerColor('inputValidation.warningForeground', + { dark: null, light: null, hcDark: null, hcLight: foreground }, + nls.localize('inputValidationWarningForeground', "Input validation foreground color for warning severity.")); + +export const inputValidationWarningBorder = registerColor('inputValidation.warningBorder', + { dark: '#B89500', light: '#B89500', hcDark: contrastBorder, hcLight: contrastBorder }, + nls.localize('inputValidationWarningBorder', "Input validation border color for warning severity.")); + +export const inputValidationErrorBackground = registerColor('inputValidation.errorBackground', + { dark: '#5A1D1D', light: '#F2DEDE', hcDark: Color.black, hcLight: Color.white }, + nls.localize('inputValidationErrorBackground', "Input validation background color for error severity.")); + +export const inputValidationErrorForeground = registerColor('inputValidation.errorForeground', + { dark: null, light: null, hcDark: null, hcLight: foreground }, + nls.localize('inputValidationErrorForeground', "Input validation foreground color for error severity.")); + +export const inputValidationErrorBorder = registerColor('inputValidation.errorBorder', + { dark: '#BE1100', light: '#BE1100', hcDark: contrastBorder, hcLight: contrastBorder }, + nls.localize('inputValidationErrorBorder', "Input validation border color for error severity.")); + + +// ----- select + +export const selectBackground = registerColor('dropdown.background', + { dark: '#3C3C3C', light: Color.white, hcDark: Color.black, hcLight: Color.white }, + nls.localize('dropdownBackground', "Dropdown background.")); + +export const selectListBackground = registerColor('dropdown.listBackground', + { dark: null, light: null, hcDark: Color.black, hcLight: Color.white }, + nls.localize('dropdownListBackground', "Dropdown list background.")); + +export const selectForeground = registerColor('dropdown.foreground', + { dark: '#F0F0F0', light: foreground, hcDark: Color.white, hcLight: foreground }, + nls.localize('dropdownForeground', "Dropdown foreground.")); + +export const selectBorder = registerColor('dropdown.border', + { dark: selectBackground, light: '#CECECE', hcDark: contrastBorder, hcLight: contrastBorder }, + nls.localize('dropdownBorder', "Dropdown border.")); + + +// ------ button + +export const buttonForeground = registerColor('button.foreground', + { dark: Color.white, light: Color.white, hcDark: Color.white, hcLight: Color.white }, + nls.localize('buttonForeground', "Button foreground color.")); + +export const buttonSeparator = registerColor('button.separator', + { dark: transparent(buttonForeground, .4), light: transparent(buttonForeground, .4), hcDark: transparent(buttonForeground, .4), hcLight: transparent(buttonForeground, .4) }, + nls.localize('buttonSeparator', "Button separator color.")); + +export const buttonBackground = registerColor('button.background', + { dark: '#0E639C', light: '#007ACC', hcDark: null, hcLight: '#0F4A85' }, + nls.localize('buttonBackground', "Button background color.")); + +export const buttonHoverBackground = registerColor('button.hoverBackground', + { dark: lighten(buttonBackground, 0.2), light: darken(buttonBackground, 0.2), hcDark: buttonBackground, hcLight: buttonBackground }, + nls.localize('buttonHoverBackground', "Button background color when hovering.")); + +export const buttonBorder = registerColor('button.border', + { dark: contrastBorder, light: contrastBorder, hcDark: contrastBorder, hcLight: contrastBorder }, + nls.localize('buttonBorder', "Button border color.")); + +export const buttonSecondaryForeground = registerColor('button.secondaryForeground', + { dark: Color.white, light: Color.white, hcDark: Color.white, hcLight: foreground }, + nls.localize('buttonSecondaryForeground', "Secondary button foreground color.")); + +export const buttonSecondaryBackground = registerColor('button.secondaryBackground', + { dark: '#3A3D41', light: '#5F6A79', hcDark: null, hcLight: Color.white }, + nls.localize('buttonSecondaryBackground', "Secondary button background color.")); + +export const buttonSecondaryHoverBackground = registerColor('button.secondaryHoverBackground', + { dark: lighten(buttonSecondaryBackground, 0.2), light: darken(buttonSecondaryBackground, 0.2), hcDark: null, hcLight: null }, + nls.localize('buttonSecondaryHoverBackground', "Secondary button background color when hovering.")); + + +// ------ checkbox + +export const checkboxBackground = registerColor('checkbox.background', + { dark: selectBackground, light: selectBackground, hcDark: selectBackground, hcLight: selectBackground }, + nls.localize('checkbox.background', "Background color of checkbox widget.")); + +export const checkboxSelectBackground = registerColor('checkbox.selectBackground', + { dark: editorWidgetBackground, light: editorWidgetBackground, hcDark: editorWidgetBackground, hcLight: editorWidgetBackground }, + nls.localize('checkbox.select.background', "Background color of checkbox widget when the element it's in is selected.")); + +export const checkboxForeground = registerColor('checkbox.foreground', + { dark: selectForeground, light: selectForeground, hcDark: selectForeground, hcLight: selectForeground }, + nls.localize('checkbox.foreground', "Foreground color of checkbox widget.")); + +export const checkboxBorder = registerColor('checkbox.border', + { dark: selectBorder, light: selectBorder, hcDark: selectBorder, hcLight: selectBorder }, + nls.localize('checkbox.border', "Border color of checkbox widget.")); + +export const checkboxSelectBorder = registerColor('checkbox.selectBorder', + { dark: iconForeground, light: iconForeground, hcDark: iconForeground, hcLight: iconForeground }, + nls.localize('checkbox.select.border', "Border color of checkbox widget when the element it's in is selected.")); + + +// ------ keybinding label + +export const keybindingLabelBackground = registerColor('keybindingLabel.background', + { dark: new Color(new RGBA(128, 128, 128, 0.17)), light: new Color(new RGBA(221, 221, 221, 0.4)), hcDark: Color.transparent, hcLight: Color.transparent }, + nls.localize('keybindingLabelBackground', "Keybinding label background color. The keybinding label is used to represent a keyboard shortcut.")); + +export const keybindingLabelForeground = registerColor('keybindingLabel.foreground', + { dark: Color.fromHex('#CCCCCC'), light: Color.fromHex('#555555'), hcDark: Color.white, hcLight: foreground }, + nls.localize('keybindingLabelForeground', "Keybinding label foreground color. The keybinding label is used to represent a keyboard shortcut.")); + +export const keybindingLabelBorder = registerColor('keybindingLabel.border', + { dark: new Color(new RGBA(51, 51, 51, 0.6)), light: new Color(new RGBA(204, 204, 204, 0.4)), hcDark: new Color(new RGBA(111, 195, 223)), hcLight: contrastBorder }, + nls.localize('keybindingLabelBorder', "Keybinding label border color. The keybinding label is used to represent a keyboard shortcut.")); + +export const keybindingLabelBottomBorder = registerColor('keybindingLabel.bottomBorder', + { dark: new Color(new RGBA(68, 68, 68, 0.6)), light: new Color(new RGBA(187, 187, 187, 0.4)), hcDark: new Color(new RGBA(111, 195, 223)), hcLight: foreground }, + nls.localize('keybindingLabelBottomBorder', "Keybinding label border bottom color. The keybinding label is used to represent a keyboard shortcut.")); diff --git a/src/vs/platform/theme/common/colors/listColors.ts b/src/vs/platform/theme/common/colors/listColors.ts new file mode 100644 index 0000000000000..b6f51e3696b07 --- /dev/null +++ b/src/vs/platform/theme/common/colors/listColors.ts @@ -0,0 +1,147 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as nls from 'vs/nls'; + +// Import the effects we need +import { Color } from 'vs/base/common/color'; +import { registerColor, darken, lighten, transparent, ifDefinedThenElse } from 'vs/platform/theme/common/colorUtils'; + +// Import the colors we need +import { foreground, contrastBorder, activeContrastBorder, focusBorder, iconForeground } from 'vs/platform/theme/common/colors/baseColors'; +import { editorWidgetBackground, editorFindMatchHighlightBorder, editorFindMatchHighlight, widgetShadow } from 'vs/platform/theme/common/colors/editorColors'; + + +export const listFocusBackground = registerColor('list.focusBackground', + { dark: null, light: null, hcDark: null, hcLight: null }, + nls.localize('listFocusBackground', "List/Tree background color for the focused item when the list/tree is active. An active list/tree has keyboard focus, an inactive does not.")); + +export const listFocusForeground = registerColor('list.focusForeground', + { dark: null, light: null, hcDark: null, hcLight: null }, + nls.localize('listFocusForeground', "List/Tree foreground color for the focused item when the list/tree is active. An active list/tree has keyboard focus, an inactive does not.")); + +export const listFocusOutline = registerColor('list.focusOutline', + { dark: focusBorder, light: focusBorder, hcDark: activeContrastBorder, hcLight: activeContrastBorder }, + nls.localize('listFocusOutline', "List/Tree outline color for the focused item when the list/tree is active. An active list/tree has keyboard focus, an inactive does not.")); + +export const listFocusAndSelectionOutline = registerColor('list.focusAndSelectionOutline', + { dark: null, light: null, hcDark: null, hcLight: null }, + nls.localize('listFocusAndSelectionOutline', "List/Tree outline color for the focused item when the list/tree is active and selected. An active list/tree has keyboard focus, an inactive does not.")); + +export const listActiveSelectionBackground = registerColor('list.activeSelectionBackground', + { dark: '#04395E', light: '#0060C0', hcDark: null, hcLight: Color.fromHex('#0F4A85').transparent(0.1) }, + nls.localize('listActiveSelectionBackground', "List/Tree background color for the selected item when the list/tree is active. An active list/tree has keyboard focus, an inactive does not.")); + +export const listActiveSelectionForeground = registerColor('list.activeSelectionForeground', + { dark: Color.white, light: Color.white, hcDark: null, hcLight: null }, + nls.localize('listActiveSelectionForeground', "List/Tree foreground color for the selected item when the list/tree is active. An active list/tree has keyboard focus, an inactive does not.")); + +export const listActiveSelectionIconForeground = registerColor('list.activeSelectionIconForeground', + { dark: null, light: null, hcDark: null, hcLight: null }, + nls.localize('listActiveSelectionIconForeground', "List/Tree icon foreground color for the selected item when the list/tree is active. An active list/tree has keyboard focus, an inactive does not.")); + +export const listInactiveSelectionBackground = registerColor('list.inactiveSelectionBackground', + { dark: '#37373D', light: '#E4E6F1', hcDark: null, hcLight: Color.fromHex('#0F4A85').transparent(0.1) }, + nls.localize('listInactiveSelectionBackground', "List/Tree background color for the selected item when the list/tree is inactive. An active list/tree has keyboard focus, an inactive does not.")); + +export const listInactiveSelectionForeground = registerColor('list.inactiveSelectionForeground', + { dark: null, light: null, hcDark: null, hcLight: null }, + nls.localize('listInactiveSelectionForeground', "List/Tree foreground color for the selected item when the list/tree is inactive. An active list/tree has keyboard focus, an inactive does not.")); + +export const listInactiveSelectionIconForeground = registerColor('list.inactiveSelectionIconForeground', + { dark: null, light: null, hcDark: null, hcLight: null }, + nls.localize('listInactiveSelectionIconForeground', "List/Tree icon foreground color for the selected item when the list/tree is inactive. An active list/tree has keyboard focus, an inactive does not.")); + +export const listInactiveFocusBackground = registerColor('list.inactiveFocusBackground', + { dark: null, light: null, hcDark: null, hcLight: null }, + nls.localize('listInactiveFocusBackground', "List/Tree background color for the focused item when the list/tree is inactive. An active list/tree has keyboard focus, an inactive does not.")); + +export const listInactiveFocusOutline = registerColor('list.inactiveFocusOutline', + { dark: null, light: null, hcDark: null, hcLight: null }, + nls.localize('listInactiveFocusOutline', "List/Tree outline color for the focused item when the list/tree is inactive. An active list/tree has keyboard focus, an inactive does not.")); + +export const listHoverBackground = registerColor('list.hoverBackground', + { dark: '#2A2D2E', light: '#F0F0F0', hcDark: Color.white.transparent(0.1), hcLight: Color.fromHex('#0F4A85').transparent(0.1) }, + nls.localize('listHoverBackground', "List/Tree background when hovering over items using the mouse.")); + +export const listHoverForeground = registerColor('list.hoverForeground', + { dark: null, light: null, hcDark: null, hcLight: null }, + nls.localize('listHoverForeground', "List/Tree foreground when hovering over items using the mouse.")); + +export const listDropOverBackground = registerColor('list.dropBackground', + { dark: '#062F4A', light: '#D6EBFF', hcDark: null, hcLight: null }, + nls.localize('listDropBackground', "List/Tree drag and drop background when moving items over other items when using the mouse.")); + +export const listDropBetweenBackground = registerColor('list.dropBetweenBackground', + { dark: iconForeground, light: iconForeground, hcDark: null, hcLight: null }, + nls.localize('listDropBetweenBackground', "List/Tree drag and drop border color when moving items between items when using the mouse.")); + +export const listHighlightForeground = registerColor('list.highlightForeground', + { dark: '#2AAAFF', light: '#0066BF', hcDark: focusBorder, hcLight: focusBorder }, + nls.localize('highlight', 'List/Tree foreground color of the match highlights when searching inside the list/tree.')); + +export const listFocusHighlightForeground = registerColor('list.focusHighlightForeground', + { dark: listHighlightForeground, light: ifDefinedThenElse(listActiveSelectionBackground, listHighlightForeground, '#BBE7FF'), hcDark: listHighlightForeground, hcLight: listHighlightForeground }, + nls.localize('listFocusHighlightForeground', 'List/Tree foreground color of the match highlights on actively focused items when searching inside the list/tree.')); + +export const listInvalidItemForeground = registerColor('list.invalidItemForeground', + { dark: '#B89500', light: '#B89500', hcDark: '#B89500', hcLight: '#B5200D' }, + nls.localize('invalidItemForeground', 'List/Tree foreground color for invalid items, for example an unresolved root in explorer.')); + +export const listErrorForeground = registerColor('list.errorForeground', + { dark: '#F88070', light: '#B01011', hcDark: null, hcLight: null }, nls.localize('listErrorForeground', 'Foreground color of list items containing errors.')); + +export const listWarningForeground = registerColor('list.warningForeground', + { dark: '#CCA700', light: '#855F00', hcDark: null, hcLight: null }, nls.localize('listWarningForeground', 'Foreground color of list items containing warnings.')); + +export const listFilterWidgetBackground = registerColor('listFilterWidget.background', + { light: darken(editorWidgetBackground, 0), dark: lighten(editorWidgetBackground, 0), hcDark: editorWidgetBackground, hcLight: editorWidgetBackground }, + nls.localize('listFilterWidgetBackground', 'Background color of the type filter widget in lists and trees.')); + +export const listFilterWidgetOutline = registerColor('listFilterWidget.outline', + { dark: Color.transparent, light: Color.transparent, hcDark: '#f38518', hcLight: '#007ACC' }, + nls.localize('listFilterWidgetOutline', 'Outline color of the type filter widget in lists and trees.')); + +export const listFilterWidgetNoMatchesOutline = registerColor('listFilterWidget.noMatchesOutline', + { dark: '#BE1100', light: '#BE1100', hcDark: contrastBorder, hcLight: contrastBorder }, + nls.localize('listFilterWidgetNoMatchesOutline', 'Outline color of the type filter widget in lists and trees, when there are no matches.')); + +export const listFilterWidgetShadow = registerColor('listFilterWidget.shadow', + { dark: widgetShadow, light: widgetShadow, hcDark: widgetShadow, hcLight: widgetShadow }, + nls.localize('listFilterWidgetShadow', 'Shadow color of the type filter widget in lists and trees.')); + +export const listFilterMatchHighlight = registerColor('list.filterMatchBackground', + { dark: editorFindMatchHighlight, light: editorFindMatchHighlight, hcDark: null, hcLight: null }, + nls.localize('listFilterMatchHighlight', 'Background color of the filtered match.')); + +export const listFilterMatchHighlightBorder = registerColor('list.filterMatchBorder', + { dark: editorFindMatchHighlightBorder, light: editorFindMatchHighlightBorder, hcDark: contrastBorder, hcLight: activeContrastBorder }, + nls.localize('listFilterMatchHighlightBorder', 'Border color of the filtered match.')); + +export const listDeemphasizedForeground = registerColor('list.deemphasizedForeground', + { dark: '#8C8C8C', light: '#8E8E90', hcDark: '#A7A8A9', hcLight: '#666666' }, + nls.localize('listDeemphasizedForeground', "List/Tree foreground color for items that are deemphasized.")); + + +// ------ tree + +export const treeIndentGuidesStroke = registerColor('tree.indentGuidesStroke', + { dark: '#585858', light: '#a9a9a9', hcDark: '#a9a9a9', hcLight: '#a5a5a5' }, + nls.localize('treeIndentGuidesStroke', "Tree stroke color for the indentation guides.")); + +export const treeInactiveIndentGuidesStroke = registerColor('tree.inactiveIndentGuidesStroke', + { dark: transparent(treeIndentGuidesStroke, 0.4), light: transparent(treeIndentGuidesStroke, 0.4), hcDark: transparent(treeIndentGuidesStroke, 0.4), hcLight: transparent(treeIndentGuidesStroke, 0.4) }, + nls.localize('treeInactiveIndentGuidesStroke', "Tree stroke color for the indentation guides that are not active.")); + + +// ------ table + +export const tableColumnsBorder = registerColor('tree.tableColumnsBorder', + { dark: '#CCCCCC20', light: '#61616120', hcDark: null, hcLight: null }, + nls.localize('tableColumnsBorder', "Table border color between columns.")); + +export const tableOddRowsBackgroundColor = registerColor('tree.tableOddRowsBackground', + { dark: transparent(foreground, 0.04), light: transparent(foreground, 0.04), hcDark: null, hcLight: null }, + nls.localize('tableOddRowsBackgroundColor', "Background color for odd table rows.")); diff --git a/src/vs/platform/theme/common/colors/menuColors.ts b/src/vs/platform/theme/common/colors/menuColors.ts new file mode 100644 index 0000000000000..6fa9a0ec3267a --- /dev/null +++ b/src/vs/platform/theme/common/colors/menuColors.ts @@ -0,0 +1,43 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as nls from 'vs/nls'; + +// Import the effects we need +import { registerColor } from 'vs/platform/theme/common/colorUtils'; + +// Import the colors we need +import { contrastBorder, activeContrastBorder } from 'vs/platform/theme/common/colors/baseColors'; +import { selectForeground, selectBackground } from 'vs/platform/theme/common/colors/inputColors'; +import { listActiveSelectionBackground, listActiveSelectionForeground } from 'vs/platform/theme/common/colors/listColors'; + + +export const menuBorder = registerColor('menu.border', + { dark: null, light: null, hcDark: contrastBorder, hcLight: contrastBorder }, + nls.localize('menuBorder', "Border color of menus.")); + +export const menuForeground = registerColor('menu.foreground', + { dark: selectForeground, light: selectForeground, hcDark: selectForeground, hcLight: selectForeground }, + nls.localize('menuForeground', "Foreground color of menu items.")); + +export const menuBackground = registerColor('menu.background', + { dark: selectBackground, light: selectBackground, hcDark: selectBackground, hcLight: selectBackground }, + nls.localize('menuBackground', "Background color of menu items.")); + +export const menuSelectionForeground = registerColor('menu.selectionForeground', + { dark: listActiveSelectionForeground, light: listActiveSelectionForeground, hcDark: listActiveSelectionForeground, hcLight: listActiveSelectionForeground }, + nls.localize('menuSelectionForeground', "Foreground color of the selected menu item in menus.")); + +export const menuSelectionBackground = registerColor('menu.selectionBackground', + { dark: listActiveSelectionBackground, light: listActiveSelectionBackground, hcDark: listActiveSelectionBackground, hcLight: listActiveSelectionBackground }, + nls.localize('menuSelectionBackground', "Background color of the selected menu item in menus.")); + +export const menuSelectionBorder = registerColor('menu.selectionBorder', + { dark: null, light: null, hcDark: activeContrastBorder, hcLight: activeContrastBorder }, + nls.localize('menuSelectionBorder', "Border color of the selected menu item in menus.")); + +export const menuSeparatorBackground = registerColor('menu.separatorBackground', + { dark: '#606060', light: '#D4D4D4', hcDark: contrastBorder, hcLight: contrastBorder }, + nls.localize('menuSeparatorBackground', "Color of a separator menu item in menus.")); diff --git a/src/vs/platform/theme/common/colors/minimapColors.ts b/src/vs/platform/theme/common/colors/minimapColors.ts new file mode 100644 index 0000000000000..0b051994d09d3 --- /dev/null +++ b/src/vs/platform/theme/common/colors/minimapColors.ts @@ -0,0 +1,59 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as nls from 'vs/nls'; + +// Import the effects we need +import { Color, RGBA } from 'vs/base/common/color'; +import { registerColor, transparent } from 'vs/platform/theme/common/colorUtils'; + +// Import the colors we need +import { editorInfoForeground, editorWarningForeground, editorWarningBorder, editorInfoBorder } from 'vs/platform/theme/common/colors/editorColors'; +import { scrollbarSliderBackground, scrollbarSliderHoverBackground, scrollbarSliderActiveBackground } from 'vs/platform/theme/common/colors/miscColors'; + + +export const minimapFindMatch = registerColor('minimap.findMatchHighlight', + { light: '#d18616', dark: '#d18616', hcDark: '#AB5A00', hcLight: '#0F4A85' }, + nls.localize('minimapFindMatchHighlight', 'Minimap marker color for find matches.'), true); + +export const minimapSelectionOccurrenceHighlight = registerColor('minimap.selectionOccurrenceHighlight', + { light: '#c9c9c9', dark: '#676767', hcDark: '#ffffff', hcLight: '#0F4A85' }, + nls.localize('minimapSelectionOccurrenceHighlight', 'Minimap marker color for repeating editor selections.'), true); + +export const minimapSelection = registerColor('minimap.selectionHighlight', + { light: '#ADD6FF', dark: '#264F78', hcDark: '#ffffff', hcLight: '#0F4A85' }, + nls.localize('minimapSelectionHighlight', 'Minimap marker color for the editor selection.'), true); + +export const minimapInfo = registerColor('minimap.infoHighlight', + { dark: editorInfoForeground, light: editorInfoForeground, hcDark: editorInfoBorder, hcLight: editorInfoBorder }, + nls.localize('minimapInfo', 'Minimap marker color for infos.')); + +export const minimapWarning = registerColor('minimap.warningHighlight', + { dark: editorWarningForeground, light: editorWarningForeground, hcDark: editorWarningBorder, hcLight: editorWarningBorder }, + nls.localize('overviewRuleWarning', 'Minimap marker color for warnings.')); + +export const minimapError = registerColor('minimap.errorHighlight', + { dark: new Color(new RGBA(255, 18, 18, 0.7)), light: new Color(new RGBA(255, 18, 18, 0.7)), hcDark: new Color(new RGBA(255, 50, 50, 1)), hcLight: '#B5200D' }, + nls.localize('minimapError', 'Minimap marker color for errors.')); + +export const minimapBackground = registerColor('minimap.background', + { dark: null, light: null, hcDark: null, hcLight: null }, + nls.localize('minimapBackground', "Minimap background color.")); + +export const minimapForegroundOpacity = registerColor('minimap.foregroundOpacity', + { dark: Color.fromHex('#000f'), light: Color.fromHex('#000f'), hcDark: Color.fromHex('#000f'), hcLight: Color.fromHex('#000f') }, + nls.localize('minimapForegroundOpacity', 'Opacity of foreground elements rendered in the minimap. For example, "#000000c0" will render the elements with 75% opacity.')); + +export const minimapSliderBackground = registerColor('minimapSlider.background', + { light: transparent(scrollbarSliderBackground, 0.5), dark: transparent(scrollbarSliderBackground, 0.5), hcDark: transparent(scrollbarSliderBackground, 0.5), hcLight: transparent(scrollbarSliderBackground, 0.5) }, + nls.localize('minimapSliderBackground', "Minimap slider background color.")); + +export const minimapSliderHoverBackground = registerColor('minimapSlider.hoverBackground', + { light: transparent(scrollbarSliderHoverBackground, 0.5), dark: transparent(scrollbarSliderHoverBackground, 0.5), hcDark: transparent(scrollbarSliderHoverBackground, 0.5), hcLight: transparent(scrollbarSliderHoverBackground, 0.5) }, + nls.localize('minimapSliderHoverBackground', "Minimap slider background color when hovering.")); + +export const minimapSliderActiveBackground = registerColor('minimapSlider.activeBackground', + { light: transparent(scrollbarSliderActiveBackground, 0.5), dark: transparent(scrollbarSliderActiveBackground, 0.5), hcDark: transparent(scrollbarSliderActiveBackground, 0.5), hcLight: transparent(scrollbarSliderActiveBackground, 0.5) }, + nls.localize('minimapSliderActiveBackground', "Minimap slider background color when clicked on.")); diff --git a/src/vs/platform/theme/common/colors/miscColors.ts b/src/vs/platform/theme/common/colors/miscColors.ts new file mode 100644 index 0000000000000..5a2ea49b7026b --- /dev/null +++ b/src/vs/platform/theme/common/colors/miscColors.ts @@ -0,0 +1,57 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as nls from 'vs/nls'; + +// Import the effects we need +import { Color } from 'vs/base/common/color'; +import { registerColor, transparent } from 'vs/platform/theme/common/colorUtils'; + +// Import the colors we need +import { contrastBorder, focusBorder } from 'vs/platform/theme/common/colors/baseColors'; + + +// ----- sash + +export const sashHoverBorder = registerColor('sash.hoverBorder', + { dark: focusBorder, light: focusBorder, hcDark: focusBorder, hcLight: focusBorder }, + nls.localize('sashActiveBorder', "Border color of active sashes.")); + + +// ----- badge + +export const badgeBackground = registerColor('badge.background', + { dark: '#4D4D4D', light: '#C4C4C4', hcDark: Color.black, hcLight: '#0F4A85' }, + nls.localize('badgeBackground', "Badge background color. Badges are small information labels, e.g. for search results count.")); + +export const badgeForeground = registerColor('badge.foreground', + { dark: Color.white, light: '#333', hcDark: Color.white, hcLight: Color.white }, + nls.localize('badgeForeground', "Badge foreground color. Badges are small information labels, e.g. for search results count.")); + + +// ----- scrollbar + +export const scrollbarShadow = registerColor('scrollbar.shadow', + { dark: '#000000', light: '#DDDDDD', hcDark: null, hcLight: null }, + nls.localize('scrollbarShadow', "Scrollbar shadow to indicate that the view is scrolled.")); + +export const scrollbarSliderBackground = registerColor('scrollbarSlider.background', + { dark: Color.fromHex('#797979').transparent(0.4), light: Color.fromHex('#646464').transparent(0.4), hcDark: transparent(contrastBorder, 0.6), hcLight: transparent(contrastBorder, 0.4) }, + nls.localize('scrollbarSliderBackground', "Scrollbar slider background color.")); + +export const scrollbarSliderHoverBackground = registerColor('scrollbarSlider.hoverBackground', + { dark: Color.fromHex('#646464').transparent(0.7), light: Color.fromHex('#646464').transparent(0.7), hcDark: transparent(contrastBorder, 0.8), hcLight: transparent(contrastBorder, 0.8) }, + nls.localize('scrollbarSliderHoverBackground', "Scrollbar slider background color when hovering.")); + +export const scrollbarSliderActiveBackground = registerColor('scrollbarSlider.activeBackground', + { dark: Color.fromHex('#BFBFBF').transparent(0.4), light: Color.fromHex('#000000').transparent(0.6), hcDark: contrastBorder, hcLight: contrastBorder }, + nls.localize('scrollbarSliderActiveBackground', "Scrollbar slider background color when clicked on.")); + + +// ----- progress bar + +export const progressBarBackground = registerColor('progressBar.background', + { dark: Color.fromHex('#0E70C0'), light: Color.fromHex('#0E70C0'), hcDark: contrastBorder, hcLight: contrastBorder }, + nls.localize('progressBarBackground', "Background color of the progress bar that can show for long running operations.")); diff --git a/src/vs/platform/theme/common/colors/quickpickColors.ts b/src/vs/platform/theme/common/colors/quickpickColors.ts new file mode 100644 index 0000000000000..7f8fc271a6e0c --- /dev/null +++ b/src/vs/platform/theme/common/colors/quickpickColors.ts @@ -0,0 +1,51 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as nls from 'vs/nls'; + +// Import the effects we need +import { Color, RGBA } from 'vs/base/common/color'; +import { registerColor, oneOf } from 'vs/platform/theme/common/colorUtils'; + +// Import the colors we need +import { editorWidgetBackground, editorWidgetForeground } from 'vs/platform/theme/common/colors/editorColors'; +import { listActiveSelectionBackground, listActiveSelectionForeground, listActiveSelectionIconForeground } from 'vs/platform/theme/common/colors/listColors'; + + +export const quickInputBackground = registerColor('quickInput.background', + { dark: editorWidgetBackground, light: editorWidgetBackground, hcDark: editorWidgetBackground, hcLight: editorWidgetBackground }, + nls.localize('pickerBackground', "Quick picker background color. The quick picker widget is the container for pickers like the command palette.")); + +export const quickInputForeground = registerColor('quickInput.foreground', + { dark: editorWidgetForeground, light: editorWidgetForeground, hcDark: editorWidgetForeground, hcLight: editorWidgetForeground }, + nls.localize('pickerForeground', "Quick picker foreground color. The quick picker widget is the container for pickers like the command palette.")); + +export const quickInputTitleBackground = registerColor('quickInputTitle.background', + { dark: new Color(new RGBA(255, 255, 255, 0.105)), light: new Color(new RGBA(0, 0, 0, 0.06)), hcDark: '#000000', hcLight: Color.white }, + nls.localize('pickerTitleBackground', "Quick picker title background color. The quick picker widget is the container for pickers like the command palette.")); + +export const pickerGroupForeground = registerColor('pickerGroup.foreground', + { dark: '#3794FF', light: '#0066BF', hcDark: Color.white, hcLight: '#0F4A85' }, + nls.localize('pickerGroupForeground', "Quick picker color for grouping labels.")); + +export const pickerGroupBorder = registerColor('pickerGroup.border', + { dark: '#3F3F46', light: '#CCCEDB', hcDark: Color.white, hcLight: '#0F4A85' }, + nls.localize('pickerGroupBorder', "Quick picker color for grouping borders.")); + +export const _deprecatedQuickInputListFocusBackground = registerColor('quickInput.list.focusBackground', + { dark: null, light: null, hcDark: null, hcLight: null }, '', undefined, + nls.localize('quickInput.list.focusBackground deprecation', "Please use quickInputList.focusBackground instead")); + +export const quickInputListFocusForeground = registerColor('quickInputList.focusForeground', + { dark: listActiveSelectionForeground, light: listActiveSelectionForeground, hcDark: listActiveSelectionForeground, hcLight: listActiveSelectionForeground }, + nls.localize('quickInput.listFocusForeground', "Quick picker foreground color for the focused item.")); + +export const quickInputListFocusIconForeground = registerColor('quickInputList.focusIconForeground', + { dark: listActiveSelectionIconForeground, light: listActiveSelectionIconForeground, hcDark: listActiveSelectionIconForeground, hcLight: listActiveSelectionIconForeground }, + nls.localize('quickInput.listFocusIconForeground', "Quick picker icon foreground color for the focused item.")); + +export const quickInputListFocusBackground = registerColor('quickInputList.focusBackground', + { dark: oneOf(_deprecatedQuickInputListFocusBackground, listActiveSelectionBackground), light: oneOf(_deprecatedQuickInputListFocusBackground, listActiveSelectionBackground), hcDark: null, hcLight: null }, + nls.localize('quickInput.listFocusBackground', "Quick picker background color for the focused item.")); diff --git a/src/vs/platform/theme/common/colors/searchColors.ts b/src/vs/platform/theme/common/colors/searchColors.ts new file mode 100644 index 0000000000000..8f10c53ab0ec4 --- /dev/null +++ b/src/vs/platform/theme/common/colors/searchColors.ts @@ -0,0 +1,29 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as nls from 'vs/nls'; + +// Import the effects we need +import { registerColor, transparent } from 'vs/platform/theme/common/colorUtils'; + +// Import the colors we need +import { foreground } from 'vs/platform/theme/common/colors/baseColors'; +import { editorFindMatchHighlight, editorFindMatchHighlightBorder } from 'vs/platform/theme/common/colors/editorColors'; + + +export const searchResultsInfoForeground = registerColor('search.resultsInfoForeground', + { light: foreground, dark: transparent(foreground, 0.65), hcDark: foreground, hcLight: foreground }, + nls.localize('search.resultsInfoForeground', "Color of the text in the search viewlet's completion message.")); + + +// ----- search editor (Distinct from normal editor find match to allow for better differentiation) + +export const searchEditorFindMatch = registerColor('searchEditor.findMatchBackground', + { light: transparent(editorFindMatchHighlight, 0.66), dark: transparent(editorFindMatchHighlight, 0.66), hcDark: editorFindMatchHighlight, hcLight: editorFindMatchHighlight }, + nls.localize('searchEditor.queryMatch', "Color of the Search Editor query matches.")); + +export const searchEditorFindMatchBorder = registerColor('searchEditor.findMatchBorder', + { light: transparent(editorFindMatchHighlightBorder, 0.66), dark: transparent(editorFindMatchHighlightBorder, 0.66), hcDark: editorFindMatchHighlightBorder, hcLight: editorFindMatchHighlightBorder }, + nls.localize('searchEditor.editorFindMatchBorder', "Border color of the Search Editor query matches.")); diff --git a/src/vs/platform/theme/common/iconRegistry.ts b/src/vs/platform/theme/common/iconRegistry.ts index 282230adee5bf..214f4846dcce6 100644 --- a/src/vs/platform/theme/common/iconRegistry.ts +++ b/src/vs/platform/theme/common/iconRegistry.ts @@ -4,7 +4,8 @@ *--------------------------------------------------------------------------------------------*/ import { RunOnceScheduler } from 'vs/base/common/async'; -import { Codicon, getCodiconFontCharacters } from 'vs/base/common/codicons'; +import { Codicon } from 'vs/base/common/codicons'; +import { getCodiconFontCharacters } from 'vs/base/common/codiconsUtil'; import { ThemeIcon, IconIdentifier } from 'vs/base/common/themables'; import { Emitter, Event } from 'vs/base/common/event'; import { IJSONSchema, IJSONSchemaMap } from 'vs/base/common/jsonSchema'; diff --git a/src/vs/platform/tunnel/common/tunnel.ts b/src/vs/platform/tunnel/common/tunnel.ts index bff2feb757605..86b4da4b409af 100644 --- a/src/vs/platform/tunnel/common/tunnel.ts +++ b/src/vs/platform/tunnel/common/tunnel.ts @@ -141,18 +141,22 @@ export interface ITunnelService { isPortPrivileged(port: number): boolean; } -export function extractLocalHostUriMetaDataForPortMapping(uri: URI, { checkQuery = true } = {}): { address: string; port: number } | undefined { +export function extractLocalHostUriMetaDataForPortMapping(uri: URI): { address: string; port: number } | undefined { if (uri.scheme !== 'http' && uri.scheme !== 'https') { return undefined; } const localhostMatch = /^(localhost|127\.0\.0\.1|0\.0\.0\.0):(\d+)$/.exec(uri.authority); - if (localhostMatch) { - return { - address: localhostMatch[1], - port: +localhostMatch[2], - }; + if (!localhostMatch) { + return undefined; } - if (!uri.query || !checkQuery) { + return { + address: localhostMatch[1], + port: +localhostMatch[2], + }; +} + +export function extractQueryLocalHostUriMetaDataForPortMapping(uri: URI): { address: string; port: number } | undefined { + if (uri.scheme !== 'http' && uri.scheme !== 'https' || !uri.query) { return undefined; } const keyvalues = uri.query.split('&'); diff --git a/src/vs/platform/tunnel/test/common/tunnel.test.ts b/src/vs/platform/tunnel/test/common/tunnel.test.ts index 4c763765aa07f..d86d3f47bd753 100644 --- a/src/vs/platform/tunnel/test/common/tunnel.test.ts +++ b/src/vs/platform/tunnel/test/common/tunnel.test.ts @@ -4,28 +4,32 @@ *--------------------------------------------------------------------------------------------*/ import * as assert from 'assert'; import { URI } from 'vs/base/common/uri'; -import { extractLocalHostUriMetaDataForPortMapping } from 'vs/platform/tunnel/common/tunnel'; +import { + extractLocalHostUriMetaDataForPortMapping, + extractQueryLocalHostUriMetaDataForPortMapping +} from 'vs/platform/tunnel/common/tunnel'; import { ensureNoDisposablesAreLeakedInTestSuite } from 'vs/base/test/common/utils'; suite('Tunnel', () => { ensureNoDisposablesAreLeakedInTestSuite(); - function portMappingDoTest(res: { address: string; port: number } | undefined, expectedAddress?: string, expectedPort?: number) { + function portMappingDoTest(uri: string, + func: (uri: URI) => { address: string; port: number } | undefined, + expectedAddress?: string, + expectedPort?: number) { + const res = func(URI.parse(uri)); assert.strictEqual(!expectedAddress, !res); assert.strictEqual(res?.address, expectedAddress); assert.strictEqual(res?.port, expectedPort); } function portMappingTest(uri: string, expectedAddress?: string, expectedPort?: number) { - for (const checkQuery of [true, false]) { - portMappingDoTest(extractLocalHostUriMetaDataForPortMapping(URI.parse(uri), { checkQuery }), expectedAddress, expectedPort); - } + portMappingDoTest(uri, extractLocalHostUriMetaDataForPortMapping, expectedAddress, expectedPort); } function portMappingTestQuery(uri: string, expectedAddress?: string, expectedPort?: number) { - portMappingDoTest(extractLocalHostUriMetaDataForPortMapping(URI.parse(uri)), expectedAddress, expectedPort); - portMappingDoTest(extractLocalHostUriMetaDataForPortMapping(URI.parse(uri), { checkQuery: false }), undefined, undefined); + portMappingDoTest(uri, extractQueryLocalHostUriMetaDataForPortMapping, expectedAddress, expectedPort); } test('portMapping', () => { diff --git a/src/vs/platform/update/common/update.ts b/src/vs/platform/update/common/update.ts index 4cc8994bd01e2..73e7d7afffe54 100644 --- a/src/vs/platform/update/common/update.ts +++ b/src/vs/platform/update/common/update.ts @@ -7,8 +7,10 @@ import { Event } from 'vs/base/common/event'; import { createDecorator } from 'vs/platform/instantiation/common/instantiation'; export interface IUpdate { + // Windows and Linux: 9a19815253d91900be5ec1016e0ecc7cc9a6950 (Commit Hash). Mac: 1.54.0 (Product Version) version: string; - productVersion: string; + productVersion?: string; + timestamp?: number; url?: string; sha256hash?: string; } @@ -63,7 +65,7 @@ export type Disabled = { type: StateType.Disabled; reason: DisablementReason }; export type Idle = { type: StateType.Idle; updateType: UpdateType; error?: string }; export type CheckingForUpdates = { type: StateType.CheckingForUpdates; explicit: boolean }; export type AvailableForDownload = { type: StateType.AvailableForDownload; update: IUpdate }; -export type Downloading = { type: StateType.Downloading; update: IUpdate }; +export type Downloading = { type: StateType.Downloading }; export type Downloaded = { type: StateType.Downloaded; update: IUpdate }; export type Updating = { type: StateType.Updating; update: IUpdate }; export type Ready = { type: StateType.Ready; update: IUpdate }; @@ -76,7 +78,7 @@ export const State = { Idle: (updateType: UpdateType, error?: string) => ({ type: StateType.Idle, updateType, error }) as Idle, CheckingForUpdates: (explicit: boolean) => ({ type: StateType.CheckingForUpdates, explicit } as CheckingForUpdates), AvailableForDownload: (update: IUpdate) => ({ type: StateType.AvailableForDownload, update } as AvailableForDownload), - Downloading: (update: IUpdate) => ({ type: StateType.Downloading, update } as Downloading), + Downloading: { type: StateType.Downloading } as Downloading, Downloaded: (update: IUpdate) => ({ type: StateType.Downloaded, update } as Downloaded), Updating: (update: IUpdate) => ({ type: StateType.Updating, update } as Updating), Ready: (update: IUpdate) => ({ type: StateType.Ready, update } as Ready), diff --git a/src/vs/platform/update/electron-main/abstractUpdateService.ts b/src/vs/platform/update/electron-main/abstractUpdateService.ts index 39e65d4a2ddcb..ea18f4ab5a665 100644 --- a/src/vs/platform/update/electron-main/abstractUpdateService.ts +++ b/src/vs/platform/update/electron-main/abstractUpdateService.ts @@ -20,7 +20,7 @@ export function createUpdateURL(platform: string, quality: string, productServic export type UpdateNotAvailableClassification = { owner: 'joaomoreno'; - explicit: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Whether the user has manually checked for updates, or this was an automatic check.' }; + explicit: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether the user has manually checked for updates, or this was an automatic check.' }; comment: 'This is used to understand how often VS Code pings the update server for an update and there\'s none available.'; }; diff --git a/src/vs/platform/update/electron-main/updateService.darwin.ts b/src/vs/platform/update/electron-main/updateService.darwin.ts index 329488abb51bc..183c69da90679 100644 --- a/src/vs/platform/update/electron-main/updateService.darwin.ts +++ b/src/vs/platform/update/electron-main/updateService.darwin.ts @@ -24,8 +24,8 @@ export class DarwinUpdateService extends AbstractUpdateService implements IRelau @memoize private get onRawError(): Event { return Event.fromNodeEventEmitter(electron.autoUpdater, 'error', (_, message) => message); } @memoize private get onRawUpdateNotAvailable(): Event { return Event.fromNodeEventEmitter(electron.autoUpdater, 'update-not-available'); } - @memoize private get onRawUpdateAvailable(): Event { return Event.fromNodeEventEmitter(electron.autoUpdater, 'update-available', (_, url, version) => ({ url, version, productVersion: version })); } - @memoize private get onRawUpdateDownloaded(): Event { return Event.fromNodeEventEmitter(electron.autoUpdater, 'update-downloaded', (_, releaseNotes, version, date) => ({ releaseNotes, version, productVersion: version, date })); } + @memoize private get onRawUpdateAvailable(): Event { return Event.fromNodeEventEmitter(electron.autoUpdater, 'update-available'); } + @memoize private get onRawUpdateDownloaded(): Event { return Event.fromNodeEventEmitter(electron.autoUpdater, 'update-downloaded', (_, releaseNotes, version, timestamp) => ({ version, productVersion: version, timestamp })); } constructor( @ILifecycleMainService lifecycleMainService: ILifecycleMainService, @@ -96,12 +96,12 @@ export class DarwinUpdateService extends AbstractUpdateService implements IRelau electron.autoUpdater.checkForUpdates(); } - private onUpdateAvailable(update: IUpdate): void { + private onUpdateAvailable(): void { if (this.state.type !== StateType.CheckingForUpdates) { return; } - this.setState(State.Downloading(update)); + this.setState(State.Downloading); } private onUpdateDownloaded(update: IUpdate): void { @@ -109,6 +109,8 @@ export class DarwinUpdateService extends AbstractUpdateService implements IRelau return; } + this.setState(State.Downloaded(update)); + type UpdateDownloadedClassification = { owner: 'joaomoreno'; version: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The version number of the new VS Code that has been downloaded.' }; diff --git a/src/vs/platform/update/electron-main/updateService.snap.ts b/src/vs/platform/update/electron-main/updateService.snap.ts index cf54be65d4502..c20ce198e0c5b 100644 --- a/src/vs/platform/update/electron-main/updateService.snap.ts +++ b/src/vs/platform/update/electron-main/updateService.snap.ts @@ -165,7 +165,7 @@ export class SnapUpdateService extends AbstractUpdateService { this.setState(State.CheckingForUpdates(false)); this.isUpdateAvailable().then(result => { if (result) { - this.setState(State.Ready({ version: 'something', productVersion: 'something' })); + this.setState(State.Ready({ version: 'something' })); } else { this.telemetryService.publicLog2<{ explicit: boolean }, UpdateNotAvailableClassification>('update:notAvailable', { explicit: false }); diff --git a/src/vs/platform/update/electron-main/updateService.win32.ts b/src/vs/platform/update/electron-main/updateService.win32.ts index ff8bbb0e559c0..4c49a758185db 100644 --- a/src/vs/platform/update/electron-main/updateService.win32.ts +++ b/src/vs/platform/update/electron-main/updateService.win32.ts @@ -135,7 +135,7 @@ export class Win32UpdateService extends AbstractUpdateService implements IRelaun return Promise.resolve(null); } - this.setState(State.Downloading(update)); + this.setState(State.Downloading); return this.cleanup(update.version).then(() => { return this.getUpdatePackagePath(update.version).then(updatePackagePath => { @@ -153,15 +153,13 @@ export class Win32UpdateService extends AbstractUpdateService implements IRelaun .then(() => updatePackagePath); }); }).then(packagePath => { - const fastUpdatesEnabled = this.configurationService.getValue('update.enableWindowsBackgroundUpdates'); - this.availableUpdate = { packagePath }; + this.setState(State.Downloaded(update)); + const fastUpdatesEnabled = this.configurationService.getValue('update.enableWindowsBackgroundUpdates'); if (fastUpdatesEnabled) { if (this.productService.target === 'user') { this.doApplyUpdate(); - } else { - this.setState(State.Downloaded(update)); } } else { this.setState(State.Ready(update)); @@ -209,7 +207,7 @@ export class Win32UpdateService extends AbstractUpdateService implements IRelaun } protected override async doApplyUpdate(): Promise { - if (this.state.type !== StateType.Downloaded && this.state.type !== StateType.Downloading) { + if (this.state.type !== StateType.Downloaded) { return Promise.resolve(undefined); } @@ -273,14 +271,13 @@ export class Win32UpdateService extends AbstractUpdateService implements IRelaun const fastUpdatesEnabled = this.configurationService.getValue('update.enableWindowsBackgroundUpdates'); const update: IUpdate = { version: 'unknown', productVersion: 'unknown' }; - this.setState(State.Downloading(update)); + this.setState(State.Downloading); this.availableUpdate = { packagePath }; + this.setState(State.Downloaded(update)); if (fastUpdatesEnabled) { if (this.productService.target === 'user') { this.doApplyUpdate(); - } else { - this.setState(State.Downloaded(update)); } } else { this.setState(State.Ready(update)); diff --git a/src/vs/platform/userDataProfile/common/userDataProfile.ts b/src/vs/platform/userDataProfile/common/userDataProfile.ts index f18de2499cd39..769f5fd2536de 100644 --- a/src/vs/platform/userDataProfile/common/userDataProfile.ts +++ b/src/vs/platform/userDataProfile/common/userDataProfile.ts @@ -335,7 +335,7 @@ export class UserDataProfilesService extends Disposable implements IUserDataProf try { const existing = this.profiles.find(p => p.name === name || p.id === id); if (existing) { - return existing; + throw new Error(`Profile with ${name} name already exists`); } const profile = toUserDataProfile(id, name, joinPath(this.profilesHome, id), this.profilesCacheHome, options, this.defaultProfile); diff --git a/src/vs/platform/userDataProfile/electron-main/userDataProfileStorageIpc.ts b/src/vs/platform/userDataProfile/electron-main/userDataProfileStorageIpc.ts index e3ac3124a1083..457dac8e1d4d8 100644 --- a/src/vs/platform/userDataProfile/electron-main/userDataProfileStorageIpc.ts +++ b/src/vs/platform/userDataProfile/electron-main/userDataProfileStorageIpc.ts @@ -97,7 +97,7 @@ export class ProfileStorageChangesListenerChannel extends Disposable implements switch (event) { case 'onDidChange': return this._onDidChange.event; } - throw new Error(`Event not found: ${event}`); + throw new Error(`[ProfileStorageChangesListenerChannel] Event not found: ${event}`); } async call(_: unknown, command: string): Promise { diff --git a/src/vs/platform/userDataSync/common/abstractSynchronizer.ts b/src/vs/platform/userDataSync/common/abstractSynchronizer.ts index eb24d9ca6b531..9f52f0fd29edd 100644 --- a/src/vs/platform/userDataSync/common/abstractSynchronizer.ts +++ b/src/vs/platform/userDataSync/common/abstractSynchronizer.ts @@ -32,7 +32,7 @@ import { IUserDataProfile, IUserDataProfilesService } from 'vs/platform/userData type IncompatibleSyncSourceClassification = { owner: 'sandy081'; comment: 'Information about the sync resource that is incompatible'; - source: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'settings sync resource. eg., settings, keybindings...' }; + source: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'settings sync resource. eg., settings, keybindings...' }; }; export function isRemoteUserData(thing: any): thing is IRemoteUserData { diff --git a/src/vs/platform/userDataSync/common/userDataAutoSyncService.ts b/src/vs/platform/userDataSync/common/userDataAutoSyncService.ts index b0776afe8dc89..f6ebd2194020e 100644 --- a/src/vs/platform/userDataSync/common/userDataAutoSyncService.ts +++ b/src/vs/platform/userDataSync/common/userDataAutoSyncService.ts @@ -31,7 +31,7 @@ type AutoSyncClassification = { type AutoSyncErrorClassification = { owner: 'sandy081'; comment: 'Information about the error that causes auto sync to fail'; - code: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'error code' }; + code: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'error code' }; service: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Settings sync service for which this error has occurred' }; }; diff --git a/src/vs/platform/userDataSync/common/userDataProfilesManifestSync.ts b/src/vs/platform/userDataSync/common/userDataProfilesManifestSync.ts index 5a532c8bae2f6..13b099ee13e8c 100644 --- a/src/vs/platform/userDataSync/common/userDataProfilesManifestSync.ts +++ b/src/vs/platform/userDataSync/common/userDataProfilesManifestSync.ts @@ -187,34 +187,26 @@ export class UserDataProfilesManifestSynchroniser extends AbstractSynchroniser i if (localChange !== Change.None) { await this.backupLocal(stringifyLocalProfiles(this.getLocalUserDataProfiles(), false)); - const promises: Promise[] = []; - for (const profile of local.added) { - promises.push((async () => { - this.logService.trace(`${this.syncResourceLogLabel}: Creating '${profile.name}' profile...`); - await this.userDataProfilesService.createProfile(profile.id, profile.name, { shortName: profile.shortName, icon: profile.icon, useDefaultFlags: profile.useDefaultFlags }); - this.logService.info(`${this.syncResourceLogLabel}: Created profile '${profile.name}'.`); - })()); - } - for (const profile of local.removed) { - promises.push((async () => { - this.logService.trace(`${this.syncResourceLogLabel}: Removing '${profile.name}' profile...`); - await this.userDataProfilesService.removeProfile(profile); - this.logService.info(`${this.syncResourceLogLabel}: Removed profile '${profile.name}'.`); - })()); - } - for (const profile of local.updated) { + await Promise.all(local.removed.map(async profile => { + this.logService.trace(`${this.syncResourceLogLabel}: Removing '${profile.name}' profile...`); + await this.userDataProfilesService.removeProfile(profile); + this.logService.info(`${this.syncResourceLogLabel}: Removed profile '${profile.name}'.`); + })); + await Promise.all(local.added.map(async profile => { + this.logService.trace(`${this.syncResourceLogLabel}: Creating '${profile.name}' profile...`); + await this.userDataProfilesService.createProfile(profile.id, profile.name, { shortName: profile.shortName, icon: profile.icon, useDefaultFlags: profile.useDefaultFlags }); + this.logService.info(`${this.syncResourceLogLabel}: Created profile '${profile.name}'.`); + })); + await Promise.all(local.updated.map(async profile => { const localProfile = this.userDataProfilesService.profiles.find(p => p.id === profile.id); if (localProfile) { - promises.push((async () => { - this.logService.trace(`${this.syncResourceLogLabel}: Updating '${profile.name}' profile...`); - await this.userDataProfilesService.updateProfile(localProfile, { name: profile.name, shortName: profile.shortName, icon: profile.icon, useDefaultFlags: profile.useDefaultFlags }); - this.logService.info(`${this.syncResourceLogLabel}: Updated profile '${profile.name}'.`); - })()); + this.logService.trace(`${this.syncResourceLogLabel}: Updating '${profile.name}' profile...`); + await this.userDataProfilesService.updateProfile(localProfile, { name: profile.name, shortName: profile.shortName, icon: profile.icon, useDefaultFlags: profile.useDefaultFlags }); + this.logService.info(`${this.syncResourceLogLabel}: Updated profile '${profile.name}'.`); } else { this.logService.info(`${this.syncResourceLogLabel}: Could not find profile with id '${profile.id}' to update.`); } - } - await Promise.all(promises); + })); } if (remoteChange !== Change.None) { diff --git a/src/vs/platform/userDataSync/common/userDataSyncEnablementService.ts b/src/vs/platform/userDataSync/common/userDataSyncEnablementService.ts index 162d93328d2c4..e1606de959c8e 100644 --- a/src/vs/platform/userDataSync/common/userDataSyncEnablementService.ts +++ b/src/vs/platform/userDataSync/common/userDataSyncEnablementService.ts @@ -14,7 +14,7 @@ import { ALL_SYNC_RESOURCES, getEnablementKey, IUserDataSyncEnablementService, I type SyncEnablementClassification = { owner: 'sandy081'; comment: 'Reporting when Settings Sync is turned on or off'; - enabled?: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Flag indicating if settings sync is enabled or not' }; + enabled?: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Flag indicating if settings sync is enabled or not' }; }; const enablementKey = 'sync.enable'; diff --git a/src/vs/platform/userDataSync/common/userDataSyncIpc.ts b/src/vs/platform/userDataSync/common/userDataSyncIpc.ts index 74725542b1093..ede978060d963 100644 --- a/src/vs/platform/userDataSync/common/userDataSyncIpc.ts +++ b/src/vs/platform/userDataSync/common/userDataSyncIpc.ts @@ -22,7 +22,7 @@ export class UserDataSyncAccountServiceChannel implements IServerChannel { case 'onDidChangeAccount': return this.service.onDidChangeAccount; case 'onTokenFailed': return this.service.onTokenFailed; } - throw new Error(`Event not found: ${event}`); + throw new Error(`[UserDataSyncAccountServiceChannel] Event not found: ${event}`); } call(context: any, command: string, args?: any): Promise { @@ -70,7 +70,7 @@ export class UserDataSyncStoreManagementServiceChannel implements IServerChannel switch (event) { case 'onDidChangeUserDataSyncStore': return this.service.onDidChangeUserDataSyncStore; } - throw new Error(`Event not found: ${event}`); + throw new Error(`[UserDataSyncStoreManagementServiceChannel] Event not found: ${event}`); } call(context: any, command: string, args?: any): Promise { diff --git a/src/vs/platform/userDataSync/common/userDataSyncService.ts b/src/vs/platform/userDataSync/common/userDataSyncService.ts index 928bff6241427..eba3ad14980fd 100644 --- a/src/vs/platform/userDataSync/common/userDataSyncService.ts +++ b/src/vs/platform/userDataSync/common/userDataSyncService.ts @@ -37,12 +37,12 @@ import { type SyncErrorClassification = { owner: 'sandy081'; comment: 'Information about the error that occurred while syncing'; - code: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'error code' }; - service: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Settings Sync service for which this error has occurred' }; - serverCode?: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Settings Sync service error code' }; - url?: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Settings Sync resource URL for which this error has occurred' }; - resource?: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Settings Sync resource for which this error has occurred' }; - executionId?: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Settings Sync execution id for which this error has occurred' }; + code: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'error code' }; + service: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Settings Sync service for which this error has occurred' }; + serverCode?: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Settings Sync service error code' }; + url?: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Settings Sync resource URL for which this error has occurred' }; + resource?: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Settings Sync resource for which this error has occurred' }; + executionId?: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Settings Sync execution id for which this error has occurred' }; }; const LAST_SYNC_TIME_KEY = 'sync.lastSyncTime'; diff --git a/src/vs/platform/userDataSync/common/userDataSyncServiceIpc.ts b/src/vs/platform/userDataSync/common/userDataSyncServiceIpc.ts index 9619fae438e26..b052fe458c5f3 100644 --- a/src/vs/platform/userDataSync/common/userDataSyncServiceIpc.ts +++ b/src/vs/platform/userDataSync/common/userDataSyncServiceIpc.ts @@ -51,7 +51,7 @@ export class UserDataSyncServiceChannel implements IServerChannel { case 'manualSync/onSynchronizeResources': return this.onManualSynchronizeResources.event; } - throw new Error(`Event not found: ${event}`); + throw new Error(`[UserDataSyncServiceChannel] Event not found: ${event}`); } async call(context: any, command: string, args?: any): Promise { diff --git a/src/vs/platform/utilityProcess/electron-main/utilityProcess.ts b/src/vs/platform/utilityProcess/electron-main/utilityProcess.ts index 6bd1d52b9864e..8f4e8fdc6a46d 100644 --- a/src/vs/platform/utilityProcess/electron-main/utilityProcess.ts +++ b/src/vs/platform/utilityProcess/electron-main/utilityProcess.ts @@ -212,7 +212,10 @@ export class UtilityProcess extends Disposable { const started = this.doStart(configuration); if (started && configuration.payload) { - this.postMessage(configuration.payload); + const posted = this.postMessage(configuration.payload); + if (posted) { + this.log('payload sent via postMessage()', Severity.Info); + } } return started; @@ -329,7 +332,7 @@ export class UtilityProcess extends Disposable { type UtilityProcessCrashClassification = { type: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The type of utility process to understand the origin of the crash better.' }; reason: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The reason of the utility process crash to understand the nature of the crash better.' }; - code: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'The exit code of the utility process to understand the nature of the crash better' }; + code: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The exit code of the utility process to understand the nature of the crash better' }; owner: 'bpasero'; comment: 'Provides insight into reasons the utility process crashed.'; }; @@ -363,12 +366,14 @@ export class UtilityProcess extends Disposable { })); } - postMessage(message: unknown, transfer?: Electron.MessagePortMain[]): void { + postMessage(message: unknown, transfer?: Electron.MessagePortMain[]): boolean { if (!this.process) { - return; // already killed, crashed or never started + return false; // already killed, crashed or never started } this.process.postMessage(message, transfer); + + return true; } connect(payload?: unknown): Electron.MessagePortMain { diff --git a/src/vs/platform/webview/common/webviewPortMapping.ts b/src/vs/platform/webview/common/webviewPortMapping.ts index 1ebc8534a284e..b47339d28fa1c 100644 --- a/src/vs/platform/webview/common/webviewPortMapping.ts +++ b/src/vs/platform/webview/common/webviewPortMapping.ts @@ -29,7 +29,7 @@ export class WebviewPortMappingManager implements IDisposable { public async getRedirect(resolveAuthority: IAddress | null | undefined, url: string): Promise { const uri = URI.parse(url); - const requestLocalHostInfo = extractLocalHostUriMetaDataForPortMapping(uri, { checkQuery: false }); + const requestLocalHostInfo = extractLocalHostUriMetaDataForPortMapping(uri); if (!requestLocalHostInfo) { return undefined; } diff --git a/src/vs/platform/window/electron-main/window.ts b/src/vs/platform/window/electron-main/window.ts index 6f744cf645458..04795036c4453 100644 --- a/src/vs/platform/window/electron-main/window.ts +++ b/src/vs/platform/window/electron-main/window.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { BrowserWindow, Rectangle } from 'electron'; +import { BrowserWindow, Rectangle, screen } from 'electron'; import { CancellationToken } from 'vs/base/common/cancellation'; import { Event } from 'vs/base/common/event'; import { IDisposable } from 'vs/base/common/lifecycle'; @@ -145,6 +145,30 @@ export const defaultWindowState = function (mode = WindowMode.Normal): IWindowSt }; }; +export const defaultAuxWindowState = function (): IWindowState { + + // Auxiliary windows are being created from a `window.open` call + // that sets `windowFeatures` that encode the desired size and + // position of the new window (`top`, `left`). + // In order to truly override this to a good default window state + // we need to set not only width and height but also x and y to + // a good location on the primary display. + + const width = 800; + const height = 600; + const workArea = screen.getPrimaryDisplay().workArea; + const x = Math.max(workArea.x + (workArea.width / 2) - (width / 2), 0); + const y = Math.max(workArea.y + (workArea.height / 2) - (height / 2), 0); + + return { + x, + y, + width, + height, + mode: WindowMode.Normal + }; +}; + export const enum WindowMode { Maximized, Normal, diff --git a/src/vs/platform/windows/electron-main/windowImpl.ts b/src/vs/platform/windows/electron-main/windowImpl.ts index 3041b4e0d7756..edde0d7258921 100644 --- a/src/vs/platform/windows/electron-main/windowImpl.ts +++ b/src/vs/platform/windows/electron-main/windowImpl.ts @@ -570,7 +570,6 @@ export class CodeWindow extends BaseWindow implements ICodeWindow { } }); - // Create the browser window mark('code/willCreateCodeBrowserWindow'); this._win = new BrowserWindow(options); @@ -779,9 +778,9 @@ export class CodeWindow extends BaseWindow implements ICodeWindow { // Telemetry type WindowErrorClassification = { - type: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'The type of window error to understand the nature of the error better.' }; + type: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The type of window error to understand the nature of the error better.' }; reason: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The reason of the window error to understand the nature of the error better.' }; - code: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'The exit code of the window process to understand the nature of the error better' }; + code: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The exit code of the window process to understand the nature of the error better' }; owner: 'bpasero'; comment: 'Provides insight into reasons the vscode window had an error.'; }; @@ -976,6 +975,13 @@ export class CodeWindow extends BaseWindow implements ICodeWindow { const proxyBypassRules = newNoProxy ? `${newNoProxy},` : ''; this.logService.trace(`Setting proxy to '${proxyRules}', bypassing '${proxyBypassRules}'`); this._win.webContents.session.setProxy({ proxyRules, proxyBypassRules, pacScript: '' }); + type appWithProxySupport = Electron.App & { + setProxy(config: Electron.Config): Promise; + resolveProxy(url: string): Promise; + }; + if (typeof (app as appWithProxySupport).setProxy === 'function') { + (app as appWithProxySupport).setProxy({ proxyRules, proxyBypassRules, pacScript: '' }); + } } } } diff --git a/src/vs/platform/windows/electron-main/windows.ts b/src/vs/platform/windows/electron-main/windows.ts index c5fd87557d72a..0cccca7c81091 100644 --- a/src/vs/platform/windows/electron-main/windows.ts +++ b/src/vs/platform/windows/electron-main/windows.ts @@ -115,7 +115,7 @@ export interface IOpenConfiguration extends IBaseOpenConfiguration { export interface IOpenEmptyConfiguration extends IBaseOpenConfiguration { } -export function defaultBrowserWindowOptions(accessor: ServicesAccessor, windowState?: IWindowState, overrides?: BrowserWindowConstructorOptions): BrowserWindowConstructorOptions & { experimentalDarkMode: boolean } { +export function defaultBrowserWindowOptions(accessor: ServicesAccessor, windowState: IWindowState, overrides?: BrowserWindowConstructorOptions): BrowserWindowConstructorOptions & { experimentalDarkMode: boolean } { const themeMainService = accessor.get(IThemeMainService); const productService = accessor.get(IProductService); const configurationService = accessor.get(IConfigurationService); @@ -129,10 +129,14 @@ export function defaultBrowserWindowOptions(accessor: ServicesAccessor, windowSt minHeight: WindowMinimumSize.HEIGHT, title: productService.nameLong, ...overrides, + x: windowState.x, + y: windowState.y, + width: windowState.width, + height: windowState.height, webPreferences: { enableWebSQL: false, spellcheck: false, - zoomFactor: zoomLevelToZoomFactor(windowState?.zoomLevel ?? windowSettings?.zoomLevel), + zoomFactor: zoomLevelToZoomFactor(windowState.zoomLevel ?? windowSettings?.zoomLevel), autoplayPolicy: 'user-gesture-required', // Enable experimental css highlight api https://chromestatus.com/feature/5436441440026624 // Refs https://github.com/microsoft/vscode/issues/140098 @@ -143,13 +147,6 @@ export function defaultBrowserWindowOptions(accessor: ServicesAccessor, windowSt experimentalDarkMode: true }; - if (windowState) { - options.x = windowState.x; - options.y = windowState.y; - options.width = windowState.width; - options.height = windowState.height; - } - if (isLinux) { options.icon = join(environmentMainService.appRoot, 'resources/linux/code.png'); // always on Linux } else if (isWindows && !environmentMainService.isBuilt) { @@ -246,8 +243,9 @@ export namespace WindowStateValidator { // some pixels (128) visible on the screen for the user to drag it back. if (displays.length === 1) { const displayWorkingArea = getWorkingArea(displays[0]); + logService.trace('window#validateWindowState: single monitor working area', displayWorkingArea); + if (displayWorkingArea) { - logService.trace('window#validateWindowState: 1 monitor working area', displayWorkingArea); function ensureStateInDisplayWorkingArea(): void { if (!state || typeof state.x !== 'number' || typeof state.y !== 'number' || !displayWorkingArea) { @@ -320,10 +318,13 @@ export namespace WindowStateValidator { try { display = screen.getDisplayMatching({ x: state.x, y: state.y, width: state.width, height: state.height }); displayWorkingArea = getWorkingArea(display); + + logService.trace('window#validateWindowState: multi-monitor working area', displayWorkingArea); } catch (error) { // Electron has weird conditions under which it throws errors // e.g. https://github.com/microsoft/vscode/issues/100334 when // large numbers are passed in + logService.error('window#validateWindowState: error finding display for window state', error); } if ( @@ -334,11 +335,11 @@ export namespace WindowStateValidator { state.x < displayWorkingArea.x + displayWorkingArea.width && // prevent window from falling out of the screen to the right state.y < displayWorkingArea.y + displayWorkingArea.height // prevent window from falling out of the screen to the bottom ) { - logService.trace('window#validateWindowState: multi-monitor working area', displayWorkingArea); - return state; } + logService.trace('window#validateWindowState: state is outside of the multi-monitor working area'); + return undefined; } diff --git a/src/vs/server/node/remoteExtensionHostAgentServer.ts b/src/vs/server/node/remoteExtensionHostAgentServer.ts index a90d28e82cf6b..84664bbb39a87 100644 --- a/src/vs/server/node/remoteExtensionHostAgentServer.ts +++ b/src/vs/server/node/remoteExtensionHostAgentServer.ts @@ -15,7 +15,7 @@ import { CharCode } from 'vs/base/common/charCode'; import { isSigPipeError, onUnexpectedError, setUnexpectedErrorHandler } from 'vs/base/common/errors'; import { isEqualOrParent } from 'vs/base/common/extpath'; import { Disposable, DisposableStore } from 'vs/base/common/lifecycle'; -import { connectionTokenQueryName, FileAccess, Schemas } from 'vs/base/common/network'; +import { connectionTokenQueryName, FileAccess, getServerRootPath, Schemas } from 'vs/base/common/network'; import { dirname, join } from 'vs/base/common/path'; import * as perf from 'vs/base/common/performance'; import * as platform from 'vs/base/common/platform'; @@ -33,7 +33,6 @@ import { ILogService } from 'vs/platform/log/common/log'; import { IProductService } from 'vs/platform/product/common/productService'; import { ConnectionType, ConnectionTypeRequest, ErrorMessage, HandshakeMessage, IRemoteExtensionHostStartParams, ITunnelConnectionStartParams, SignRequest } from 'vs/platform/remote/common/remoteAgentConnection'; import { RemoteAgentConnectionContext } from 'vs/platform/remote/common/remoteAgentEnvironment'; -import { getRemoteServerRootPath } from 'vs/platform/remote/common/remoteHosts'; import { ITelemetryService } from 'vs/platform/telemetry/common/telemetry'; import { ExtensionHostConnection } from 'vs/server/node/extensionHostConnection'; import { ManagementConnection } from 'vs/server/node/remoteExtensionManagement'; @@ -75,6 +74,7 @@ class RemoteExtensionHostAgentServer extends Disposable implements IServerAPI { private readonly _connectionToken: ServerConnectionToken, private readonly _vsdaMod: typeof vsda | null, hasWebClient: boolean, + serverBasePath: string | undefined, @IServerEnvironmentService private readonly _environmentService: IServerEnvironmentService, @IProductService private readonly _productService: IProductService, @ILogService private readonly _logService: ILogService, @@ -82,13 +82,13 @@ class RemoteExtensionHostAgentServer extends Disposable implements IServerAPI { ) { super(); - this._serverRootPath = getRemoteServerRootPath(_productService); + this._serverRootPath = getServerRootPath(_productService, serverBasePath); this._extHostConnections = Object.create(null); this._managementConnections = Object.create(null); this._allReconnectionTokens = new Set(); this._webClientServer = ( hasWebClient - ? this._instantiationService.createInstance(WebClientServer, this._connectionToken) + ? this._instantiationService.createInstance(WebClientServer, this._connectionToken, serverBasePath ?? '/', this._serverRootPath) : null ); this._logService.info(`Extension host agent started.`); @@ -665,6 +665,7 @@ export interface IServerAPI { } export async function createServer(address: string | net.AddressInfo | null, args: ServerParsedArgs, REMOTE_DATA_FOLDER: string): Promise { + const connectionToken = await determineServerConnectionToken(args); if (connectionToken instanceof ServerConnectionTokenParseError) { console.warn(connectionToken.message); @@ -774,15 +775,20 @@ export async function createServer(address: string | net.AddressInfo | null, arg return null; }); + let serverBasePath = args['server-base-path']; + if (serverBasePath && !serverBasePath.startsWith('/')) { + serverBasePath = `/${serverBasePath}`; + } + const hasWebClient = fs.existsSync(FileAccess.asFileUri('vs/code/browser/workbench/workbench.html').fsPath); if (hasWebClient && address && typeof address !== 'string') { // ships the web ui! const queryPart = (connectionToken.type !== ServerConnectionTokenType.None ? `?${connectionTokenQueryName}=${connectionToken.value}` : ''); - console.log(`Web UI available at http://localhost${address.port === 80 ? '' : `:${address.port}`}/${queryPart}`); + console.log(`Web UI available at http://localhost${address.port === 80 ? '' : `:${address.port}`}${serverBasePath ?? ''}${queryPart}`); } - const remoteExtensionHostAgentServer = instantiationService.createInstance(RemoteExtensionHostAgentServer, socketServer, connectionToken, vsdaMod, hasWebClient); + const remoteExtensionHostAgentServer = instantiationService.createInstance(RemoteExtensionHostAgentServer, socketServer, connectionToken, vsdaMod, hasWebClient, serverBasePath); perf.mark('code/server/ready'); const currentTime = performance.now(); diff --git a/src/vs/server/node/remoteTerminalChannel.ts b/src/vs/server/node/remoteTerminalChannel.ts index caec44f7f3aaa..657d3e8238ae8 100644 --- a/src/vs/server/node/remoteTerminalChannel.ts +++ b/src/vs/server/node/remoteTerminalChannel.ts @@ -32,6 +32,7 @@ import { IExtensionManagementService } from 'vs/platform/extensionManagement/com import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; import { ILogService } from 'vs/platform/log/common/log'; import { promiseWithResolvers } from 'vs/base/common/async'; +import { shouldUseEnvironmentVariableCollection } from 'vs/platform/terminal/common/terminalEnvironment'; class CustomVariableResolver extends AbstractVariableResolverService { constructor( @@ -235,7 +236,7 @@ export class RemoteTerminalChannel extends Disposable implements IServerChannel< ); // Apply extension environment variable collections to the environment - if (!shellLaunchConfig.strictEnv) { + if (shouldUseEnvironmentVariableCollection(shellLaunchConfig)) { const entries: [string, IEnvironmentVariableCollection][] = []; for (const [k, v, d] of args.envVariableCollections) { entries.push([k, { map: deserializeEnvironmentVariableCollection(v), descriptionMap: deserializeEnvironmentDescriptionMap(d) }]); diff --git a/src/vs/server/node/serverEnvironmentService.ts b/src/vs/server/node/serverEnvironmentService.ts index 900815a06d2d1..fce1842f1bd35 100644 --- a/src/vs/server/node/serverEnvironmentService.ts +++ b/src/vs/server/node/serverEnvironmentService.ts @@ -19,6 +19,7 @@ export const serverOptions: OptionDescriptions> = { 'host': { type: 'string', cat: 'o', args: 'ip-address', description: nls.localize('host', "The host name or IP address the server should listen to. If not set, defaults to 'localhost'.") }, 'port': { type: 'string', cat: 'o', args: 'port | port range', description: nls.localize('port', "The port the server should listen to. If 0 is passed a random free port is picked. If a range in the format num-num is passed, a free port from the range (end inclusive) is selected.") }, 'socket-path': { type: 'string', cat: 'o', args: 'path', description: nls.localize('socket-path', "The path to a socket file for the server to listen to.") }, + 'server-base-path': { type: 'string', cat: 'o', args: 'path', description: nls.localize('server-base-path', "The path under which the web UI and the code server is provided. Defaults to '/'.`") }, 'connection-token': { type: 'string', cat: 'o', args: 'token', deprecates: ['connectionToken'], description: nls.localize('connection-token', "A secret that must be included with all requests.") }, 'connection-token-file': { type: 'string', cat: 'o', args: 'path', deprecates: ['connection-secret', 'connectionTokenFile'], description: nls.localize('connection-token-file', "Path to a file that contains the connection token.") }, 'without-connection-token': { type: 'boolean', cat: 'o', description: nls.localize('without-connection-token', "Run without a connection token. Only use this if the connection is secured by other means.") }, @@ -102,6 +103,12 @@ export interface ServerParsedArgs { port?: string; 'socket-path'?: string; + /** + * The path under which the web UI and the code server is provided. + * By defaults it is '/'.` + */ + 'server-base-path'?: string; + /** * A secret token that must be provided by the web client with all requests. * Use only `[0-9A-Za-z\-]`. diff --git a/src/vs/server/node/webClientServer.ts b/src/vs/server/node/webClientServer.ts index a46d6748d0b6c..f0c43c66aeff8 100644 --- a/src/vs/server/node/webClientServer.ts +++ b/src/vs/server/node/webClientServer.ts @@ -28,7 +28,6 @@ import { streamToBuffer } from 'vs/base/common/buffer'; import { IProductConfiguration } from 'vs/base/common/product'; import { isString } from 'vs/base/common/types'; import { CharCode } from 'vs/base/common/charCode'; -import { getRemoteServerRootPath } from 'vs/platform/remote/common/remoteHosts'; import { IExtensionManifest } from 'vs/platform/extensions/common/extensions'; const textMimeType = { @@ -104,13 +103,15 @@ export class WebClientServer { constructor( private readonly _connectionToken: ServerConnectionToken, + private readonly _basePath: string, + readonly serverRootPath: string, @IServerEnvironmentService private readonly _environmentService: IServerEnvironmentService, @ILogService private readonly _logService: ILogService, @IRequestService private readonly _requestService: IRequestService, @IProductService private readonly _productService: IProductService, ) { this._webExtensionResourceUrlTemplate = this._productService.extensionsGallery?.resourceUrlTemplate ? URI.parse(this._productService.extensionsGallery.resourceUrlTemplate) : undefined; - const serverRootPath = getRemoteServerRootPath(_productService); + this._staticRoute = `${serverRootPath}/static`; this._callbackRoute = `${serverRootPath}/callback`; this._webExtensionRoute = `${serverRootPath}/web-extension-resource`; @@ -128,7 +129,7 @@ export class WebClientServer { if (pathname.startsWith(this._staticRoute) && pathname.charCodeAt(this._staticRoute.length) === CharCode.Slash) { return this._handleStatic(req, res, parsedUrl); } - if (pathname === '/') { + if (pathname === this._basePath) { return this._handleRoot(req, res, parsedUrl); } if (pathname === this._callbackRoute) { @@ -262,7 +263,7 @@ export class WebClientServer { newQuery[key] = parsedUrl.query[key]; } } - const newLocation = url.format({ pathname: '/', query: newQuery }); + const newLocation = url.format({ pathname: parsedUrl.pathname, query: newQuery }); responseHeaders['Location'] = newLocation; res.writeHead(302, responseHeaders); @@ -326,6 +327,7 @@ export class WebClientServer { const workbenchWebConfiguration = { remoteAuthority, + serverBasePath: this._basePath, _wrapWebWorkerExtHostInIframe, developmentOptions: { enableSmokeTestDriver: this._environmentService.args['enable-smoke-test-driver'] ? true : undefined, logLevel: this._logService.getLevel() }, settingsSyncOptions: !this._environmentService.isBuilt && this._environmentService.args['enable-sync'] ? { enabled: true } : undefined, diff --git a/src/vs/workbench/api/browser/extensionHost.contribution.ts b/src/vs/workbench/api/browser/extensionHost.contribution.ts index c986c67b5e91c..1b0939e0f778b 100644 --- a/src/vs/workbench/api/browser/extensionHost.contribution.ts +++ b/src/vs/workbench/api/browser/extensionHost.contribution.ts @@ -11,13 +11,13 @@ import { JSONValidationExtensionPoint } from 'vs/workbench/api/common/jsonValida import { ColorExtensionPoint } from 'vs/workbench/services/themes/common/colorExtensionPoint'; import { IconExtensionPoint } from 'vs/workbench/services/themes/common/iconExtensionPoint'; import { TokenClassificationExtensionPoints } from 'vs/workbench/services/themes/common/tokenClassificationExtensionPoint'; -import { LanguageConfigurationFileHandler } from 'vs/workbench/contrib/codeEditor/browser/languageConfigurationExtensionPoint'; +import { LanguageConfigurationFileHandler } from 'vs/workbench/contrib/codeEditor/common/languageConfigurationExtensionPoint'; import { StatusBarItemsExtensionPoint } from 'vs/workbench/api/browser/statusBarExtensionPoint'; // --- mainThread participants import './mainThreadLocalization'; import './mainThreadBulkEdits'; -import './mainThreadChatProvider'; +import './mainThreadLanguageModels'; import './mainThreadChatAgents2'; import './mainThreadChatVariables'; import './mainThreadCodeInsets'; @@ -59,6 +59,7 @@ import './mainThreadStatusBar'; import './mainThreadStorage'; import './mainThreadTelemetry'; import './mainThreadTerminalService'; +import './mainThreadTerminalShellIntegration'; import './mainThreadTheming'; import './mainThreadTreeViews'; import './mainThreadDownloadService'; @@ -84,10 +85,9 @@ import './mainThreadTimeline'; import './mainThreadTesting'; import './mainThreadSecretState'; import './mainThreadShare'; -import './mainThreadProfilContentHandlers'; +import './mainThreadProfileContentHandlers'; import './mainThreadAiRelatedInformation'; import './mainThreadAiEmbeddingVector'; -import './mainThreadIssueReporter'; export class ExtensionPoints implements IWorkbenchContribution { diff --git a/src/vs/workbench/api/browser/mainThreadAuthentication.ts b/src/vs/workbench/api/browser/mainThreadAuthentication.ts index 22c82811bb4ff..b3ebdd940c30d 100644 --- a/src/vs/workbench/api/browser/mainThreadAuthentication.ts +++ b/src/vs/workbench/api/browser/mainThreadAuthentication.ts @@ -6,18 +6,32 @@ import { Disposable, DisposableMap } from 'vs/base/common/lifecycle'; import * as nls from 'vs/nls'; import { extHostNamedCustomer, IExtHostContext } from 'vs/workbench/services/extensions/common/extHostCustomers'; -import { getAuthenticationProviderActivationEvent, addAccountUsage } from 'vs/workbench/services/authentication/browser/authenticationService'; -import { IAuthenticationCreateSessionOptions, AuthenticationSession, AuthenticationSessionsChangeEvent, IAuthenticationProvider, IAuthenticationService } from 'vs/workbench/services/authentication/common/authentication'; +import { IAuthenticationCreateSessionOptions, AuthenticationSession, AuthenticationSessionsChangeEvent, IAuthenticationProvider, IAuthenticationService, IAuthenticationExtensionsService } from 'vs/workbench/services/authentication/common/authentication'; import { ExtHostAuthenticationShape, ExtHostContext, MainContext, MainThreadAuthenticationShape } from '../common/extHost.protocol'; -import { IDialogService } from 'vs/platform/dialogs/common/dialogs'; -import { IStorageService, StorageScope } from 'vs/platform/storage/common/storage'; +import { IDialogService, IPromptButton } from 'vs/platform/dialogs/common/dialogs'; import Severity from 'vs/base/common/severity'; import { INotificationService } from 'vs/platform/notification/common/notification'; import { ActivationKind, IExtensionService } from 'vs/workbench/services/extensions/common/extensions'; import { ITelemetryService } from 'vs/platform/telemetry/common/telemetry'; -import type { AuthenticationGetSessionOptions } from 'vscode'; import { Emitter, Event } from 'vs/base/common/event'; +import { IAuthenticationAccessService } from 'vs/workbench/services/authentication/browser/authenticationAccessService'; +import { IAuthenticationUsageService } from 'vs/workbench/services/authentication/browser/authenticationUsageService'; +import { getAuthenticationProviderActivationEvent } from 'vs/workbench/services/authentication/browser/authenticationService'; +import { URI, UriComponents } from 'vs/base/common/uri'; +import { IOpenerService } from 'vs/platform/opener/common/opener'; + +interface AuthenticationForceNewSessionOptions { + detail?: string; + learnMore?: UriComponents; + sessionToRecreate?: AuthenticationSession; +} +interface AuthenticationGetSessionOptions { + clearSessionPreference?: boolean; + createIfNone?: boolean; + forceNewSession?: boolean | AuthenticationForceNewSessionOptions; + silent?: boolean; +} export class MainThreadAuthenticationProvider extends Disposable implements IAuthenticationProvider { @@ -58,11 +72,14 @@ export class MainThreadAuthentication extends Disposable implements MainThreadAu constructor( extHostContext: IExtHostContext, @IAuthenticationService private readonly authenticationService: IAuthenticationService, + @IAuthenticationExtensionsService private readonly authenticationExtensionsService: IAuthenticationExtensionsService, + @IAuthenticationAccessService private readonly authenticationAccessService: IAuthenticationAccessService, + @IAuthenticationUsageService private readonly authenticationUsageService: IAuthenticationUsageService, @IDialogService private readonly dialogService: IDialogService, - @IStorageService private readonly storageService: IStorageService, @INotificationService private readonly notificationService: INotificationService, @IExtensionService private readonly extensionService: IExtensionService, - @ITelemetryService private readonly telemetryService: ITelemetryService + @ITelemetryService private readonly telemetryService: ITelemetryService, + @IOpenerService private readonly openerService: IOpenerService ) { super(); this._proxy = extHostContext.getProxy(ExtHostContext.ExtHostAuthentication); @@ -100,23 +117,43 @@ export class MainThreadAuthentication extends Disposable implements MainThreadAu $removeSession(providerId: string, sessionId: string): Promise { return this.authenticationService.removeSession(providerId, sessionId); } - private async loginPrompt(providerName: string, extensionName: string, recreatingSession: boolean, detail?: string): Promise { + private async loginPrompt(providerName: string, extensionName: string, recreatingSession: boolean, options?: AuthenticationForceNewSessionOptions): Promise { const message = recreatingSession ? nls.localize('confirmRelogin', "The extension '{0}' wants you to sign in again using {1}.", extensionName, providerName) : nls.localize('confirmLogin', "The extension '{0}' wants to sign in using {1}.", extensionName, providerName); - const { confirmed } = await this.dialogService.confirm({ + + const buttons: IPromptButton[] = [ + { + label: nls.localize({ key: 'allow', comment: ['&& denotes a mnemonic'] }, "&&Allow"), + run() { + return true; + }, + } + ]; + if (options?.learnMore) { + buttons.push({ + label: nls.localize('learnMore', "Learn more"), + run: async () => { + const result = this.loginPrompt(providerName, extensionName, recreatingSession, options); + await this.openerService.open(URI.revive(options.learnMore!), { allowCommands: true }); + return await result; + } + }); + } + const { result } = await this.dialogService.prompt({ type: Severity.Info, message, - detail, - primaryButton: nls.localize({ key: 'allow', comment: ['&& denotes a mnemonic'] }, "&&Allow") + buttons, + detail: options?.detail, + cancelButton: true, }); - return confirmed; + return result ?? false; } private async doGetSession(providerId: string, scopes: string[], extensionId: string, extensionName: string, options: AuthenticationGetSessionOptions): Promise { const sessions = await this.authenticationService.getSessions(providerId, scopes, true); - const supportsMultipleAccounts = this.authenticationService.supportsMultipleAccounts(providerId); + const provider = this.authenticationService.getProvider(providerId); // Error cases if (options.forceNewSession && options.createIfNone) { @@ -131,22 +168,22 @@ export class MainThreadAuthentication extends Disposable implements MainThreadAu // Check if the sessions we have are valid if (!options.forceNewSession && sessions.length) { - if (supportsMultipleAccounts) { + if (provider.supportsMultipleAccounts) { if (options.clearSessionPreference) { // Clearing the session preference is usually paired with createIfNone, so just remove the preference and // defer to the rest of the logic in this function to choose the session. - this.authenticationService.removeSessionPreference(providerId, extensionId, scopes); + this.authenticationExtensionsService.removeSessionPreference(providerId, extensionId, scopes); } else { // If we have an existing session preference, use that. If not, we'll return any valid session at the end of this function. - const existingSessionPreference = this.authenticationService.getSessionPreference(providerId, extensionId, scopes); + const existingSessionPreference = this.authenticationExtensionsService.getSessionPreference(providerId, extensionId, scopes); if (existingSessionPreference) { const matchingSession = sessions.find(session => session.id === existingSessionPreference); - if (matchingSession && this.authenticationService.isAccessAllowed(providerId, matchingSession.account.label, extensionId)) { + if (matchingSession && this.authenticationAccessService.isAccessAllowed(providerId, matchingSession.account.label, extensionId)) { return matchingSession; } } } - } else if (this.authenticationService.isAccessAllowed(providerId, sessions[0].account.label, extensionId)) { + } else if (this.authenticationAccessService.isAccessAllowed(providerId, sessions[0].account.label, extensionId)) { return sessions[0]; } } @@ -154,51 +191,44 @@ export class MainThreadAuthentication extends Disposable implements MainThreadAu // We may need to prompt because we don't have a valid session // modal flows if (options.createIfNone || options.forceNewSession) { - const providerName = this.authenticationService.getLabel(providerId); - const detail = (typeof options.forceNewSession === 'object') ? options.forceNewSession.detail : undefined; + let uiOptions: AuthenticationForceNewSessionOptions | undefined; + if (typeof options.forceNewSession === 'object') { + uiOptions = options.forceNewSession; + } // We only want to show the "recreating session" prompt if we are using forceNewSession & there are sessions // that we will be "forcing through". const recreatingSession = !!(options.forceNewSession && sessions.length); - const isAllowed = await this.loginPrompt(providerName, extensionName, recreatingSession, detail); + const isAllowed = await this.loginPrompt(provider.label, extensionName, recreatingSession, uiOptions); if (!isAllowed) { throw new Error('User did not consent to login.'); } let session; if (sessions?.length && !options.forceNewSession) { - session = supportsMultipleAccounts - ? await this.authenticationService.selectSession(providerId, extensionId, extensionName, scopes, sessions) + session = provider.supportsMultipleAccounts + ? await this.authenticationExtensionsService.selectSession(providerId, extensionId, extensionName, scopes, sessions) : sessions[0]; } else { let sessionToRecreate: AuthenticationSession | undefined; if (typeof options.forceNewSession === 'object' && options.forceNewSession.sessionToRecreate) { sessionToRecreate = options.forceNewSession.sessionToRecreate as AuthenticationSession; } else { - const sessionIdToRecreate = this.authenticationService.getSessionPreference(providerId, extensionId, scopes); + const sessionIdToRecreate = this.authenticationExtensionsService.getSessionPreference(providerId, extensionId, scopes); sessionToRecreate = sessionIdToRecreate ? sessions.find(session => session.id === sessionIdToRecreate) : undefined; } session = await this.authenticationService.createSession(providerId, scopes, { activateImmediate: true, sessionToRecreate }); } - this.authenticationService.updateAllowedExtension(providerId, session.account.label, extensionId, extensionName, true); - this.authenticationService.updateSessionPreference(providerId, extensionId, session); + this.authenticationAccessService.updateAllowedExtensions(providerId, session.account.label, [{ id: extensionId, name: extensionName, allowed: true }]); + this.authenticationExtensionsService.updateSessionPreference(providerId, extensionId, session); return session; } // For the silent flows, if we have a session, even though it may not be the user's preference, we'll return it anyway because it might be for a specific // set of scopes. - const validSession = sessions.find(session => this.authenticationService.isAccessAllowed(providerId, session.account.label, extensionId)); + const validSession = sessions.find(session => this.authenticationAccessService.isAccessAllowed(providerId, session.account.label, extensionId)); if (validSession) { - // Migration. If we have a valid session, but no preference, we'll set the preference to the valid session. - // TODO: Remove this after in a few releases. - if (!this.authenticationService.getSessionPreference(providerId, extensionId, scopes)) { - if (this.storageService.get(`${extensionName}-${providerId}`, StorageScope.APPLICATION)) { - this.storageService.remove(`${extensionName}-${providerId}`, StorageScope.APPLICATION); - } - this.authenticationService.updateAllowedExtension(providerId, validSession.account.label, extensionId, extensionName, true); - this.authenticationService.updateSessionPreference(providerId, extensionId, validSession); - } return validSession; } @@ -207,8 +237,8 @@ export class MainThreadAuthentication extends Disposable implements MainThreadAu // If there is a potential session, but the extension doesn't have access to it, use the "grant access" flow, // otherwise request a new one. sessions.length - ? this.authenticationService.requestSessionAccess(providerId, extensionId, extensionName, scopes, sessions) - : await this.authenticationService.requestNewSession(providerId, scopes, extensionId, extensionName); + ? this.authenticationExtensionsService.requestSessionAccess(providerId, extensionId, extensionName, scopes, sessions) + : await this.authenticationExtensionsService.requestNewSession(providerId, scopes, extensionId, extensionName); } return undefined; } @@ -218,7 +248,7 @@ export class MainThreadAuthentication extends Disposable implements MainThreadAu if (session) { this.sendProviderUsageTelemetry(extensionId, providerId); - addAccountUsage(this.storageService, providerId, session.account.label, extensionId, extensionName); + this.authenticationUsageService.addAccountUsage(providerId, session.account.label, extensionId, extensionName); } return session; @@ -226,11 +256,11 @@ export class MainThreadAuthentication extends Disposable implements MainThreadAu async $getSessions(providerId: string, scopes: readonly string[], extensionId: string, extensionName: string): Promise { const sessions = await this.authenticationService.getSessions(providerId, [...scopes], true); - const accessibleSessions = sessions.filter(s => this.authenticationService.isAccessAllowed(providerId, s.account.label, extensionId)); + const accessibleSessions = sessions.filter(s => this.authenticationAccessService.isAccessAllowed(providerId, s.account.label, extensionId)); if (accessibleSessions.length) { this.sendProviderUsageTelemetry(extensionId, providerId); for (const session of accessibleSessions) { - addAccountUsage(this.storageService, providerId, session.account.label, extensionId, extensionName); + this.authenticationUsageService.addAccountUsage(providerId, session.account.label, extensionId, extensionName); } } return accessibleSessions; diff --git a/src/vs/workbench/api/browser/mainThreadBulkEdits.ts b/src/vs/workbench/api/browser/mainThreadBulkEdits.ts index 6a42b3b1050b2..dfd425729a340 100644 --- a/src/vs/workbench/api/browser/mainThreadBulkEdits.ts +++ b/src/vs/workbench/api/browser/mainThreadBulkEdits.ts @@ -9,9 +9,11 @@ import { IBulkEditService, ResourceFileEdit, ResourceTextEdit } from 'vs/editor/ import { WorkspaceEdit } from 'vs/editor/common/languages'; import { ILogService } from 'vs/platform/log/common/log'; import { IUriIdentityService } from 'vs/platform/uriIdentity/common/uriIdentity'; -import { IWorkspaceEditDto, IWorkspaceFileEditDto, MainContext, MainThreadBulkEditsShape } from 'vs/workbench/api/common/extHost.protocol'; +import { IWorkspaceCellEditDto, IWorkspaceEditDto, IWorkspaceFileEditDto, MainContext, MainThreadBulkEditsShape } from 'vs/workbench/api/common/extHost.protocol'; import { ResourceNotebookCellEdit } from 'vs/workbench/contrib/bulkEdit/browser/bulkCellEdits'; +import { CellEditType } from 'vs/workbench/contrib/notebook/common/notebookCommon'; import { IExtHostContext, extHostNamedCustomer } from 'vs/workbench/services/extensions/common/extHostCustomers'; +import { SerializableObjectWithBuffers } from 'vs/workbench/services/extensions/common/proxyIdentifier'; @extHostNamedCustomer(MainContext.MainThreadBulkEdits) @@ -26,8 +28,8 @@ export class MainThreadBulkEdits implements MainThreadBulkEditsShape { dispose(): void { } - $tryApplyWorkspaceEdit(dto: IWorkspaceEditDto, undoRedoGroupId?: number, isRefactoring?: boolean): Promise { - const edits = reviveWorkspaceEditDto(dto, this._uriIdentService); + $tryApplyWorkspaceEdit(dto: SerializableObjectWithBuffers, undoRedoGroupId?: number, isRefactoring?: boolean): Promise { + const edits = reviveWorkspaceEditDto(dto.value, this._uriIdentService); return this._bulkEditService.apply(edits, { undoRedoGroupId, respectAutoSaveConfig: isRefactoring }).then((res) => res.isApplied, err => { this._logService.warn(`IGNORING workspace edit: ${err}`); return false; @@ -66,6 +68,24 @@ export function reviveWorkspaceEditDto(data: IWorkspaceEditDto | undefined, uriI } if (ResourceNotebookCellEdit.is(edit)) { edit.resource = uriIdentityService.asCanonicalUri(edit.resource); + const cellEdit = (edit as IWorkspaceCellEditDto).cellEdit; + if (cellEdit.editType === CellEditType.Replace) { + edit.cellEdit = { + ...cellEdit, + cells: cellEdit.cells.map(cell => ({ + ...cell, + outputs: cell.outputs.map(output => ({ + ...output, + outputs: output.items.map(item => { + return { + mime: item.mime, + data: item.valueBytes + }; + }) + })) + })) + }; + } } } return data; diff --git a/src/vs/workbench/api/browser/mainThreadChat.ts b/src/vs/workbench/api/browser/mainThreadChat.ts index c7155e716a949..3a85f9dc1bea7 100644 --- a/src/vs/workbench/api/browser/mainThreadChat.ts +++ b/src/vs/workbench/api/browser/mainThreadChat.ts @@ -6,10 +6,11 @@ import { Emitter } from 'vs/base/common/event'; import { Disposable, DisposableMap } from 'vs/base/common/lifecycle'; import { URI, UriComponents } from 'vs/base/common/uri'; +import { ILogService } from 'vs/platform/log/common/log'; import { ExtHostChatShape, ExtHostContext, MainContext, MainThreadChatShape } from 'vs/workbench/api/common/extHost.protocol'; import { IChatWidgetService } from 'vs/workbench/contrib/chat/browser/chat'; import { IChatContributionService } from 'vs/workbench/contrib/chat/common/chatContributionService'; -import { IChatDynamicRequest, IChatService } from 'vs/workbench/contrib/chat/common/chatService'; +import { IChatService } from 'vs/workbench/contrib/chat/common/chatService'; import { IExtHostContext, extHostNamedCustomer } from 'vs/workbench/services/extensions/common/extHostCustomers'; @extHostNamedCustomer(MainContext.MainThreadChat) @@ -24,25 +25,27 @@ export class MainThreadChat extends Disposable implements MainThreadChatShape { extHostContext: IExtHostContext, @IChatService private readonly _chatService: IChatService, @IChatWidgetService private readonly _chatWidgetService: IChatWidgetService, - @IChatContributionService private readonly chatContribService: IChatContributionService, + @IChatContributionService private readonly _chatContribService: IChatContributionService, + @ILogService private readonly _logService: ILogService, ) { super(); this._proxy = extHostContext.getProxy(ExtHostContext.ExtHostChat); } - $transferChatSession(sessionId: number, toWorkspace: UriComponents): void { - const sessionIdStr = this._chatService.getSessionId(sessionId); - if (!sessionIdStr) { - throw new Error(`Failed to transfer session. Unknown session provider ID: ${sessionId}`); + $transferActiveChatSession(toWorkspace: UriComponents): void { + const widget = this._chatWidgetService.lastFocusedWidget; + const sessionId = widget?.viewModel?.model.sessionId; + if (!sessionId) { + this._logService.error(`MainThreadChat#$transferActiveChatSession: No active chat session found`); + return; } - const widget = this._chatWidgetService.getWidgetBySessionId(sessionIdStr); const inputValue = widget?.inputEditor.getValue() ?? ''; - this._chatService.transferChatSession({ sessionId: sessionIdStr, inputValue: inputValue }, URI.revive(toWorkspace)); + this._chatService.transferChatSession({ sessionId, inputValue }, URI.revive(toWorkspace)); } async $registerChatProvider(handle: number, id: string): Promise { - const registration = this.chatContribService.registeredProviders.find(staticProvider => staticProvider.id === id); + const registration = this._chatContribService.registeredProviders.find(staticProvider => staticProvider.id === id); if (!registration) { throw new Error(`Provider ${id} must be declared in the package.json.`); } @@ -55,18 +58,10 @@ export class MainThreadChat extends Disposable implements MainThreadChatShape { return undefined; } - const responderAvatarIconUri = session.responderAvatarIconUri && - URI.revive(session.responderAvatarIconUri); - const emitter = new Emitter(); this._stateEmitters.set(session.id, emitter); return { id: session.id, - requesterUsername: session.requesterUsername, - requesterAvatarIconUri: URI.revive(session.requesterAvatarIconUri), - responderUsername: session.responderUsername, - responderAvatarIconUri, - inputPlaceholder: session.inputPlaceholder, dispose: () => { emitter.dispose(); this._stateEmitters.delete(session.id); @@ -83,13 +78,6 @@ export class MainThreadChat extends Disposable implements MainThreadChatShape { this._stateEmitters.get(sessionId)?.fire(state); } - async $sendRequestToProvider(providerId: string, message: IChatDynamicRequest): Promise { - const widget = await this._chatWidgetService.revealViewForProvider(providerId); - if (widget && widget.viewModel) { - this._chatService.sendRequestToProvider(widget.viewModel.sessionId, message); - } - } - async $unregisterChatProvider(handle: number): Promise { this._providerRegistrations.deleteAndDispose(handle); } diff --git a/src/vs/workbench/api/browser/mainThreadChatAgents2.ts b/src/vs/workbench/api/browser/mainThreadChatAgents2.ts index 0f1263149b9ff..0aaaee8cf143e 100644 --- a/src/vs/workbench/api/browser/mainThreadChatAgents2.ts +++ b/src/vs/workbench/api/browser/mainThreadChatAgents2.ts @@ -13,23 +13,24 @@ import { getWordAtText } from 'vs/editor/common/core/wordHelper'; import { CompletionContext, CompletionItem, CompletionItemKind, CompletionList } from 'vs/editor/common/languages'; import { ITextModel } from 'vs/editor/common/model'; import { ILanguageFeaturesService } from 'vs/editor/common/services/languageFeatures'; +import { ExtensionIdentifier } from 'vs/platform/extensions/common/extensions'; import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; import { ExtHostChatAgentsShape2, ExtHostContext, IChatProgressDto, IExtensionChatAgentMetadata, MainContext, MainThreadChatAgentsShape2 } from 'vs/workbench/api/common/extHost.protocol'; import { IChatWidgetService } from 'vs/workbench/contrib/chat/browser/chat'; import { ChatInputPart } from 'vs/workbench/contrib/chat/browser/chatInputPart'; import { AddDynamicVariableAction, IAddDynamicVariableContext } from 'vs/workbench/contrib/chat/browser/contrib/chatDynamicVariables'; -import { IChatAgentCommand, IChatAgentService } from 'vs/workbench/contrib/chat/common/chatAgents'; +import { ChatAgentLocation, IChatAgentImplementation, IChatAgentService } from 'vs/workbench/contrib/chat/common/chatAgents'; import { ChatRequestAgentPart } from 'vs/workbench/contrib/chat/common/chatParserTypes'; import { ChatRequestParser } from 'vs/workbench/contrib/chat/common/chatRequestParser'; import { IChatFollowup, IChatProgress, IChatService } from 'vs/workbench/contrib/chat/common/chatService'; import { IExtHostContext, extHostNamedCustomer } from 'vs/workbench/services/extensions/common/extHostCustomers'; -type AgentData = { +interface AgentData { dispose: () => void; - name: string; - hasSlashCommands?: boolean; + id: string; + extensionId: ExtensionIdentifier; hasFollowups?: boolean; -}; +} @extHostNamedCustomer(MainContext.MainThreadChatAgents2) export class MainThreadChatAgents2 extends Disposable implements MainThreadChatAgentsShape2 { @@ -57,11 +58,11 @@ export class MainThreadChatAgents2 extends Disposable implements MainThreadChatA this._register(this._chatService.onDidPerformUserAction(e => { if (typeof e.agentId === 'string') { for (const [handle, agent] of this._agents) { - if (agent.name === e.agentId) { + if (agent.id === e.agentId) { if (e.action.kind === 'vote') { - this._proxy.$acceptFeedback(handle, e.sessionId, e.requestId, e.action.direction); + this._proxy.$acceptFeedback(handle, e.result ?? {}, e.action.direction); } else { - this._proxy.$acceptAction(handle, e.sessionId, e.requestId, e); + this._proxy.$acceptAction(handle, e.result || {}, e); } break; } @@ -74,11 +75,19 @@ export class MainThreadChatAgents2 extends Disposable implements MainThreadChatA this._agents.deleteAndDispose(handle); } - $registerAgent(handle: number, name: string, metadata: IExtensionChatAgentMetadata): void { - let lastSlashCommands: IChatAgentCommand[] | undefined; - const d = this._chatAgentService.registerAgent({ - id: name, - metadata: revive(metadata), + $registerAgent(handle: number, extension: ExtensionIdentifier, id: string, metadata: IExtensionChatAgentMetadata, dynamicProps: { name: string; description: string } | undefined): void { + const staticAgentRegistration = this._chatAgentService.getAgent(id); + if (!staticAgentRegistration && !dynamicProps) { + if (this._chatAgentService.getAgentsByName(id).length) { + // Likely some extension authors will not adopt the new ID, so give a hint if they register a + // participant by name instead of ID. + throw new Error(`chatParticipant must be declared with an ID in package.json. The "id" property may be missing! "${id}"`); + } + + throw new Error(`chatParticipant must be declared in package.json: ${id}`); + } + + const impl: IChatAgentImplementation = { invoke: async (request, progress, history, token) => { this._pendingProgress.set(request.requestId, progress); try { @@ -87,22 +96,12 @@ export class MainThreadChatAgents2 extends Disposable implements MainThreadChatA this._pendingProgress.delete(request.requestId); } }, - provideFollowups: async (sessionId, token): Promise => { + provideFollowups: async (request, result, history, token): Promise => { if (!this._agents.get(handle)?.hasFollowups) { return []; } - return this._proxy.$provideFollowups(handle, sessionId, token); - }, - get lastSlashCommands() { - return lastSlashCommands; - }, - provideSlashCommands: async (token) => { - if (!this._agents.get(handle)?.hasSlashCommands) { - return []; // save an IPC call - } - lastSlashCommands = await this._proxy.$provideSlashCommands(handle, token); - return lastSlashCommands; + return this._proxy.$provideFollowups(request, handle, result, { history }, token); }, provideWelcomeMessage: (token: CancellationToken) => { return this._proxy.$provideWelcomeMessage(handle, token); @@ -110,11 +109,29 @@ export class MainThreadChatAgents2 extends Disposable implements MainThreadChatA provideSampleQuestions: (token: CancellationToken) => { return this._proxy.$provideSampleQuestions(handle, token); } - }); + }; + + let disposable: IDisposable; + if (!staticAgentRegistration && dynamicProps) { + disposable = this._chatAgentService.registerDynamicAgent( + { + id, + name: dynamicProps.name, + description: dynamicProps.description, + extensionId: extension, + metadata: revive(metadata), + slashCommands: [], + locations: [ChatAgentLocation.Panel] // TODO all dynamic participants are panel only? + }, + impl); + } else { + disposable = this._chatAgentService.registerAgentImplementation(id, impl); + } + this._agents.set(handle, { - name, - dispose: d.dispose, - hasSlashCommands: metadata.hasSlashCommands, + id: id, + extensionId: extension, + dispose: disposable.dispose, hasFollowups: metadata.hasFollowups }); } @@ -124,9 +141,8 @@ export class MainThreadChatAgents2 extends Disposable implements MainThreadChatA if (!data) { throw new Error(`No agent with handle ${handle} registered`); } - data.hasSlashCommands = metadataUpdate.hasSlashCommands; data.hasFollowups = metadataUpdate.hasFollowups; - this._chatAgentService.updateAgent(data.name, revive(metadataUpdate)); + this._chatAgentService.updateAgent(data.id, revive(metadataUpdate)); } async $handleProgressChunk(requestId: string, progress: IChatProgressDto): Promise { @@ -154,8 +170,8 @@ export class MainThreadChatAgents2 extends Disposable implements MainThreadChatA const parsedRequest = this._instantiationService.createInstance(ChatRequestParser).parseChatRequest(widget.viewModel.sessionId, model.getValue()).parts; const agentPart = parsedRequest.find((part): part is ChatRequestAgentPart => part instanceof ChatRequestAgentPart); - const thisAgentName = this._agents.get(handle)?.name; - if (agentPart?.agent.id !== thisAgentName) { + const thisAgentId = this._agents.get(handle)?.id; + if (agentPart?.agent.id !== thisAgentId) { return; } diff --git a/src/vs/workbench/api/browser/mainThreadChatProvider.ts b/src/vs/workbench/api/browser/mainThreadChatProvider.ts deleted file mode 100644 index 24ce8d39797ee..0000000000000 --- a/src/vs/workbench/api/browser/mainThreadChatProvider.ts +++ /dev/null @@ -1,95 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { CancellationToken } from 'vs/base/common/cancellation'; -import { DisposableMap, DisposableStore } from 'vs/base/common/lifecycle'; -import { ExtensionIdentifier } from 'vs/platform/extensions/common/extensions'; -import { ILogService } from 'vs/platform/log/common/log'; -import { IProgress, Progress } from 'vs/platform/progress/common/progress'; -import { ExtHostChatProviderShape, ExtHostContext, MainContext, MainThreadChatProviderShape } from 'vs/workbench/api/common/extHost.protocol'; -import { IChatResponseProviderMetadata, IChatResponseFragment, IChatProviderService, IChatMessage } from 'vs/workbench/contrib/chat/common/chatProvider'; -import { CHAT_FEATURE_ID } from 'vs/workbench/contrib/chat/common/chatService'; -import { IExtensionFeaturesManagementService } from 'vs/workbench/services/extensionManagement/common/extensionFeatures'; -import { IExtHostContext, extHostNamedCustomer } from 'vs/workbench/services/extensions/common/extHostCustomers'; - -@extHostNamedCustomer(MainContext.MainThreadChatProvider) -export class MainThreadChatProvider implements MainThreadChatProviderShape { - - private readonly _proxy: ExtHostChatProviderShape; - private readonly _store = new DisposableStore(); - private readonly _providerRegistrations = new DisposableMap(); - private readonly _pendingProgress = new Map>(); - - constructor( - extHostContext: IExtHostContext, - @IChatProviderService private readonly _chatProviderService: IChatProviderService, - @IExtensionFeaturesManagementService private readonly _extensionFeaturesManagementService: IExtensionFeaturesManagementService, - @ILogService private readonly _logService: ILogService, - ) { - this._proxy = extHostContext.getProxy(ExtHostContext.ExtHostChatProvider); - - this._proxy.$updateLanguageModels({ added: _chatProviderService.getProviders() }); - this._proxy.$updateAccesslist(_extensionFeaturesManagementService.getEnablementData(CHAT_FEATURE_ID)); - this._store.add(_chatProviderService.onDidChangeProviders(this._proxy.$updateLanguageModels, this._proxy)); - this._store.add(_extensionFeaturesManagementService.onDidChangeEnablement(e => { - if (e.featureId === CHAT_FEATURE_ID) { - this._proxy.$updateAccesslist(_extensionFeaturesManagementService.getEnablementData(CHAT_FEATURE_ID)); - } - })); - } - - dispose(): void { - this._providerRegistrations.dispose(); - this._store.dispose(); - } - - $registerProvider(handle: number, identifier: string, metadata: IChatResponseProviderMetadata): void { - const registration = this._chatProviderService.registerChatResponseProvider(identifier, { - metadata, - provideChatResponse: async (messages, options, progress, token) => { - const requestId = (Math.random() * 1e6) | 0; - this._pendingProgress.set(requestId, progress); - try { - await this._proxy.$provideLanguageModelResponse(handle, requestId, messages, options, token); - } finally { - this._pendingProgress.delete(requestId); - } - } - }); - this._providerRegistrations.set(handle, registration); - } - - async $handleProgressChunk(requestId: number, chunk: IChatResponseFragment): Promise { - this._pendingProgress.get(requestId)?.report(chunk); - } - - $unregisterProvider(handle: number): void { - this._providerRegistrations.deleteAndDispose(handle); - } - - async $prepareChatAccess(extension: ExtensionIdentifier, providerId: string, justification?: string): Promise { - const access = await this._extensionFeaturesManagementService.getAccess(extension, CHAT_FEATURE_ID, justification); - if (!access) { - return undefined; - } - return this._chatProviderService.lookupChatResponseProvider(providerId); - } - - async $fetchResponse(extension: ExtensionIdentifier, providerId: string, requestId: number, messages: IChatMessage[], options: {}, token: CancellationToken): Promise { - this._logService.debug('[CHAT] extension request STARTED', extension.value, requestId); - - const task = this._chatProviderService.fetchChatResponse(providerId, messages, options, new Progress(value => { - this._proxy.$handleResponseFragment(requestId, value); - }), token); - - task.catch(err => { - this._logService.error('[CHAT] extension request ERRORED', err, extension.value, requestId); - }).finally(() => { - this._logService.debug('[CHAT] extension request DONE', extension.value, requestId); - }); - - return task; - } -} diff --git a/src/vs/workbench/api/browser/mainThreadChatVariables.ts b/src/vs/workbench/api/browser/mainThreadChatVariables.ts index 1f5e98ff8ac8b..fbdc3060424db 100644 --- a/src/vs/workbench/api/browser/mainThreadChatVariables.ts +++ b/src/vs/workbench/api/browser/mainThreadChatVariables.ts @@ -5,8 +5,8 @@ import { DisposableMap } from 'vs/base/common/lifecycle'; import { revive } from 'vs/base/common/marshalling'; -import { ExtHostChatVariablesShape, ExtHostContext, MainContext, MainThreadChatVariablesShape } from 'vs/workbench/api/common/extHost.protocol'; -import { IChatRequestVariableValue, IChatVariableData, IChatVariablesService } from 'vs/workbench/contrib/chat/common/chatVariables'; +import { ExtHostChatVariablesShape, ExtHostContext, IChatVariableResolverProgressDto, MainContext, MainThreadChatVariablesShape } from 'vs/workbench/api/common/extHost.protocol'; +import { IChatRequestVariableValue, IChatVariableData, IChatVariableResolverProgress, IChatVariablesService } from 'vs/workbench/contrib/chat/common/chatVariables'; import { IExtHostContext, extHostNamedCustomer } from 'vs/workbench/services/extensions/common/extHostCustomers'; @extHostNamedCustomer(MainContext.MainThreadChatVariables) @@ -14,6 +14,7 @@ export class MainThreadChatVariables implements MainThreadChatVariablesShape { private readonly _proxy: ExtHostChatVariablesShape; private readonly _variables = new DisposableMap(); + private readonly _pendingProgress = new Map void>(); constructor( extHostContext: IExtHostContext, @@ -27,12 +28,22 @@ export class MainThreadChatVariables implements MainThreadChatVariablesShape { } $registerVariable(handle: number, data: IChatVariableData): void { - const registration = this._chatVariablesService.registerVariable(data, async (messageText, _arg, _model, token) => { - return revive(await this._proxy.$resolveVariable(handle, messageText, token)); + const registration = this._chatVariablesService.registerVariable(data, async (messageText, _arg, model, progress, token) => { + const varRequestId = `${model.sessionId}-${handle}`; + this._pendingProgress.set(varRequestId, progress); + const result = revive(await this._proxy.$resolveVariable(handle, varRequestId, messageText, token)); + + this._pendingProgress.delete(varRequestId); + return result; }); this._variables.set(handle, registration); } + async $handleProgressChunk(requestId: string, progress: IChatVariableResolverProgressDto): Promise { + const revivedProgress = revive(progress); + this._pendingProgress.get(requestId)?.(revivedProgress as IChatVariableResolverProgress); + } + $unregisterVariable(handle: number): void { this._variables.deleteAndDispose(handle); } diff --git a/src/vs/workbench/api/browser/mainThreadCodeInsets.ts b/src/vs/workbench/api/browser/mainThreadCodeInsets.ts index 4ad9e6548079e..3fa0eef969f14 100644 --- a/src/vs/workbench/api/browser/mainThreadCodeInsets.ts +++ b/src/vs/workbench/api/browser/mainThreadCodeInsets.ts @@ -3,6 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { getWindow } from 'vs/base/browser/dom'; import { DisposableStore } from 'vs/base/common/lifecycle'; import { isEqual } from 'vs/base/common/resources'; import { URI, UriComponents } from 'vs/base/common/uri'; @@ -43,7 +44,7 @@ class EditorWebviewZone implements IViewZone { this.heightInLines = height; editor.changeViewZones(accessor => this._id = accessor.addZone(this)); - webview.mountTo(this.domNode); + webview.mountTo(this.domNode, getWindow(editor.getDomNode())); } dispose(): void { diff --git a/src/vs/workbench/api/browser/mainThreadComments.ts b/src/vs/workbench/api/browser/mainThreadComments.ts index 538c754e1b331..5bfadbbc409c3 100644 --- a/src/vs/workbench/api/browser/mainThreadComments.ts +++ b/src/vs/workbench/api/browser/mainThreadComments.ts @@ -26,6 +26,7 @@ import { MarshalledId } from 'vs/base/common/marshallingIds'; import { ICellRange } from 'vs/workbench/contrib/notebook/common/notebookRange'; import { Schemas } from 'vs/base/common/network'; import { IViewsService } from 'vs/workbench/services/views/common/viewsService'; +import { MarshalledCommentThread } from 'vs/workbench/common/comments'; export class MainThreadCommentThread implements languages.CommentThread { private _input?: languages.CommentInput; @@ -150,6 +151,20 @@ export class MainThreadCommentThread implements languages.CommentThread { this._onDidChangeState.fire(this._state); } + private _applicability: languages.CommentThreadApplicability | undefined; + + get applicability(): languages.CommentThreadApplicability | undefined { + return this._applicability; + } + + set applicability(value: languages.CommentThreadApplicability | undefined) { + this._applicability = value; + this._onDidChangeApplicability.fire(value); + } + + private readonly _onDidChangeApplicability = new Emitter(); + readonly onDidChangeApplicability: Event = this._onDidChangeApplicability.event; + public get isTemplate(): boolean { return this._isTemplate; } @@ -184,6 +199,7 @@ export class MainThreadCommentThread implements languages.CommentThread { if (modified('collapseState')) { this.initialCollapsibleState = changes.collapseState; } if (modified('canReply')) { this.canReply = changes.canReply!; } if (modified('state')) { this.state = changes.state!; } + if (modified('applicability')) { this.applicability = changes.applicability!; } if (modified('isTemplate')) { this._isTemplate = changes.isTemplate!; } } @@ -197,7 +213,7 @@ export class MainThreadCommentThread implements languages.CommentThread { this._onDidChangeState.dispose(); } - toJSON(): any { + toJSON(): MarshalledCommentThread { return { $mid: MarshalledId.CommentThread, commentControlHandle: this.controllerHandle, @@ -248,6 +264,10 @@ export class MainThreadCommentController implements ICommentController { return this._features; } + get owner() { + return this._id; + } + constructor( private readonly _proxy: ExtHostCommentsShape, private readonly _commentService: ICommentService, @@ -370,8 +390,8 @@ export class MainThreadCommentController implements ICommentController { } } - updateCommentingRanges() { - this._commentService.updateCommentingRanges(this._uniqueId); + updateCommentingRanges(resourceHints?: languages.CommentingRangeResourceHint) { + this._commentService.updateCommentingRanges(this._uniqueId, resourceHints); } private getKnownThread(commentThreadHandle: number): MainThreadCommentThread { @@ -385,7 +405,7 @@ export class MainThreadCommentController implements ICommentController { async getDocumentComments(resource: URI, token: CancellationToken) { if (resource.scheme === Schemas.vscodeNotebookCell) { return { - owner: this._uniqueId, + uniqueOwner: this._uniqueId, label: this.label, threads: [], commentingRanges: { @@ -407,7 +427,7 @@ export class MainThreadCommentController implements ICommentController { const commentingRanges = await this._proxy.$provideCommentingRanges(this.handle, resource, token); return { - owner: this._uniqueId, + uniqueOwner: this._uniqueId, label: this.label, threads: ret, commentingRanges: { @@ -421,7 +441,7 @@ export class MainThreadCommentController implements ICommentController { async getNotebookComments(resource: URI, token: CancellationToken) { if (resource.scheme !== Schemas.vscodeNotebookCell) { return { - owner: this._uniqueId, + uniqueOwner: this._uniqueId, label: this.label, threads: [] }; @@ -436,7 +456,7 @@ export class MainThreadCommentController implements ICommentController { } return { - owner: this._uniqueId, + uniqueOwner: this._uniqueId, label: this.label, threads: ret }; @@ -591,14 +611,14 @@ export class MainThreadComments extends Disposable implements MainThreadComments return provider.deleteCommentThread(commentThreadHandle); } - $updateCommentingRanges(handle: number) { + $updateCommentingRanges(handle: number, resourceHints?: languages.CommentingRangeResourceHint) { const provider = this._commentControllers.get(handle); if (!provider) { return; } - provider.updateCommentingRanges(); + provider.updateCommentingRanges(resourceHints); } private registerView(commentsViewAlreadyRegistered: boolean) { diff --git a/src/vs/workbench/api/browser/mainThreadCustomEditors.ts b/src/vs/workbench/api/browser/mainThreadCustomEditors.ts index d32e4ef318ef4..3f992a4cfad85 100644 --- a/src/vs/workbench/api/browser/mainThreadCustomEditors.ts +++ b/src/vs/workbench/api/browser/mainThreadCustomEditors.ts @@ -664,6 +664,8 @@ class MainThreadCustomEditorModel extends ResourceWorkingCopy implements ICustom } } + public get canHotExit() { return typeof this._backupId === 'string' && this._hotExitState.type === HotExitState.Type.Allowed; } + public async backup(token: CancellationToken): Promise { const editors = this._getEditors(); if (!editors.length) { @@ -735,6 +737,6 @@ class MainThreadCustomEditorModel extends ResourceWorkingCopy implements ICustom return backupData; } - throw new Error(`Cannot back up in this state: ${errorMessage}`); + throw new Error(`Cannot backup in this state: ${errorMessage}`); } } diff --git a/src/vs/workbench/api/browser/mainThreadDebugService.ts b/src/vs/workbench/api/browser/mainThreadDebugService.ts index ea668f70f3cb8..e5dfc1a814e55 100644 --- a/src/vs/workbench/api/browser/mainThreadDebugService.ts +++ b/src/vs/workbench/api/browser/mainThreadDebugService.ts @@ -5,7 +5,7 @@ import { DisposableMap, DisposableStore, IDisposable, toDisposable } from 'vs/base/common/lifecycle'; import { URI as uri, UriComponents } from 'vs/base/common/uri'; -import { IDebugService, IConfig, IDebugConfigurationProvider, IBreakpoint, IFunctionBreakpoint, IBreakpointData, IDebugAdapter, IDebugAdapterDescriptorFactory, IDebugSession, IDebugAdapterFactory, IDataBreakpoint, IDebugSessionOptions, IInstructionBreakpoint, DebugConfigurationProviderTriggerKind, IDebugVisualization } from 'vs/workbench/contrib/debug/common/debug'; +import { IDebugService, IConfig, IDebugConfigurationProvider, IBreakpoint, IFunctionBreakpoint, IBreakpointData, IDebugAdapter, IDebugAdapterDescriptorFactory, IDebugSession, IDebugAdapterFactory, IDataBreakpoint, IDebugSessionOptions, IInstructionBreakpoint, DebugConfigurationProviderTriggerKind, IDebugVisualization, DataBreakpointSetType } from 'vs/workbench/contrib/debug/common/debug'; import { ExtHostContext, ExtHostDebugServiceShape, MainThreadDebugServiceShape, DebugSessionUUID, MainContext, IBreakpointsDeltaDto, ISourceMultiBreakpointDto, ISourceBreakpointDto, IFunctionBreakpointDto, IDebugSessionDto, IDataBreakpointDto, IStartDebuggingOptions, IDebugConfiguration, IThreadFocusDto, IStackFrameFocusDto @@ -18,6 +18,7 @@ import { convertToVSCPaths, convertToDAPaths, isSessionAttach } from 'vs/workben import { ErrorNoTelemetry } from 'vs/base/common/errors'; import { IDebugVisualizerService } from 'vs/workbench/contrib/debug/common/debugVisualizers'; import { ExtensionIdentifier } from 'vs/platform/extensions/common/extensions'; +import { Event } from 'vs/base/common/event'; @extHostNamedCustomer(MainContext.MainThreadDebugService) export class MainThreadDebugService implements MainThreadDebugServiceShape, IDebugAdapterFactory { @@ -30,6 +31,7 @@ export class MainThreadDebugService implements MainThreadDebugServiceShape, IDeb private readonly _debugAdapterDescriptorFactories: Map; private readonly _extHostKnownSessions: Set; private readonly _visualizerHandles = new Map(); + private readonly _visualizerTreeHandles = new Map(); constructor( extHostContext: IExtHostContext, @@ -87,31 +89,45 @@ export class MainThreadDebugService implements MainThreadDebugServiceShape, IDeb this._debugAdapterDescriptorFactories = new Map(); this._extHostKnownSessions = new Set(); - this._toDispose.add(this.debugService.getViewModel().onDidFocusThread(({ thread, explicit, session }) => { - if (session) { - const dto: IThreadFocusDto = { + const viewModel = this.debugService.getViewModel(); + this._toDispose.add(Event.any(viewModel.onDidFocusStackFrame, viewModel.onDidFocusThread)(() => { + const stackFrame = viewModel.focusedStackFrame; + const thread = viewModel.focusedThread; + if (stackFrame) { + this._proxy.$acceptStackFrameFocus({ + kind: 'stackFrame', + threadId: stackFrame.thread.threadId, + frameId: stackFrame.frameId, + sessionId: stackFrame.thread.session.getId(), + } satisfies IStackFrameFocusDto); + } else if (thread) { + this._proxy.$acceptStackFrameFocus({ kind: 'thread', - threadId: thread?.threadId, - sessionId: session.getId(), - }; - this._proxy.$acceptStackFrameFocus(dto); + threadId: thread.threadId, + sessionId: thread.session.getId(), + } satisfies IThreadFocusDto); + } else { + this._proxy.$acceptStackFrameFocus(undefined); } })); - this._toDispose.add(this.debugService.getViewModel().onDidFocusStackFrame(({ stackFrame, explicit, session }) => { - if (session) { - const dto: IStackFrameFocusDto = { - kind: 'stackFrame', - threadId: stackFrame?.thread.threadId, - frameId: stackFrame?.frameId, - sessionId: session.getId(), - }; - this._proxy.$acceptStackFrameFocus(dto); - } - })); this.sendBreakpointsAndListen(); } + $registerDebugVisualizerTree(treeId: string, canEdit: boolean): void { + this.visualizerService.registerTree(treeId, { + disposeItem: id => this._proxy.$disposeVisualizedTree(id), + getChildren: e => this._proxy.$getVisualizerTreeItemChildren(treeId, e), + getTreeItem: e => this._proxy.$getVisualizerTreeItem(treeId, e), + editItem: canEdit ? ((e, v) => this._proxy.$editVisualizerTreeItem(e, v)) : undefined + }); + } + + $unregisterDebugVisualizerTree(treeId: string): void { + this._visualizerTreeHandles.get(treeId)?.dispose(); + this._visualizerTreeHandles.delete(treeId); + } + $registerDebugVisualizer(extensionId: string, id: string): void { const handle = this.visualizerService.register({ extensionId: new ExtensionIdentifier(extensionId), @@ -202,14 +218,22 @@ export class MainThreadDebugService implements MainThreadDebugServiceShape, IDeb column: l.character > 0 ? l.character + 1 : undefined, // a column value of 0 results in an omitted column attribute; see #46784 condition: l.condition, hitCondition: l.hitCondition, - logMessage: l.logMessage + logMessage: l.logMessage, + mode: l.mode, } ); this.debugService.addBreakpoints(uri.revive(dto.uri), rawbps); } else if (dto.type === 'function') { - this.debugService.addFunctionBreakpoint(dto.functionName, dto.id); + this.debugService.addFunctionBreakpoint(dto.functionName, dto.id, dto.mode); } else if (dto.type === 'data') { - this.debugService.addDataBreakpoint(dto.label, dto.dataId, dto.canPersist, dto.accessTypes, dto.accessType); + this.debugService.addDataBreakpoint({ + description: dto.label, + src: { type: DataBreakpointSetType.Variable, dataId: dto.dataId }, + canPersist: dto.canPersist, + accessTypes: dto.accessTypes, + accessType: dto.accessType, + mode: dto.mode + }); } } return Promise.resolve(); @@ -420,19 +444,20 @@ export class MainThreadDebugService implements MainThreadDebugServiceShape, IDeb logMessage: fbp.logMessage, functionName: fbp.name }; - } else if ('dataId' in bp) { + } else if ('src' in bp) { const dbp = bp; - return { + return { type: 'data', id: dbp.getId(), - dataId: dbp.dataId, + dataId: dbp.src.type === DataBreakpointSetType.Variable ? dbp.src.dataId : dbp.src.address, enabled: dbp.enabled, condition: dbp.condition, hitCondition: dbp.hitCondition, logMessage: dbp.logMessage, + accessType: dbp.accessType, label: dbp.description, canPersist: dbp.canPersist - }; + } satisfies IDataBreakpointDto; } else { const sbp = bp; return { diff --git a/src/vs/workbench/api/browser/mainThreadEditorTabs.ts b/src/vs/workbench/api/browser/mainThreadEditorTabs.ts index d23f8e91fd5c4..880bc7dee2385 100644 --- a/src/vs/workbench/api/browser/mainThreadEditorTabs.ts +++ b/src/vs/workbench/api/browser/mainThreadEditorTabs.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { DisposableStore } from 'vs/base/common/lifecycle'; -import { ExtHostContext, IExtHostEditorTabsShape, MainContext, IEditorTabDto, IEditorTabGroupDto, MainThreadEditorTabsShape, AnyInputDto, TabInputKind, TabModelOperationKind } from 'vs/workbench/api/common/extHost.protocol'; +import { ExtHostContext, IExtHostEditorTabsShape, MainContext, IEditorTabDto, IEditorTabGroupDto, MainThreadEditorTabsShape, AnyInputDto, TabInputKind, TabModelOperationKind, TextDiffInputDto } from 'vs/workbench/api/common/extHost.protocol'; import { extHostNamedCustomer, IExtHostContext } from 'vs/workbench/services/extensions/common/extHostCustomers'; import { EditorResourceAccessor, GroupModelChangeKind, SideBySideEditor } from 'vs/workbench/common/editor'; import { DiffEditorInput } from 'vs/workbench/common/editor/diffEditorInput'; @@ -26,6 +26,7 @@ import { InteractiveEditorInput } from 'vs/workbench/contrib/interactive/browser import { MergeEditorInput } from 'vs/workbench/contrib/mergeEditor/browser/mergeEditorInput'; import { ILogService } from 'vs/platform/log/common/log'; import { ChatEditorInput } from 'vs/workbench/contrib/chat/browser/chatEditorInput'; +import { MultiDiffEditorInput } from 'vs/workbench/contrib/multiDiffEditor/browser/multiDiffEditorInput'; interface TabInfo { tab: IEditorTabDto; @@ -199,6 +200,24 @@ export class MainThreadEditorTabs implements MainThreadEditorTabsShape { }; } + if (editor instanceof MultiDiffEditorInput) { + const diffEditors: TextDiffInputDto[] = []; + for (const resource of (editor?.initialResources ?? [])) { + if (resource.original && resource.modified) { + diffEditors.push({ + kind: TabInputKind.TextDiffInput, + original: resource.original, + modified: resource.modified + }); + } + } + + return { + kind: TabInputKind.MultiDiffEditorInput, + diffEditors + }; + } + return { kind: TabInputKind.UnknownInput }; } @@ -453,6 +472,10 @@ export class MainThreadEditorTabs implements MainThreadEditorTabsShape { * Builds the model from scratch based on the current state of the editor service. */ private _createTabsModel(): void { + if (this._editorGroupsService.groups.length === 0) { + return; // skip this invalid state, it may happen when the entire editor area is transitioning to other state ("editor working sets") + } + this._tabGroupModel = []; this._groupLookup.clear(); this._tabInfoLookup.clear(); @@ -553,6 +576,9 @@ export class MainThreadEditorTabs implements MainThreadEditorTabsShape { this._onDidTabPreviewChange(groupId, event.editorIndex, event.editor); break; } + case GroupModelChangeKind.EDITOR_TRANSIENT: + // Currently not exposed in the API + break; case GroupModelChangeKind.EDITOR_MOVE: if (isGroupEditorMoveEvent(event) && event.editor && event.editorIndex !== undefined && event.oldEditorIndex !== undefined) { this._onDidTabMove(groupId, event.editorIndex, event.oldEditorIndex, event.editor); diff --git a/src/vs/workbench/api/browser/mainThreadFileSystemEventService.ts b/src/vs/workbench/api/browser/mainThreadFileSystemEventService.ts index 20c6c76910d64..22976049d5ad6 100644 --- a/src/vs/workbench/api/browser/mainThreadFileSystemEventService.ts +++ b/src/vs/workbench/api/browser/mainThreadFileSystemEventService.ts @@ -230,7 +230,7 @@ export class MainThreadFileSystemEventService implements MainThreadFileSystemEve opts.recursive = false; } } catch (error) { - this._logService.error(`MainThreadFileSystemEventService#$watch(): failed to stat a resource for file watching (extension: ${extensionId}, path: ${uri.toString(true)}, recursive: ${opts.recursive}, session: ${session}): ${error}`); + // ignore } } @@ -254,21 +254,9 @@ export class MainThreadFileSystemEventService implements MainThreadFileSystemEve // Uncorrelated file watching gets special treatment else { + this._logService.trace(`MainThreadFileSystemEventService#$watch(): request to start watching uncorrelated (extension: ${extensionId}, path: ${uri.toString(true)}, recursive: ${opts.recursive}, session: ${session})`); - // Refuse to watch anything that is already watched via - // our workspace watchers in case the request is a - // recursive file watcher and does not opt-in to event - // correlation via specific exclude rules. - // Still allow for non-recursive watch requests as a way - // to bypass configured exclude rules though - // (see https://github.com/microsoft/vscode/issues/146066) const workspaceFolder = this._contextService.getWorkspaceFolder(uri); - if (workspaceFolder && opts.recursive) { - this._logService.trace(`MainThreadFileSystemEventService#$watch(): ignoring request to start watching because path is inside workspace (extension: ${extensionId}, path: ${uri.toString(true)}, recursive: ${opts.recursive}, session: ${session})`); - return; - } - - this._logService.trace(`MainThreadFileSystemEventService#$watch(): request to start watching uncorrelated (extension: ${extensionId}, path: ${uri.toString(true)}, recursive: ${opts.recursive}, session: ${session})`); // Automatically add `files.watcherExclude` patterns when watching // recursively to give users a chance to configure exclude rules @@ -295,7 +283,7 @@ export class MainThreadFileSystemEventService implements MainThreadFileSystemEve // `/bar` but will not work as include for files within // `bar` unless a suffix of `/**` if added. // (https://github.com/microsoft/vscode/issues/148245) - else if (workspaceFolder) { + else if (!opts.recursive && workspaceFolder) { const config = this._configurationService.getValue(); if (config.files?.watcherExclude) { for (const key in config.files.watcherExclude) { diff --git a/src/vs/workbench/api/browser/mainThreadInlineChat.ts b/src/vs/workbench/api/browser/mainThreadInlineChat.ts index 60c2ab5fb80b2..48207f86ffa87 100644 --- a/src/vs/workbench/api/browser/mainThreadInlineChat.ts +++ b/src/vs/workbench/api/browser/mainThreadInlineChat.ts @@ -10,6 +10,7 @@ import { reviveWorkspaceEditDto } from 'vs/workbench/api/browser/mainThreadBulkE import { ExtHostContext, ExtHostInlineChatShape, MainContext, MainThreadInlineChatShape as MainThreadInlineChatShape } from 'vs/workbench/api/common/extHost.protocol'; import { IExtHostContext, extHostNamedCustomer } from 'vs/workbench/services/extensions/common/extHostCustomers'; import { IProgress } from 'vs/platform/progress/common/progress'; +import { ExtensionIdentifier } from 'vs/platform/extensions/common/extensions'; @extHostNamedCustomer(MainContext.MainThreadInlineChat) export class MainThreadInlineChat implements MainThreadInlineChatShape { @@ -31,9 +32,9 @@ export class MainThreadInlineChat implements MainThreadInlineChatShape { this._registrations.dispose(); } - async $registerInteractiveEditorProvider(handle: number, label: string, debugName: string, supportsFeedback: boolean, supportsFollowups: boolean, supportIssueReporting: boolean): Promise { + async $registerInteractiveEditorProvider(handle: number, label: string, extensionId: ExtensionIdentifier, supportsFeedback: boolean, supportsFollowups: boolean, supportIssueReporting: boolean): Promise { const unreg = this._inlineChatService.addProvider({ - debugName, + extensionId, label, supportIssueReporting, prepareInlineChatSession: async (model, range, token) => { diff --git a/src/vs/workbench/api/browser/mainThreadIssueReporter.ts b/src/vs/workbench/api/browser/mainThreadIssueReporter.ts deleted file mode 100644 index c3afade0cbbc3..0000000000000 --- a/src/vs/workbench/api/browser/mainThreadIssueReporter.ts +++ /dev/null @@ -1,58 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { CancellationToken } from 'vs/base/common/cancellation'; -import { Disposable, DisposableMap } from 'vs/base/common/lifecycle'; -import { URI } from 'vs/base/common/uri'; -import { ExtHostContext, ExtHostIssueReporterShape, MainContext, MainThreadIssueReporterShape } from 'vs/workbench/api/common/extHost.protocol'; -import { IExtHostContext, extHostNamedCustomer } from 'vs/workbench/services/extensions/common/extHostCustomers'; -import { IIssueDataProvider, IIssueUriRequestHandler, IWorkbenchIssueService } from 'vs/workbench/services/issue/common/issue'; - -@extHostNamedCustomer(MainContext.MainThreadIssueReporter) -export class MainThreadIssueReporter extends Disposable implements MainThreadIssueReporterShape { - private readonly _proxy: ExtHostIssueReporterShape; - private readonly _registrationsUri = this._register(new DisposableMap()); - private readonly _registrationsData = this._register(new DisposableMap()); - - constructor( - context: IExtHostContext, - @IWorkbenchIssueService private readonly _issueService: IWorkbenchIssueService - ) { - super(); - this._proxy = context.getProxy(ExtHostContext.ExtHostIssueReporter); - } - - $registerIssueUriRequestHandler(extensionId: string): void { - const handler: IIssueUriRequestHandler = { - provideIssueUrl: async (token: CancellationToken) => { - const parts = await this._proxy.$getIssueReporterUri(extensionId, token); - return URI.from(parts); - } - }; - this._registrationsUri.set(extensionId, this._issueService.registerIssueUriRequestHandler(extensionId, handler)); - } - - $unregisterIssueUriRequestHandler(extensionId: string): void { - this._registrationsUri.deleteAndDispose(extensionId); - } - - $registerIssueDataProvider(extensionId: string): void { - const provider: IIssueDataProvider = { - provideIssueExtensionData: async (token: CancellationToken) => { - const parts = await this._proxy.$getIssueReporterData(extensionId, token); - return parts; - }, - provideIssueExtensionTemplate: async (token: CancellationToken) => { - const parts = await this._proxy.$getIssueReporterTemplate(extensionId, token); - return parts; - } - }; - this._registrationsData.set(extensionId, this._issueService.registerIssueDataProvider(extensionId, provider)); - } - - $unregisterIssueDataProvider(extensionId: string): void { - this._registrationsData.deleteAndDispose(extensionId); - } -} diff --git a/src/vs/workbench/api/browser/mainThreadLanguageFeatures.ts b/src/vs/workbench/api/browser/mainThreadLanguageFeatures.ts index 497fc46663b71..d89eecf9a3fc3 100644 --- a/src/vs/workbench/api/browser/mainThreadLanguageFeatures.ts +++ b/src/vs/workbench/api/browser/mainThreadLanguageFeatures.ts @@ -32,9 +32,10 @@ import * as callh from 'vs/workbench/contrib/callHierarchy/common/callHierarchy' import * as search from 'vs/workbench/contrib/search/common/search'; import * as typeh from 'vs/workbench/contrib/typeHierarchy/common/typeHierarchy'; import { extHostNamedCustomer, IExtHostContext } from 'vs/workbench/services/extensions/common/extHostCustomers'; -import { ExtHostContext, ExtHostLanguageFeaturesShape, ICallHierarchyItemDto, ICodeActionDto, ICodeActionProviderMetadataDto, IdentifiableInlineCompletion, IdentifiableInlineCompletions, IDocumentDropEditProviderMetadata, IDocumentFilterDto, IIndentationRuleDto, IInlayHintDto, ILanguageConfigurationDto, ILanguageWordDefinitionDto, ILinkDto, ILocationDto, ILocationLinkDto, IOnEnterRuleDto, IPasteEditProviderMetadataDto, IRegExpDto, ISignatureHelpProviderMetadataDto, ISuggestDataDto, ISuggestDataDtoField, ISuggestResultDtoField, ITypeHierarchyItemDto, IWorkspaceSymbolDto, MainContext, MainThreadLanguageFeaturesShape } from '../common/extHost.protocol'; +import { ExtHostContext, ExtHostLanguageFeaturesShape, ICallHierarchyItemDto, ICodeActionDto, ICodeActionProviderMetadataDto, IdentifiableInlineCompletion, IdentifiableInlineCompletions, IdentifiableInlineEdit, IDocumentDropEditProviderMetadata, IDocumentFilterDto, IIndentationRuleDto, IInlayHintDto, ILanguageConfigurationDto, ILanguageWordDefinitionDto, ILinkDto, ILocationDto, ILocationLinkDto, IOnEnterRuleDto, IPasteEditDto, IPasteEditProviderMetadataDto, IRegExpDto, ISignatureHelpProviderMetadataDto, ISuggestDataDto, ISuggestDataDtoField, ISuggestResultDtoField, ITypeHierarchyItemDto, IWorkspaceSymbolDto, MainContext, MainThreadLanguageFeaturesShape } from '../common/extHost.protocol'; import { ResourceMap } from 'vs/base/common/map'; import { isFalsyOrEmpty } from 'vs/base/common/arrays'; +import { HierarchicalKind } from 'vs/base/common/hierarchicalKind'; @extHostNamedCustomer(MainContext.MainThreadLanguageFeatures) export class MainThreadLanguageFeatures extends Disposable implements MainThreadLanguageFeaturesShape { @@ -398,8 +399,8 @@ export class MainThreadLanguageFeatures extends Disposable implements MainThread private readonly _pasteEditProviders = new Map(); - $registerPasteEditProvider(handle: number, selector: IDocumentFilterDto[], id: string, metadata: IPasteEditProviderMetadataDto): void { - const provider = new MainThreadPasteEditProvider(handle, this._proxy, id, metadata, this._uriIdentService); + $registerPasteEditProvider(handle: number, selector: IDocumentFilterDto[], metadata: IPasteEditProviderMetadataDto): void { + const provider = new MainThreadPasteEditProvider(handle, this._proxy, metadata, this._uriIdentService); this._pasteEditProviders.set(handle, provider); this._registrations.set(handle, combinedDisposable( this._languageFeaturesService.documentPasteEditProvider.register(selector, provider), @@ -479,7 +480,7 @@ export class MainThreadLanguageFeatures extends Disposable implements MainThread // --- rename $registerRenameSupport(handle: number, selector: IDocumentFilterDto[], supportResolveLocation: boolean): void { - this._registrations.set(handle, this._languageFeaturesService.renameProvider.register(selector, { + this._registrations.set(handle, this._languageFeaturesService.renameProvider.register(selector, { provideRenameEdits: (model: ITextModel, position: EditorPosition, newName: string, token: CancellationToken) => { return this._proxy.$provideRenameEdits(handle, model.uri, position, newName, token).then(data => reviveWorkspaceEditDto(data, this._uriIdentService)); }, @@ -489,6 +490,14 @@ export class MainThreadLanguageFeatures extends Disposable implements MainThread })); } + $registerNewSymbolNamesProvider(handle: number, selector: IDocumentFilterDto[]): void { + this._registrations.set(handle, this._languageFeaturesService.newSymbolNamesProvider.register(selector, { + provideNewSymbolNames: (model: ITextModel, range: IRange, token: CancellationToken): Promise => { + return this._proxy.$provideNewSymbolNames(handle, model.uri, range, token); + } + } satisfies languages.NewSymbolNamesProvider)); + } + // --- semantic tokens $registerDocumentSemanticTokensProvider(handle: number, selector: IDocumentFilterDto[], legend: languages.SemanticTokensLegend, eventHandle: number | undefined): void { @@ -593,9 +602,9 @@ export class MainThreadLanguageFeatures extends Disposable implements MainThread await this._proxy.$handleInlineCompletionDidShow(handle, completions.pid, item.idx, updatedInsertText); } }, - handlePartialAccept: async (completions, item, acceptedCharacters): Promise => { + handlePartialAccept: async (completions, item, acceptedCharacters, info: languages.PartialAcceptInfo): Promise => { if (supportsHandleEvents) { - await this._proxy.$handleInlineCompletionPartialAccept(handle, completions.pid, item.idx, acceptedCharacters); + await this._proxy.$handleInlineCompletionPartialAccept(handle, completions.pid, item.idx, acceptedCharacters, info); } }, freeInlineCompletions: (completions: IdentifiableInlineCompletions): void => { @@ -610,6 +619,19 @@ export class MainThreadLanguageFeatures extends Disposable implements MainThread this._registrations.set(handle, this._languageFeaturesService.inlineCompletionsProvider.register(selector, provider)); } + $registerInlineEditProvider(handle: number, selector: IDocumentFilterDto[], extensionId: ExtensionIdentifier): void { + const provider: languages.InlineEditProvider = { + provideInlineEdit: async (model: ITextModel, context: languages.IInlineEditContext, token: CancellationToken): Promise => { + return this._proxy.$provideInlineEdit(handle, model.uri, context, token); + }, + freeInlineEdit: (edit: IdentifiableInlineEdit): void => { + this._proxy.$freeInlineEdit(handle, edit.pid); + } + + }; + this._registrations.set(handle, this._languageFeaturesService.inlineEditProvider.register(selector, provider)); + } + // --- parameter hints $registerSignatureHelpProvider(handle: number, selector: IDocumentFilterDto[], metadata: ISignatureHelpProviderMetadataDto): void { @@ -942,8 +964,8 @@ export class MainThreadLanguageFeatures extends Disposable implements MainThread private readonly _documentOnDropEditProviders = new Map(); - $registerDocumentOnDropEditProvider(handle: number, selector: IDocumentFilterDto[], id: string | undefined, metadata: IDocumentDropEditProviderMetadata): void { - const provider = new MainThreadDocumentOnDropEditProvider(handle, this._proxy, id, metadata, this._uriIdentService); + $registerDocumentOnDropEditProvider(handle: number, selector: IDocumentFilterDto[], metadata: IDocumentDropEditProviderMetadata): void { + const provider = new MainThreadDocumentOnDropEditProvider(handle, this._proxy, metadata, this._uriIdentService); this._documentOnDropEditProviders.set(handle, provider); this._registrations.set(handle, combinedDisposable( this._languageFeaturesService.documentOnDropEditProvider.register(selector, provider), @@ -971,23 +993,23 @@ class MainThreadPasteEditProvider implements languages.DocumentPasteEditProvider private readonly dataTransfers = new DataTransferFileCache(); - public readonly id: string; public readonly copyMimeTypes?: readonly string[]; public readonly pasteMimeTypes?: readonly string[]; + public readonly providedPasteEditKinds?: readonly HierarchicalKind[]; readonly prepareDocumentPaste?: languages.DocumentPasteEditProvider['prepareDocumentPaste']; readonly provideDocumentPasteEdits?: languages.DocumentPasteEditProvider['provideDocumentPasteEdits']; + readonly resolveDocumentPasteEdit?: languages.DocumentPasteEditProvider['resolveDocumentPasteEdit']; constructor( private readonly _handle: number, private readonly _proxy: ExtHostLanguageFeaturesShape, - id: string, metadata: IPasteEditProviderMetadataDto, @IUriIdentityService private readonly _uriIdentService: IUriIdentityService ) { - this.id = id; this.copyMimeTypes = metadata.copyMimeTypes; this.pasteMimeTypes = metadata.pasteMimeTypes; + this.providedPasteEditKinds = metadata.providedPasteEditKinds?.map(kind => new HierarchicalKind(kind)); if (metadata.supportsCopy) { this.prepareDocumentPaste = async (model: ITextModel, selections: readonly IRange[], dataTransfer: IReadonlyVSDataTransfer, token: CancellationToken): Promise => { @@ -1018,20 +1040,41 @@ class MainThreadPasteEditProvider implements languages.DocumentPasteEditProvider return; } - const result = await this._proxy.$providePasteEdits(this._handle, request.id, model.uri, selections, dataTransferDto, token); - if (!result) { + const edits = await this._proxy.$providePasteEdits(this._handle, request.id, model.uri, selections, dataTransferDto, { + only: context.only?.value, + triggerKind: context.triggerKind, + }, token); + if (!edits) { return; } return { - ...result, - additionalEdit: result.additionalEdit ? reviveWorkspaceEditDto(result.additionalEdit, this._uriIdentService, dataId => this.resolveFileData(request.id, dataId)) : undefined, + edits: edits.map((edit): languages.DocumentPasteEdit => { + return { + ...edit, + kind: edit.kind ? new HierarchicalKind(edit.kind.value) : new HierarchicalKind(''), + yieldTo: edit.yieldTo?.map(x => ({ kind: new HierarchicalKind(x) })), + additionalEdit: edit.additionalEdit ? reviveWorkspaceEditDto(edit.additionalEdit, this._uriIdentService, dataId => this.resolveFileData(request.id, dataId)) : undefined, + }; + }), + dispose: () => { + this._proxy.$releasePasteEdits(this._handle, request.id); + }, }; } finally { request.dispose(); } }; } + if (metadata.supportsResolve) { + this.resolveDocumentPasteEdit = async (edit: languages.DocumentPasteEdit, token: CancellationToken) => { + const resolved = await this._proxy.$resolvePasteEdit(this._handle, (edit)._cacheId!, token); + if (resolved.additionalEdit) { + edit.additionalEdit = reviveWorkspaceEditDto(resolved.additionalEdit, this._uriIdentService); + } + return edit; + }; + } } resolveFileData(requestId: number, dataId: string): Promise { @@ -1043,21 +1086,18 @@ class MainThreadDocumentOnDropEditProvider implements languages.DocumentOnDropEd private readonly dataTransfers = new DataTransferFileCache(); - readonly id: string | undefined; readonly dropMimeTypes?: readonly string[]; constructor( private readonly _handle: number, private readonly _proxy: ExtHostLanguageFeaturesShape, - id: string | undefined, metadata: IDocumentDropEditProviderMetadata | undefined, @IUriIdentityService private readonly _uriIdentService: IUriIdentityService ) { - this.id = id; this.dropMimeTypes = metadata?.dropMimeTypes ?? ['*/*']; } - async provideDocumentOnDropEdits(model: ITextModel, position: IPosition, dataTransfer: IReadonlyVSDataTransfer, token: CancellationToken): Promise { + async provideDocumentOnDropEdits(model: ITextModel, position: IPosition, dataTransfer: IReadonlyVSDataTransfer, token: CancellationToken): Promise { const request = this.dataTransfers.add(dataTransfer); try { const dataTransferDto = await typeConvert.DataTransfer.from(dataTransfer); @@ -1065,15 +1105,19 @@ class MainThreadDocumentOnDropEditProvider implements languages.DocumentOnDropEd return; } - const edit = await this._proxy.$provideDocumentOnDropEdits(this._handle, request.id, model.uri, position, dataTransferDto, token); - if (!edit) { + const edits = await this._proxy.$provideDocumentOnDropEdits(this._handle, request.id, model.uri, position, dataTransferDto, token); + if (!edits) { return; } - return { - ...edit, - additionalEdit: reviveWorkspaceEditDto(edit.additionalEdit, this._uriIdentService, dataId => this.resolveDocumentOnDropFileData(request.id, dataId)), - }; + return edits.map(edit => { + return { + ...edit, + yieldTo: edit.yieldTo?.map(x => ({ kind: new HierarchicalKind(x) })), + kind: edit.kind ? new HierarchicalKind(edit.kind) : undefined, + additionalEdit: reviveWorkspaceEditDto(edit.additionalEdit, this._uriIdentService, dataId => this.resolveDocumentOnDropFileData(request.id, dataId)), + }; + }); } finally { request.dispose(); } diff --git a/src/vs/workbench/api/browser/mainThreadLanguageModels.ts b/src/vs/workbench/api/browser/mainThreadLanguageModels.ts new file mode 100644 index 0000000000000..9a886a6713af2 --- /dev/null +++ b/src/vs/workbench/api/browser/mainThreadLanguageModels.ts @@ -0,0 +1,203 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { coalesce } from 'vs/base/common/arrays'; +import { CancellationToken } from 'vs/base/common/cancellation'; +import { Emitter, Event } from 'vs/base/common/event'; +import { Disposable, DisposableMap, DisposableStore, IDisposable, toDisposable } from 'vs/base/common/lifecycle'; +import { localize } from 'vs/nls'; +import { ExtensionIdentifier } from 'vs/platform/extensions/common/extensions'; +import { ILogService } from 'vs/platform/log/common/log'; +import { IProgress, Progress } from 'vs/platform/progress/common/progress'; +import { Registry } from 'vs/platform/registry/common/platform'; +import { ExtHostLanguageModelsShape, ExtHostContext, MainContext, MainThreadLanguageModelsShape } from 'vs/workbench/api/common/extHost.protocol'; +import { ILanguageModelChatMetadata, IChatResponseFragment, ILanguageModelsService, IChatMessage } from 'vs/workbench/contrib/chat/common/languageModels'; +import { IAuthenticationAccessService } from 'vs/workbench/services/authentication/browser/authenticationAccessService'; +import { AuthenticationSession, AuthenticationSessionsChangeEvent, IAuthenticationProvider, IAuthenticationProviderCreateSessionOptions, IAuthenticationService, INTERNAL_AUTH_PROVIDER_PREFIX } from 'vs/workbench/services/authentication/common/authentication'; +import { Extensions, IExtensionFeaturesManagementService, IExtensionFeaturesRegistry } from 'vs/workbench/services/extensionManagement/common/extensionFeatures'; +import { IExtHostContext, extHostNamedCustomer } from 'vs/workbench/services/extensions/common/extHostCustomers'; +import { IExtensionService } from 'vs/workbench/services/extensions/common/extensions'; + +@extHostNamedCustomer(MainContext.MainThreadLanguageModels) +export class MainThreadLanguageModels implements MainThreadLanguageModelsShape { + + private readonly _proxy: ExtHostLanguageModelsShape; + private readonly _store = new DisposableStore(); + private readonly _providerRegistrations = new DisposableMap(); + private readonly _pendingProgress = new Map>(); + + constructor( + extHostContext: IExtHostContext, + @ILanguageModelsService private readonly _chatProviderService: ILanguageModelsService, + @IExtensionFeaturesManagementService private readonly _extensionFeaturesManagementService: IExtensionFeaturesManagementService, + @ILogService private readonly _logService: ILogService, + @IAuthenticationService private readonly _authenticationService: IAuthenticationService, + @IAuthenticationAccessService private readonly _authenticationAccessService: IAuthenticationAccessService, + @IExtensionService private readonly _extensionService: IExtensionService + ) { + this._proxy = extHostContext.getProxy(ExtHostContext.ExtHostChatProvider); + + this._proxy.$updateLanguageModels({ added: coalesce(_chatProviderService.getLanguageModelIds().map(id => _chatProviderService.lookupLanguageModel(id))) }); + this._store.add(_chatProviderService.onDidChangeLanguageModels(this._proxy.$updateLanguageModels, this._proxy)); + } + + dispose(): void { + this._providerRegistrations.dispose(); + this._store.dispose(); + } + + $registerLanguageModelProvider(handle: number, identifier: string, metadata: ILanguageModelChatMetadata): void { + const dipsosables = new DisposableStore(); + dipsosables.add(this._chatProviderService.registerLanguageModelChat(identifier, { + metadata, + provideChatResponse: async (messages, from, options, progress, token) => { + const requestId = (Math.random() * 1e6) | 0; + this._pendingProgress.set(requestId, progress); + try { + await this._proxy.$provideLanguageModelResponse(handle, requestId, from, messages, options, token); + } finally { + this._pendingProgress.delete(requestId); + } + } + })); + if (metadata.auth) { + dipsosables.add(this._registerAuthenticationProvider(metadata.extension, metadata.auth)); + } + dipsosables.add(Registry.as(Extensions.ExtensionFeaturesRegistry).registerExtensionFeature({ + id: `lm-${identifier}`, + label: localize('languageModels', "Language Model ({0})", `${identifier}`), + access: { + canToggle: false, + }, + })); + this._providerRegistrations.set(handle, dipsosables); + } + + async $handleProgressChunk(requestId: number, chunk: IChatResponseFragment): Promise { + this._pendingProgress.get(requestId)?.report(chunk); + } + + $unregisterProvider(handle: number): void { + this._providerRegistrations.deleteAndDispose(handle); + } + + async $prepareChatAccess(extension: ExtensionIdentifier, providerId: string, justification?: string): Promise { + + const activate = this._extensionService.activateByEvent(`onLanguageModelAccess:${providerId}`); + const metadata = this._chatProviderService.lookupLanguageModel(providerId); + + if (metadata) { + return metadata; + } + + await Promise.race([ + activate, + Event.toPromise(Event.filter(this._chatProviderService.onDidChangeLanguageModels, e => Boolean(e.added?.some(value => value.identifier === providerId)))) + ]); + + return this._chatProviderService.lookupLanguageModel(providerId); + } + + async $fetchResponse(extension: ExtensionIdentifier, providerId: string, requestId: number, messages: IChatMessage[], options: {}, token: CancellationToken): Promise { + await this._extensionFeaturesManagementService.getAccess(extension, `lm-${providerId}`); + + this._logService.debug('[CHAT] extension request STARTED', extension.value, requestId); + + const task = this._chatProviderService.makeLanguageModelChatRequest(providerId, extension, messages, options, new Progress(value => { + this._proxy.$handleResponseFragment(requestId, value); + }), token); + + task.catch(err => { + this._logService.error('[CHAT] extension request ERRORED', err, extension.value, requestId); + throw err; + }).finally(() => { + this._logService.debug('[CHAT] extension request DONE', extension.value, requestId); + }); + + return task; + } + + private _registerAuthenticationProvider(extension: ExtensionIdentifier, auth: { providerLabel: string; accountLabel?: string | undefined }): IDisposable { + // This needs to be done in both MainThread & ExtHost ChatProvider + const authProviderId = INTERNAL_AUTH_PROVIDER_PREFIX + extension.value; + + // Only register one auth provider per extension + if (this._authenticationService.getProviderIds().includes(authProviderId)) { + return Disposable.None; + } + + const accountLabel = auth.accountLabel ?? localize('languageModelsAccountId', 'Language Models'); + const disposables = new DisposableStore(); + this._authenticationService.registerAuthenticationProvider(authProviderId, new LanguageModelAccessAuthProvider(authProviderId, auth.providerLabel, accountLabel)); + disposables.add(toDisposable(() => { + this._authenticationService.unregisterAuthenticationProvider(authProviderId); + })); + disposables.add(this._authenticationAccessService.onDidChangeExtensionSessionAccess(async (e) => { + const allowedExtensions = this._authenticationAccessService.readAllowedExtensions(authProviderId, accountLabel); + const accessList = []; + for (const allowedExtension of allowedExtensions) { + const from = await this._extensionService.getExtension(allowedExtension.id); + if (from) { + accessList.push({ + from: from.identifier, + to: extension, + enabled: allowedExtension.allowed ?? true + }); + } + } + this._proxy.$updateModelAccesslist(accessList); + })); + return disposables; + } +} + +// The fake AuthenticationProvider that will be used to gate access to the Language Model. There will be one per provider. +class LanguageModelAccessAuthProvider implements IAuthenticationProvider { + supportsMultipleAccounts = false; + + // Important for updating the UI + private _onDidChangeSessions: Emitter = new Emitter(); + onDidChangeSessions: Event = this._onDidChangeSessions.event; + + private _session: AuthenticationSession | undefined; + + constructor(readonly id: string, readonly label: string, private readonly _accountLabel: string) { } + + async getSessions(scopes?: string[] | undefined): Promise { + // If there are no scopes and no session that means no extension has requested a session yet + // and the user is simply opening the Account menu. In that case, we should not return any "sessions". + if (scopes === undefined && !this._session) { + return []; + } + if (this._session) { + return [this._session]; + } + return [await this.createSession(scopes || [], {})]; + } + async createSession(scopes: string[], options: IAuthenticationProviderCreateSessionOptions): Promise { + this._session = this._createFakeSession(scopes); + this._onDidChangeSessions.fire({ added: [this._session], changed: [], removed: [] }); + return this._session; + } + removeSession(sessionId: string): Promise { + if (this._session) { + this._onDidChangeSessions.fire({ added: [], changed: [], removed: [this._session!] }); + this._session = undefined; + } + return Promise.resolve(); + } + + private _createFakeSession(scopes: string[]): AuthenticationSession { + return { + id: 'fake-session', + account: { + id: this.id, + label: this._accountLabel, + }, + accessToken: 'fake-access-token', + scopes, + }; + } +} diff --git a/src/vs/workbench/api/browser/mainThreadProfilContentHandlers.ts b/src/vs/workbench/api/browser/mainThreadProfileContentHandlers.ts similarity index 100% rename from src/vs/workbench/api/browser/mainThreadProfilContentHandlers.ts rename to src/vs/workbench/api/browser/mainThreadProfileContentHandlers.ts diff --git a/src/vs/workbench/api/browser/mainThreadQuickDiff.ts b/src/vs/workbench/api/browser/mainThreadQuickDiff.ts index 48f53faea39b3..d5312097ee9ab 100644 --- a/src/vs/workbench/api/browser/mainThreadQuickDiff.ts +++ b/src/vs/workbench/api/browser/mainThreadQuickDiff.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { CancellationTokenSource } from 'vs/base/common/cancellation'; +import { CancellationToken } from 'vs/base/common/cancellation'; import { DisposableMap, IDisposable } from 'vs/base/common/lifecycle'; import { URI, UriComponents } from 'vs/base/common/uri'; import { ExtHostContext, ExtHostQuickDiffShape, IDocumentFilterDto, MainContext, MainThreadQuickDiffShape } from 'vs/workbench/api/common/extHost.protocol'; @@ -30,7 +30,7 @@ export class MainThreadQuickDiff implements MainThreadQuickDiffShape { selector, isSCM: false, getOriginalResource: async (uri: URI) => { - return URI.revive(await this.proxy.$provideOriginalResource(handle, uri, new CancellationTokenSource().token)); + return URI.revive(await this.proxy.$provideOriginalResource(handle, uri, CancellationToken.None)); } }; const disposable = this.quickDiffService.addQuickDiffProvider(provider); diff --git a/src/vs/workbench/api/browser/mainThreadSearch.ts b/src/vs/workbench/api/browser/mainThreadSearch.ts index 797a4ee92a4c8..9b1af87b574e4 100644 --- a/src/vs/workbench/api/browser/mainThreadSearch.ts +++ b/src/vs/workbench/api/browser/mainThreadSearch.ts @@ -9,9 +9,11 @@ import { URI, UriComponents } from 'vs/base/common/uri'; import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; import { ITelemetryService } from 'vs/platform/telemetry/common/telemetry'; import { extHostNamedCustomer, IExtHostContext } from 'vs/workbench/services/extensions/common/extHostCustomers'; -import { IFileMatch, IFileQuery, IRawFileMatch2, ISearchComplete, ISearchCompleteStats, ISearchProgressItem, ISearchResultProvider, ISearchService, ITextQuery, QueryType, SearchProviderType } from 'vs/workbench/services/search/common/search'; +import { IFileMatch, IFileQuery, IRawFileMatch2, ISearchComplete, ISearchCompleteStats, ISearchProgressItem, ISearchQuery, ISearchResultProvider, ISearchService, ITextQuery, QueryType, SearchProviderType } from 'vs/workbench/services/search/common/search'; import { ExtHostContext, ExtHostSearchShape, MainContext, MainThreadSearchShape } from '../common/extHost.protocol'; import { revive } from 'vs/base/common/marshalling'; +import * as Constants from 'vs/workbench/contrib/search/common/constants'; +import { IContextKeyService } from 'vs/platform/contextkey/common/contextkey'; @extHostNamedCustomer(MainContext.MainThreadSearch) export class MainThreadSearch implements MainThreadSearchShape { @@ -24,6 +26,7 @@ export class MainThreadSearch implements MainThreadSearchShape { @ISearchService private readonly _searchService: ISearchService, @ITelemetryService private readonly _telemetryService: ITelemetryService, @IConfigurationService _configurationService: IConfigurationService, + @IContextKeyService protected contextKeyService: IContextKeyService, ) { this._proxy = extHostContext.getProxy(ExtHostContext.ExtHostSearch); this._proxy.$enableExtensionHostSearch(); @@ -38,6 +41,11 @@ export class MainThreadSearch implements MainThreadSearchShape { this._searchProvider.set(handle, new RemoteSearchProvider(this._searchService, SearchProviderType.text, scheme, handle, this._proxy)); } + $registerAITextSearchProvider(handle: number, scheme: string): void { + Constants.SearchContext.hasAIResultProvider.bindTo(this.contextKeyService).set(true); + this._searchProvider.set(handle, new RemoteSearchProvider(this._searchService, SearchProviderType.aiText, scheme, handle, this._proxy)); + } + $registerFileSearchProvider(handle: number, scheme: string): void { this._searchProvider.set(handle, new RemoteSearchProvider(this._searchService, SearchProviderType.file, scheme, handle, this._proxy)); } @@ -64,7 +72,6 @@ export class MainThreadSearch implements MainThreadSearchShape { provider.handleFindMatch(session, data); } - $handleTelemetry(eventName: string, data: any): void { this._telemetryService.publicLog(eventName, data); } @@ -126,7 +133,7 @@ class RemoteSearchProvider implements ISearchResultProvider, IDisposable { return this.doSearch(query, onProgress, token); } - doSearch(query: ITextQuery | IFileQuery, onProgress?: (p: ISearchProgressItem) => void, token: CancellationToken = CancellationToken.None): Promise { + doSearch(query: ISearchQuery, onProgress?: (p: ISearchProgressItem) => void, token: CancellationToken = CancellationToken.None): Promise { if (!query.folderQueries.length) { throw new Error('Empty folderQueries'); } @@ -134,9 +141,7 @@ class RemoteSearchProvider implements ISearchResultProvider, IDisposable { const search = new SearchOperation(onProgress); this._searches.set(search.id, search); - const searchP = query.type === QueryType.File - ? this._proxy.$provideFileSearchResults(this._handle, search.id, query, token) - : this._proxy.$provideTextSearchResults(this._handle, search.id, query, token); + const searchP = this._provideSearchResults(query, search.id, token); return Promise.resolve(searchP).then((result: ISearchCompleteStats) => { this._searches.delete(search.id); @@ -169,4 +174,15 @@ class RemoteSearchProvider implements ISearchResultProvider, IDisposable { } }); } + + private _provideSearchResults(query: ISearchQuery, session: number, token: CancellationToken): Promise { + switch (query.type) { + case QueryType.File: + return this._proxy.$provideFileSearchResults(this._handle, session, query, token); + case QueryType.Text: + return this._proxy.$provideTextSearchResults(this._handle, session, query, token); + default: + return this._proxy.$provideAITextSearchResults(this._handle, session, query, token); + } + } } diff --git a/src/vs/workbench/api/browser/mainThreadShare.ts b/src/vs/workbench/api/browser/mainThreadShare.ts index 1974180b331d9..d517c23c906bb 100644 --- a/src/vs/workbench/api/browser/mainThreadShare.ts +++ b/src/vs/workbench/api/browser/mainThreadShare.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { CancellationTokenSource } from 'vs/base/common/cancellation'; +import { CancellationToken } from 'vs/base/common/cancellation'; import { IDisposable, dispose } from 'vs/base/common/lifecycle'; import { URI } from 'vs/base/common/uri'; import { ExtHostContext, ExtHostShareShape, IDocumentFilterDto, MainContext, MainThreadShareShape } from 'vs/workbench/api/common/extHost.protocol'; @@ -31,7 +31,7 @@ export class MainThreadShare implements MainThreadShareShape { selector, priority, provideShare: async (item: IShareableItem) => { - const result = await this.proxy.$provideShare(handle, item, new CancellationTokenSource().token); + const result = await this.proxy.$provideShare(handle, item, CancellationToken.None); return typeof result === 'string' ? result : URI.revive(result); } }; diff --git a/src/vs/workbench/api/browser/mainThreadSpeech.ts b/src/vs/workbench/api/browser/mainThreadSpeech.ts index d670ffd66a1f6..56ce1bca62330 100644 --- a/src/vs/workbench/api/browser/mainThreadSpeech.ts +++ b/src/vs/workbench/api/browser/mainThreadSpeech.ts @@ -3,8 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { CancellationTokenSource } from 'vs/base/common/cancellation'; -import { Emitter } from 'vs/base/common/event'; +import { Emitter, Event } from 'vs/base/common/event'; import { DisposableStore, IDisposable } from 'vs/base/common/lifecycle'; import { ILogService } from 'vs/platform/log/common/log'; import { ExtHostContext, ExtHostSpeechShape, MainContext, MainThreadSpeechShape } from 'vs/workbench/api/common/extHost.protocol'; @@ -42,44 +41,54 @@ export class MainThreadSpeech implements MainThreadSpeechShape { const registration = this.speechService.registerSpeechProvider(identifier, { metadata, - createSpeechToTextSession: token => { + createSpeechToTextSession: (token, options) => { + if (token.isCancellationRequested) { + return { + onDidChange: Event.None + }; + } + const disposables = new DisposableStore(); - const cts = new CancellationTokenSource(token); const session = Math.random(); - this.proxy.$createSpeechToTextSession(handle, session); - disposables.add(token.onCancellationRequested(() => this.proxy.$cancelSpeechToTextSession(session))); + this.proxy.$createSpeechToTextSession(handle, session, options?.language); const onDidChange = disposables.add(new Emitter()); this.speechToTextSessions.set(session, { onDidChange }); + disposables.add(token.onCancellationRequested(() => { + this.proxy.$cancelSpeechToTextSession(session); + this.speechToTextSessions.delete(session); + disposables.dispose(); + })); + return { - onDidChange: onDidChange.event, - dispose: () => { - cts.dispose(true); - this.speechToTextSessions.delete(session); - disposables.dispose(); - } + onDidChange: onDidChange.event }; }, createKeywordRecognitionSession: token => { + if (token.isCancellationRequested) { + return { + onDidChange: Event.None + }; + } + const disposables = new DisposableStore(); - const cts = new CancellationTokenSource(token); const session = Math.random(); this.proxy.$createKeywordRecognitionSession(handle, session); - disposables.add(token.onCancellationRequested(() => this.proxy.$cancelKeywordRecognitionSession(session))); const onDidChange = disposables.add(new Emitter()); this.keywordRecognitionSessions.set(session, { onDidChange }); + disposables.add(token.onCancellationRequested(() => { + this.proxy.$cancelKeywordRecognitionSession(session); + this.keywordRecognitionSessions.delete(session); + disposables.dispose(); + })); + return { - onDidChange: onDidChange.event, - dispose: () => { - cts.dispose(true); - this.keywordRecognitionSessions.delete(session); - disposables.dispose(); - } + onDidChange: onDidChange.event }; } }); diff --git a/src/vs/workbench/api/browser/mainThreadTask.ts b/src/vs/workbench/api/browser/mainThreadTask.ts index 8fe9a431321b7..9c37c5979783d 100644 --- a/src/vs/workbench/api/browser/mainThreadTask.ts +++ b/src/vs/workbench/api/browser/mainThreadTask.ts @@ -10,7 +10,7 @@ import { generateUuid } from 'vs/base/common/uuid'; import * as Types from 'vs/base/common/types'; import * as Platform from 'vs/base/common/platform'; import { IStringDictionary } from 'vs/base/common/collections'; -import { IDisposable } from 'vs/base/common/lifecycle'; +import { Disposable, IDisposable } from 'vs/base/common/lifecycle'; import { IWorkspace, IWorkspaceContextService, IWorkspaceFolder } from 'vs/platform/workspace/common/workspace'; @@ -414,7 +414,7 @@ namespace TaskFilterDTO { } @extHostNamedCustomer(MainContext.MainThreadTask) -export class MainThreadTask implements MainThreadTaskShape { +export class MainThreadTask extends Disposable implements MainThreadTaskShape { private readonly _extHostContext: IExtHostContext | undefined; private readonly _proxy: ExtHostTaskShape; @@ -426,9 +426,10 @@ export class MainThreadTask implements MainThreadTaskShape { @IWorkspaceContextService private readonly _workspaceContextServer: IWorkspaceContextService, @IConfigurationResolverService private readonly _configurationResolverService: IConfigurationResolverService ) { + super(); this._proxy = extHostContext.getProxy(ExtHostContext.ExtHostTask); this._providers = new Map(); - this._taskService.onDidStateChange(async (event: ITaskEvent) => { + this._register(this._taskService.onDidStateChange(async (event: ITaskEvent) => { if (event.kind === TaskEventKind.Changed) { return; } @@ -453,14 +454,15 @@ export class MainThreadTask implements MainThreadTaskShape { } else if (event.kind === TaskEventKind.End) { this._proxy.$OnDidEndTask(TaskExecutionDTO.from(task.getTaskExecution())); } - }); + })); } - public dispose(): void { + public override dispose(): void { for (const value of this._providers.values()) { value.disposable.dispose(); } this._providers.clear(); + super.dispose(); } $createTaskId(taskDTO: ITaskDTO): Promise { diff --git a/src/vs/workbench/api/browser/mainThreadTerminalService.ts b/src/vs/workbench/api/browser/mainThreadTerminalService.ts index 2293708c63b83..663c3fa572894 100644 --- a/src/vs/workbench/api/browser/mainThreadTerminalService.ts +++ b/src/vs/workbench/api/browser/mainThreadTerminalService.ts @@ -25,7 +25,6 @@ import { ITerminalLinkProviderService } from 'vs/workbench/contrib/terminalContr import { ITerminalQuickFixService, ITerminalQuickFix, TerminalQuickFixType } from 'vs/workbench/contrib/terminalContrib/quickFix/browser/quickFix'; import { TerminalCapability } from 'vs/platform/terminal/common/capabilities/capabilities'; - @extHostNamedCustomer(MainContext.MainThreadTerminalService) export class MainThreadTerminalService implements MainThreadTerminalServiceShape { @@ -50,7 +49,7 @@ export class MainThreadTerminalService implements MainThreadTerminalServiceShape * provided through this, even from multiple ext link providers. Xterm should remove lower * priority intersecting links itself. */ - private _linkProvider = this._store.add(new MutableDisposable()); + private readonly _linkProvider = this._store.add(new MutableDisposable()); private _os: OperatingSystem = OS; @@ -152,6 +151,7 @@ export class MainThreadTerminalService implements MainThreadTerminalServiceShape ? (id, cols, rows) => new TerminalProcessExtHostProxy(id, cols, rows, this._terminalService) : undefined, extHostTerminalId, + forceShellIntegration: launchConfig.forceShellIntegration, isFeatureTerminal: launchConfig.isFeatureTerminal, isExtensionOwnedTerminal: launchConfig.isExtensionOwnedTerminal, useShellEnvironment: launchConfig.useShellEnvironment, diff --git a/src/vs/workbench/api/browser/mainThreadTerminalShellIntegration.ts b/src/vs/workbench/api/browser/mainThreadTerminalShellIntegration.ts new file mode 100644 index 0000000000000..97057f0628b8f --- /dev/null +++ b/src/vs/workbench/api/browser/mainThreadTerminalShellIntegration.ts @@ -0,0 +1,82 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Event } from 'vs/base/common/event'; +import { Disposable } from 'vs/base/common/lifecycle'; +import { URI } from 'vs/base/common/uri'; +import { TerminalCapability, type ITerminalCommand } from 'vs/platform/terminal/common/capabilities/capabilities'; +import { ExtHostContext, MainContext, type ExtHostTerminalShellIntegrationShape, type MainThreadTerminalShellIntegrationShape } from 'vs/workbench/api/common/extHost.protocol'; +import { ITerminalService } from 'vs/workbench/contrib/terminal/browser/terminal'; +import { IWorkbenchEnvironmentService } from 'vs/workbench/services/environment/common/environmentService'; +import { extHostNamedCustomer, type IExtHostContext } from 'vs/workbench/services/extensions/common/extHostCustomers'; + +@extHostNamedCustomer(MainContext.MainThreadTerminalShellIntegration) +export class MainThreadTerminalShellIntegration extends Disposable implements MainThreadTerminalShellIntegrationShape { + private readonly _proxy: ExtHostTerminalShellIntegrationShape; + + constructor( + extHostContext: IExtHostContext, + @ITerminalService private readonly _terminalService: ITerminalService, + @IWorkbenchEnvironmentService workbenchEnvironmentService: IWorkbenchEnvironmentService + ) { + super(); + + this._proxy = extHostContext.getProxy(ExtHostContext.ExtHostTerminalShellIntegration); + + // onDidChangeTerminalShellIntegration + const onDidAddCommandDetection = this._store.add(this._terminalService.createOnInstanceEvent(instance => { + return Event.map( + Event.filter(instance.capabilities.onDidAddCapabilityType, e => { + return e === TerminalCapability.CommandDetection; + }), () => instance + ); + })).event; + this._store.add(onDidAddCommandDetection(e => this._proxy.$shellIntegrationChange(e.instanceId))); + + // onDidStartTerminalShellExecution + const commandDetectionStartEvent = this._store.add(this._terminalService.createOnInstanceCapabilityEvent(TerminalCapability.CommandDetection, e => e.onCommandExecuted)); + let currentCommand: ITerminalCommand | undefined; + this._store.add(commandDetectionStartEvent.event(e => { + // Prevent duplicate events from being sent in case command detection double fires the + // event + if (e.data === currentCommand) { + return; + } + // String paths are not exposed in the extension API + currentCommand = e.data; + this._proxy.$shellExecutionStart(e.instance.instanceId, e.data.command, this._convertCwdToUri(e.data.cwd)); + })); + + // onDidEndTerminalShellExecution + const commandDetectionEndEvent = this._store.add(this._terminalService.createOnInstanceCapabilityEvent(TerminalCapability.CommandDetection, e => e.onCommandFinished)); + this._store.add(commandDetectionEndEvent.event(e => { + currentCommand = undefined; + this._proxy.$shellExecutionEnd(e.instance.instanceId, e.data.command, e.data.exitCode); + })); + + // onDidChangeTerminalShellIntegration via cwd + const cwdChangeEvent = this._store.add(this._terminalService.createOnInstanceCapabilityEvent(TerminalCapability.CwdDetection, e => e.onDidChangeCwd)); + this._store.add(cwdChangeEvent.event(e => { + this._proxy.$cwdChange(e.instance.instanceId, this._convertCwdToUri(e.data)); + })); + + // Clean up after dispose + this._store.add(this._terminalService.onDidDisposeInstance(e => this._proxy.$closeTerminal(e.instanceId))); + + // TerminalShellExecution.createDataStream + // TODO: Support this on remote; it should go via the server + if (!workbenchEnvironmentService.remoteAuthority) { + this._store.add(this._terminalService.onAnyInstanceData(e => this._proxy.$shellExecutionData(e.instance.instanceId, e.data))); + } + } + + $executeCommand(terminalId: number, commandLine: string): void { + this._terminalService.getInstanceFromId(terminalId)?.runCommand(commandLine, true); + } + + private _convertCwdToUri(cwd: string | undefined): URI | undefined { + return cwd ? URI.file(cwd) : undefined; + } +} diff --git a/src/vs/workbench/api/browser/mainThreadTesting.ts b/src/vs/workbench/api/browser/mainThreadTesting.ts index fa5376ca4b1b4..a316bec0eb315 100644 --- a/src/vs/workbench/api/browser/mainThreadTesting.ts +++ b/src/vs/workbench/api/browser/mainThreadTesting.ts @@ -7,7 +7,7 @@ import { VSBuffer } from 'vs/base/common/buffer'; import { CancellationToken } from 'vs/base/common/cancellation'; import { Event } from 'vs/base/common/event'; import { Disposable, DisposableStore, IDisposable, MutableDisposable, toDisposable } from 'vs/base/common/lifecycle'; -import { ISettableObservable } from 'vs/base/common/observable'; +import { ISettableObservable, transaction } from 'vs/base/common/observable'; import { WellDefinedPrefixTree } from 'vs/base/common/prefixTree'; import { URI } from 'vs/base/common/uri'; import { Range } from 'vs/editor/common/core/range'; @@ -19,7 +19,7 @@ import { ITestProfileService } from 'vs/workbench/contrib/testing/common/testPro import { LiveTestResult } from 'vs/workbench/contrib/testing/common/testResult'; import { ITestResultService } from 'vs/workbench/contrib/testing/common/testResultService'; import { IMainThreadTestController, ITestRootProvider, ITestService } from 'vs/workbench/contrib/testing/common/testService'; -import { CoverageDetails, ExtensionRunTestsRequest, IFileCoverage, ITestItem, ITestMessage, ITestRunProfile, ITestRunTask, ResolvedTestRunRequest, TestResultState, TestsDiffOp } from 'vs/workbench/contrib/testing/common/testTypes'; +import { CoverageDetails, ExtensionRunTestsRequest, IFileCoverage, ITestItem, ITestMessage, ITestRunProfile, ITestRunTask, ResolvedTestRunRequest, TestResultState, TestRunProfileBitset, TestsDiffOp } from 'vs/workbench/contrib/testing/common/testTypes'; import { IExtHostContext, extHostNamedCustomer } from 'vs/workbench/services/extensions/common/extHostCustomers'; import { ExtHostContext, ExtHostTestingShape, ILocationDto, ITestControllerPatch, MainContext, MainThreadTestingShape } from '../common/extHost.protocol'; @@ -50,18 +50,28 @@ export class MainThreadTesting extends Disposable implements MainThreadTestingSh this._register(Event.debounce(testProfiles.onDidChange, (_last, e) => e)(() => { const obj: Record = {}; - for (const { controller, profiles } of this.testProfiles.all()) { - obj[controller.id] = profiles.filter(p => p.isDefault).map(p => p.profileId); + for (const group of [TestRunProfileBitset.Run, TestRunProfileBitset.Debug, TestRunProfileBitset.Coverage]) { + for (const profile of this.testProfiles.getGroupDefaultProfiles(group)) { + obj[profile.controllerId] ??= []; + obj[profile.controllerId].push(profile.profileId); + } } this.proxy.$setDefaultRunProfiles(obj); })); this._register(resultService.onResultsChanged(evt => { - const results = 'completed' in evt ? evt.completed : ('inserted' in evt ? evt.inserted : undefined); - const serialized = results?.toJSONWithMessages(); - if (serialized) { - this.proxy.$publishTestResults([serialized]); + if ('completed' in evt) { + const serialized = evt.completed.toJSONWithMessages(); + if (serialized) { + this.proxy.$publishTestResults([serialized]); + } + } else if ('removed' in evt) { + evt.removed.forEach(r => { + if (r instanceof LiveTestResult) { + this.proxy.$disposeRun(r.id); + } + }); } })); } @@ -121,21 +131,28 @@ export class MainThreadTesting extends Disposable implements MainThreadTestingSh /** * @inheritdoc */ - $signalCoverageAvailable(runId: string, taskId: string, available: boolean): void { + $appendCoverage(runId: string, taskId: string, coverage: IFileCoverage.Serialized): void { this.withLiveRun(runId, run => { const task = run.tasks.find(t => t.id === taskId); if (!task) { return; } - const fn = available ? ((token: CancellationToken) => TestCoverage.load(taskId, { - provideFileCoverage: async token => await this.proxy.$provideFileCoverage(runId, taskId, token) - .then(c => c.map(u => IFileCoverage.deserialize(this.uriIdentityService, u))), - resolveFileCoverage: (i, token) => this.proxy.$resolveFileCoverage(runId, taskId, i, token) - .then(d => d.map(CoverageDetails.deserialize)), - }, this.uriIdentityService, token)) : undefined; - - (task.coverage as ISettableObservable Promise)>).set(fn, undefined); + const deserialized = IFileCoverage.deserialize(this.uriIdentityService, coverage); + + transaction(tx => { + let value = task.coverage.read(undefined); + if (!value) { + value = new TestCoverage(taskId, this.uriIdentityService, { + getCoverageDetails: (id, token) => this.proxy.$getCoverageDetails(id, token) + .then(r => r.map(CoverageDetails.deserialize)), + }); + value.append(deserialized, tx); + (task.coverage as ISettableObservable).set(value, tx); + } else { + value.append(deserialized, tx); + } + }); }); } diff --git a/src/vs/workbench/api/browser/mainThreadWorkspace.ts b/src/vs/workbench/api/browser/mainThreadWorkspace.ts index e04ee2837d95d..180932b6d6d26 100644 --- a/src/vs/workbench/api/browser/mainThreadWorkspace.ts +++ b/src/vs/workbench/api/browser/mainThreadWorkspace.ts @@ -19,7 +19,7 @@ import { WorkspaceTrustRequestOptions, IWorkspaceTrustManagementService, IWorksp import { IWorkspace, IWorkspaceContextService, WorkbenchState, isUntitledWorkspace, WorkspaceFolder } from 'vs/platform/workspace/common/workspace'; import { extHostNamedCustomer, IExtHostContext } from 'vs/workbench/services/extensions/common/extHostCustomers'; import { checkGlobFileExists } from 'vs/workbench/services/extensions/common/workspaceContains'; -import { ITextQueryBuilderOptions, QueryBuilder } from 'vs/workbench/services/search/common/queryBuilder'; +import { IFileQueryBuilderOptions, ITextQueryBuilderOptions, QueryBuilder } from 'vs/workbench/services/search/common/queryBuilder'; import { IEditorService, ISaveEditorsResult } from 'vs/workbench/services/editor/common/editorService'; import { IFileMatch, IPatternInfo, ISearchProgressItem, ISearchService } from 'vs/workbench/services/search/common/search'; import { IWorkspaceEditingService } from 'vs/workbench/services/workspaces/common/workspaceEditing'; @@ -140,21 +140,14 @@ export class MainThreadWorkspace implements MainThreadWorkspaceShape { // --- search --- - $startFileSearch(includePattern: string | null, _includeFolder: UriComponents | null, excludePatternOrDisregardExcludes: string | false | null, maxResults: number | null, token: CancellationToken): Promise { + $startFileSearch(_includeFolder: UriComponents | null, options: IFileQueryBuilderOptions, token: CancellationToken): Promise { const includeFolder = URI.revive(_includeFolder); const workspace = this._contextService.getWorkspace(); const query = this._queryBuilder.file( includeFolder ? [includeFolder] : workspace.folders, - { - maxResults: maxResults ?? undefined, - disregardExcludeSettings: (excludePatternOrDisregardExcludes === false) || undefined, - disregardSearchExcludeSettings: true, - disregardIgnoreFiles: true, - includePattern: includePattern ?? undefined, - excludePattern: typeof excludePatternOrDisregardExcludes === 'string' ? excludePatternOrDisregardExcludes : undefined, - _reason: 'startFileSearch' - }); + options + ); return this._searchService.fileSearch(query, token).then(result => { return result.results.map(m => m.resource); diff --git a/src/vs/workbench/api/common/configurationExtensionPoint.ts b/src/vs/workbench/api/common/configurationExtensionPoint.ts index 57fa4cef7355d..496651fe769c7 100644 --- a/src/vs/workbench/api/common/configurationExtensionPoint.ts +++ b/src/vs/workbench/api/common/configurationExtensionPoint.ts @@ -415,9 +415,9 @@ class SettingsTableRenderer extends Disposable implements IExtensionFeatureTable const rows: IRowData[][] = contrib.sort((a, b) => a.localeCompare(b)) .map(key => { return [ - { data: key, type: 'code' }, + new MarkdownString().appendMarkdown(`\`${key}\``), properties[key].markdownDescription ? new MarkdownString(properties[key].markdownDescription, false) : properties[key].description ?? '', - { data: `${isUndefined(properties[key].default) ? getDefaultValue(properties[key].type) : JSON.stringify(properties[key].default)}`, type: 'code' } + new MarkdownString().appendCodeblock('json', JSON.stringify(isUndefined(properties[key].default) ? getDefaultValue(properties[key].type) : properties[key].default, null, 2)), ]; }); diff --git a/src/vs/workbench/api/common/extHost.api.impl.ts b/src/vs/workbench/api/common/extHost.api.impl.ts index dd4a277260676..86fbf665c2d27 100644 --- a/src/vs/workbench/api/common/extHost.api.impl.ts +++ b/src/vs/workbench/api/common/extHost.api.impl.ts @@ -11,7 +11,7 @@ import { Schemas, matchesScheme } from 'vs/base/common/network'; import Severity from 'vs/base/common/severity'; import { URI } from 'vs/base/common/uri'; import { TextEditorCursorStyle } from 'vs/editor/common/config/editorOptions'; -import { score } from 'vs/editor/common/languageSelector'; +import { score, targetsNotebooks } from 'vs/editor/common/languageSelector'; import * as languageConfiguration from 'vs/editor/common/languages/languageConfiguration'; import { OverviewRulerLane } from 'vs/editor/common/model'; import { ExtensionIdentifierSet, IExtensionDescription } from 'vs/platform/extensions/common/extensions'; @@ -25,11 +25,11 @@ import { CandidatePortSource, ExtHostContext, ExtHostLogLevelServiceShape, MainC import { ExtHostRelatedInformation } from 'vs/workbench/api/common/extHostAiRelatedInformation'; import { ExtHostApiCommands } from 'vs/workbench/api/common/extHostApiCommands'; import { IExtHostApiDeprecationService } from 'vs/workbench/api/common/extHostApiDeprecationService'; -import { ExtHostAuthentication } from 'vs/workbench/api/common/extHostAuthentication'; +import { IExtHostAuthentication } from 'vs/workbench/api/common/extHostAuthentication'; import { ExtHostBulkEdits } from 'vs/workbench/api/common/extHostBulkEdits'; import { ExtHostChat } from 'vs/workbench/api/common/extHostChat'; import { ExtHostChatAgents2 } from 'vs/workbench/api/common/extHostChatAgents2'; -import { ExtHostChatProvider } from 'vs/workbench/api/common/extHostChatProvider'; +import { IExtHostLanguageModels } from 'vs/workbench/api/common/extHostLanguageModels'; import { ExtHostChatVariables } from 'vs/workbench/api/common/extHostChatVariables'; import { ExtHostClipboard } from 'vs/workbench/api/common/extHostClipboard'; import { ExtHostEditorInsets } from 'vs/workbench/api/common/extHostCodeInsets'; @@ -55,7 +55,6 @@ import { IExtHostFileSystemInfo } from 'vs/workbench/api/common/extHostFileSyste import { IExtHostInitDataService } from 'vs/workbench/api/common/extHostInitDataService'; import { ExtHostInteractiveEditor } from 'vs/workbench/api/common/extHostInlineChat'; import { ExtHostInteractive } from 'vs/workbench/api/common/extHostInteractive'; -import { ExtHostIssueReporter } from 'vs/workbench/api/common/extHostIssueReporter'; import { ExtHostLabelService } from 'vs/workbench/api/common/extHostLabelService'; import { ExtHostLanguageFeatures } from 'vs/workbench/api/common/extHostLanguageFeatures'; import { ExtHostLanguages } from 'vs/workbench/api/common/extHostLanguages'; @@ -108,6 +107,7 @@ import { checkProposedApiEnabled, isProposedApiEnabled } from 'vs/workbench/serv import { ProxyIdentifier } from 'vs/workbench/services/extensions/common/proxyIdentifier'; import { TextSearchCompleteMessageType } from 'vs/workbench/services/search/common/searchExtTypes'; import type * as vscode from 'vscode'; +import { IExtHostTerminalShellIntegration } from 'vs/workbench/api/common/extHostTerminalShellIntegration'; export interface IExtensionRegistries { mine: ExtensionDescriptionRegistry; @@ -143,6 +143,8 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I const extHostSecretState = accessor.get(IExtHostSecretState); const extHostEditorTabs = accessor.get(IExtHostEditorTabs); const extHostManagedSockets = accessor.get(IExtHostManagedSockets); + const extHostAuthentication = accessor.get(IExtHostAuthentication); + const extHostLanguageModels = accessor.get(IExtHostLanguageModels); // register addressable instances rpcProtocol.set(ExtHostContext.ExtHostFileSystemInfo, extHostFileSystemInfo); @@ -157,12 +159,15 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I rpcProtocol.set(ExtHostContext.ExtHostTelemetry, extHostTelemetry); rpcProtocol.set(ExtHostContext.ExtHostEditorTabs, extHostEditorTabs); rpcProtocol.set(ExtHostContext.ExtHostManagedSockets, extHostManagedSockets); + rpcProtocol.set(ExtHostContext.ExtHostAuthentication, extHostAuthentication); + rpcProtocol.set(ExtHostContext.ExtHostChatProvider, extHostLanguageModels); // automatically create and register addressable instances const extHostDecorations = rpcProtocol.set(ExtHostContext.ExtHostDecorations, accessor.get(IExtHostDecorations)); const extHostDocumentsAndEditors = rpcProtocol.set(ExtHostContext.ExtHostDocumentsAndEditors, accessor.get(IExtHostDocumentsAndEditors)); const extHostCommands = rpcProtocol.set(ExtHostContext.ExtHostCommands, accessor.get(IExtHostCommands)); const extHostTerminalService = rpcProtocol.set(ExtHostContext.ExtHostTerminalService, accessor.get(IExtHostTerminalService)); + const extHostTerminalShellIntegration = rpcProtocol.set(ExtHostContext.ExtHostTerminalShellIntegration, accessor.get(IExtHostTerminalShellIntegration)); const extHostDebugService = rpcProtocol.set(ExtHostContext.ExtHostDebugService, accessor.get(IExtHostDebugService)); const extHostSearch = rpcProtocol.set(ExtHostContext.ExtHostSearch, accessor.get(IExtHostSearch)); const extHostTask = rpcProtocol.set(ExtHostContext.ExtHostTask, accessor.get(IExtHostTask)); @@ -196,7 +201,6 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I const extHostProgress = rpcProtocol.set(ExtHostContext.ExtHostProgress, new ExtHostProgress(rpcProtocol.getProxy(MainContext.MainThreadProgress))); const extHostLabelService = rpcProtocol.set(ExtHostContext.ExtHostLabelService, new ExtHostLabelService(rpcProtocol)); const extHostTheming = rpcProtocol.set(ExtHostContext.ExtHostTheming, new ExtHostTheming(rpcProtocol)); - const extHostAuthentication = rpcProtocol.set(ExtHostContext.ExtHostAuthentication, new ExtHostAuthentication(rpcProtocol)); const extHostTimeline = rpcProtocol.set(ExtHostContext.ExtHostTimeline, new ExtHostTimeline(rpcProtocol, extHostCommands)); const extHostWebviews = rpcProtocol.set(ExtHostContext.ExtHostWebviews, new ExtHostWebviews(rpcProtocol, initData.remote, extHostWorkspace, extHostLogService, extHostApiDeprecation)); const extHostWebviewPanels = rpcProtocol.set(ExtHostContext.ExtHostWebviewPanels, new ExtHostWebviewPanels(rpcProtocol, extHostWebviews, extHostWorkspace)); @@ -207,13 +211,11 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I const extHostProfileContentHandlers = rpcProtocol.set(ExtHostContext.ExtHostProfileContentHandlers, new ExtHostProfileContentHandlers(rpcProtocol)); rpcProtocol.set(ExtHostContext.ExtHostInteractive, new ExtHostInteractive(rpcProtocol, extHostNotebook, extHostDocumentsAndEditors, extHostCommands, extHostLogService)); const extHostInteractiveEditor = rpcProtocol.set(ExtHostContext.ExtHostInlineChat, new ExtHostInteractiveEditor(rpcProtocol, extHostCommands, extHostDocuments, extHostLogService)); - const extHostChatProvider = rpcProtocol.set(ExtHostContext.ExtHostChatProvider, new ExtHostChatProvider(rpcProtocol, extHostLogService)); - const extHostChatAgents2 = rpcProtocol.set(ExtHostContext.ExtHostChatAgents2, new ExtHostChatAgents2(rpcProtocol, extHostLogService)); + const extHostChatAgents2 = rpcProtocol.set(ExtHostContext.ExtHostChatAgents2, new ExtHostChatAgents2(rpcProtocol, extHostLogService, extHostCommands)); const extHostChatVariables = rpcProtocol.set(ExtHostContext.ExtHostChatVariables, new ExtHostChatVariables(rpcProtocol)); const extHostChat = rpcProtocol.set(ExtHostContext.ExtHostChat, new ExtHostChat(rpcProtocol)); const extHostAiRelatedInformation = rpcProtocol.set(ExtHostContext.ExtHostAiRelatedInformation, new ExtHostRelatedInformation(rpcProtocol)); const extHostAiEmbeddingVector = rpcProtocol.set(ExtHostContext.ExtHostAiEmbeddingVector, new ExtHostAiEmbeddingVector(rpcProtocol)); - const extHostIssueReporter = rpcProtocol.set(ExtHostContext.ExtHostIssueReporter, new ExtHostIssueReporter(rpcProtocol)); const extHostStatusBar = rpcProtocol.set(ExtHostContext.ExtHostStatusBar, new ExtHostStatusBar(rpcProtocol, extHostCommands.converter)); const extHostSpeech = rpcProtocol.set(ExtHostContext.ExtHostSpeech, new ExtHostSpeech(rpcProtocol)); @@ -284,6 +286,9 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I const authentication: typeof vscode.authentication = { getSession(providerId: string, scopes: readonly string[], options?: vscode.AuthenticationGetSessionOptions) { + if (typeof options?.forceNewSession === 'object' && options.forceNewSession.learnMore) { + checkProposedApiEnabled(extension, 'authLearnMore'); + } return extHostAuthentication.getSession(extension, providerId, scopes, options as any); }, getSessions(providerId: string, scopes: readonly string[]) { @@ -423,14 +428,6 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I get onDidChangeLogLevel() { return _asExtensionEvent(extHostLogService.onDidChangeLogLevel); }, - registerIssueUriRequestHandler(handler: vscode.IssueUriRequestHandler) { - checkProposedApiEnabled(extension, 'handleIssueUri'); - return extHostIssueReporter.registerIssueUriRequestHandler(extension, handler); - }, - registerIssueDataProvider(handler: vscode.IssueDataProvider) { - checkProposedApiEnabled(extension, 'handleIssueUri'); - return extHostIssueReporter.registerIssueDataProvider(extension, handler); - }, get appQuality(): string | undefined { checkProposedApiEnabled(extension, 'resolvers'); return initData.quality; @@ -533,8 +530,12 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I return extHostLanguages.changeLanguage(document.uri, languageId); }, match(selector: vscode.DocumentSelector, document: vscode.TextDocument): number { - const notebook = extHostDocuments.getDocumentData(document.uri)?.notebook; - return score(typeConverters.LanguageSelector.from(selector), document.uri, document.languageId, true, notebook?.uri, notebook?.notebookType); + const interalSelector = typeConverters.LanguageSelector.from(selector); + let notebook: vscode.NotebookDocument | undefined; + if (targetsNotebooks(interalSelector)) { + notebook = extHostNotebook.notebookDocuments.find(value => value.apiNotebook.getCells().find(c => c.document === document))?.apiNotebook; + } + return score(interalSelector, document.uri, document.languageId, true, notebook?.uri, notebook?.notebookType); }, registerCodeActionsProvider(selector: vscode.DocumentSelector, provider: vscode.CodeActionProvider, metadata?: vscode.CodeActionProviderMetadata): vscode.Disposable { return extHostLanguageFeatures.registerCodeActionProvider(extension, checkSelector(selector), provider, metadata); @@ -582,6 +583,10 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I registerRenameProvider(selector: vscode.DocumentSelector, provider: vscode.RenameProvider): vscode.Disposable { return extHostLanguageFeatures.registerRenameProvider(extension, checkSelector(selector), provider); }, + registerNewSymbolNamesProvider(selector: vscode.DocumentSelector, provider: vscode.NewSymbolNamesProvider): vscode.Disposable { + checkProposedApiEnabled(extension, 'newSymbolNamesProvider'); + return extHostLanguageFeatures.registerNewSymbolNamesProvider(extension, checkSelector(selector), provider); + }, registerDocumentSymbolProvider(selector: vscode.DocumentSelector, provider: vscode.DocumentSymbolProvider, metadata?: vscode.DocumentSymbolProviderMetadata): vscode.Disposable { return extHostLanguageFeatures.registerDocumentSymbolProvider(extension, checkSelector(selector), provider, metadata); }, @@ -624,6 +629,10 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I } return extHostLanguageFeatures.registerInlineCompletionsProvider(extension, checkSelector(selector), provider, metadata); }, + registerInlineEditProvider(selector: vscode.DocumentSelector, provider: vscode.InlineEditProvider): vscode.Disposable { + checkProposedApiEnabled(extension, 'inlineEdit'); + return extHostLanguageFeatures.registerInlineEditProvider(extension, checkSelector(selector), provider); + }, registerDocumentLinkProvider(selector: vscode.DocumentSelector, provider: vscode.DocumentLinkProvider): vscode.Disposable { return extHostLanguageFeatures.registerDocumentLinkProvider(extension, checkSelector(selector), provider); }, @@ -729,6 +738,18 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I checkProposedApiEnabled(extension, 'terminalExecuteCommandEvent'); return _asExtensionEvent(extHostTerminalService.onDidExecuteTerminalCommand)(listener, thisArg, disposables); }, + onDidChangeTerminalShellIntegration(listener, thisArg?, disposables?) { + checkProposedApiEnabled(extension, 'terminalShellIntegration'); + return _asExtensionEvent(extHostTerminalShellIntegration.onDidChangeTerminalShellIntegration)(listener, thisArg, disposables); + }, + onDidStartTerminalShellExecution(listener, thisArg?, disposables?) { + checkProposedApiEnabled(extension, 'terminalShellIntegration'); + return _asExtensionEvent(extHostTerminalShellIntegration.onDidStartTerminalShellExecution)(listener, thisArg, disposables); + }, + onDidEndTerminalShellExecution(listener, thisArg?, disposables?) { + checkProposedApiEnabled(extension, 'terminalShellIntegration'); + return _asExtensionEvent(extHostTerminalShellIntegration.onDidEndTerminalShellExecution)(listener, thisArg, disposables); + }, get state() { return extHostWindow.getState(extension); }, @@ -938,6 +959,10 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I // Note, undefined/null have different meanings on "exclude" return extHostWorkspace.findFiles(include, exclude, maxResults, extension.identifier, token); }, + findFiles2: (filePattern, options?, token?) => { + checkProposedApiEnabled(extension, 'findFiles2'); + return extHostWorkspace.findFiles2(filePattern, options, extension.identifier, token); + }, findTextInFiles: (query: vscode.TextSearchQuery, optionsOrCallback: vscode.FindTextInFilesOptions | ((result: vscode.TextSearchResult) => void), callbackOrToken?: vscode.CancellationToken | ((result: vscode.TextSearchResult) => void), token?: vscode.CancellationToken) => { checkProposedApiEnabled(extension, 'findTextInFiles'); let options: vscode.FindTextInFilesOptions; @@ -1096,6 +1121,12 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I checkProposedApiEnabled(extension, 'textSearchProvider'); return extHostSearch.registerTextSearchProvider(scheme, provider); }, + registerAITextSearchProvider: (scheme: string, provider: vscode.AITextSearchProvider) => { + // there are some dependencies on textSearchProvider, so we need to check for both + checkProposedApiEnabled(extension, 'aiTextSearchProvider'); + checkProposedApiEnabled(extension, 'textSearchProvider'); + return extHostSearch.registerAITextSearchProvider(scheme, provider); + }, registerRemoteAuthorityResolver: (authorityPrefix: string, resolver: vscode.RemoteAuthorityResolver) => { checkProposedApiEnabled(extension, 'resolvers'); return extensionService.registerRemoteAuthorityResolver(authorityPrefix, resolver); @@ -1214,13 +1245,20 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I get breakpoints() { return extHostDebugService.breakpoints; }, - get stackFrameFocus() { - return extHostDebugService.stackFrameFocus; + get activeStackItem() { + if (!isProposedApiEnabled(extension, 'debugFocus')) { + return undefined; + } + return extHostDebugService.activeStackItem; }, registerDebugVisualizationProvider(id, provider) { checkProposedApiEnabled(extension, 'debugVisualization'); return extHostDebugService.registerDebugVisualizationProvider(extension, id, provider); }, + registerDebugVisualizationTreeProvider(id, provider) { + checkProposedApiEnabled(extension, 'debugVisualization'); + return extHostDebugService.registerDebugVisualizationTree(extension, id, provider); + }, onDidStartDebugSession(listener, thisArg?, disposables?) { return _asExtensionEvent(extHostDebugService.onDidStartDebugSession)(listener, thisArg, disposables); }, @@ -1236,9 +1274,9 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I onDidChangeBreakpoints(listener, thisArgs?, disposables?) { return _asExtensionEvent(extHostDebugService.onDidChangeBreakpoints)(listener, thisArgs, disposables); }, - onDidChangeStackFrameFocus(listener, thisArg?, disposables?) { + onDidChangeActiveStackItem(listener, thisArg?, disposables?) { checkProposedApiEnabled(extension, 'debugFocus'); - return _asExtensionEvent(extHostDebugService.onDidChangeStackFrameFocus)(listener, thisArg, disposables); + return _asExtensionEvent(extHostDebugService.onDidChangeActiveStackItem)(listener, thisArg, disposables); }, registerDebugConfigurationProvider(debugType: string, provider: vscode.DebugConfigurationProvider, triggerKind?: vscode.DebugConfigurationProviderTriggerKind) { return extHostDebugService.registerDebugConfigurationProvider(debugType, provider, triggerKind || DebugConfigurationProviderTriggerKind.Initial); @@ -1357,13 +1395,9 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I checkProposedApiEnabled(extension, 'interactive'); return extHostChat.registerChatProvider(extension, id, provider); }, - sendInteractiveRequestToProvider(providerId: string, message: vscode.InteractiveSessionDynamicRequest) { + transferActiveChat(toWorkspace: vscode.Uri) { checkProposedApiEnabled(extension, 'interactive'); - return extHostChat.sendInteractiveRequestToProvider(providerId, message); - }, - transferChatSession(session: vscode.InteractiveSession, toWorkspace: vscode.Uri) { - checkProposedApiEnabled(extension, 'interactive'); - return extHostChat.transferChatSession(session, toWorkspace); + return extHostChat.transferActiveChat(toWorkspace); } }; @@ -1383,36 +1417,44 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I } }; - // namespace: llm + // namespace: chat const chat: typeof vscode.chat = { registerChatResponseProvider(id: string, provider: vscode.ChatResponseProvider, metadata: vscode.ChatResponseProviderMetadata) { checkProposedApiEnabled(extension, 'chatProvider'); - return extHostChatProvider.registerLanguageModel(extension.identifier, id, provider, metadata); - }, - requestLanguageModelAccess(id, options) { - checkProposedApiEnabled(extension, 'chatRequestAccess'); - return extHostChatProvider.requestLanguageModelAccess(extension.identifier, id, options); - }, - get languageModels() { - checkProposedApiEnabled(extension, 'chatRequestAccess'); - return extHostChatProvider.getLanguageModelIds(); - }, - onDidChangeLanguageModels: (listener, thisArgs?, disposables?) => { - checkProposedApiEnabled(extension, 'chatRequestAccess'); - return extHostChatProvider.onDidChangeProviders(listener, thisArgs, disposables); + return extHostLanguageModels.registerLanguageModel(extension, id, provider, metadata); }, - registerVariable(name: string, description: string, resolver: vscode.ChatVariableResolver) { - checkProposedApiEnabled(extension, 'chatAgents2'); + registerChatVariableResolver(name: string, description: string, resolver: vscode.ChatVariableResolver) { + checkProposedApiEnabled(extension, 'chatVariableResolver'); return extHostChatVariables.registerVariableResolver(extension, name, description, resolver); }, registerMappedEditsProvider(selector: vscode.DocumentSelector, provider: vscode.MappedEditsProvider) { checkProposedApiEnabled(extension, 'mappedEditsProvider'); return extHostLanguageFeatures.registerMappedEditsProvider(extension, selector, provider); }, - createChatAgent(name: string, handler: vscode.ChatAgentExtendedHandler) { - checkProposedApiEnabled(extension, 'chatAgents2'); - return extHostChatAgents2.createChatAgent(extension, name, handler); + createChatParticipant(id: string, handler: vscode.ChatExtendedRequestHandler) { + checkProposedApiEnabled(extension, 'chatParticipant'); + return extHostChatAgents2.createChatAgent(extension, id, handler); }, + createDynamicChatParticipant(id: string, name: string, description: string, handler: vscode.ChatExtendedRequestHandler): vscode.ChatParticipant { + checkProposedApiEnabled(extension, 'chatParticipantAdditions'); + return extHostChatAgents2.createDynamicChatAgent(extension, id, name, description, handler); + } + }; + + // namespace: lm + const lm: typeof vscode.lm = { + get languageModels() { + checkProposedApiEnabled(extension, 'languageModels'); + return extHostLanguageModels.getLanguageModelIds(); + }, + onDidChangeLanguageModels: (listener, thisArgs?, disposables?) => { + checkProposedApiEnabled(extension, 'languageModels'); + return extHostLanguageModels.onDidChangeProviders(listener, thisArgs, disposables); + }, + sendChatRequest(languageModel: string, messages: vscode.LanguageModelChatMessage[], options: vscode.LanguageModelChatRequestOptions, token: vscode.CancellationToken) { + checkProposedApiEnabled(extension, 'languageModels'); + return extHostLanguageModels.sendChatRequest(extension, languageModel, messages, options, token); + } }; // namespace: speech @@ -1437,6 +1479,7 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I interactive, l10n, languages, + lm, notebooks, scm, speech, @@ -1447,11 +1490,9 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I // types Breakpoint: extHostTypes.Breakpoint, TerminalOutputAnchor: extHostTypes.TerminalOutputAnchor, - ChatAgentResultFeedbackKind: extHostTypes.ChatAgentResultFeedbackKind, - ChatMessage: extHostTypes.ChatMessage, - ChatMessageRole: extHostTypes.ChatMessageRole, + ChatResultFeedbackKind: extHostTypes.ChatResultFeedbackKind, ChatVariableLevel: extHostTypes.ChatVariableLevel, - ChatAgentCompletionItem: extHostTypes.ChatAgentCompletionItem, + ChatCompletionItem: extHostTypes.ChatCompletionItem, CallHierarchyIncomingCall: extHostTypes.CallHierarchyIncomingCall, CallHierarchyItem: extHostTypes.CallHierarchyItem, CallHierarchyOutgoingCall: extHostTypes.CallHierarchyOutgoingCall, @@ -1470,6 +1511,7 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I CommentState: extHostTypes.CommentState, CommentThreadCollapsibleState: extHostTypes.CommentThreadCollapsibleState, CommentThreadState: extHostTypes.CommentThreadState, + CommentThreadApplicability: extHostTypes.CommentThreadApplicability, CompletionItem: extHostTypes.CompletionItem, CompletionItemKind: extHostTypes.CompletionItemKind, CompletionItemTag: extHostTypes.CompletionItemTag, @@ -1580,7 +1622,9 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I ViewColumn: extHostTypes.ViewColumn, WorkspaceEdit: extHostTypes.WorkspaceEdit, // proposed api types + DocumentPasteTriggerKind: extHostTypes.DocumentPasteTriggerKind, DocumentDropEdit: extHostTypes.DocumentDropEdit, + DocumentPasteEditKind: extHostTypes.DocumentPasteEditKind, DocumentPasteEdit: extHostTypes.DocumentPasteEdit, InlayHint: extHostTypes.InlayHint, InlayHintLabelPart: extHostTypes.InlayHintLabelPart, @@ -1612,17 +1656,16 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I TestResultState: extHostTypes.TestResultState, TestRunRequest: extHostTypes.TestRunRequest, TestMessage: extHostTypes.TestMessage, - TestMessage2: extHostTypes.TestMessage, // back compat for Oct 2023 TestTag: extHostTypes.TestTag, TestRunProfileKind: extHostTypes.TestRunProfileKind, TextSearchCompleteMessageType: TextSearchCompleteMessageType, DataTransfer: extHostTypes.DataTransfer, DataTransferItem: extHostTypes.DataTransferItem, - CoveredCount: extHostTypes.CoveredCount, + TestCoverageCount: extHostTypes.TestCoverageCount, FileCoverage: extHostTypes.FileCoverage, StatementCoverage: extHostTypes.StatementCoverage, BranchCoverage: extHostTypes.BranchCoverage, - FunctionCoverage: extHostTypes.FunctionCoverage, + DeclarationCoverage: extHostTypes.DeclarationCoverage, WorkspaceTrustState: extHostTypes.WorkspaceTrustState, LanguageStatusSeverity: extHostTypes.LanguageStatusSeverity, QuickPickItemKind: extHostTypes.QuickPickItemKind, @@ -1637,23 +1680,40 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I TabInputTerminal: extHostTypes.TerminalEditorTabInput, TabInputInteractiveWindow: extHostTypes.InteractiveWindowInput, TabInputChat: extHostTypes.ChatEditorTabInput, + TabInputTextMultiDiff: extHostTypes.TextMultiDiffTabInput, TelemetryTrustedValue: TelemetryTrustedValue, LogLevel: LogLevel, EditSessionIdentityMatch: EditSessionIdentityMatch, InteractiveSessionVoteDirection: extHostTypes.InteractiveSessionVoteDirection, - ChatAgentCopyKind: extHostTypes.ChatAgentCopyKind, + ChatCopyKind: extHostTypes.ChatCopyKind, InteractiveEditorResponseFeedbackKind: extHostTypes.InteractiveEditorResponseFeedbackKind, - StackFrameFocus: extHostTypes.StackFrameFocus, - ThreadFocus: extHostTypes.ThreadFocus, + StackFrame: extHostTypes.StackFrame, + Thread: extHostTypes.Thread, RelatedInformationType: extHostTypes.RelatedInformationType, SpeechToTextStatus: extHostTypes.SpeechToTextStatus, + PartialAcceptTriggerKind: extHostTypes.PartialAcceptTriggerKind, KeywordRecognitionStatus: extHostTypes.KeywordRecognitionStatus, - ChatResponseTextPart: extHostTypes.ChatResponseTextPart, ChatResponseMarkdownPart: extHostTypes.ChatResponseMarkdownPart, ChatResponseFileTreePart: extHostTypes.ChatResponseFileTreePart, ChatResponseAnchorPart: extHostTypes.ChatResponseAnchorPart, ChatResponseProgressPart: extHostTypes.ChatResponseProgressPart, ChatResponseReferencePart: extHostTypes.ChatResponseReferencePart, + ChatResponseTextEditPart: extHostTypes.ChatResponseTextEditPart, + ChatResponseCommandButtonPart: extHostTypes.ChatResponseCommandButtonPart, + ChatRequestTurn: extHostTypes.ChatRequestTurn, + ChatResponseTurn: extHostTypes.ChatResponseTurn, + ChatLocation: extHostTypes.ChatLocation, + LanguageModelChatSystemMessage: extHostTypes.LanguageModelChatSystemMessage, + LanguageModelChatUserMessage: extHostTypes.LanguageModelChatUserMessage, + LanguageModelChatAssistantMessage: extHostTypes.LanguageModelChatAssistantMessage, + LanguageModelSystemMessage: extHostTypes.LanguageModelChatSystemMessage, + LanguageModelUserMessage: extHostTypes.LanguageModelChatUserMessage, + LanguageModelAssistantMessage: extHostTypes.LanguageModelChatAssistantMessage, + LanguageModelError: extHostTypes.LanguageModelError, + NewSymbolName: extHostTypes.NewSymbolName, + NewSymbolNameTag: extHostTypes.NewSymbolNameTag, + InlineEdit: extHostTypes.InlineEdit, + InlineEditTriggerKind: extHostTypes.InlineEditTriggerKind, }; }; } diff --git a/src/vs/workbench/api/common/extHost.common.services.ts b/src/vs/workbench/api/common/extHost.common.services.ts index faf45b596a757..d01a3219f948a 100644 --- a/src/vs/workbench/api/common/extHost.common.services.ts +++ b/src/vs/workbench/api/common/extHost.common.services.ts @@ -28,11 +28,16 @@ import { ILoggerService } from 'vs/platform/log/common/log'; import { ExtHostVariableResolverProviderService, IExtHostVariableResolverProvider } from 'vs/workbench/api/common/extHostVariableResolverService'; import { ExtHostLocalizationService, IExtHostLocalizationService } from 'vs/workbench/api/common/extHostLocalizationService'; import { ExtHostManagedSockets, IExtHostManagedSockets } from 'vs/workbench/api/common/extHostManagedSockets'; +import { ExtHostAuthentication, IExtHostAuthentication } from 'vs/workbench/api/common/extHostAuthentication'; +import { ExtHostLanguageModels, IExtHostLanguageModels } from 'vs/workbench/api/common/extHostLanguageModels'; +import { IExtHostTerminalShellIntegration, ExtHostTerminalShellIntegration } from 'vs/workbench/api/common/extHostTerminalShellIntegration'; registerSingleton(IExtHostLocalizationService, ExtHostLocalizationService, InstantiationType.Delayed); registerSingleton(ILoggerService, ExtHostLoggerService, InstantiationType.Delayed); registerSingleton(IExtHostApiDeprecationService, ExtHostApiDeprecationService, InstantiationType.Delayed); registerSingleton(IExtHostCommands, ExtHostCommands, InstantiationType.Eager); +registerSingleton(IExtHostAuthentication, ExtHostAuthentication, InstantiationType.Eager); +registerSingleton(IExtHostLanguageModels, ExtHostLanguageModels, InstantiationType.Eager); registerSingleton(IExtHostConfiguration, ExtHostConfiguration, InstantiationType.Eager); registerSingleton(IExtHostConsumerFileSystem, ExtHostConsumerFileSystem, InstantiationType.Eager); registerSingleton(IExtHostDebugService, WorkerExtHostDebugService, InstantiationType.Eager); @@ -45,6 +50,7 @@ registerSingleton(IExtHostSearch, ExtHostSearch, InstantiationType.Eager); registerSingleton(IExtHostStorage, ExtHostStorage, InstantiationType.Eager); registerSingleton(IExtHostTask, WorkerExtHostTask, InstantiationType.Eager); registerSingleton(IExtHostTerminalService, WorkerExtHostTerminalService, InstantiationType.Eager); +registerSingleton(IExtHostTerminalShellIntegration, ExtHostTerminalShellIntegration, InstantiationType.Eager); registerSingleton(IExtHostTunnelService, ExtHostTunnelService, InstantiationType.Eager); registerSingleton(IExtHostWindow, ExtHostWindow, InstantiationType.Eager); registerSingleton(IExtHostWorkspace, ExtHostWorkspace, InstantiationType.Eager); diff --git a/src/vs/workbench/api/common/extHost.protocol.ts b/src/vs/workbench/api/common/extHost.protocol.ts index 8826cef84333a..a2ec2e84c91e9 100644 --- a/src/vs/workbench/api/common/extHost.protocol.ts +++ b/src/vs/workbench/api/common/extHost.protocol.ts @@ -50,13 +50,13 @@ import * as tasks from 'vs/workbench/api/common/shared/tasks'; import { SaveReason } from 'vs/workbench/common/editor'; import { IRevealOptions, ITreeItem, IViewBadge } from 'vs/workbench/common/views'; import { CallHierarchyItem } from 'vs/workbench/contrib/callHierarchy/common/callHierarchy'; -import { IChatAgentCommand, IChatAgentMetadata, IChatAgentRequest, IChatAgentResult } from 'vs/workbench/contrib/chat/common/chatAgents'; +import { IChatAgentMetadata, IChatAgentRequest, IChatAgentResult } from 'vs/workbench/contrib/chat/common/chatAgents'; import { IChatProgressResponseContent } from 'vs/workbench/contrib/chat/common/chatModel'; -import { IChatMessage, IChatResponseFragment, IChatResponseProviderMetadata } from 'vs/workbench/contrib/chat/common/chatProvider'; -import { IChatDynamicRequest, IChatFollowup, IChatProgress, IChatReplyFollowup, IChatResponseErrorDetails, IChatUserActionEvent, InteractiveSessionVoteDirection } from 'vs/workbench/contrib/chat/common/chatService'; -import { IChatRequestVariableValue, IChatVariableData } from 'vs/workbench/contrib/chat/common/chatVariables'; -import { DebugConfigurationProviderTriggerKind, MainThreadDebugVisualization, IAdapterDescriptor, IConfig, IDebugSessionReplMode, IDebugVisualization, IDebugVisualizationContext } from 'vs/workbench/contrib/debug/common/debug'; -import { IInlineChatBulkEditResponse, IInlineChatEditResponse, IInlineChatProgressItem, IInlineChatRequest, IInlineChatSession, InlineChatResponseFeedbackKind } from 'vs/workbench/contrib/inlineChat/common/inlineChat'; +import { IChatFollowup, IChatProgress, IChatResponseErrorDetails, IChatUserActionEvent, InteractiveSessionVoteDirection } from 'vs/workbench/contrib/chat/common/chatService'; +import { IChatRequestVariableValue, IChatVariableData, IChatVariableResolverProgress } from 'vs/workbench/contrib/chat/common/chatVariables'; +import { IChatMessage, IChatResponseFragment, ILanguageModelChatMetadata } from 'vs/workbench/contrib/chat/common/languageModels'; +import { DebugConfigurationProviderTriggerKind, IAdapterDescriptor, IConfig, IDebugSessionReplMode, IDebugVisualization, IDebugVisualizationContext, IDebugVisualizationTreeItem, MainThreadDebugVisualization } from 'vs/workbench/contrib/debug/common/debug'; +import { IInlineChatBulkEditResponse, IInlineChatEditResponse, IInlineChatFollowup, IInlineChatProgressItem, IInlineChatRequest, IInlineChatSession, InlineChatResponseFeedbackKind } from 'vs/workbench/contrib/inlineChat/common/inlineChat'; import * as notebookCommon from 'vs/workbench/contrib/notebook/common/notebookCommon'; import { CellExecutionUpdateType } from 'vs/workbench/contrib/notebook/common/notebookExecutionService'; import { ICellExecutionComplete, ICellExecutionStateUpdate } from 'vs/workbench/contrib/notebook/common/notebookExecutionStateService'; @@ -78,7 +78,7 @@ import { Dto, IRPCProtocol, SerializableObjectWithBuffers, createProxyIdentifier import { ILanguageStatus } from 'vs/workbench/services/languageStatus/common/languageStatusService'; import { OutputChannelUpdateMode } from 'vs/workbench/services/output/common/output'; import { CandidatePort } from 'vs/workbench/services/remote/common/tunnelModel'; -import { ITextQueryBuilderOptions } from 'vs/workbench/services/search/common/queryBuilder'; +import { IFileQueryBuilderOptions, ITextQueryBuilderOptions } from 'vs/workbench/services/search/common/queryBuilder'; import * as search from 'vs/workbench/services/search/common/search'; import { ISaveProfileResult } from 'vs/workbench/services/userDataProfile/common/userDataProfile'; @@ -135,6 +135,7 @@ export type CommentThreadChanges = Partial<{ collapseState: languages.CommentThreadCollapsibleState; canReply: boolean; state: languages.CommentThreadState; + applicability: languages.CommentThreadApplicability; isTemplate: boolean; }>; @@ -145,7 +146,7 @@ export interface MainThreadCommentsShape extends IDisposable { $createCommentThread(handle: number, commentThreadHandle: number, threadId: string, resource: UriComponents, range: IRange | ICellRange | undefined, extensionId: ExtensionIdentifier, isTemplate: boolean): languages.CommentThread | undefined; $updateCommentThread(handle: number, commentThreadHandle: number, threadId: string, resource: UriComponents, changes: CommentThreadChanges): void; $deleteCommentThread(handle: number, commentThreadHandle: number): void; - $updateCommentingRanges(handle: number): void; + $updateCommentingRanges(handle: number, resourceHints?: languages.CommentingRangeResourceHint): void; } export interface AuthenticationForceNewSessionOptions { @@ -261,7 +262,7 @@ export interface ITextDocumentShowOptions { } export interface MainThreadBulkEditsShape extends IDisposable { - $tryApplyWorkspaceEdit(workspaceEditDto: IWorkspaceEditDto, undoRedoGroupId?: number, respectAutoSaveConfig?: boolean): Promise; + $tryApplyWorkspaceEdit(workspaceEditDto: SerializableObjectWithBuffers, undoRedoGroupId?: number, respectAutoSaveConfig?: boolean): Promise; } export interface MainThreadTextEditorsShape extends IDisposable { @@ -392,6 +393,10 @@ export interface IdentifiableInlineCompletion extends languages.InlineCompletion idx: number; } +export interface IdentifiableInlineEdit extends languages.IInlineEdit { + pid: number; +} + export interface MainThreadLanguageFeaturesShape extends IDisposable { $unregister(handle: number): void; $registerDocumentSymbolProvider(handle: number, selector: IDocumentFilterDto[], label: string): void; @@ -410,17 +415,19 @@ export interface MainThreadLanguageFeaturesShape extends IDisposable { $registerLinkedEditingRangeProvider(handle: number, selector: IDocumentFilterDto[]): void; $registerReferenceSupport(handle: number, selector: IDocumentFilterDto[]): void; $registerQuickFixSupport(handle: number, selector: IDocumentFilterDto[], metadata: ICodeActionProviderMetadataDto, displayName: string, supportsResolve: boolean): void; - $registerPasteEditProvider(handle: number, selector: IDocumentFilterDto[], id: string, metadata: IPasteEditProviderMetadataDto): void; + $registerPasteEditProvider(handle: number, selector: IDocumentFilterDto[], metadata: IPasteEditProviderMetadataDto): void; $registerDocumentFormattingSupport(handle: number, selector: IDocumentFilterDto[], extensionId: ExtensionIdentifier, displayName: string): void; $registerRangeFormattingSupport(handle: number, selector: IDocumentFilterDto[], extensionId: ExtensionIdentifier, displayName: string, supportRanges: boolean): void; $registerOnTypeFormattingSupport(handle: number, selector: IDocumentFilterDto[], autoFormatTriggerCharacters: string[], extensionId: ExtensionIdentifier): void; $registerNavigateTypeSupport(handle: number, supportsResolve: boolean): void; $registerRenameSupport(handle: number, selector: IDocumentFilterDto[], supportsResolveInitialValues: boolean): void; + $registerNewSymbolNamesProvider(handle: number, selector: IDocumentFilterDto[]): void; $registerDocumentSemanticTokensProvider(handle: number, selector: IDocumentFilterDto[], legend: languages.SemanticTokensLegend, eventHandle: number | undefined): void; $emitDocumentSemanticTokensEvent(eventHandle: number): void; $registerDocumentRangeSemanticTokensProvider(handle: number, selector: IDocumentFilterDto[], legend: languages.SemanticTokensLegend): void; $registerCompletionsProvider(handle: number, selector: IDocumentFilterDto[], triggerCharacters: string[], supportsResolveDetails: boolean, extensionId: ExtensionIdentifier): void; $registerInlineCompletionsSupport(handle: number, selector: IDocumentFilterDto[], supportsHandleDidShowCompletionItem: boolean, extensionId: string, yieldsToExtensionIds: string[]): void; + $registerInlineEditProvider(handle: number, selector: IDocumentFilterDto[], extensionId: ExtensionIdentifier): void; $registerSignatureHelpProvider(handle: number, selector: IDocumentFilterDto[], metadata: ISignatureHelpProviderMetadataDto): void; $registerInlayHintsProvider(handle: number, selector: IDocumentFilterDto[], supportsResolve: boolean, eventHandle: number | undefined, displayName: string | undefined): void; $emitInlayHintsEvent(eventHandle: number): void; @@ -431,7 +438,7 @@ export interface MainThreadLanguageFeaturesShape extends IDisposable { $registerSelectionRangeProvider(handle: number, selector: IDocumentFilterDto[]): void; $registerCallHierarchyProvider(handle: number, selector: IDocumentFilterDto[]): void; $registerTypeHierarchyProvider(handle: number, selector: IDocumentFilterDto[]): void; - $registerDocumentOnDropEditProvider(handle: number, selector: IDocumentFilterDto[], id: string | undefined, metadata?: IDocumentDropEditProviderMetadata): void; + $registerDocumentOnDropEditProvider(handle: number, selector: IDocumentFilterDto[], metadata?: IDocumentDropEditProviderMetadata): void; $resolvePasteFileData(handle: number, requestId: number, dataId: string): Promise; $resolveDocumentOnDropFileData(handle: number, requestId: number, dataId: string): Promise; $setLanguageConfiguration(handle: number, languageId: string, configuration: ILanguageConfigurationDto): void; @@ -494,6 +501,7 @@ export interface TerminalLaunchConfig { strictEnv?: boolean; hideFromUser?: boolean; isExtensionCustomPtyTerminal?: boolean; + forceShellIntegration?: boolean; isFeatureTerminal?: boolean; isExtensionOwnedTerminal?: boolean; useShellEnvironment?: boolean; @@ -529,6 +537,10 @@ export interface MainThreadTerminalServiceShape extends IDisposable { $sendProcessExit(terminalId: number, exitCode: number | undefined): void; } +export interface MainThreadTerminalShellIntegrationShape extends IDisposable { + $executeCommand(terminalId: number, commandLine: string): void; +} + export type TransferQuickPickItemOrSeparator = TransferQuickPickItem | quickInput.IQuickPickSeparator; export interface TransferQuickPickItem { handle: number; @@ -691,7 +703,8 @@ export const enum TabInputKind { WebviewEditorInput, TerminalEditorInput, InteractiveEditorInput, - ChatEditorInput + ChatEditorInput, + MultiDiffEditorInput } export const enum TabModelOperationKind { @@ -759,11 +772,16 @@ export interface ChatEditorInputDto { providerId: string; } +export interface MultiDiffEditorInputDto { + kind: TabInputKind.MultiDiffEditorInput; + diffEditors: TextDiffInputDto[]; +} + export interface TabInputDto { kind: TabInputKind.TerminalEditorInput; } -export type AnyInputDto = UnknownInputDto | TextInputDto | TextDiffInputDto | TextMergeInputDto | NotebookInputDto | NotebookDiffInputDto | CustomInputDto | WebviewInputDto | InteractiveEditorInputDto | ChatEditorInputDto | TabInputDto; +export type AnyInputDto = UnknownInputDto | TextInputDto | TextDiffInputDto | MultiDiffEditorInputDto | TextMergeInputDto | NotebookInputDto | NotebookDiffInputDto | CustomInputDto | WebviewInputDto | InteractiveEditorInputDto | ChatEditorInputDto | TabInputDto; export interface MainThreadEditorTabsShape extends IDisposable { // manage tabs: move, close, rearrange etc @@ -1118,8 +1136,12 @@ export interface VariablesResult { id: number; name: string; value: string; + type?: string; + language?: string; + expression?: string; hasNamedChildren: boolean; indexedChildrenCount: number; + extensionId: string; } export interface MainThreadNotebookKernelsShape extends IDisposable { @@ -1163,36 +1185,35 @@ export interface MainThreadSpeechShape extends IDisposable { } export interface ExtHostSpeechShape { - $createSpeechToTextSession(handle: number, session: number): Promise; + $createSpeechToTextSession(handle: number, session: number, language?: string): Promise; $cancelSpeechToTextSession(session: number): Promise; $createKeywordRecognitionSession(handle: number, session: number): Promise; $cancelKeywordRecognitionSession(session: number): Promise; } -export interface MainThreadChatProviderShape extends IDisposable { - $registerProvider(handle: number, identifier: string, metadata: IChatResponseProviderMetadata): void; +export interface MainThreadLanguageModelsShape extends IDisposable { + $registerLanguageModelProvider(handle: number, identifier: string, metadata: ILanguageModelChatMetadata): void; $unregisterProvider(handle: number): void; $handleProgressChunk(requestId: number, chunk: IChatResponseFragment): Promise; - $prepareChatAccess(extension: ExtensionIdentifier, providerId: string, justification?: string): Promise; + $prepareChatAccess(extension: ExtensionIdentifier, providerId: string, justification?: string): Promise; $fetchResponse(extension: ExtensionIdentifier, provider: string, requestId: number, messages: IChatMessage[], options: {}, token: CancellationToken): Promise; } -export interface ExtHostChatProviderShape { - $updateLanguageModels(data: { added?: string[]; removed?: string[] }): void; - $updateAccesslist(data: { extension: ExtensionIdentifier; enabled: boolean }[]): void; - $provideLanguageModelResponse(handle: number, requestId: number, messages: IChatMessage[], options: { [name: string]: any }, token: CancellationToken): Promise; +export interface ExtHostLanguageModelsShape { + $updateLanguageModels(data: { added?: ILanguageModelChatMetadata[]; removed?: string[] }): void; + $updateModelAccesslist(data: { from: ExtensionIdentifier; to: ExtensionIdentifier; enabled: boolean }[]): void; + $provideLanguageModelResponse(handle: number, requestId: number, from: ExtensionIdentifier, messages: IChatMessage[], options: { [name: string]: any }, token: CancellationToken): Promise; $handleResponseFragment(requestId: number, chunk: IChatResponseFragment): Promise; } export interface IExtensionChatAgentMetadata extends Dto { - hasSlashCommands?: boolean; hasFollowups?: boolean; } export interface MainThreadChatAgentsShape2 extends IDisposable { - $registerAgent(handle: number, name: string, metadata: IExtensionChatAgentMetadata): void; + $registerAgent(handle: number, extension: ExtensionIdentifier, id: string, metadata: IExtensionChatAgentMetadata, dynamicProps: { name: string; description: string } | undefined): void; $registerAgentCompletionsProvider(handle: number, triggerCharacters: string[]): void; $unregisterAgentCompletionsProvider(handle: number): void; $updateAgent(handle: number, metadataUpdate: IExtensionChatAgentMetadata): void; @@ -1219,29 +1240,32 @@ export type IChatAgentHistoryEntryDto = { export interface ExtHostChatAgentsShape2 { $invokeAgent(handle: number, request: IChatAgentRequest, context: { history: IChatAgentHistoryEntryDto[] }, token: CancellationToken): Promise; - $provideSlashCommands(handle: number, token: CancellationToken): Promise; - $provideFollowups(handle: number, sessionId: string, token: CancellationToken): Promise; - $acceptFeedback(handle: number, sessionId: string, requestId: string, vote: InteractiveSessionVoteDirection, reportIssue?: boolean): void; - $acceptAction(handle: number, sessionId: string, requestId: string, action: IChatUserActionEvent): void; + $provideFollowups(request: IChatAgentRequest, handle: number, result: IChatAgentResult, context: { history: IChatAgentHistoryEntryDto[] }, token: CancellationToken): Promise; + $acceptFeedback(handle: number, result: IChatAgentResult, vote: InteractiveSessionVoteDirection, reportIssue?: boolean): void; + $acceptAction(handle: number, result: IChatAgentResult, action: IChatUserActionEvent): void; $invokeCompletionProvider(handle: number, query: string, token: CancellationToken): Promise; $provideWelcomeMessage(handle: number, token: CancellationToken): Promise<(string | IMarkdownString)[] | undefined>; - $provideSampleQuestions(handle: number, token: CancellationToken): Promise; + $provideSampleQuestions(handle: number, token: CancellationToken): Promise; $releaseSession(sessionId: string): void; } +export type IChatVariableResolverProgressDto = + | Dto; + export interface MainThreadChatVariablesShape extends IDisposable { $registerVariable(handle: number, data: IChatVariableData): void; + $handleProgressChunk(requestId: string, progress: IChatVariableResolverProgressDto): Promise; $unregisterVariable(handle: number): void; } export type IChatRequestVariableValueDto = Dto; export interface ExtHostChatVariablesShape { - $resolveVariable(handle: number, messageText: string, token: CancellationToken): Promise; + $resolveVariable(handle: number, requestId: string, messageText: string, token: CancellationToken): Promise; } export interface MainThreadInlineChatShape extends IDisposable { - $registerInteractiveEditorProvider(handle: number, label: string, debugName: string, supportsFeedback: boolean, supportsFollowups: boolean, supportsIssueReporting: boolean): Promise; + $registerInteractiveEditorProvider(handle: number, label: string, extensionId: ExtensionIdentifier, supportsFeedback: boolean, supportsFollowups: boolean, supportsIssueReporting: boolean): Promise; $handleProgressChunk(requestId: string, chunk: Dto): Promise; $unregisterInteractiveEditorProvider(handle: number): Promise; } @@ -1251,7 +1275,7 @@ export type IInlineChatResponseDto = Dto; $provideResponse(handle: number, session: IInlineChatSession, request: IInlineChatRequest, token: CancellationToken): Promise; - $provideFollowups(handle: number, sessionId: number, responseId: number, token: CancellationToken): Promise; + $provideFollowups(handle: number, sessionId: number, responseId: number, token: CancellationToken): Promise; $handleFeedback(handle: number, sessionId: number, responseId: number, kind: InlineChatResponseFeedbackKind): void; $releaseSession(handle: number, sessionId: number): void; } @@ -1264,11 +1288,6 @@ export interface MainThreadUrlsShape extends IDisposable { export interface IChatDto { id: number; - requesterUsername: string; - requesterAvatarIconUri?: UriComponents; - responderUsername: string; - responderAvatarIconUri?: UriComponents; - inputPlaceholder?: string; } export interface IChatRequestDto { @@ -1302,9 +1321,8 @@ export type IChatProgressDto = export interface MainThreadChatShape extends IDisposable { $registerChatProvider(handle: number, id: string): Promise; $acceptChatState(sessionId: number, state: any): Promise; - $sendRequestToProvider(providerId: string, message: IChatDynamicRequest): void; $unregisterChatProvider(handle: number): Promise; - $transferChatSession(sessionId: number, toWorkspace: UriComponents): void; + $transferActiveChatSession(toWorkspace: UriComponents): void; } export interface ExtHostChatShape { @@ -1341,7 +1359,7 @@ export interface ITextSearchComplete { } export interface MainThreadWorkspaceShape extends IDisposable { - $startFileSearch(includePattern: string | null, includeFolder: UriComponents | null, excludePatternOrDisregardExcludes: string | false | null, maxResults: number | null, token: CancellationToken): Promise; + $startFileSearch(includeFolder: UriComponents | null, options: IFileQueryBuilderOptions, token: CancellationToken): Promise; $startTextSearch(query: search.IPatternInfo, folder: UriComponents | null, options: ITextQueryBuilderOptions, requestId: number, token: CancellationToken): Promise; $checkExists(folders: readonly UriComponents[], includes: string[], token: CancellationToken): Promise; $save(uri: UriComponents, options: { saveAs: boolean }): Promise; @@ -1390,6 +1408,7 @@ export interface MainThreadLabelServiceShape extends IDisposable { export interface MainThreadSearchShape extends IDisposable { $registerFileSearchProvider(handle: number, scheme: string): void; + $registerAITextSearchProvider(handle: number, scheme: string): void; $registerTextSearchProvider(handle: number, scheme: string): void; $unregisterProvider(handle: number): void; $handleFileMatch(handle: number, session: number, data: UriComponents[]): void; @@ -1474,15 +1493,15 @@ export type SCMRawResourceSplices = [ export interface SCMHistoryItemGroupDto { readonly id: string; - readonly label: string; + readonly name: string; readonly base?: Omit; } export interface SCMHistoryItemDto { readonly id: string; readonly parentIds: string[]; - readonly label: string; - readonly description?: string; + readonly message: string; + readonly author?: string; readonly icon?: UriComponents | { light: UriComponents; dark: UriComponents } | ThemeIcon; readonly timestamp?: number; } @@ -1562,6 +1581,8 @@ export interface MainThreadDebugServiceShape extends IDisposable { $unregisterBreakpoints(breakpointIds: string[], functionBreakpointIds: string[], dataBreakpointIds: string[]): Promise; $registerDebugVisualizer(extensionId: string, id: string): void; $unregisterDebugVisualizer(extensionId: string, id: string): void; + $registerDebugVisualizerTree(treeId: string, canEdit: boolean): void; + $unregisterDebugVisualizerTree(treeId: string): void; } export interface IOpenUriOptions { @@ -1801,6 +1822,7 @@ export interface ExtHostSecretStateShape { export interface ExtHostSearchShape { $enableExtensionHostSearch(): void; $provideFileSearchResults(handle: number, session: number, query: search.IRawQuery, token: CancellationToken): Promise; + $provideAITextSearchResults(handle: number, session: number, query: search.IRawAITextQuery, token: CancellationToken): Promise; $provideTextSearchResults(handle: number, session: number, query: search.IRawTextQuery, token: CancellationToken): Promise; $clearCache(cacheKey: string): Promise; } @@ -2050,16 +2072,26 @@ export type ITypeHierarchyItemDto = Dto; export interface IPasteEditProviderMetadataDto { readonly supportsCopy: boolean; readonly supportsPaste: boolean; + readonly supportsResolve: boolean; + + readonly providedPasteEditKinds?: readonly string[]; readonly copyMimeTypes?: readonly string[]; readonly pasteMimeTypes?: readonly string[]; } +export interface IDocumentPasteContextDto { + readonly only: string | undefined; + readonly triggerKind: languages.DocumentPasteTriggerKind; + +} + export interface IPasteEditDto { - label: string; - detail: string; + _cacheId?: ChainedCacheId; + title: string; + kind: { value: string } | undefined; insertText: string | { snippet: string }; additionalEdit?: IWorkspaceEditDto; - yieldTo?: readonly languages.DropYieldTo[]; + yieldTo?: readonly string[]; } export interface IDocumentDropEditProviderMetadata { @@ -2067,10 +2099,11 @@ export interface IDocumentDropEditProviderMetadata { } export interface IDocumentOnDropEditDto { - label: string; + title: string; + kind: string | undefined; insertText: string | { snippet: string }; additionalEdit?: IWorkspaceEditDto; - yieldTo?: readonly languages.DropYieldTo[]; + yieldTo?: readonly string[]; } export interface ExtHostLanguageFeaturesShape { @@ -2093,7 +2126,9 @@ export interface ExtHostLanguageFeaturesShape { $resolveCodeAction(handle: number, id: ChainedCacheId, token: CancellationToken): Promise<{ edit?: IWorkspaceEditDto; command?: ICommandDto }>; $releaseCodeActions(handle: number, cacheId: number): void; $prepareDocumentPaste(handle: number, uri: UriComponents, ranges: readonly IRange[], dataTransfer: DataTransferDTO, token: CancellationToken): Promise; - $providePasteEdits(handle: number, requestId: number, uri: UriComponents, ranges: IRange[], dataTransfer: DataTransferDTO, token: CancellationToken): Promise; + $providePasteEdits(handle: number, requestId: number, uri: UriComponents, ranges: IRange[], dataTransfer: DataTransferDTO, context: IDocumentPasteContextDto, token: CancellationToken): Promise; + $resolvePasteEdit(handle: number, id: ChainedCacheId, token: CancellationToken): Promise<{ additionalEdit?: IWorkspaceEditDto }>; + $releasePasteEdits(handle: number, cacheId: number): void; $provideDocumentFormattingEdits(handle: number, resource: UriComponents, options: languages.FormattingOptions, token: CancellationToken): Promise; $provideDocumentRangeFormattingEdits(handle: number, resource: UriComponents, range: IRange, options: languages.FormattingOptions, token: CancellationToken): Promise; $provideDocumentRangesFormattingEdits(handle: number, resource: UriComponents, range: IRange[], options: languages.FormattingOptions, token: CancellationToken): Promise; @@ -2103,6 +2138,7 @@ export interface ExtHostLanguageFeaturesShape { $releaseWorkspaceSymbols(handle: number, id: number): void; $provideRenameEdits(handle: number, resource: UriComponents, position: IPosition, newName: string, token: CancellationToken): Promise; $resolveRenameLocation(handle: number, resource: UriComponents, position: IPosition, token: CancellationToken): Promise; + $provideNewSymbolNames(handle: number, resource: UriComponents, range: IRange, token: CancellationToken): Promise; $provideDocumentSemanticTokens(handle: number, resource: UriComponents, previousResultId: number, token: CancellationToken): Promise; $releaseDocumentSemanticTokens(handle: number, semanticColoringResultId: number): void; $provideDocumentRangeSemanticTokens(handle: number, resource: UriComponents, range: IRange, token: CancellationToken): Promise; @@ -2111,7 +2147,7 @@ export interface ExtHostLanguageFeaturesShape { $releaseCompletionItems(handle: number, id: number): void; $provideInlineCompletions(handle: number, resource: UriComponents, position: IPosition, context: languages.InlineCompletionContext, token: CancellationToken): Promise; $handleInlineCompletionDidShow(handle: number, pid: number, idx: number, updatedInsertText: string): void; - $handleInlineCompletionPartialAccept(handle: number, pid: number, idx: number, acceptedCharacters: number): void; + $handleInlineCompletionPartialAccept(handle: number, pid: number, idx: number, acceptedCharacters: number, info: languages.PartialAcceptInfo): void; $freeInlineCompletionsList(handle: number, pid: number): void; $provideSignatureHelp(handle: number, resource: UriComponents, position: IPosition, context: languages.SignatureHelpContext, token: CancellationToken): Promise; $releaseSignatureHelp(handle: number, id: number): void; @@ -2134,8 +2170,10 @@ export interface ExtHostLanguageFeaturesShape { $provideTypeHierarchySupertypes(handle: number, sessionId: string, itemId: string, token: CancellationToken): Promise; $provideTypeHierarchySubtypes(handle: number, sessionId: string, itemId: string, token: CancellationToken): Promise; $releaseTypeHierarchy(handle: number, sessionId: string): void; - $provideDocumentOnDropEdits(handle: number, requestId: number, resource: UriComponents, position: IPosition, dataTransferDto: DataTransferDTO, token: CancellationToken): Promise; + $provideDocumentOnDropEdits(handle: number, requestId: number, resource: UriComponents, position: IPosition, dataTransferDto: DataTransferDTO, token: CancellationToken): Promise; $provideMappedEdits(handle: number, document: UriComponents, codeBlocks: string[], context: IMappedEditsContextDto, token: CancellationToken): Promise; + $provideInlineEdit(handle: number, document: UriComponents, context: languages.IInlineEditContext, token: CancellationToken): Promise; + $freeInlineEdit(handle: number, pid: number): void; } export interface ExtHostQuickOpenShape { @@ -2228,6 +2266,15 @@ export interface ExtHostTerminalServiceShape { $provideTerminalQuickFixes(id: string, matchResult: TerminalCommandMatchResultDto, token: CancellationToken): Promise | undefined>; } +export interface ExtHostTerminalShellIntegrationShape { + $shellIntegrationChange(instanceId: number): void; + $shellExecutionStart(instanceId: number, commandLine: string | undefined, cwd: UriComponents | undefined): void; + $shellExecutionEnd(instanceId: number, commandLine: string | undefined, exitCode: number | undefined): void; + $shellExecutionData(instanceId: number, data: string): void; + $cwdChange(instanceId: number, cwd: UriComponents | undefined): void; + $closeTerminal(instanceId: number): void; +} + export interface ExtHostSCMShape { $provideOriginalResource(sourceControlHandle: number, uri: UriComponents, token: CancellationToken): Promise; $onInputBoxValueChange(sourceControlHandle: number, value: string): void; @@ -2267,11 +2314,13 @@ export interface IBreakpointDto { condition?: string; hitCondition?: string; logMessage?: string; + mode?: string; } export interface IFunctionBreakpointDto extends IBreakpointDto { type: 'function'; functionName: string; + mode?: string; } export interface IDataBreakpointDto extends IBreakpointDto { @@ -2281,6 +2330,7 @@ export interface IDataBreakpointDto extends IBreakpointDto { label: string; accessTypes?: DebugProtocol.DataBreakpointAccessType[]; accessType: DebugProtocol.DataBreakpointAccessType; + mode?: string; } export interface ISourceBreakpointDto extends IBreakpointDto { @@ -2307,6 +2357,7 @@ export interface ISourceMultiBreakpointDto { logMessage?: string; line: number; character: number; + mode?: string; }[]; } @@ -2324,14 +2375,14 @@ export type IDebugSessionDto = IDebugSessionFullDto | DebugSessionUUID; export interface IThreadFocusDto { kind: 'thread'; sessionId: string; - threadId: number | undefined; + threadId: number; } export interface IStackFrameFocusDto { kind: 'stackFrame'; sessionId: string; - threadId: number | undefined; - frameId: number | undefined; + threadId: number; + frameId: number; } @@ -2356,6 +2407,10 @@ export interface ExtHostDebugServiceShape { $resolveDebugVisualizer(id: number, token: CancellationToken): Promise; $executeDebugVisualizerCommand(id: number): Promise; $disposeDebugVisualizers(ids: number[]): void; + $getVisualizerTreeItem(treeId: string, element: IDebugVisualizationContext): Promise; + $getVisualizerTreeItemChildren(treeId: string, element: number): Promise; + $editVisualizerTreeItem(element: number, value: string): Promise; + $disposeVisualizedTree(element: number): void; } @@ -2595,19 +2650,6 @@ export interface MainThreadLocalizationShape extends IDisposable { $fetchBundleContents(uriComponents: UriComponents): Promise; } -export interface ExtHostIssueReporterShape { - $getIssueReporterUri(extensionId: string, token: CancellationToken): Promise; - $getIssueReporterData(extensionId: string, token: CancellationToken): Promise; - $getIssueReporterTemplate(extensionId: string, token: CancellationToken): Promise; -} - -export interface MainThreadIssueReporterShape extends IDisposable { - $registerIssueUriRequestHandler(extensionId: string): void; - $unregisterIssueUriRequestHandler(extensionId: string): void; - $registerIssueDataProvider(extensionId: string): void; - $unregisterIssueDataProvider(extensionId: string): void; -} - export interface TunnelDto { remoteAddress: { port: number; host: string }; localAddress: { port: number; host: string } | string; @@ -2645,13 +2687,10 @@ export interface ExtHostTestingShape { $publishTestResults(results: ISerializedTestResults[]): void; /** Expands a test item's children, by the given number of levels. */ $expandTest(testId: string, levels: number): Promise; - /** Requests file coverage for a test run. Errors if not available. */ - $provideFileCoverage(runId: string, taskId: string, token: CancellationToken): Promise; - /** - * Requests coverage details for the file index in coverage data for the run. - * Requires file coverage to have been previously requested via $provideFileCoverage. - */ - $resolveFileCoverage(runId: string, taskId: string, fileIndex: number, token: CancellationToken): Promise; + /** Requests coverage details for a test run. Errors if not available. */ + $getCoverageDetails(coverageId: string, token: CancellationToken): Promise; + /** Disposes resources associated with a test run. */ + $disposeRun(runId: string): void; /** Configures a test run config. */ $configureRunProfile(controllerId: string, configId: number): void; /** Asks the controller to refresh its tests */ @@ -2722,7 +2761,7 @@ export interface MainThreadTestingShape { /** Appends raw output to the test run.. */ $appendOutputToRun(runId: string, taskId: string, output: VSBuffer, location?: ILocationDto, testId?: string): void; /** Triggered when coverage is added to test results. */ - $signalCoverageAvailable(runId: string, taskId: string, available: boolean): void; + $appendCoverage(runId: string, taskId: string, coverage: IFileCoverage.Serialized): void; /** Signals a task in a test run started. */ $startedTestRunTask(runId: string, task: ITestRunTask): void; /** Signals a task in a test run ended. */ @@ -2740,7 +2779,7 @@ export interface MainThreadTestingShape { export const MainContext = { MainThreadAuthentication: createProxyIdentifier('MainThreadAuthentication'), MainThreadBulkEdits: createProxyIdentifier('MainThreadBulkEdits'), - MainThreadChatProvider: createProxyIdentifier('MainThreadChatProvider'), + MainThreadLanguageModels: createProxyIdentifier('MainThreadLanguageModels'), MainThreadChatAgents2: createProxyIdentifier('MainThreadChatAgents2'), MainThreadChatVariables: createProxyIdentifier('MainThreadChatVariables'), MainThreadClipboard: createProxyIdentifier('MainThreadClipboard'), @@ -2774,6 +2813,7 @@ export const MainContext = { MainThreadSpeech: createProxyIdentifier('MainThreadSpeechProvider'), MainThreadTelemetry: createProxyIdentifier('MainThreadTelemetry'), MainThreadTerminalService: createProxyIdentifier('MainThreadTerminalService'), + MainThreadTerminalShellIntegration: createProxyIdentifier('MainThreadTerminalShellIntegration'), MainThreadWebviews: createProxyIdentifier('MainThreadWebviews'), MainThreadWebviewPanels: createProxyIdentifier('MainThreadWebviewPanels'), MainThreadWebviewViews: createProxyIdentifier('MainThreadWebviewViews'), @@ -2806,8 +2846,7 @@ export const MainContext = { MainThreadTesting: createProxyIdentifier('MainThreadTesting'), MainThreadLocalization: createProxyIdentifier('MainThreadLocalizationShape'), MainThreadAiRelatedInformation: createProxyIdentifier('MainThreadAiRelatedInformation'), - MainThreadAiEmbeddingVector: createProxyIdentifier('MainThreadAiEmbeddingVector'), - MainThreadIssueReporter: createProxyIdentifier('MainThreadIssueReporter'), + MainThreadAiEmbeddingVector: createProxyIdentifier('MainThreadAiEmbeddingVector') }; export const ExtHostContext = { @@ -2834,6 +2873,7 @@ export const ExtHostContext = { ExtHostExtensionService: createProxyIdentifier('ExtHostExtensionService'), ExtHostLogLevelServiceShape: createProxyIdentifier('ExtHostLogLevelServiceShape'), ExtHostTerminalService: createProxyIdentifier('ExtHostTerminalService'), + ExtHostTerminalShellIntegration: createProxyIdentifier('ExtHostTerminalShellIntegration'), ExtHostSCM: createProxyIdentifier('ExtHostSCM'), ExtHostSearch: createProxyIdentifier('ExtHostSearch'), ExtHostTask: createProxyIdentifier('ExtHostTask'), @@ -2865,7 +2905,7 @@ export const ExtHostContext = { ExtHostChat: createProxyIdentifier('ExtHostChat'), ExtHostChatAgents2: createProxyIdentifier('ExtHostChatAgents'), ExtHostChatVariables: createProxyIdentifier('ExtHostChatVariables'), - ExtHostChatProvider: createProxyIdentifier('ExtHostChatProvider'), + ExtHostChatProvider: createProxyIdentifier('ExtHostChatProvider'), ExtHostSpeech: createProxyIdentifier('ExtHostSpeech'), ExtHostAiRelatedInformation: createProxyIdentifier('ExtHostAiRelatedInformation'), ExtHostAiEmbeddingVector: createProxyIdentifier('ExtHostAiEmbeddingVector'), @@ -2876,6 +2916,5 @@ export const ExtHostContext = { ExtHostTimeline: createProxyIdentifier('ExtHostTimeline'), ExtHostTesting: createProxyIdentifier('ExtHostTesting'), ExtHostTelemetry: createProxyIdentifier('ExtHostTelemetry'), - ExtHostLocalization: createProxyIdentifier('ExtHostLocalization'), - ExtHostIssueReporter: createProxyIdentifier('ExtHostIssueReporter'), + ExtHostLocalization: createProxyIdentifier('ExtHostLocalization') }; diff --git a/src/vs/workbench/api/common/extHostAuthentication.ts b/src/vs/workbench/api/common/extHostAuthentication.ts index ca07cbfef64d5..1c562edf76a19 100644 --- a/src/vs/workbench/api/common/extHostAuthentication.ts +++ b/src/vs/workbench/api/common/extHostAuthentication.ts @@ -5,9 +5,15 @@ import type * as vscode from 'vscode'; import { Emitter, Event } from 'vs/base/common/event'; -import { IMainContext, MainContext, MainThreadAuthenticationShape, ExtHostAuthenticationShape } from 'vs/workbench/api/common/extHost.protocol'; +import { MainContext, MainThreadAuthenticationShape, ExtHostAuthenticationShape } from 'vs/workbench/api/common/extHost.protocol'; import { Disposable } from 'vs/workbench/api/common/extHostTypes'; import { IExtensionDescription, ExtensionIdentifier } from 'vs/platform/extensions/common/extensions'; +import { INTERNAL_AUTH_PROVIDER_PREFIX } from 'vs/workbench/services/authentication/common/authentication'; +import { createDecorator } from 'vs/platform/instantiation/common/instantiation'; +import { IExtHostRpcService } from 'vs/workbench/api/common/extHostRpcService'; + +export interface IExtHostAuthentication extends ExtHostAuthentication { } +export const IExtHostAuthentication = createDecorator('IExtHostAuthentication'); interface ProviderWithMetadata { label: string; @@ -16,6 +22,9 @@ interface ProviderWithMetadata { } export class ExtHostAuthentication implements ExtHostAuthenticationShape { + + declare _serviceBrand: undefined; + private _proxy: MainThreadAuthenticationShape; private _authenticationProviders: Map = new Map(); @@ -25,8 +34,10 @@ export class ExtHostAuthentication implements ExtHostAuthenticationShape { private _getSessionTaskSingler = new TaskSingler(); private _getSessionsTaskSingler = new TaskSingler>(); - constructor(mainContext: IMainContext) { - this._proxy = mainContext.getProxy(MainContext.MainThreadAuthentication); + constructor( + @IExtHostRpcService extHostRpc: IExtHostRpcService + ) { + this._proxy = extHostRpc.getProxy(MainContext.MainThreadAuthentication); } async getSession(requestingExtension: IExtensionDescription, providerId: string, scopes: readonly string[], options: vscode.AuthenticationGetSessionOptions & ({ createIfNone: true } | { forceNewSession: true } | { forceNewSession: vscode.AuthenticationForceNewSessionOptions })): Promise; @@ -106,7 +117,10 @@ export class ExtHostAuthentication implements ExtHostAuthenticationShape { } $onDidChangeAuthenticationSessions(id: string, label: string) { - this._onDidChangeSessions.fire({ provider: { id, label } }); + // Don't fire events for the internal auth providers + if (!id.startsWith(INTERNAL_AUTH_PROVIDER_PREFIX)) { + this._onDidChangeSessions.fire({ provider: { id, label } }); + } return Promise.resolve(); } } diff --git a/src/vs/workbench/api/common/extHostBulkEdits.ts b/src/vs/workbench/api/common/extHostBulkEdits.ts index bdbfe2e5ba838..281a003c40c52 100644 --- a/src/vs/workbench/api/common/extHostBulkEdits.ts +++ b/src/vs/workbench/api/common/extHostBulkEdits.ts @@ -8,6 +8,7 @@ import { MainContext, MainThreadBulkEditsShape } from 'vs/workbench/api/common/e import { ExtHostDocumentsAndEditors } from 'vs/workbench/api/common/extHostDocumentsAndEditors'; import { IExtHostRpcService } from 'vs/workbench/api/common/extHostRpcService'; import { WorkspaceEdit } from 'vs/workbench/api/common/extHostTypeConverters'; +import { SerializableObjectWithBuffers } from 'vs/workbench/services/extensions/common/proxyIdentifier'; import type * as vscode from 'vscode'; export class ExtHostBulkEdits { @@ -28,7 +29,7 @@ export class ExtHostBulkEdits { } applyWorkspaceEdit(edit: vscode.WorkspaceEdit, extension: IExtensionDescription, metadata: vscode.WorkspaceEditMetadata | undefined): Promise { - const dto = WorkspaceEdit.from(edit, this._versionInformationProvider); + const dto = new SerializableObjectWithBuffers(WorkspaceEdit.from(edit, this._versionInformationProvider)); return this._proxy.$tryApplyWorkspaceEdit(dto, undefined, metadata?.isRefactoring ?? false); } } diff --git a/src/vs/workbench/api/common/extHostChat.ts b/src/vs/workbench/api/common/extHostChat.ts index d125a8b8f797d..38199dfef3b61 100644 --- a/src/vs/workbench/api/common/extHostChat.ts +++ b/src/vs/workbench/api/common/extHostChat.ts @@ -4,7 +4,6 @@ *--------------------------------------------------------------------------------------------*/ import { CancellationToken } from 'vs/base/common/cancellation'; -import { Iterable } from 'vs/base/common/iterator'; import { toDisposable } from 'vs/base/common/lifecycle'; import { IRelaxedExtensionDescription } from 'vs/platform/extensions/common/extensions'; import { ExtHostChatShape, IChatDto, IMainContext, MainContext, MainThreadChatShape } from 'vs/workbench/api/common/extHost.protocol'; @@ -49,17 +48,8 @@ export class ExtHostChat implements ExtHostChatShape { }); } - transferChatSession(session: vscode.InteractiveSession, newWorkspace: vscode.Uri): void { - const sessionId = Iterable.find(this._chatSessions.keys(), key => this._chatSessions.get(key) === session) ?? 0; - if (typeof sessionId !== 'number') { - return; - } - - this._proxy.$transferChatSession(sessionId, newWorkspace); - } - - sendInteractiveRequestToProvider(providerId: string, message: vscode.InteractiveSessionDynamicRequest): void { - this._proxy.$sendRequestToProvider(providerId, message); + transferActiveChat(newWorkspace: vscode.Uri): void { + this._proxy.$transferActiveChatSession(newWorkspace); } async $prepareChat(handle: number, token: CancellationToken): Promise { @@ -78,11 +68,6 @@ export class ExtHostChat implements ExtHostChatShape { return { id, - requesterUsername: session.requester?.name, - requesterAvatarIconUri: session.requester?.icon, - responderUsername: session.responder?.name, - responderAvatarIconUri: session.responder?.icon, - inputPlaceholder: session.inputPlaceholder, }; } diff --git a/src/vs/workbench/api/common/extHostChatAgents2.ts b/src/vs/workbench/api/common/extHostChatAgents2.ts index dbcce3f64d7a1..b2fc70c434635 100644 --- a/src/vs/workbench/api/common/extHostChatAgents2.ts +++ b/src/vs/workbench/api/common/extHostChatAgents2.ts @@ -3,22 +3,27 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { Location } from 'vs/editor/common/languages'; import { coalesce } from 'vs/base/common/arrays'; import { raceCancellation } from 'vs/base/common/async'; import { CancellationToken } from 'vs/base/common/cancellation'; import { toErrorMessage } from 'vs/base/common/errorMessage'; import { Emitter } from 'vs/base/common/event'; -import { IMarkdownString, MarkdownString } from 'vs/base/common/htmlContent'; +import { IMarkdownString } from 'vs/base/common/htmlContent'; +import { Iterable } from 'vs/base/common/iterator'; +import { DisposableMap, DisposableStore } from 'vs/base/common/lifecycle'; import { StopWatch } from 'vs/base/common/stopwatch'; +import { assertType } from 'vs/base/common/types'; import { URI } from 'vs/base/common/uri'; import { localize } from 'vs/nls'; -import { IExtensionDescription } from 'vs/platform/extensions/common/extensions'; +import { ExtensionIdentifier, IExtensionDescription } from 'vs/platform/extensions/common/extensions'; import { ILogService } from 'vs/platform/log/common/log'; import { ExtHostChatAgentsShape2, IChatAgentCompletionItem, IChatAgentHistoryEntryDto, IMainContext, MainContext, MainThreadChatAgentsShape2 } from 'vs/workbench/api/common/extHost.protocol'; +import { CommandsConverter, ExtHostCommands } from 'vs/workbench/api/common/extHostCommands'; import * as typeConvert from 'vs/workbench/api/common/extHostTypeConverters'; import * as extHostTypes from 'vs/workbench/api/common/extHostTypes'; -import { IChatAgentCommand, IChatAgentRequest, IChatAgentResult } from 'vs/workbench/contrib/chat/common/chatAgents'; -import { IChatFollowup, IChatProgress, IChatReplyFollowup, IChatUserActionEvent, InteractiveSessionVoteDirection } from 'vs/workbench/contrib/chat/common/chatService'; +import { IChatAgentRequest, IChatAgentResult } from 'vs/workbench/contrib/chat/common/chatAgents'; +import { IChatContentReference, IChatFollowup, IChatProgress, IChatUserActionEvent, InteractiveSessionVoteDirection } from 'vs/workbench/contrib/chat/common/chatService'; import { checkProposedApiEnabled, isProposedApiEnabled } from 'vs/workbench/services/extensions/common/extensions'; import { Dto } from 'vs/workbench/services/extensions/common/proxyIdentifier'; import type * as vscode from 'vscode'; @@ -28,13 +33,15 @@ class ChatAgentResponseStream { private _stopWatch = StopWatch.create(false); private _isClosed: boolean = false; private _firstProgress: number | undefined; - private _apiObject: vscode.ChatAgentExtendedResponseStream | undefined; + private _apiObject: vscode.ChatExtendedResponseStream | undefined; constructor( private readonly _extension: IExtensionDescription, private readonly _request: IChatAgentRequest, private readonly _proxy: MainThreadChatAgentsShape2, - @ILogService private readonly _logService: ILogService, + private readonly _logService: ILogService, + private readonly _commandsConverter: CommandsConverter, + private readonly _sessionDisposables: DisposableStore ) { } close() { @@ -72,50 +79,98 @@ class ChatAgentResponseStream { }; this._apiObject = { - text(value) { - throwIfDone(this.text); - this.markdown(new MarkdownString().appendText(value)); - return this; - }, markdown(value) { throwIfDone(this.markdown); const part = new extHostTypes.ChatResponseMarkdownPart(value); - const dto = typeConvert.ChatResponseMarkdownPart.to(part); + const dto = typeConvert.ChatResponseMarkdownPart.from(part); _report(dto); return this; }, filetree(value, baseUri) { throwIfDone(this.filetree); const part = new extHostTypes.ChatResponseFileTreePart(value, baseUri); - const dto = typeConvert.ChatResponseFilesPart.to(part); + const dto = typeConvert.ChatResponseFilesPart.from(part); _report(dto); return this; }, anchor(value, title?: string) { throwIfDone(this.anchor); const part = new extHostTypes.ChatResponseAnchorPart(value, title); - const dto = typeConvert.ChatResponseAnchorPart.to(part); + const dto = typeConvert.ChatResponseAnchorPart.from(part); + _report(dto); + return this; + }, + button(value) { + throwIfDone(this.anchor); + const part = new extHostTypes.ChatResponseCommandButtonPart(value); + const dto = typeConvert.ChatResponseCommandButtonPart.from(part, that._commandsConverter, that._sessionDisposables); _report(dto); return this; }, progress(value) { throwIfDone(this.progress); const part = new extHostTypes.ChatResponseProgressPart(value); - const dto = typeConvert.ChatResponseProgressPart.to(part); + const dto = typeConvert.ChatResponseProgressPart.from(part); _report(dto); return this; }, reference(value) { throwIfDone(this.reference); - const part = new extHostTypes.ChatResponseReferencePart(value); - const dto = typeConvert.ChatResponseReferencePart.to(part); + + if ('variableName' in value && !value.value) { + // The participant used this variable. Does that variable have any references to pull in? + const matchingVarData = that._request.variables.variables.find(v => v.name === value.variableName); + if (matchingVarData) { + let references: Dto[] | undefined; + if (matchingVarData.references?.length) { + references = matchingVarData.references.map(r => ({ + kind: 'reference', + reference: { variableName: value.variableName, value: r.reference as URI | Location } + } satisfies IChatContentReference)); + } else { + // Participant sent a variableName reference but the variable produced no references. Show variable reference with no value + const part = new extHostTypes.ChatResponseReferencePart(value); + const dto = typeConvert.ChatResponseReferencePart.from(part); + references = [dto]; + } + + references.forEach(r => _report(r)); + return this; + } else { + // Something went wrong- that variable doesn't actually exist + } + } else { + const part = new extHostTypes.ChatResponseReferencePart(value); + const dto = typeConvert.ChatResponseReferencePart.from(part); + _report(dto); + } + + return this; + }, + textEdit(target, edits) { + throwIfDone(this.textEdit); + checkProposedApiEnabled(that._extension, 'chatParticipantAdditions'); + + const part = new extHostTypes.ChatResponseTextEditPart(target, edits); + const dto = typeConvert.ChatResponseTextEditPart.from(part); _report(dto); return this; }, push(part) { throwIfDone(this.push); - const dto = typeConvert.ChatResponsePart.to(part); - _report(dto); + + if (part instanceof extHostTypes.ChatResponseTextEditPart) { + checkProposedApiEnabled(that._extension, 'chatParticipantAdditions'); + } + + if (part instanceof extHostTypes.ChatResponseReferencePart) { + // Ensure variable reference values get fixed up + this.reference(part.value); + } else { + const dto = typeConvert.ChatResponsePart.from(part, that._commandsConverter, that._sessionDisposables); + _report(dto); + } + return this; }, report(progress) { @@ -145,41 +200,53 @@ export class ExtHostChatAgents2 implements ExtHostChatAgentsShape2 { private static _idPool = 0; - private readonly _agents = new Map>(); + private readonly _agents = new Map(); private readonly _proxy: MainThreadChatAgentsShape2; - private readonly _previousResultMap: Map = new Map(); - private readonly _resultsBySessionAndRequestId: Map> = new Map(); + private readonly _sessionDisposables: DisposableMap = new DisposableMap(); constructor( mainContext: IMainContext, private readonly _logService: ILogService, + private readonly commands: ExtHostCommands, ) { this._proxy = mainContext.getProxy(MainContext.MainThreadChatAgents2); } - createChatAgent(extension: IExtensionDescription, name: string, handler: vscode.ChatAgentExtendedHandler): vscode.ChatAgent2 { + createChatAgent(extension: IExtensionDescription, id: string, handler: vscode.ChatExtendedRequestHandler): vscode.ChatParticipant { const handle = ExtHostChatAgents2._idPool++; - const agent = new ExtHostChatAgent(extension, name, this._proxy, handle, handler); + const agent = new ExtHostChatAgent(extension, id, this._proxy, handle, handler); this._agents.set(handle, agent); - this._proxy.$registerAgent(handle, name, {}); + this._proxy.$registerAgent(handle, extension.identifier, id, {}, undefined); return agent.apiAgent; } - async $invokeAgent(handle: number, request: IChatAgentRequest, context: { history: IChatAgentHistoryEntryDto[] }, token: CancellationToken): Promise { - // Clear the previous result so that $acceptFeedback or $acceptAction during a request will be ignored. - // We may want to support sending those during a request. - this._previousResultMap.delete(request.sessionId); + createDynamicChatAgent(extension: IExtensionDescription, id: string, name: string, description: string, handler: vscode.ChatExtendedRequestHandler): vscode.ChatParticipant { + const handle = ExtHostChatAgents2._idPool++; + const agent = new ExtHostChatAgent(extension, id, this._proxy, handle, handler); + this._agents.set(handle, agent); + this._proxy.$registerAgent(handle, extension.identifier, id, { isSticky: true }, { name, description }); + return agent.apiAgent; + } + + async $invokeAgent(handle: number, request: IChatAgentRequest, context: { history: IChatAgentHistoryEntryDto[] }, token: CancellationToken): Promise { const agent = this._agents.get(handle); if (!agent) { throw new Error(`[CHAT](${handle}) CANNOT invoke agent because the agent is not registered`); } - const stream = new ChatAgentResponseStream(agent.extension, request, this._proxy, this._logService); + // Init session disposables + let sessionDisposables = this._sessionDisposables.get(request.sessionId); + if (!sessionDisposables) { + sessionDisposables = new DisposableStore(); + this._sessionDisposables.set(request.sessionId, sessionDisposables); + } + + const stream = new ChatAgentResponseStream(agent.extension, request, this._proxy, this._logService, this.commands.converter, sessionDisposables); try { - const convertedHistory = await this.prepareHistory(agent, request, context); + const convertedHistory = await this.prepareHistoryTurns(request.agentId, context); const task = agent.invoke( typeConvert.ChatAgentRequest.to(request), { history: convertedHistory }, @@ -188,109 +255,110 @@ export class ExtHostChatAgents2 implements ExtHostChatAgentsShape2 { ); return await raceCancellation(Promise.resolve(task).then((result) => { - if (result) { - this._previousResultMap.set(request.sessionId, result); - let sessionResults = this._resultsBySessionAndRequestId.get(request.sessionId); - if (!sessionResults) { - sessionResults = new Map(); - this._resultsBySessionAndRequestId.set(request.sessionId, sessionResults); + if (result?.metadata) { + try { + JSON.stringify(result.metadata); + } catch (err) { + const msg = `result.metadata MUST be JSON.stringify-able. Got error: ${err.message}`; + this._logService.error(`[${agent.extension.identifier.value}] [@${agent.id}] ${msg}`, agent.extension); + return { errorDetails: { message: msg }, timings: stream.timings }; } - sessionResults.set(request.requestId, result); - - return { errorDetails: result.errorDetails, timings: stream.timings }; - } else { - this._previousResultMap.delete(request.sessionId); } - - return undefined; + return { errorDetails: result?.errorDetails, timings: stream.timings, metadata: result?.metadata }; }), token); } catch (e) { this._logService.error(e, agent.extension); - return { errorDetails: { message: localize('errorResponse', "Error from provider: {0}", toErrorMessage(e)), responseIsIncomplete: true } }; + return { errorDetails: { message: localize('errorResponse', "Error from participant: {0}", toErrorMessage(e)), responseIsIncomplete: true } }; } finally { stream.close(); } } - private async prepareHistory(agent: ExtHostChatAgent, request: IChatAgentRequest, context: { history: IChatAgentHistoryEntryDto[] }): Promise { - return coalesce(await Promise.all(context.history - .map(async h => { - const result = request.agentId === h.request.agentId && this._resultsBySessionAndRequestId.get(request.sessionId)?.get(h.request.requestId) - || h.result; - return { - request: typeConvert.ChatAgentRequest.to(h.request), - response: coalesce(h.response.map(r => typeConvert.ChatResponsePart.from(r))), - result - } satisfies vscode.ChatAgentHistoryEntry; - }))); - } + private async prepareHistoryTurns(agentId: string, context: { history: IChatAgentHistoryEntryDto[] }): Promise<(vscode.ChatRequestTurn | vscode.ChatResponseTurn)[]> { - $releaseSession(sessionId: string): void { - this._previousResultMap.delete(sessionId); - this._resultsBySessionAndRequestId.delete(sessionId); - } + const res: (vscode.ChatRequestTurn | vscode.ChatResponseTurn)[] = []; - async $provideSlashCommands(handle: number, token: CancellationToken): Promise { - const agent = this._agents.get(handle); - if (!agent) { - // this is OK, the agent might have disposed while the request was in flight - return []; + for (const h of context.history) { + const ehResult = typeConvert.ChatAgentResult.to(h.result); + const result: vscode.ChatResult = agentId === h.request.agentId ? + ehResult : + { ...ehResult, metadata: undefined }; + + // REQUEST turn + res.push(new extHostTypes.ChatRequestTurn(h.request.message, h.request.command, h.request.variables.variables.map(typeConvert.ChatAgentResolvedVariable.to), h.request.agentId)); + + // RESPONSE turn + const parts = coalesce(h.response.map(r => typeConvert.ChatResponsePart.toContent(r, this.commands.converter))); + res.push(new extHostTypes.ChatResponseTurn(parts, result, h.request.agentId, h.request.command)); } - return agent.provideSlashCommands(token); + + return res; + } + + $releaseSession(sessionId: string): void { + this._sessionDisposables.deleteAndDispose(sessionId); } - $provideFollowups(handle: number, sessionId: string, token: CancellationToken): Promise { + async $provideFollowups(request: IChatAgentRequest, handle: number, result: IChatAgentResult, context: { history: IChatAgentHistoryEntryDto[] }, token: CancellationToken): Promise { const agent = this._agents.get(handle); if (!agent) { return Promise.resolve([]); } - const result = this._previousResultMap.get(sessionId); - if (!result) { - return Promise.resolve([]); - } - - return agent.provideFollowups(result, token); + const convertedHistory = await this.prepareHistoryTurns(agent.id, context); + + const ehResult = typeConvert.ChatAgentResult.to(result); + return (await agent.provideFollowups(ehResult, { history: convertedHistory }, token)) + .filter(f => { + // The followup must refer to a participant that exists from the same extension + const isValid = !f.participant || Iterable.some( + this._agents.values(), + a => a.id === f.participant && ExtensionIdentifier.equals(a.extension.identifier, agent.extension.identifier)); + if (!isValid) { + this._logService.warn(`[@${agent.id}] ChatFollowup refers to an unknown participant: ${f.participant}`); + } + return isValid; + }) + .map(f => typeConvert.ChatFollowup.from(f, request)); } - $acceptFeedback(handle: number, sessionId: string, requestId: string, vote: InteractiveSessionVoteDirection, reportIssue?: boolean): void { + $acceptFeedback(handle: number, result: IChatAgentResult, vote: InteractiveSessionVoteDirection, reportIssue?: boolean): void { const agent = this._agents.get(handle); if (!agent) { return; } - const result = this._resultsBySessionAndRequestId.get(sessionId)?.get(requestId); - if (!result) { - return; - } - let kind: extHostTypes.ChatAgentResultFeedbackKind; + const ehResult = typeConvert.ChatAgentResult.to(result); + let kind: extHostTypes.ChatResultFeedbackKind; switch (vote) { case InteractiveSessionVoteDirection.Down: - kind = extHostTypes.ChatAgentResultFeedbackKind.Unhelpful; + kind = extHostTypes.ChatResultFeedbackKind.Unhelpful; break; case InteractiveSessionVoteDirection.Up: - kind = extHostTypes.ChatAgentResultFeedbackKind.Helpful; + kind = extHostTypes.ChatResultFeedbackKind.Helpful; break; } - agent.acceptFeedback(reportIssue ? Object.freeze({ result, kind, reportIssue }) : Object.freeze({ result, kind })); + agent.acceptFeedback(reportIssue ? + Object.freeze({ result: ehResult, kind, reportIssue }) : + Object.freeze({ result: ehResult, kind })); } - $acceptAction(handle: number, sessionId: string, requestId: string, action: IChatUserActionEvent): void { + $acceptAction(handle: number, result: IChatAgentResult, event: IChatUserActionEvent): void { const agent = this._agents.get(handle); if (!agent) { return; } - const result = this._resultsBySessionAndRequestId.get(sessionId)?.get(requestId); - if (!result) { - return; - } - if (action.action.kind === 'vote') { + if (event.action.kind === 'vote') { // handled by $acceptFeedback return; } - agent.acceptAction(Object.freeze({ action: action.action, result })); + + const ehAction = typeConvert.ChatAgentUserActionEvent.to(result, event, this.commands.converter); + if (ehAction) { + agent.acceptAction(Object.freeze(ehAction)); + } } async $invokeCompletionProvider(handle: number, query: string, token: CancellationToken): Promise { @@ -312,51 +380,52 @@ export class ExtHostChatAgents2 implements ExtHostChatAgentsShape2 { return await agent.provideWelcomeMessage(token); } - async $provideSampleQuestions(handle: number, token: CancellationToken): Promise { + async $provideSampleQuestions(handle: number, token: CancellationToken): Promise { const agent = this._agents.get(handle); if (!agent) { return; } - return await agent.provideSampleQuestions(token); + return (await agent.provideSampleQuestions(token)) + .map(f => typeConvert.ChatFollowup.from(f, undefined)); } } -class ExtHostChatAgent { +class ExtHostChatAgent { - private _subCommandProvider: vscode.ChatAgentSubCommandProvider | undefined; - private _followupProvider: vscode.ChatAgentFollowupProvider | undefined; - private _description: string | undefined; + private _followupProvider: vscode.ChatFollowupProvider | undefined; private _fullName: string | undefined; private _iconPath: vscode.Uri | { light: vscode.Uri; dark: vscode.Uri } | vscode.ThemeIcon | undefined; private _isDefault: boolean | undefined; private _helpTextPrefix: string | vscode.MarkdownString | undefined; + private _helpTextVariablesPrefix: string | vscode.MarkdownString | undefined; private _helpTextPostfix: string | vscode.MarkdownString | undefined; private _sampleRequest?: string; private _isSecondary: boolean | undefined; - private _onDidReceiveFeedback = new Emitter>(); - private _onDidPerformAction = new Emitter(); + private _onDidReceiveFeedback = new Emitter(); + private _onDidPerformAction = new Emitter(); private _supportIssueReporting: boolean | undefined; - private _agentVariableProvider?: { provider: vscode.ChatAgentCompletionItemProvider; triggerCharacters: string[] }; - private _welcomeMessageProvider?: vscode.ChatAgentWelcomeMessageProvider | undefined; + private _agentVariableProvider?: { provider: vscode.ChatParticipantCompletionItemProvider; triggerCharacters: string[] }; + private _welcomeMessageProvider?: vscode.ChatWelcomeMessageProvider | undefined; + private _requester: vscode.ChatRequesterInformation | undefined; constructor( public readonly extension: IExtensionDescription, public readonly id: string, private readonly _proxy: MainThreadChatAgentsShape2, private readonly _handle: number, - private readonly _callback: vscode.ChatAgentExtendedHandler, + private _requestHandler: vscode.ChatExtendedRequestHandler, ) { } - acceptFeedback(feedback: vscode.ChatAgentResult2Feedback) { + acceptFeedback(feedback: vscode.ChatResultFeedback) { this._onDidReceiveFeedback.fire(feedback); } - acceptAction(event: vscode.ChatAgentUserActionEvent) { + acceptAction(event: vscode.ChatUserActionEvent) { this._onDidPerformAction.fire(event); } - async invokeCompletionProvider(query: string, token: CancellationToken): Promise { + async invokeCompletionProvider(query: string, token: CancellationToken): Promise { if (!this._agentVariableProvider) { return []; } @@ -364,33 +433,20 @@ class ExtHostChatAgent { return await this._agentVariableProvider.provider.provideCompletionItems(query, token) ?? []; } - async provideSlashCommands(token: CancellationToken): Promise { - if (!this._subCommandProvider) { - return []; - } - const result = await this._subCommandProvider.provideSubCommands(token); - if (!result) { - return []; - } - return result - .map(c => ({ - name: c.name, - description: c.description, - followupPlaceholder: c.followupPlaceholder, - shouldRepopulate: c.shouldRepopulate, - sampleRequest: c.sampleRequest - })); - } - - async provideFollowups(result: TResult, token: CancellationToken): Promise { + async provideFollowups(result: vscode.ChatResult, context: vscode.ChatContext, token: CancellationToken): Promise { if (!this._followupProvider) { return []; } - const followups = await this._followupProvider.provideFollowups(result, token); + + const followups = await this._followupProvider.provideFollowups(result, context, token); if (!followups) { return []; } - return followups.map(f => typeConvert.ChatFollowup.from(f)); + return followups + // Filter out "command followups" from older providers + .filter(f => !(f && 'commandId' in f)) + // Filter out followups from older providers before 'message' changed to 'prompt' + .filter(f => !(f && 'message' in f)); } async provideWelcomeMessage(token: CancellationToken): Promise<(string | IMarkdownString)[] | undefined> { @@ -410,7 +466,7 @@ class ExtHostChatAgent { }); } - async provideSampleQuestions(token: CancellationToken): Promise { + async provideSampleQuestions(token: CancellationToken): Promise { if (!this._welcomeMessageProvider || !this._welcomeMessageProvider.provideSampleQuestions) { return []; } @@ -419,10 +475,10 @@ class ExtHostChatAgent { return []; } - return content?.map(f => typeConvert.ChatReplyFollowup.from(f)); + return content; } - get apiAgent(): vscode.ChatAgent2 { + get apiAgent(): vscode.ChatParticipant { let disposed = false; let updateScheduled = false; const updateMetadataSoon = () => { @@ -435,7 +491,6 @@ class ExtHostChatAgent { updateScheduled = true; queueMicrotask(() => { this._proxy.$updateAgent(this._handle, { - description: this._description ?? '', fullName: this._fullName, icon: !this._iconPath ? undefined : this._iconPath instanceof URI ? this._iconPath : @@ -445,14 +500,14 @@ class ExtHostChatAgent { 'dark' in this._iconPath ? this._iconPath.dark : undefined, themeIcon: this._iconPath instanceof extHostTypes.ThemeIcon ? this._iconPath : undefined, - hasSlashCommands: this._subCommandProvider !== undefined, hasFollowups: this._followupProvider !== undefined, - isDefault: this._isDefault, isSecondary: this._isSecondary, helpTextPrefix: (!this._helpTextPrefix || typeof this._helpTextPrefix === 'string') ? this._helpTextPrefix : typeConvert.MarkdownString.from(this._helpTextPrefix), + helpTextVariablesPrefix: (!this._helpTextVariablesPrefix || typeof this._helpTextVariablesPrefix === 'string') ? this._helpTextVariablesPrefix : typeConvert.MarkdownString.from(this._helpTextVariablesPrefix), helpTextPostfix: (!this._helpTextPostfix || typeof this._helpTextPostfix === 'string') ? this._helpTextPostfix : typeConvert.MarkdownString.from(this._helpTextPostfix), sampleRequest: this._sampleRequest, - supportIssueReporting: this._supportIssueReporting + supportIssueReporting: this._supportIssueReporting, + requester: this._requester }); updateScheduled = false; }); @@ -460,20 +515,15 @@ class ExtHostChatAgent { const that = this; return { - get name() { + get id() { return that.id; }, - get description() { - return that._description ?? ''; - }, - set description(v) { - that._description = v; - updateMetadataSoon(); - }, get fullName() { + checkProposedApiEnabled(that.extension, 'defaultChatParticipant'); return that._fullName ?? that.extension.displayName ?? that.extension.name; }, set fullName(v) { + checkProposedApiEnabled(that.extension, 'defaultChatParticipant'); that._fullName = v; updateMetadataSoon(); }, @@ -484,12 +534,12 @@ class ExtHostChatAgent { that._iconPath = v; updateMetadataSoon(); }, - get subCommandProvider() { - return that._subCommandProvider; + get requestHandler() { + return that._requestHandler; }, - set subCommandProvider(v) { - that._subCommandProvider = v; - updateMetadataSoon(); + set requestHandler(v) { + assertType(typeof v === 'function', 'Invalid request handler'); + that._requestHandler = v; }, get followupProvider() { return that._followupProvider; @@ -499,46 +549,47 @@ class ExtHostChatAgent { updateMetadataSoon(); }, get isDefault() { - checkProposedApiEnabled(that.extension, 'defaultChatAgent'); + checkProposedApiEnabled(that.extension, 'defaultChatParticipant'); return that._isDefault; }, set isDefault(v) { - checkProposedApiEnabled(that.extension, 'defaultChatAgent'); + checkProposedApiEnabled(that.extension, 'defaultChatParticipant'); that._isDefault = v; updateMetadataSoon(); }, get helpTextPrefix() { - checkProposedApiEnabled(that.extension, 'defaultChatAgent'); + checkProposedApiEnabled(that.extension, 'defaultChatParticipant'); return that._helpTextPrefix; }, set helpTextPrefix(v) { - checkProposedApiEnabled(that.extension, 'defaultChatAgent'); - if (!that._isDefault) { - throw new Error('helpTextPrefix is only available on the default chat agent'); - } - + checkProposedApiEnabled(that.extension, 'defaultChatParticipant'); that._helpTextPrefix = v; updateMetadataSoon(); }, + get helpTextVariablesPrefix() { + checkProposedApiEnabled(that.extension, 'defaultChatParticipant'); + return that._helpTextVariablesPrefix; + }, + set helpTextVariablesPrefix(v) { + checkProposedApiEnabled(that.extension, 'defaultChatParticipant'); + that._helpTextVariablesPrefix = v; + updateMetadataSoon(); + }, get helpTextPostfix() { - checkProposedApiEnabled(that.extension, 'defaultChatAgent'); + checkProposedApiEnabled(that.extension, 'defaultChatParticipant'); return that._helpTextPostfix; }, set helpTextPostfix(v) { - checkProposedApiEnabled(that.extension, 'defaultChatAgent'); - if (!that._isDefault) { - throw new Error('helpTextPostfix is only available on the default chat agent'); - } - + checkProposedApiEnabled(that.extension, 'defaultChatParticipant'); that._helpTextPostfix = v; updateMetadataSoon(); }, get isSecondary() { - checkProposedApiEnabled(that.extension, 'defaultChatAgent'); + checkProposedApiEnabled(that.extension, 'defaultChatParticipant'); return that._isSecondary; }, set isSecondary(v) { - checkProposedApiEnabled(that.extension, 'defaultChatAgent'); + checkProposedApiEnabled(that.extension, 'defaultChatParticipant'); that._isSecondary = v; updateMetadataSoon(); }, @@ -550,18 +601,19 @@ class ExtHostChatAgent { updateMetadataSoon(); }, get supportIssueReporting() { - checkProposedApiEnabled(that.extension, 'chatAgents2Additions'); + checkProposedApiEnabled(that.extension, 'chatParticipantAdditions'); return that._supportIssueReporting; }, set supportIssueReporting(v) { - checkProposedApiEnabled(that.extension, 'chatAgents2Additions'); + checkProposedApiEnabled(that.extension, 'chatParticipantAdditions'); that._supportIssueReporting = v; updateMetadataSoon(); }, get onDidReceiveFeedback() { return that._onDidReceiveFeedback.event; }, - set agentVariableProvider(v) { + set participantVariableProvider(v) { + checkProposedApiEnabled(that.extension, 'chatParticipantAdditions'); that._agentVariableProvider = v; if (v) { if (!v.triggerCharacters.length) { @@ -573,31 +625,40 @@ class ExtHostChatAgent { that._proxy.$unregisterAgentCompletionsProvider(that._handle); } }, - get agentVariableProvider() { + get participantVariableProvider() { + checkProposedApiEnabled(that.extension, 'chatParticipantAdditions'); return that._agentVariableProvider; }, set welcomeMessageProvider(v) { + checkProposedApiEnabled(that.extension, 'defaultChatParticipant'); that._welcomeMessageProvider = v; updateMetadataSoon(); }, get welcomeMessageProvider() { + checkProposedApiEnabled(that.extension, 'defaultChatParticipant'); return that._welcomeMessageProvider; }, - onDidPerformAction: !isProposedApiEnabled(this.extension, 'chatAgents2Additions') + onDidPerformAction: !isProposedApiEnabled(this.extension, 'chatParticipantAdditions') ? undefined! : this._onDidPerformAction.event , + set requester(v) { + that._requester = v; + updateMetadataSoon(); + }, + get requester() { + return that._requester; + }, dispose() { disposed = true; - that._subCommandProvider = undefined; that._followupProvider = undefined; that._onDidReceiveFeedback.dispose(); that._proxy.$unregisterAgent(that._handle); }, - } satisfies vscode.ChatAgent2; + } satisfies vscode.ChatParticipant; } - invoke(request: vscode.ChatAgentRequest, context: vscode.ChatAgentContext, response: vscode.ChatAgentExtendedResponseStream, token: CancellationToken): vscode.ProviderResult { - return this._callback(request, context, response, token); + invoke(request: vscode.ChatRequest, context: vscode.ChatContext, response: vscode.ChatExtendedResponseStream, token: CancellationToken): vscode.ProviderResult { + return this._requestHandler(request, context, response, token); } } diff --git a/src/vs/workbench/api/common/extHostChatProvider.ts b/src/vs/workbench/api/common/extHostChatProvider.ts deleted file mode 100644 index c76f419515b31..0000000000000 --- a/src/vs/workbench/api/common/extHostChatProvider.ts +++ /dev/null @@ -1,256 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { CancellationToken, CancellationTokenSource } from 'vs/base/common/cancellation'; -import { IDisposable, toDisposable } from 'vs/base/common/lifecycle'; -import { ILogService } from 'vs/platform/log/common/log'; -import { ExtHostChatProviderShape, IMainContext, MainContext, MainThreadChatProviderShape } from 'vs/workbench/api/common/extHost.protocol'; -import * as typeConvert from 'vs/workbench/api/common/extHostTypeConverters'; -import type * as vscode from 'vscode'; -import { Progress } from 'vs/platform/progress/common/progress'; -import { IChatMessage, IChatResponseFragment } from 'vs/workbench/contrib/chat/common/chatProvider'; -import { ExtensionIdentifier, ExtensionIdentifierMap, ExtensionIdentifierSet } from 'vs/platform/extensions/common/extensions'; -import { AsyncIterableSource } from 'vs/base/common/async'; -import { Emitter, Event } from 'vs/base/common/event'; - -type LanguageModelData = { - readonly extension: ExtensionIdentifier; - readonly provider: vscode.ChatResponseProvider; -}; - -class LanguageModelResponseStream { - - readonly apiObj: vscode.LanguageModelResponseStream; - readonly stream = new AsyncIterableSource(); - - constructor(option: number, stream?: AsyncIterableSource) { - this.stream = stream ?? new AsyncIterableSource(); - const that = this; - this.apiObj = { - option: option, - response: that.stream.asyncIterable - }; - } -} - -class LanguageModelRequest { - - readonly apiObject: vscode.LanguageModelRequest; - - private readonly _onDidStart = new Emitter(); - private readonly _responseStreams = new Map(); - private readonly _defaultStream = new AsyncIterableSource(); - private _isDone: boolean = false; - - constructor( - promise: Promise, - readonly cts: CancellationTokenSource - ) { - const that = this; - this.apiObject = { - result: promise, - response: that._defaultStream.asyncIterable, - onDidStartResponseStream: that._onDidStart.event, - cancel() { cts.cancel(); }, - }; - - promise.finally(() => { - this._isDone = true; - if (this._responseStreams.size > 0) { - for (const [, value] of this._responseStreams) { - value.stream.resolve(); - } - } else { - this._defaultStream.resolve(); - } - }); - } - - handleFragment(fragment: IChatResponseFragment): void { - if (this._isDone) { - return; - } - let res = this._responseStreams.get(fragment.index); - if (!res) { - if (this._responseStreams.size === 0) { - // the first response claims the default response - res = new LanguageModelResponseStream(fragment.index, this._defaultStream); - } else { - res = new LanguageModelResponseStream(fragment.index); - } - this._responseStreams.set(fragment.index, res); - this._onDidStart.fire(res.apiObj); - } - res.stream.emitOne(fragment.part); - } - -} - -export class ExtHostChatProvider implements ExtHostChatProviderShape { - - private static _idPool = 1; - - private readonly _proxy: MainThreadChatProviderShape; - private readonly _onDidChangeAccess = new Emitter(); - private readonly _onDidChangeProviders = new Emitter(); - readonly onDidChangeProviders = this._onDidChangeProviders.event; - - private readonly _languageModels = new Map(); - private readonly _languageModelIds = new Set(); // these are ALL models, not just the one in this EH - private readonly _accesslist = new ExtensionIdentifierMap(); - private readonly _pendingRequest = new Map(); - - - constructor( - mainContext: IMainContext, - private readonly _logService: ILogService, - ) { - this._proxy = mainContext.getProxy(MainContext.MainThreadChatProvider); - } - - dispose(): void { - this._onDidChangeAccess.dispose(); - this._onDidChangeProviders.dispose(); - } - - registerLanguageModel(extension: ExtensionIdentifier, identifier: string, provider: vscode.ChatResponseProvider, metadata: vscode.ChatResponseProviderMetadata): IDisposable { - - const handle = ExtHostChatProvider._idPool++; - this._languageModels.set(handle, { extension, provider }); - this._proxy.$registerProvider(handle, identifier, { extension, model: metadata.name ?? '' }); - - return toDisposable(() => { - this._languageModels.delete(handle); - this._proxy.$unregisterProvider(handle); - }); - } - - async $provideLanguageModelResponse(handle: number, requestId: number, messages: IChatMessage[], options: { [name: string]: any }, token: CancellationToken): Promise { - const data = this._languageModels.get(handle); - if (!data) { - return; - } - const progress = new Progress(async fragment => { - if (token.isCancellationRequested) { - this._logService.warn(`[CHAT](${data.extension.value}) CANNOT send progress because the REQUEST IS CANCELLED`); - return; - } - this._proxy.$handleProgressChunk(requestId, { index: fragment.index, part: fragment.part }); - }); - - return data.provider.provideChatResponse(messages.map(typeConvert.ChatMessage.to), options, progress, token); - } - - //#region --- making request - - - - - $updateLanguageModels(data: { added?: string[] | undefined; removed?: string[] | undefined }): void { - const added: string[] = []; - const removed: string[] = []; - if (data.added) { - for (const id of data.added) { - this._languageModelIds.add(id); - added.push(id); - } - } - if (data.removed) { - for (const id of data.removed) { - // clean up - this._languageModelIds.delete(id); - removed.push(id); - - // cancel pending requests for this model - for (const [key, value] of this._pendingRequest) { - if (value.languageModelId === id) { - value.res.cts.cancel(); - this._pendingRequest.delete(key); - } - } - } - } - - this._onDidChangeProviders.fire(Object.freeze({ - added: Object.freeze(added), - removed: Object.freeze(removed) - })); - } - - getLanguageModelIds(): string[] { - return Array.from(this._languageModelIds); - } - - $updateAccesslist(data: { extension: ExtensionIdentifier; enabled: boolean }[]): void { - const updated = new ExtensionIdentifierSet(); - for (const { extension, enabled } of data) { - const oldValue = this._accesslist.get(extension); - if (oldValue !== enabled) { - this._accesslist.set(extension, enabled); - updated.add(extension); - } - } - this._onDidChangeAccess.fire(updated); - } - - async requestLanguageModelAccess(from: ExtensionIdentifier, languageModelId: string, options?: vscode.LanguageModelAccessOptions): Promise { - // check if the extension is in the access list and allowed to make chat requests - if (this._accesslist.get(from) === false) { - throw new Error('Extension is NOT allowed to make chat requests'); - } - - const metadata = await this._proxy.$prepareChatAccess(from, languageModelId, options?.justification); - - if (!metadata) { - if (!this._accesslist.get(from)) { - throw new Error('Extension is NOT allowed to make chat requests'); - } - throw new Error(`Language model '${languageModelId}' NOT found`); - } - - const that = this; - - return { - get model() { - return metadata.model; - }, - get isRevoked() { - return !that._accesslist.get(from) || !that._languageModelIds.has(languageModelId); - }, - get onDidChangeAccess() { - const onDidChangeAccess = Event.filter(that._onDidChangeAccess.event, set => set.has(from)); - const onDidRemoveLM = Event.filter(that._onDidChangeProviders.event, e => e.removed.includes(languageModelId)); - return Event.signal(Event.any(onDidChangeAccess, onDidRemoveLM)); - }, - makeRequest(messages, options, token) { - if (!that._accesslist.get(from)) { - throw new Error('Access to chat has been revoked'); - } - if (!that._languageModelIds.has(languageModelId)) { - throw new Error('Language Model has been removed'); - } - const cts = new CancellationTokenSource(token); - const requestId = (Math.random() * 1e6) | 0; - const requestPromise = that._proxy.$fetchResponse(from, languageModelId, requestId, messages.map(typeConvert.ChatMessage.from), options ?? {}, cts.token); - const res = new LanguageModelRequest(requestPromise, cts); - that._pendingRequest.set(requestId, { languageModelId, res }); - - requestPromise.finally(() => { - that._pendingRequest.delete(requestId); - cts.dispose(); - }); - - return res.apiObject; - }, - }; - } - - async $handleResponseFragment(requestId: number, chunk: IChatResponseFragment): Promise { - const data = this._pendingRequest.get(requestId);//.report(chunk); - if (data) { - data.res.handleFragment(chunk); - } - } -} diff --git a/src/vs/workbench/api/common/extHostChatVariables.ts b/src/vs/workbench/api/common/extHostChatVariables.ts index a151a67432f31..afc7ef7d1d561 100644 --- a/src/vs/workbench/api/common/extHostChatVariables.ts +++ b/src/vs/workbench/api/common/extHostChatVariables.ts @@ -3,35 +3,46 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import type * as vscode from 'vscode'; import { CancellationToken } from 'vs/base/common/cancellation'; +import { onUnexpectedExternalError } from 'vs/base/common/errors'; import { IDisposable, toDisposable } from 'vs/base/common/lifecycle'; -import { ExtensionIdentifier, IExtensionDescription } from 'vs/platform/extensions/common/extensions'; -import { ExtHostChatVariablesShape, IMainContext, MainContext, MainThreadChatVariablesShape } from 'vs/workbench/api/common/extHost.protocol'; +import { IExtensionDescription } from 'vs/platform/extensions/common/extensions'; +import { ExtHostChatVariablesShape, IChatVariableResolverProgressDto, IMainContext, MainContext, MainThreadChatVariablesShape } from 'vs/workbench/api/common/extHost.protocol'; +import * as typeConvert from 'vs/workbench/api/common/extHostTypeConverters'; +import * as extHostTypes from 'vs/workbench/api/common/extHostTypes'; import { IChatRequestVariableValue, IChatVariableData } from 'vs/workbench/contrib/chat/common/chatVariables'; -import { onUnexpectedExternalError } from 'vs/base/common/errors'; -import { ChatVariable } from 'vs/workbench/api/common/extHostTypeConverters'; +import { checkProposedApiEnabled } from 'vs/workbench/services/extensions/common/extensions'; +import type * as vscode from 'vscode'; export class ExtHostChatVariables implements ExtHostChatVariablesShape { private static _idPool = 0; - private readonly _resolver = new Map(); + private readonly _resolver = new Map(); private readonly _proxy: MainThreadChatVariablesShape; constructor(mainContext: IMainContext) { this._proxy = mainContext.getProxy(MainContext.MainThreadChatVariables); } - async $resolveVariable(handle: number, messageText: string, token: CancellationToken): Promise { + async $resolveVariable(handle: number, requestId: string, messageText: string, token: CancellationToken): Promise { const item = this._resolver.get(handle); if (!item) { return undefined; } try { - const value = await item.resolver.resolve(item.data.name, { prompt: messageText }, token); - if (value) { - return value.map(ChatVariable.from); + if (item.resolver.resolve2) { + checkProposedApiEnabled(item.extension, 'chatParticipantAdditions'); + const stream = new ChatVariableResolverResponseStream(requestId, this._proxy); + const value = await item.resolver.resolve2(item.data.name, { prompt: messageText }, stream.apiObject, token); + if (value) { + return value.map(typeConvert.ChatVariable.from); + } + } else { + const value = await item.resolver.resolve(item.data.name, { prompt: messageText }, token); + if (value) { + return value.map(typeConvert.ChatVariable.from); + } } } catch (err) { onUnexpectedExternalError(err); @@ -41,7 +52,7 @@ export class ExtHostChatVariables implements ExtHostChatVariablesShape { registerVariableResolver(extension: IExtensionDescription, name: string, description: string, resolver: vscode.ChatVariableResolver): IDisposable { const handle = ExtHostChatVariables._idPool++; - this._resolver.set(handle, { extension: extension.identifier, data: { name, description }, resolver: resolver }); + this._resolver.set(handle, { extension, data: { name, description }, resolver: resolver }); this._proxy.$registerVariable(handle, { name, description }); return toDisposable(() => { @@ -50,3 +61,66 @@ export class ExtHostChatVariables implements ExtHostChatVariablesShape { }); } } + +class ChatVariableResolverResponseStream { + + private _isClosed: boolean = false; + private _apiObject: vscode.ChatVariableResolverResponseStream | undefined; + + constructor( + private readonly _requestId: string, + private readonly _proxy: MainThreadChatVariablesShape, + ) { } + + close() { + this._isClosed = true; + } + + get apiObject() { + if (!this._apiObject) { + const that = this; + + function throwIfDone(source: Function | undefined) { + if (that._isClosed) { + const err = new Error('Response stream has been closed'); + Error.captureStackTrace(err, source); + throw err; + } + } + + const _report = (progress: IChatVariableResolverProgressDto) => { + this._proxy.$handleProgressChunk(this._requestId, progress); + }; + + this._apiObject = { + progress(value) { + throwIfDone(this.progress); + const part = new extHostTypes.ChatResponseProgressPart(value); + const dto = typeConvert.ChatResponseProgressPart.from(part); + _report(dto); + return this; + }, + reference(value) { + throwIfDone(this.reference); + const part = new extHostTypes.ChatResponseReferencePart(value); + const dto = typeConvert.ChatResponseReferencePart.from(part); + _report(dto); + return this; + }, + push(part) { + throwIfDone(this.push); + + if (part instanceof extHostTypes.ChatResponseReferencePart) { + _report(typeConvert.ChatResponseReferencePart.from(part)); + } else if (part instanceof extHostTypes.ChatResponseProgressPart) { + _report(typeConvert.ChatResponseProgressPart.from(part)); + } + + return this; + } + }; + } + + return this._apiObject; + } +} diff --git a/src/vs/workbench/api/common/extHostCommands.ts b/src/vs/workbench/api/common/extHostCommands.ts index 586056f01a359..15730c390b94f 100644 --- a/src/vs/workbench/api/common/extHostCommands.ts +++ b/src/vs/workbench/api/common/extHostCommands.ts @@ -292,7 +292,7 @@ export class ExtHostCommands implements ExtHostCommandsShape { type ExtensionActionTelemetryMeta = { extensionId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The id of the extension handling the command, informing which extensions provide most-used functionality.' }; id: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The id of the command, to understand which specific extension features are most popular.' }; - duration: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'The duration of the command execution, to detect performance issues' }; + duration: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The duration of the command execution, to detect performance issues' }; owner: 'digitarald'; comment: 'Used to gain insight on the most popular commands used from extensions'; }; diff --git a/src/vs/workbench/api/common/extHostComments.ts b/src/vs/workbench/api/common/extHostComments.ts index 0f04b3f2455b9..38678540e4ad1 100644 --- a/src/vs/workbench/api/common/extHostComments.ts +++ b/src/vs/workbench/api/common/extHostComments.ts @@ -20,6 +20,7 @@ import type * as vscode from 'vscode'; import { ExtHostCommentsShape, IMainContext, MainContext, CommentThreadChanges, CommentChanges } from './extHost.protocol'; import { ExtHostCommands } from './extHostCommands'; import { checkProposedApiEnabled } from 'vs/workbench/services/extensions/common/extensions'; +import { MarshalledCommentThread } from 'vs/workbench/common/comments'; type ProviderHandle = number; @@ -53,16 +54,17 @@ export function createExtHostComments(mainContext: IMainContext, commands: ExtHo return commentController.value; } else if (arg && arg.$mid === MarshalledId.CommentThread) { - const commentController = this._commentControllers.get(arg.commentControlHandle); + const marshalledCommentThread: MarshalledCommentThread = arg; + const commentController = this._commentControllers.get(marshalledCommentThread.commentControlHandle); if (!commentController) { - return arg; + return marshalledCommentThread; } - const commentThread = commentController.getCommentThread(arg.commentThreadHandle); + const commentThread = commentController.getCommentThread(marshalledCommentThread.commentThreadHandle); if (!commentThread) { - return arg; + return marshalledCommentThread; } return commentThread.value; @@ -194,16 +196,16 @@ export function createExtHostComments(mainContext: IMainContext, commands: ExtHo commentController?.$deleteCommentThread(commentThreadHandle); } - $provideCommentingRanges(commentControllerHandle: number, uriComponents: UriComponents, token: CancellationToken): Promise<{ ranges: IRange[]; fileComments: boolean } | undefined> { + async $provideCommentingRanges(commentControllerHandle: number, uriComponents: UriComponents, token: CancellationToken): Promise<{ ranges: IRange[]; fileComments: boolean } | undefined> { const commentController = this._commentControllers.get(commentControllerHandle); if (!commentController || !commentController.commentingRangeProvider) { return Promise.resolve(undefined); } - const document = documents.getDocument(URI.revive(uriComponents)); + const document = await documents.ensureDocumentData(URI.revive(uriComponents)); return asPromise(async () => { - const rangesResult = await (commentController.commentingRangeProvider as vscode.CommentingRangeProvider2).provideCommentingRanges(document, token); + const rangesResult = await (commentController.commentingRangeProvider as vscode.CommentingRangeProvider2).provideCommentingRanges(document.document, token); let ranges: { ranges: vscode.Range[]; fileComments: boolean } | undefined; if (Array.isArray(rangesResult)) { ranges = { @@ -263,6 +265,7 @@ export function createExtHostComments(mainContext: IMainContext, commands: ExtHo canReply: boolean; state: vscode.CommentThreadState; isTemplate: boolean; + applicability: vscode.CommentThreadApplicability; }>; class ExtHostCommentThread implements vscode.CommentThread2 { @@ -366,15 +369,21 @@ export function createExtHostComments(mainContext: IMainContext, commands: ExtHo this._onDidUpdateCommentThread.fire(); } - private _state?: vscode.CommentThreadState; + private _state?: vscode.CommentThreadState | { resolved?: vscode.CommentThreadState; applicability?: vscode.CommentThreadApplicability }; - get state(): vscode.CommentThreadState { + get state(): vscode.CommentThreadState | { resolved?: vscode.CommentThreadState; applicability?: vscode.CommentThreadApplicability } | undefined { return this._state!; } - set state(newState: vscode.CommentThreadState) { + set state(newState: vscode.CommentThreadState | { resolved?: vscode.CommentThreadState; applicability?: vscode.CommentThreadApplicability }) { this._state = newState; - this.modifications.state = newState; + if (typeof newState === 'object') { + checkProposedApiEnabled(this.extensionDescription, 'commentThreadApplicability'); + this.modifications.state = newState.resolved; + this.modifications.applicability = newState.applicability; + } else { + this.modifications.state = newState; + } this._onDidUpdateCommentThread.fire(); } @@ -388,7 +397,7 @@ export function createExtHostComments(mainContext: IMainContext, commands: ExtHo private _commentsMap: Map = new Map(); - private _acceptInputDisposables = new MutableDisposable(); + private readonly _acceptInputDisposables = new MutableDisposable(); readonly value: vscode.CommentThread2; @@ -452,8 +461,8 @@ export function createExtHostComments(mainContext: IMainContext, commands: ExtHo set contextValue(value: string | undefined) { that.contextValue = value; }, get label() { return that.label; }, set label(value: string | undefined) { that.label = value; }, - get state() { return that.state; }, - set state(value: vscode.CommentThreadState) { that.state = value; }, + get state(): vscode.CommentThreadState | { resolved?: vscode.CommentThreadState; applicability?: vscode.CommentThreadApplicability } | undefined { return that.state; }, + set state(value: vscode.CommentThreadState | { resolved?: vscode.CommentThreadState; applicability?: vscode.CommentThreadApplicability }) { that.state = value; }, dispose: () => { that.dispose(); } @@ -508,6 +517,9 @@ export function createExtHostComments(mainContext: IMainContext, commands: ExtHo if (modified('state')) { formattedModifications.state = convertToState(this._state); } + if (modified('applicability')) { + formattedModifications.applicability = convertToRelevance(this._state); + } if (modified('isTemplate')) { formattedModifications.isTemplate = this._isTemplate; } @@ -565,7 +577,10 @@ export function createExtHostComments(mainContext: IMainContext, commands: ExtHo set commentingRangeProvider(provider: vscode.CommentingRangeProvider | undefined) { this._commentingRangeProvider = provider; - proxy.$updateCommentingRanges(this.handle); + if (provider?.resourceHints) { + checkProposedApiEnabled(this._extension, 'commentingRangeHint'); + } + proxy.$updateCommentingRanges(this.handle, provider?.resourceHints); } private _reactionHandler?: ReactionHandler; @@ -761,9 +776,16 @@ export function createExtHostComments(mainContext: IMainContext, commands: ExtHo return languages.CommentThreadCollapsibleState.Collapsed; } - function convertToState(kind: vscode.CommentThreadState | undefined): languages.CommentThreadState { - if (kind !== undefined) { - switch (kind) { + function convertToState(kind: vscode.CommentThreadState | { resolved?: vscode.CommentThreadState; applicability?: vscode.CommentThreadApplicability } | undefined): languages.CommentThreadState { + let resolvedKind: vscode.CommentThreadState | undefined; + if (typeof kind === 'object') { + resolvedKind = kind.resolved; + } else { + resolvedKind = kind; + } + + if (resolvedKind !== undefined) { + switch (resolvedKind) { case types.CommentThreadState.Unresolved: return languages.CommentThreadState.Unresolved; case types.CommentThreadState.Resolved: @@ -773,5 +795,22 @@ export function createExtHostComments(mainContext: IMainContext, commands: ExtHo return languages.CommentThreadState.Unresolved; } + function convertToRelevance(kind: vscode.CommentThreadState | { resolved?: vscode.CommentThreadState; applicability?: vscode.CommentThreadApplicability } | undefined): languages.CommentThreadApplicability { + let applicabilityKind: vscode.CommentThreadApplicability | undefined = undefined; + if (typeof kind === 'object') { + applicabilityKind = kind.applicability; + } + + if (applicabilityKind !== undefined) { + switch (applicabilityKind) { + case types.CommentThreadApplicability.Current: + return languages.CommentThreadApplicability.Current; + case types.CommentThreadApplicability.Outdated: + return languages.CommentThreadApplicability.Outdated; + } + } + return languages.CommentThreadApplicability.Current; + } + return new ExtHostCommentsImpl(); } diff --git a/src/vs/workbench/api/common/extHostDebugService.ts b/src/vs/workbench/api/common/extHostDebugService.ts index ced402e29f88c..e38d3ee11b18e 100644 --- a/src/vs/workbench/api/common/extHostDebugService.ts +++ b/src/vs/workbench/api/common/extHostDebugService.ts @@ -7,7 +7,7 @@ import { asPromise } from 'vs/base/common/async'; import { CancellationToken } from 'vs/base/common/cancellation'; import { Emitter, Event } from 'vs/base/common/event'; import { URI, UriComponents } from 'vs/base/common/uri'; -import { ExtensionIdentifier, IExtensionDescription } from 'vs/platform/extensions/common/extensions'; +import { ExtensionIdentifier, IExtensionDescription, IRelaxedExtensionDescription } from 'vs/platform/extensions/common/extensions'; import { createDecorator } from 'vs/platform/instantiation/common/instantiation'; import { ISignService } from 'vs/platform/sign/common/sign'; import { IWorkspaceFolder } from 'vs/platform/workspace/common/workspace'; @@ -15,10 +15,10 @@ import { DebugSessionUUID, ExtHostDebugServiceShape, IBreakpointsDeltaDto, IThre import { IExtHostEditorTabs } from 'vs/workbench/api/common/extHostEditorTabs'; import { IExtHostExtensionService } from 'vs/workbench/api/common/extHostExtensionService'; import { IExtHostRpcService } from 'vs/workbench/api/common/extHostRpcService'; -import { Breakpoint, DataBreakpoint, DebugAdapterExecutable, DebugAdapterInlineImplementation, DebugAdapterNamedPipeServer, DebugAdapterServer, DebugConsoleMode, Disposable, FunctionBreakpoint, Location, Position, setBreakpointId, SourceBreakpoint, ThreadFocus, StackFrameFocus, ThemeIcon } from 'vs/workbench/api/common/extHostTypes'; +import { Breakpoint, DataBreakpoint, DebugAdapterExecutable, DebugAdapterInlineImplementation, DebugAdapterNamedPipeServer, DebugAdapterServer, DebugConsoleMode, Disposable, FunctionBreakpoint, Location, Position, setBreakpointId, SourceBreakpoint, Thread, StackFrame, ThemeIcon } from 'vs/workbench/api/common/extHostTypes'; import { IExtHostWorkspace } from 'vs/workbench/api/common/extHostWorkspace'; import { AbstractDebugAdapter } from 'vs/workbench/contrib/debug/common/abstractDebugAdapter'; -import { MainThreadDebugVisualization, IAdapterDescriptor, IConfig, IDebugAdapter, IDebugAdapterExecutable, IDebugAdapterNamedPipeServer, IDebugAdapterServer, IDebugVisualization, IDebugVisualizationContext, IDebuggerContribution, DebugVisualizationType } from 'vs/workbench/contrib/debug/common/debug'; +import { MainThreadDebugVisualization, IAdapterDescriptor, IConfig, IDebugAdapter, IDebugAdapterExecutable, IDebugAdapterNamedPipeServer, IDebugAdapterServer, IDebugVisualization, IDebugVisualizationContext, IDebuggerContribution, DebugVisualizationType, IDebugVisualizationTreeItem } from 'vs/workbench/contrib/debug/common/debug'; import { convertToDAPaths, convertToVSCPaths, isDebuggerMainContribution } from 'vs/workbench/contrib/debug/common/debugUtils'; import { ExtensionDescriptionRegistry } from 'vs/workbench/services/extensions/common/extensionDescriptionRegistry'; import { Dto } from 'vs/workbench/services/extensions/common/proxyIdentifier'; @@ -28,6 +28,7 @@ import { IExtHostVariableResolverProvider } from './extHostVariableResolverServi import { toDisposable } from 'vs/base/common/lifecycle'; import { ThemeIcon as ThemeIconUtils } from 'vs/base/common/themables'; import { IExtHostCommands } from 'vs/workbench/api/common/extHostCommands'; +import * as Convert from 'vs/workbench/api/common/extHostTypeConverters'; export const IExtHostDebugService = createDecorator('IExtHostDebugService'); @@ -43,8 +44,8 @@ export interface IExtHostDebugService extends ExtHostDebugServiceShape { onDidReceiveDebugSessionCustomEvent: Event; onDidChangeBreakpoints: Event; breakpoints: vscode.Breakpoint[]; - onDidChangeStackFrameFocus: Event; - stackFrameFocus: vscode.ThreadFocus | vscode.StackFrameFocus | undefined; + onDidChangeActiveStackItem: Event; + activeStackItem: vscode.Thread | vscode.StackFrame | undefined; addBreakpoints(breakpoints0: readonly vscode.Breakpoint[]): Promise; removeBreakpoints(breakpoints0: readonly vscode.Breakpoint[]): Promise; @@ -54,6 +55,7 @@ export interface IExtHostDebugService extends ExtHostDebugServiceShape { registerDebugAdapterDescriptorFactory(extension: IExtensionDescription, type: string, factory: vscode.DebugAdapterDescriptorFactory): vscode.Disposable; registerDebugAdapterTrackerFactory(type: string, factory: vscode.DebugAdapterTrackerFactory): vscode.Disposable; registerDebugVisualizationProvider(extension: IExtensionDescription, id: string, provider: vscode.DebugVisualizationProvider): vscode.Disposable; + registerDebugVisualizationTree(extension: IExtensionDescription, id: string, provider: vscode.DebugVisualizationTree): vscode.Disposable; asDebugSourceUri(source: vscode.DebugProtocolSource, session?: vscode.DebugSession): vscode.Uri; } @@ -95,16 +97,21 @@ export abstract class ExtHostDebugServiceBase implements IExtHostDebugService, E private readonly _onDidChangeBreakpoints: Emitter; - private _stackFrameFocus: vscode.ThreadFocus | vscode.StackFrameFocus | undefined; - private readonly _onDidChangeStackFrameFocus: Emitter; + private _activeStackItem: vscode.Thread | vscode.StackFrame | undefined; + private readonly _onDidChangeActiveStackItem: Emitter; private _debugAdapters: Map; private _debugAdaptersTrackers: Map; + + private _debugVisualizationTreeItemIdsCounter = 0; private readonly _debugVisualizationProviders = new Map(); + private readonly _debugVisualizationTrees = new Map(); + private readonly _debugVisualizationTreeItemIds = new WeakMap(); + private readonly _debugVisualizationElements = new Map(); private _signService: ISignService | undefined; - private readonly _visualizers = new Map(); + private readonly _visualizers = new Map(); private _visualizerIdCounter = 0; constructor( @@ -137,7 +144,7 @@ export abstract class ExtHostDebugServiceBase implements IExtHostDebugService, E this._onDidChangeBreakpoints = new Emitter(); - this._onDidChangeStackFrameFocus = new Emitter(); + this._onDidChangeActiveStackItem = new Emitter(); this._activeDebugConsole = new ExtHostDebugConsole(this._debugServiceProxy); @@ -151,6 +158,77 @@ export abstract class ExtHostDebugServiceBase implements IExtHostDebugService, E }); } + public async $getVisualizerTreeItem(treeId: string, element: IDebugVisualizationContext): Promise { + const context = this.hydrateVisualizationContext(element); + if (!context) { + return undefined; + } + + const item = await this._debugVisualizationTrees.get(treeId)?.getTreeItem?.(context); + return item ? this.convertVisualizerTreeItem(treeId, item) : undefined; + } + + public registerDebugVisualizationTree(manifest: Readonly, id: string, provider: vscode.DebugVisualizationTree): vscode.Disposable { + const extensionId = ExtensionIdentifier.toKey(manifest.identifier); + const key = this.extensionVisKey(extensionId, id); + if (this._debugVisualizationProviders.has(key)) { + throw new Error(`A debug visualization provider with id '${id}' is already registered`); + } + + this._debugVisualizationTrees.set(key, provider); + this._debugServiceProxy.$registerDebugVisualizerTree(key, !!provider.editItem); + return toDisposable(() => { + this._debugServiceProxy.$unregisterDebugVisualizerTree(key); + this._debugVisualizationTrees.delete(id); + }); + } + + public async $getVisualizerTreeItemChildren(treeId: string, element: number): Promise { + const item = this._debugVisualizationElements.get(element)?.item; + if (!item) { + return []; + } + + const children = await this._debugVisualizationTrees.get(treeId)?.getChildren?.(item); + return children?.map(i => this.convertVisualizerTreeItem(treeId, i)) || []; + } + + public async $editVisualizerTreeItem(element: number, value: string): Promise { + const e = this._debugVisualizationElements.get(element); + if (!e) { return undefined; } + + const r = await this._debugVisualizationTrees.get(e.provider)?.editItem?.(e.item, value); + return this.convertVisualizerTreeItem(e.provider, r || e.item); + } + + public $disposeVisualizedTree(element: number): void { + const root = this._debugVisualizationElements.get(element); + if (!root) { + return; + } + + const queue = [root.children]; + for (const children of queue) { + if (children) { + for (const child of children) { + queue.push(this._debugVisualizationElements.get(child)?.children); + this._debugVisualizationElements.delete(child); + } + } + } + } + + private convertVisualizerTreeItem(treeId: string, item: vscode.DebugTreeItem): IDebugVisualizationTreeItem { + let id = this._debugVisualizationTreeItemIds.get(item); + if (!id) { + id = this._debugVisualizationTreeItemIdsCounter++; + this._debugVisualizationTreeItemIds.set(item, id); + this._debugVisualizationElements.set(id, { provider: treeId, item }); + } + + return Convert.DebugTreeItem.from(item, id); + } + public asDebugSourceUri(src: vscode.DebugProtocolSource, session?: vscode.DebugSession): URI { const source = src; @@ -200,12 +278,12 @@ export abstract class ExtHostDebugServiceBase implements IExtHostDebugService, E // extension debug API - get stackFrameFocus(): vscode.ThreadFocus | vscode.StackFrameFocus | undefined { - return this._stackFrameFocus; + get activeStackItem(): vscode.Thread | vscode.StackFrame | undefined { + return this._activeStackItem; } - get onDidChangeStackFrameFocus(): Event { - return this._onDidChangeStackFrameFocus.event; + get onDidChangeActiveStackItem(): Event { + return this._onDidChangeActiveStackItem.event; } get onDidChangeBreakpoints(): Event { @@ -224,7 +302,7 @@ export abstract class ExtHostDebugServiceBase implements IExtHostDebugService, E throw new Error(`No debug visualizer found with id '${id}'`); } - let { v, provider } = visualizer; + let { v, provider, extensionId } = visualizer; if (!v.visualization) { v = await provider.resolveDebugVisualization?.(v, token) || v; visualizer.v = v; @@ -234,7 +312,7 @@ export abstract class ExtHostDebugServiceBase implements IExtHostDebugService, E throw new Error(`No visualization returned from resolveDebugVisualization in '${provider}'`); } - return this.serializeVisualization(v.visualization)!; + return this.serializeVisualization(extensionId, v.visualization)!; } public async $executeDebugVisualizerCommand(id: number): Promise { @@ -249,21 +327,26 @@ export abstract class ExtHostDebugServiceBase implements IExtHostDebugService, E } } - public async $provideDebugVisualizers(extensionId: string, id: string, context: IDebugVisualizationContext, token: CancellationToken): Promise { + private hydrateVisualizationContext(context: IDebugVisualizationContext): vscode.DebugVisualizationContext | undefined { const session = this._debugSessions.get(context.sessionId); - const key = this.extensionVisKey(extensionId, id); - const provider = this._debugVisualizationProviders.get(key); - if (!session || !provider) { - return []; // probably ended in the meantime - } - - const visualizations = await provider.provideDebugVisualization({ + return session && { session: session.api, variable: context.variable, containerId: context.containerId, frameId: context.frameId, threadId: context.threadId, - }, token); + }; + } + + public async $provideDebugVisualizers(extensionId: string, id: string, context: IDebugVisualizationContext, token: CancellationToken): Promise { + const contextHydrated = this.hydrateVisualizationContext(context); + const key = this.extensionVisKey(extensionId, id); + const provider = this._debugVisualizationProviders.get(key); + if (!contextHydrated || !provider) { + return []; // probably ended in the meantime + } + + const visualizations = await provider.provideDebugVisualization(contextHydrated, token); if (!visualizations) { return []; @@ -271,14 +354,14 @@ export abstract class ExtHostDebugServiceBase implements IExtHostDebugService, E return visualizations.map(v => { const id = ++this._visualizerIdCounter; - this._visualizers.set(id, { v, provider }); + this._visualizers.set(id, { v, provider, extensionId }); const icon = v.iconPath ? this.getIconPathOrClass(v.iconPath) : undefined; return { id, name: v.name, iconClass: icon?.iconClass, iconPath: icon?.iconPath, - visualization: this.serializeVisualization(v.visualization), + visualization: this.serializeVisualization(extensionId, v.visualization), }; }); } @@ -344,7 +427,8 @@ export abstract class ExtHostDebugServiceBase implements IExtHostDebugService, E hitCondition: bp.hitCondition, logMessage: bp.logMessage, line: bp.location.range.start.line, - character: bp.location.range.start.character + character: bp.location.range.start.character, + mode: bp.mode, }); } else if (bp instanceof FunctionBreakpoint) { dtos.push({ @@ -354,7 +438,8 @@ export abstract class ExtHostDebugServiceBase implements IExtHostDebugService, E hitCondition: bp.hitCondition, logMessage: bp.logMessage, condition: bp.condition, - functionName: bp.functionName + functionName: bp.functionName, + mode: bp.mode, }); } } @@ -630,12 +715,12 @@ export abstract class ExtHostDebugServiceBase implements IExtHostDebugService, E if (id && !this._breakpoints.has(id)) { let bp: Breakpoint; if (bpd.type === 'function') { - bp = new FunctionBreakpoint(bpd.functionName, bpd.enabled, bpd.condition, bpd.hitCondition, bpd.logMessage); + bp = new FunctionBreakpoint(bpd.functionName, bpd.enabled, bpd.condition, bpd.hitCondition, bpd.logMessage, bpd.mode); } else if (bpd.type === 'data') { - bp = new DataBreakpoint(bpd.label, bpd.dataId, bpd.canPersist, bpd.enabled, bpd.hitCondition, bpd.condition, bpd.logMessage); + bp = new DataBreakpoint(bpd.label, bpd.dataId, bpd.canPersist, bpd.enabled, bpd.hitCondition, bpd.condition, bpd.logMessage, bpd.mode); } else { const uri = URI.revive(bpd.uri); - bp = new SourceBreakpoint(new Location(uri, new Position(bpd.line, bpd.character)), bpd.enabled, bpd.condition, bpd.hitCondition, bpd.logMessage); + bp = new SourceBreakpoint(new Location(uri, new Position(bpd.line, bpd.character)), bpd.enabled, bpd.condition, bpd.hitCondition, bpd.logMessage, bpd.mode); } setBreakpointId(bp, id); this._breakpoints.set(id, bp); @@ -683,21 +768,19 @@ export abstract class ExtHostDebugServiceBase implements IExtHostDebugService, E this.fireBreakpointChanges(a, r, c); } - public async $acceptStackFrameFocus(focusDto: IThreadFocusDto | IStackFrameFocusDto): Promise { - let focus: ThreadFocus | StackFrameFocus; - const session = focusDto.sessionId ? await this.getSession(focusDto.sessionId) : undefined; - if (!session) { - throw new Error('no DebugSession found for debug focus context'); - } - - if (focusDto.kind === 'thread') { - focus = new ThreadFocus(session.api, focusDto.threadId); - } else { - focus = new StackFrameFocus(session.api, focusDto.threadId, focusDto.frameId); + public async $acceptStackFrameFocus(focusDto: IThreadFocusDto | IStackFrameFocusDto | undefined): Promise { + let focus: vscode.Thread | vscode.StackFrame | undefined; + if (focusDto) { + const session = await this.getSession(focusDto.sessionId); + if (focusDto.kind === 'thread') { + focus = new Thread(session.api, focusDto.threadId); + } else { + focus = new StackFrame(session.api, focusDto.threadId, focusDto.frameId); + } } - this._stackFrameFocus = focus; - this._onDidChangeStackFrameFocus.fire(this._stackFrameFocus); + this._activeStackItem = focus; + this._onDidChangeActiveStackItem.fire(this._activeStackItem); } public $provideDebugConfigurations(configProviderHandle: number, folderUri: UriComponents | undefined, token: CancellationToken): Promise { @@ -962,7 +1045,7 @@ export abstract class ExtHostDebugServiceBase implements IExtHostDebugService, E return `${extensionId}\0${id}`; } - private serializeVisualization(viz: vscode.DebugVisualization['visualization']): MainThreadDebugVisualization | undefined { + private serializeVisualization(extensionId: string, viz: vscode.DebugVisualization['visualization']): MainThreadDebugVisualization | undefined { if (!viz) { return undefined; } @@ -971,6 +1054,10 @@ export abstract class ExtHostDebugServiceBase implements IExtHostDebugService, E return { type: DebugVisualizationType.Command }; } + if ('treeId' in viz) { + return { type: DebugVisualizationType.Tree, id: `${extensionId}\0${viz.treeId}` }; + } + throw new Error('Unsupported debug visualization type'); } diff --git a/src/vs/workbench/api/common/extHostDocumentData.ts b/src/vs/workbench/api/common/extHostDocumentData.ts index 139f826be433e..ee321b2575a87 100644 --- a/src/vs/workbench/api/common/extHostDocumentData.ts +++ b/src/vs/workbench/api/common/extHostDocumentData.ts @@ -37,7 +37,6 @@ export class ExtHostDocumentData extends MirrorTextModel { uri: URI, lines: string[], eol: string, versionId: number, private _languageId: string, private _isDirty: boolean, - public readonly notebook?: vscode.NotebookDocument | undefined ) { super(uri, lines, eol, versionId); } diff --git a/src/vs/workbench/api/common/extHostDocumentSaveParticipant.ts b/src/vs/workbench/api/common/extHostDocumentSaveParticipant.ts index 7a90e5db0f916..de3123da9de25 100644 --- a/src/vs/workbench/api/common/extHostDocumentSaveParticipant.ts +++ b/src/vs/workbench/api/common/extHostDocumentSaveParticipant.ts @@ -15,6 +15,7 @@ import type * as vscode from 'vscode'; import { LinkedList } from 'vs/base/common/linkedList'; import { ILogService } from 'vs/platform/log/common/log'; import { IExtensionDescription } from 'vs/platform/extensions/common/extensions'; +import { SerializableObjectWithBuffers } from 'vs/workbench/services/extensions/common/proxyIdentifier'; type Listener = [Function, any, IExtensionDescription]; @@ -165,7 +166,7 @@ export class ExtHostDocumentSaveParticipant implements ExtHostDocumentSavePartic } if (version === document.version) { - return this._mainThreadBulkEdits.$tryApplyWorkspaceEdit(dto); + return this._mainThreadBulkEdits.$tryApplyWorkspaceEdit(new SerializableObjectWithBuffers(dto)); } return Promise.reject(new Error('concurrent_edits')); diff --git a/src/vs/workbench/api/common/extHostDocumentsAndEditors.ts b/src/vs/workbench/api/common/extHostDocumentsAndEditors.ts index bb64f26fa8e80..224d9d5537132 100644 --- a/src/vs/workbench/api/common/extHostDocumentsAndEditors.ts +++ b/src/vs/workbench/api/common/extHostDocumentsAndEditors.ts @@ -9,7 +9,7 @@ import { Emitter, Event } from 'vs/base/common/event'; import { dispose } from 'vs/base/common/lifecycle'; import { URI } from 'vs/base/common/uri'; import { createDecorator } from 'vs/platform/instantiation/common/instantiation'; -import { ExtHostDocumentsAndEditorsShape, IDocumentsAndEditorsDelta, IModelAddedData, MainContext } from 'vs/workbench/api/common/extHost.protocol'; +import { ExtHostDocumentsAndEditorsShape, IDocumentsAndEditorsDelta, MainContext } from 'vs/workbench/api/common/extHost.protocol'; import { ExtHostDocumentData } from 'vs/workbench/api/common/extHostDocumentData'; import { IExtHostRpcService } from 'vs/workbench/api/common/extHostRpcService'; import { ExtHostTextEditor } from 'vs/workbench/api/common/extHostTextEditor'; @@ -31,14 +31,6 @@ class Reference { } } -export interface IExtHostModelAddedData extends IModelAddedData { - notebook?: vscode.NotebookDocument; -} - -export interface IExtHostDocumentsAndEditorsDelta extends IDocumentsAndEditorsDelta { - addedDocuments?: IExtHostModelAddedData[]; -} - export class ExtHostDocumentsAndEditors implements ExtHostDocumentsAndEditorsShape { readonly _serviceBrand: undefined; @@ -67,7 +59,7 @@ export class ExtHostDocumentsAndEditors implements ExtHostDocumentsAndEditorsSha this.acceptDocumentsAndEditorsDelta(delta); } - acceptDocumentsAndEditorsDelta(delta: IExtHostDocumentsAndEditorsDelta): void { + acceptDocumentsAndEditorsDelta(delta: IDocumentsAndEditorsDelta): void { const removedDocuments: ExtHostDocumentData[] = []; const addedDocuments: ExtHostDocumentData[] = []; @@ -105,7 +97,6 @@ export class ExtHostDocumentsAndEditors implements ExtHostDocumentsAndEditorsSha data.versionId, data.languageId, data.isDirty, - data.notebook )); this._documents.set(resource, ref); addedDocuments.push(ref.value); diff --git a/src/vs/workbench/api/common/extHostEditorTabs.ts b/src/vs/workbench/api/common/extHostEditorTabs.ts index 1bdb2bf11e47d..d0e035825a6dc 100644 --- a/src/vs/workbench/api/common/extHostEditorTabs.ts +++ b/src/vs/workbench/api/common/extHostEditorTabs.ts @@ -11,7 +11,7 @@ import { createDecorator } from 'vs/platform/instantiation/common/instantiation' import { IEditorTabDto, IEditorTabGroupDto, IExtHostEditorTabsShape, MainContext, MainThreadEditorTabsShape, TabInputKind, TabModelOperationKind, TabOperation } from 'vs/workbench/api/common/extHost.protocol'; import { IExtHostRpcService } from 'vs/workbench/api/common/extHostRpcService'; import * as typeConverters from 'vs/workbench/api/common/extHostTypeConverters'; -import { ChatEditorTabInput, CustomEditorTabInput, InteractiveWindowInput, NotebookDiffEditorTabInput, NotebookEditorTabInput, TerminalEditorTabInput, TextDiffTabInput, TextMergeTabInput, TextTabInput, WebviewEditorTabInput } from 'vs/workbench/api/common/extHostTypes'; +import { ChatEditorTabInput, CustomEditorTabInput, InteractiveWindowInput, NotebookDiffEditorTabInput, NotebookEditorTabInput, TerminalEditorTabInput, TextDiffTabInput, TextMergeTabInput, TextTabInput, WebviewEditorTabInput, TextMultiDiffTabInput } from 'vs/workbench/api/common/extHostTypes'; import type * as vscode from 'vscode'; export interface IExtHostEditorTabs extends IExtHostEditorTabsShape { @@ -21,7 +21,7 @@ export interface IExtHostEditorTabs extends IExtHostEditorTabsShape { export const IExtHostEditorTabs = createDecorator('IExtHostEditorTabs'); -type AnyTabInput = TextTabInput | TextDiffTabInput | CustomEditorTabInput | NotebookEditorTabInput | NotebookDiffEditorTabInput | WebviewEditorTabInput | TerminalEditorTabInput | InteractiveWindowInput | ChatEditorTabInput; +type AnyTabInput = TextTabInput | TextDiffTabInput | TextMultiDiffTabInput | CustomEditorTabInput | NotebookEditorTabInput | NotebookDiffEditorTabInput | WebviewEditorTabInput | TerminalEditorTabInput | InteractiveWindowInput | ChatEditorTabInput; class ExtHostEditorTab { private _apiObject: vscode.Tab | undefined; @@ -100,6 +100,8 @@ class ExtHostEditorTab { return new InteractiveWindowInput(URI.revive(this._dto.input.uri), URI.revive(this._dto.input.inputBoxUri)); case TabInputKind.ChatEditorInput: return new ChatEditorTabInput(this._dto.input.providerId); + case TabInputKind.MultiDiffEditorInput: + return new TextMultiDiffTabInput(this._dto.input.diffEditors.map(diff => new TextDiffTabInput(URI.revive(diff.original), URI.revive(diff.modified)))); default: return undefined; } diff --git a/src/vs/workbench/api/common/extHostExtensionActivator.ts b/src/vs/workbench/api/common/extHostExtensionActivator.ts index 26c8795aaa642..2c51f37aed2dc 100644 --- a/src/vs/workbench/api/common/extHostExtensionActivator.ts +++ b/src/vs/workbench/api/common/extHostExtensionActivator.ts @@ -28,10 +28,10 @@ export interface IExtensionAPI { } export type ExtensionActivationTimesFragment = { - startup?: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Activation occurred during startup' }; - codeLoadingTime?: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Time it took to load the extension\'s code' }; - activateCallTime?: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Time it took to call activate' }; - activateResolvedTime?: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Time it took for async-activation to finish' }; + startup?: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Activation occurred during startup' }; + codeLoadingTime?: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Time it took to load the extension\'s code' }; + activateCallTime?: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Time it took to call activate' }; + activateResolvedTime?: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Time it took for async-activation to finish' }; }; export class ExtensionActivationTimes { @@ -231,7 +231,7 @@ export class ExtensionsActivator implements IDisposable { public activateById(extensionId: ExtensionIdentifier, reason: ExtensionActivationReason): Promise { const desc = this._registry.getExtensionDescription(extensionId); if (!desc) { - throw new Error(`Extension '${extensionId}' is not known`); + throw new Error(`Extension '${extensionId.value}' is not known`); } return this._activateExtensions([{ id: desc.identifier, reason }]); } diff --git a/src/vs/workbench/api/common/extHostExtensionService.ts b/src/vs/workbench/api/common/extHostExtensionService.ts index 91f910aa4c8fd..4ea250c3bf8d6 100644 --- a/src/vs/workbench/api/common/extHostExtensionService.ts +++ b/src/vs/workbench/api/common/extHostExtensionService.ts @@ -36,6 +36,7 @@ import { IExtHostRpcService } from 'vs/workbench/api/common/extHostRpcService'; import { ServiceCollection } from 'vs/platform/instantiation/common/serviceCollection'; import { IExtHostTunnelService } from 'vs/workbench/api/common/extHostTunnelService'; import { IExtHostTerminalService } from 'vs/workbench/api/common/extHostTerminalService'; +import { IExtHostLanguageModels } from 'vs/workbench/api/common/extHostLanguageModels'; import { Emitter, Event } from 'vs/base/common/event'; import { IExtensionActivationHost, checkActivateWorkspaceContainsExtension } from 'vs/workbench/services/extensions/common/workspaceContains'; import { ExtHostSecretState, IExtHostSecretState } from 'vs/workbench/api/common/extHostSecretState'; @@ -74,7 +75,7 @@ type TelemetryActivationEventFragment = { extensionVersion: { classification: 'PublicNonPersonalData'; purpose: 'FeatureInsight'; comment: 'The version of the extension' }; publisherDisplayName: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The publisher of the extension' }; activationEvents: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'All activation events of the extension' }; - isBuiltin: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'If the extension is builtin or git installed' }; + isBuiltin: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'If the extension is builtin or git installed' }; reason: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The activation event' }; reasonId: { classification: 'PublicNonPersonalData'; purpose: 'FeatureInsight'; comment: 'The identifier of the activation event' }; }; @@ -116,6 +117,7 @@ export abstract class AbstractExtHostExtensionService extends Disposable impleme private readonly _storagePath: IExtensionStoragePaths; private readonly _activator: ExtensionsActivator; private _extensionPathIndex: Promise | null; + private _realPathCache = new Map>(); private readonly _resolvers: { [authorityPrefix: string]: vscode.RemoteAuthorityResolver }; @@ -136,6 +138,7 @@ export abstract class AbstractExtHostExtensionService extends Disposable impleme @IExtHostTerminalService extHostTerminalService: IExtHostTerminalService, @IExtHostLocalizationService extHostLocalizationService: IExtHostLocalizationService, @IExtHostManagedSockets private readonly _extHostManagedSockets: IExtHostManagedSockets, + @IExtHostLanguageModels private readonly _extHostLanguageModels: IExtHostLanguageModels, ) { super(); this._hostUtils = hostUtils; @@ -330,11 +333,16 @@ export abstract class AbstractExtHostExtensionService extends Disposable impleme } /** - * Applies realpath to file-uris and returns all others uris unmodified + * Applies realpath to file-uris and returns all others uris unmodified. + * The real path is cached for the lifetime of the extension host. */ private async _realPathExtensionUri(uri: URI): Promise { if (uri.scheme === Schemas.file && this._hostUtils.fsRealpath) { - const realpathValue = await this._hostUtils.fsRealpath(uri.fsPath); + const fsPath = uri.fsPath; + if (!this._realPathCache.has(fsPath)) { + this._realPathCache.set(fsPath, this._hostUtils.fsRealpath(fsPath)); + } + const realpathValue = await this._realPathCache.get(fsPath)!; return URI.file(realpathValue); } return uri; @@ -489,6 +497,7 @@ export abstract class AbstractExtHostExtensionService extends Disposable impleme private _loadExtensionContext(extensionDescription: IExtensionDescription): Promise { + const lanuageModelAccessInformation = this._extHostLanguageModels.createLanguageModelAccessInformation(extensionDescription); const globalState = new ExtensionGlobalMemento(extensionDescription, this._storage); const workspaceState = new ExtensionMemento(extensionDescription.identifier.value, false, this._storage); const secrets = new ExtensionSecrets(extensionDescription, this._secretState); @@ -517,6 +526,7 @@ export abstract class AbstractExtHostExtensionService extends Disposable impleme workspaceState, secrets, subscriptions: [], + get languageModelAccessInformation() { return lanuageModelAccessInformation; }, get extensionUri() { return extensionDescription.extensionLocation; }, get extensionPath() { return extensionDescription.extensionLocation.fsPath; }, asAbsolutePath(relativePath: string) { return path.join(extensionDescription.extensionLocation.fsPath, relativePath); }, @@ -982,10 +992,13 @@ export abstract class AbstractExtHostExtensionService extends Disposable impleme return result; } - public $startExtensionHost(extensionsDelta: IExtensionDescriptionDelta): Promise { + public async $startExtensionHost(extensionsDelta: IExtensionDescriptionDelta): Promise { extensionsDelta.toAdd.forEach((extension) => (extension).extensionLocation = URI.revive(extension.extensionLocation)); const { globalRegistry, myExtensions } = applyExtensionsDelta(this._activationEventsReader, this._globalRegistry, this._myRegistry, extensionsDelta); + const newSearchTree = await this._createExtensionPathIndex(myExtensions); + const extensionsPaths = await this.getExtensionPathIndex(); + extensionsPaths.setSearchTree(newSearchTree); this._globalRegistry.set(globalRegistry.getAllExtensionDescriptions()); this._myRegistry.set(myExtensions); diff --git a/src/vs/workbench/api/common/extHostInlineChat.ts b/src/vs/workbench/api/common/extHostInlineChat.ts index 47ec20ccea2be..efd9ee8b24855 100644 --- a/src/vs/workbench/api/common/extHostInlineChat.ts +++ b/src/vs/workbench/api/common/extHostInlineChat.ts @@ -3,23 +3,22 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { raceCancellation } from 'vs/base/common/async'; import { CancellationToken } from 'vs/base/common/cancellation'; import { toDisposable } from 'vs/base/common/lifecycle'; import { URI, UriComponents } from 'vs/base/common/uri'; +import { IPosition } from 'vs/editor/common/core/position'; +import { IRange } from 'vs/editor/common/core/range'; import { ISelection } from 'vs/editor/common/core/selection'; -import { IInlineChatSession, IInlineChatRequest, InlineChatResponseFeedbackKind, InlineChatResponseType } from 'vs/workbench/contrib/inlineChat/common/inlineChat'; import { IRelaxedExtensionDescription } from 'vs/platform/extensions/common/extensions'; import { ILogService } from 'vs/platform/log/common/log'; import { ExtHostInlineChatShape, IInlineChatResponseDto, IMainContext, MainContext, MainThreadInlineChatShape } from 'vs/workbench/api/common/extHost.protocol'; +import { ApiCommand, ApiCommandArgument, ApiCommandResult, ExtHostCommands } from 'vs/workbench/api/common/extHostCommands'; import { ExtHostDocuments } from 'vs/workbench/api/common/extHostDocuments'; import * as typeConvert from 'vs/workbench/api/common/extHostTypeConverters'; import * as extHostTypes from 'vs/workbench/api/common/extHostTypes'; +import { IInlineChatFollowup, IInlineChatRequest, IInlineChatSession, InlineChatResponseFeedbackKind, InlineChatResponseType } from 'vs/workbench/contrib/inlineChat/common/inlineChat'; import type * as vscode from 'vscode'; -import { ApiCommand, ApiCommandArgument, ApiCommandResult, ExtHostCommands } from 'vs/workbench/api/common/extHostCommands'; -import { IRange } from 'vs/editor/common/core/range'; -import { IPosition } from 'vs/editor/common/core/position'; -import { raceCancellation } from 'vs/base/common/async'; -import { IChatFollowup } from 'vs/workbench/contrib/chat/common/chatService'; class ProviderWrapper { @@ -97,7 +96,7 @@ export class ExtHostInteractiveEditor implements ExtHostInlineChatShape { registerProvider(extension: Readonly, provider: vscode.InteractiveEditorSessionProvider, metadata?: vscode.InteractiveEditorSessionProviderMetadata): vscode.Disposable { const wrapper = new ProviderWrapper(extension, provider); this._inputProvider.set(wrapper.handle, wrapper); - this._proxy.$registerInteractiveEditorProvider(wrapper.handle, metadata?.label ?? extension.displayName ?? extension.name, extension.identifier.value, typeof provider.handleInteractiveEditorResponseFeedback === 'function', typeof provider.provideFollowups === 'function', metadata?.supportReportIssue ?? false); + this._proxy.$registerInteractiveEditorProvider(wrapper.handle, metadata?.label ?? extension.displayName ?? extension.name, extension.identifier, typeof provider.handleInteractiveEditorResponseFeedback === 'function', typeof provider.provideFollowups === 'function', metadata?.supportReportIssue ?? false); return toDisposable(() => { this._proxy.$unregisterInteractiveEditorProvider(wrapper.handle); this._inputProvider.delete(wrapper.handle); @@ -228,14 +227,14 @@ export class ExtHostInteractiveEditor implements ExtHostInlineChatShape { } } - async $provideFollowups(handle: number, sessionId: number, responseId: number, token: CancellationToken): Promise { + async $provideFollowups(handle: number, sessionId: number, responseId: number, token: CancellationToken): Promise { const entry = this._inputProvider.get(handle); const sessionData = this._inputSessions.get(sessionId); const response = sessionData?.responses[responseId]; if (entry && response && entry.provider.provideFollowups) { const task = Promise.resolve(entry.provider.provideFollowups(sessionData.session, response, token)); const followups = await raceCancellation(task, token); - return followups?.map(typeConvert.ChatFollowup.from); + return followups?.map(typeConvert.ChatInlineFollowup.from); } return undefined; } diff --git a/src/vs/workbench/api/common/extHostInteractive.ts b/src/vs/workbench/api/common/extHostInteractive.ts index b53d846c2e141..63296ab10300b 100644 --- a/src/vs/workbench/api/common/extHostInteractive.ts +++ b/src/vs/workbench/api/common/extHostInteractive.ts @@ -52,7 +52,6 @@ export class ExtHostInteractive implements ExtHostInteractiveShape { uri: uri, isDirty: false, versionId: 1, - notebook: this._extHostNotebooks.getNotebookDocument(URI.revive(notebookUri))?.apiNotebook }] }); } diff --git a/src/vs/workbench/api/common/extHostIssueReporter.ts b/src/vs/workbench/api/common/extHostIssueReporter.ts deleted file mode 100644 index b70e009364514..0000000000000 --- a/src/vs/workbench/api/common/extHostIssueReporter.ts +++ /dev/null @@ -1,95 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { CancellationToken } from 'vs/base/common/cancellation'; -import { UriComponents } from 'vs/base/common/uri'; -import { IExtensionDescription } from 'vs/platform/extensions/common/extensions'; -import { ExtHostIssueReporterShape, IMainContext, MainContext, MainThreadIssueReporterShape } from 'vs/workbench/api/common/extHost.protocol'; -import { Disposable } from 'vs/workbench/api/common/extHostTypes'; -import type { IssueDataProvider, IssueUriRequestHandler } from 'vscode'; - -export class ExtHostIssueReporter implements ExtHostIssueReporterShape { - private _IssueUriRequestHandlers: Map = new Map(); - private _IssueDataProviders: Map = new Map(); - - private readonly _proxy: MainThreadIssueReporterShape; - - constructor( - mainContext: IMainContext - ) { - this._proxy = mainContext.getProxy(MainContext.MainThreadIssueReporter); - } - - async $getIssueReporterUri(extensionId: string, token: CancellationToken): Promise { - if (this._IssueUriRequestHandlers.size === 0) { - throw new Error('No issue request handlers registered'); - } - - const provider = this._IssueUriRequestHandlers.get(extensionId); - if (!provider) { - throw new Error('Issue request handler not found'); - } - - const result = await provider.handleIssueUrlRequest(); - if (!result) { - throw new Error('Issue request handler returned no result'); - } - return result; - } - - async $getIssueReporterData(extensionId: string, token: CancellationToken): Promise { - if (this._IssueDataProviders.size === 0) { - throw new Error('No issue request handlers registered'); - } - - const provider = this._IssueDataProviders.get(extensionId); - if (!provider) { - throw new Error('Issue data provider not found'); - } - - const result = await provider.provideIssueData(token); - if (!result) { - throw new Error('Issue data provider returned no result'); - } - return result; - } - - async $getIssueReporterTemplate(extensionId: string, token: CancellationToken): Promise { - if (this._IssueDataProviders.size === 0) { - throw new Error('No issue request handlers registered'); - } - - const provider = this._IssueDataProviders.get(extensionId); - if (!provider) { - throw new Error('Issue data provider not found'); - } - - const result = await provider.provideIssueTemplate(token); - if (!result) { - throw new Error('Issue template provider returned no result'); - } - return result; - } - - registerIssueUriRequestHandler(extension: IExtensionDescription, provider: IssueUriRequestHandler): Disposable { - const extensionId = extension.identifier.value; - this._IssueUriRequestHandlers.set(extensionId, provider); - this._proxy.$registerIssueUriRequestHandler(extensionId); - return new Disposable(() => { - this._proxy.$unregisterIssueUriRequestHandler(extensionId); - this._IssueUriRequestHandlers.delete(extensionId); - }); - } - - registerIssueDataProvider(extension: IExtensionDescription, provider: IssueDataProvider): Disposable { - const extensionId = extension.identifier.value; - this._IssueDataProviders.set(extensionId, provider); - this._proxy.$registerIssueDataProvider(extensionId); - return new Disposable(() => { - this._proxy.$unregisterIssueDataProvider(extensionId); - this._IssueDataProviders.delete(extensionId); - }); - } -} diff --git a/src/vs/workbench/api/common/extHostLanguageFeatures.ts b/src/vs/workbench/api/common/extHostLanguageFeatures.ts index b0a8742c349da..fe84fef3af9cb 100644 --- a/src/vs/workbench/api/common/extHostLanguageFeatures.ts +++ b/src/vs/workbench/api/common/extHostLanguageFeatures.ts @@ -3,40 +3,40 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { URI, UriComponents } from 'vs/base/common/uri'; +import { asArray, coalesce, isFalsyOrEmpty, isNonEmptyArray } from 'vs/base/common/arrays'; +import { raceCancellationError } from 'vs/base/common/async'; +import { VSBuffer } from 'vs/base/common/buffer'; +import { CancellationToken } from 'vs/base/common/cancellation'; +import { NotImplementedError, isCancellationError } from 'vs/base/common/errors'; +import { IdGenerator } from 'vs/base/common/idGenerator'; +import { DisposableStore } from 'vs/base/common/lifecycle'; import { equals, mixin } from 'vs/base/common/objects'; -import type * as vscode from 'vscode'; -import * as typeConvert from 'vs/workbench/api/common/extHostTypeConverters'; -import { Range, Disposable, CompletionList, SnippetString, CodeActionKind, SymbolInformation, DocumentSymbol, SemanticTokensEdits, SemanticTokens, SemanticTokensEdit, Location, InlineCompletionTriggerKind, InternalDataTransferItem, SyntaxTokenType } from 'vs/workbench/api/common/extHostTypes'; -import { ISingleEditOperation } from 'vs/editor/common/core/editOperation'; -import * as languages from 'vs/editor/common/languages'; -import { ExtHostDocuments } from 'vs/workbench/api/common/extHostDocuments'; -import { ExtHostCommands, CommandsConverter } from 'vs/workbench/api/common/extHostCommands'; -import { ExtHostDiagnostics } from 'vs/workbench/api/common/extHostDiagnostics'; -import * as extHostProtocol from './extHost.protocol'; +import { StopWatch } from 'vs/base/common/stopwatch'; import { regExpLeadsToEndlessLoop } from 'vs/base/common/strings'; -import { IPosition } from 'vs/editor/common/core/position'; -import { IRange, Range as EditorRange } from 'vs/editor/common/core/range'; -import { isFalsyOrEmpty, isNonEmptyArray, coalesce } from 'vs/base/common/arrays'; import { assertType, isObject } from 'vs/base/common/types'; -import { ISelection, Selection } from 'vs/editor/common/core/selection'; -import { ILogService } from 'vs/platform/log/common/log'; -import { CancellationToken } from 'vs/base/common/cancellation'; -import { ExtensionIdentifier, IExtensionDescription } from 'vs/platform/extensions/common/extensions'; +import { URI, UriComponents } from 'vs/base/common/uri'; import { IURITransformer } from 'vs/base/common/uriIpc'; -import { DisposableStore } from 'vs/base/common/lifecycle'; -import { VSBuffer } from 'vs/base/common/buffer'; +import { ISingleEditOperation } from 'vs/editor/common/core/editOperation'; +import { IPosition } from 'vs/editor/common/core/position'; +import { Range as EditorRange, IRange } from 'vs/editor/common/core/range'; +import { ISelection, Selection } from 'vs/editor/common/core/selection'; +import * as languages from 'vs/editor/common/languages'; +import { IAutoClosingPairConditional } from 'vs/editor/common/languages/languageConfiguration'; import { encodeSemanticTokensDto } from 'vs/editor/common/services/semanticTokensDto'; -import { IdGenerator } from 'vs/base/common/idGenerator'; +import { localize } from 'vs/nls'; +import { ExtensionIdentifier, IExtensionDescription } from 'vs/platform/extensions/common/extensions'; +import { ILogService } from 'vs/platform/log/common/log'; import { IExtHostApiDeprecationService } from 'vs/workbench/api/common/extHostApiDeprecationService'; -import { Cache } from './cache'; -import { StopWatch } from 'vs/base/common/stopwatch'; -import { isCancellationError, NotImplementedError } from 'vs/base/common/errors'; -import { raceCancellationError } from 'vs/base/common/async'; -import { isProposedApiEnabled } from 'vs/workbench/services/extensions/common/extensions'; +import { CommandsConverter, ExtHostCommands } from 'vs/workbench/api/common/extHostCommands'; +import { ExtHostDiagnostics } from 'vs/workbench/api/common/extHostDiagnostics'; +import { ExtHostDocuments } from 'vs/workbench/api/common/extHostDocuments'; import { ExtHostTelemetry, IExtHostTelemetry } from 'vs/workbench/api/common/extHostTelemetry'; -import { localize } from 'vs/nls'; -import { IAutoClosingPairConditional } from 'vs/editor/common/languages/languageConfiguration'; +import * as typeConvert from 'vs/workbench/api/common/extHostTypeConverters'; +import { CodeActionKind, CompletionList, Disposable, DocumentPasteEditKind, DocumentSymbol, InlineCompletionTriggerKind, InlineEditTriggerKind, InternalDataTransferItem, Location, Range, SemanticTokens, SemanticTokensEdit, SemanticTokensEdits, SnippetString, SymbolInformation, SyntaxTokenType } from 'vs/workbench/api/common/extHostTypes'; +import { isProposedApiEnabled } from 'vs/workbench/services/extensions/common/extensions'; +import type * as vscode from 'vscode'; +import { Cache } from './cache'; +import * as extHostProtocol from './extHost.protocol'; // --- adapter @@ -537,9 +537,7 @@ class CodeActionAdapter { class DocumentPasteEditProvider { - public static toInternalProviderId(extId: string, editId: string): string { - return extId + '.' + editId; - } + private readonly _cache = new Cache('DocumentPasteEdit'); constructor( private readonly _proxy: extHostProtocol.MainThreadLanguageFeaturesShape, @@ -570,9 +568,9 @@ class DocumentPasteEditProvider { return typeConvert.DataTransfer.from(entries); } - async providePasteEdits(requestId: number, resource: URI, ranges: IRange[], dataTransferDto: extHostProtocol.DataTransferDTO, token: CancellationToken): Promise { + async providePasteEdits(requestId: number, resource: URI, ranges: IRange[], dataTransferDto: extHostProtocol.DataTransferDTO, context: extHostProtocol.IDocumentPasteContextDto, token: CancellationToken): Promise { if (!this._provider.provideDocumentPasteEdits) { - return; + return []; } const doc = this._documents.getDocument(resource); @@ -582,20 +580,40 @@ class DocumentPasteEditProvider { return (await this._proxy.$resolvePasteFileData(this._handle, requestId, id)).buffer; }); - const edit = await this._provider.provideDocumentPasteEdits(doc, vscodeRanges, dataTransfer, token); - if (!edit) { - return; + const edits = await this._provider.provideDocumentPasteEdits(doc, vscodeRanges, dataTransfer, { + only: context.only ? new DocumentPasteEditKind(context.only) : undefined, + triggerKind: context.triggerKind, + }, token); + if (!edits || token.isCancellationRequested) { + return []; } - return { - label: edit.label ?? localize('defaultPasteLabel', "Paste using '{0}' extension", this._extension.displayName || this._extension.name), - detail: this._extension.displayName || this._extension.name, - yieldTo: edit.yieldTo?.map(yTo => { - return 'mimeType' in yTo ? yTo : { providerId: DocumentPasteEditProvider.toInternalProviderId(yTo.extensionId, yTo.providerId) }; - }), + const cacheId = this._cache.add(edits); + + return edits.map((edit, i): extHostProtocol.IPasteEditDto => ({ + _cacheId: [cacheId, i], + title: edit.title ?? localize('defaultPasteLabel', "Paste using '{0}' extension", this._extension.displayName || this._extension.name), + kind: edit.kind, + yieldTo: edit.yieldTo?.map(x => x.value), insertText: typeof edit.insertText === 'string' ? edit.insertText : { snippet: edit.insertText.value }, additionalEdit: edit.additionalEdit ? typeConvert.WorkspaceEdit.from(edit.additionalEdit, undefined) : undefined, - }; + })); + } + + async resolvePasteEdit(id: extHostProtocol.ChainedCacheId, token: CancellationToken): Promise<{ additionalEdit?: extHostProtocol.IWorkspaceEditDto }> { + const [sessionId, itemId] = id; + const item = this._cache.get(sessionId, itemId); + if (!item || !this._provider.resolveDocumentPasteEdit) { + return {}; // this should not happen... + } + + const resolvedItem = (await this._provider.resolveDocumentPasteEdit(item, token)) ?? item; + const additionalEdit = resolvedItem.additionalEdit ? typeConvert.WorkspaceEdit.from(resolvedItem.additionalEdit, undefined) : undefined; + return { additionalEdit }; + } + + releasePasteEdits(id: number): any { + this._cache.delete(id); } } @@ -816,6 +834,47 @@ class RenameAdapter { } } +class NewSymbolNamesAdapter { + + constructor( + private readonly _documents: ExtHostDocuments, + private readonly _provider: vscode.NewSymbolNamesProvider, + private readonly _logService: ILogService + ) { } + + async provideNewSymbolNames(resource: URI, range: IRange, token: CancellationToken): Promise { + + const doc = this._documents.getDocument(resource); + const pos = typeConvert.Range.to(range); + + try { + const value = await this._provider.provideNewSymbolNames(doc, pos, token); + if (!value) { + return undefined; + } + return value.map(v => + typeof v === 'string' /* @ulugbekna: for backward compatibility because `value` used to be just `string[]` */ + ? { newSymbolName: v } + : { newSymbolName: v.newSymbolName, tags: v.tags } + ); + } catch (err: unknown) { + this._logService.error(NewSymbolNamesAdapter._asMessage(err) ?? JSON.stringify(err, null, '\t') /* @ulugbekna: assuming `err` doesn't have circular references that could result in an exception when converting to JSON */); + return undefined; + } + } + + // @ulugbekna: this method is also defined in RenameAdapter but seems OK to be duplicated + private static _asMessage(err: any): string | undefined { + if (typeof err === 'string') { + return err; + } else if (err instanceof Error && typeof err.message === 'string') { + return err.message; + } else { + return undefined; + } + } +} + class SemanticTokensPreviousResult { constructor( readonly resultId: string | undefined, @@ -1189,7 +1248,7 @@ class InlineCompletionAdapterBase { handleDidShowCompletionItem(pid: number, idx: number, updatedInsertText: string): void { } - handlePartialAccept(pid: number, idx: number, acceptedCharacters: number): void { } + handlePartialAccept(pid: number, idx: number, acceptedCharacters: number, info: languages.PartialAcceptInfo): void { } } class InlineCompletionAdapter extends InlineCompletionAdapterBase { @@ -1304,13 +1363,89 @@ class InlineCompletionAdapter extends InlineCompletionAdapterBase { } } - override handlePartialAccept(pid: number, idx: number, acceptedCharacters: number): void { + override handlePartialAccept(pid: number, idx: number, acceptedCharacters: number, info: languages.PartialAcceptInfo): void { const completionItem = this._references.get(pid)?.items[idx]; if (completionItem) { if (this._provider.handleDidPartiallyAcceptCompletionItem && this._isAdditionsProposedApiEnabled) { this._provider.handleDidPartiallyAcceptCompletionItem(completionItem, acceptedCharacters); + this._provider.handleDidPartiallyAcceptCompletionItem(completionItem, typeConvert.PartialAcceptInfo.to(info)); + } + } + } +} + +class InlineEditAdapter { + private readonly _references = new ReferenceMap<{ + dispose(): void; + item: vscode.InlineEdit; + }>(); + + private languageTriggerKindToVSCodeTriggerKind: Record = { + [languages.InlineEditTriggerKind.Automatic]: InlineEditTriggerKind.Automatic, + [languages.InlineEditTriggerKind.Invoke]: InlineEditTriggerKind.Invoke, + }; + + async provideInlineEdits(uri: URI, context: languages.IInlineEditContext, token: CancellationToken): Promise { + const doc = this._documents.getDocument(uri); + const result = await this._provider.provideInlineEdit(doc, { + triggerKind: this.languageTriggerKindToVSCodeTriggerKind[context.triggerKind] + }, token); + + if (!result) { + // undefined and null are valid results + return undefined; + } + + if (token.isCancellationRequested) { + // cancelled -> return without further ado, esp no caching + // of results as they will leak + return undefined; + } + let disposableStore: DisposableStore | undefined = undefined; + const pid = this._references.createReferenceId({ + dispose() { + disposableStore?.dispose(); + }, + item: result + }); + + let acceptCommand: languages.Command | undefined = undefined; + if (result.accepted) { + if (!disposableStore) { + disposableStore = new DisposableStore(); + } + acceptCommand = this._commands.toInternal(result.accepted, disposableStore); + } + let rejectCommand: languages.Command | undefined = undefined; + if (result.rejected) { + if (!disposableStore) { + disposableStore = new DisposableStore(); } + rejectCommand = this._commands.toInternal(result.rejected, disposableStore); } + + const langResult: extHostProtocol.IdentifiableInlineEdit = { + pid, + text: result.text, + range: typeConvert.Range.from(result.range), + accepted: acceptCommand, + rejected: rejectCommand, + }; + + return langResult; + } + + disposeEdit(pid: number) { + const data = this._references.disposeReferenceId(pid); + data?.dispose(); + } + + constructor( + _extension: IExtensionDescription, + private readonly _documents: ExtHostDocuments, + private readonly _provider: vscode.InlineEditProvider, + private readonly _commands: CommandsConverter, + ) { } } @@ -1823,10 +1958,6 @@ class TypeHierarchyAdapter { class DocumentOnDropEditAdapter { - public static toInternalProviderId(extId: string, editId: string): string { - return extId + '.' + editId; - } - constructor( private readonly _proxy: extHostProtocol.MainThreadLanguageFeaturesShape, private readonly _documents: ExtHostDocuments, @@ -1835,25 +1966,25 @@ class DocumentOnDropEditAdapter { private readonly _extension: IExtensionDescription, ) { } - async provideDocumentOnDropEdits(requestId: number, uri: URI, position: IPosition, dataTransferDto: extHostProtocol.DataTransferDTO, token: CancellationToken): Promise { + async provideDocumentOnDropEdits(requestId: number, uri: URI, position: IPosition, dataTransferDto: extHostProtocol.DataTransferDTO, token: CancellationToken): Promise { const doc = this._documents.getDocument(uri); const pos = typeConvert.Position.to(position); const dataTransfer = typeConvert.DataTransfer.toDataTransfer(dataTransferDto, async (id) => { return (await this._proxy.$resolveDocumentOnDropFileData(this._handle, requestId, id)).buffer; }); - const edit = await this._provider.provideDocumentDropEdits(doc, pos, dataTransfer, token); - if (!edit) { + const edits = await this._provider.provideDocumentDropEdits(doc, pos, dataTransfer, token); + if (!edits) { return undefined; } - return { - label: edit.label ?? localize('defaultDropLabel', "Drop using '{0}' extension", this._extension.displayName || this._extension.name), - yieldTo: edit.yieldTo?.map(yTo => { - return 'mimeType' in yTo ? yTo : { providerId: DocumentOnDropEditAdapter.toInternalProviderId(yTo.extensionId, yTo.providerId) }; - }), + + return asArray(edits).map((edit): extHostProtocol.IDocumentOnDropEditDto => ({ + title: edit.title ?? localize('defaultDropLabel', "Drop using '{0}' extension", this._extension.displayName || this._extension.name), + kind: edit.kind?.value, + yieldTo: edit.yieldTo?.map(x => x.value), insertText: typeof edit.insertText === 'string' ? edit.insertText : { snippet: edit.insertText.value }, additionalEdit: edit.additionalEdit ? typeConvert.WorkspaceEdit.from(edit.additionalEdit, undefined) : undefined, - }; + })); } } @@ -1905,7 +2036,7 @@ type Adapter = DocumentSymbolAdapter | CodeLensAdapter | DefinitionAdapter | Hov | DocumentSemanticTokensAdapter | DocumentRangeSemanticTokensAdapter | EvaluatableExpressionAdapter | InlineValuesAdapter | LinkedEditingRangeAdapter | InlayHintsAdapter | InlineCompletionAdapter - | DocumentOnDropEditAdapter | MappedEditsAdapter; + | DocumentOnDropEditAdapter | MappedEditsAdapter | NewSymbolNamesAdapter | InlineEditAdapter; class AdapterData { constructor( @@ -2287,6 +2418,16 @@ export class ExtHostLanguageFeatures implements extHostProtocol.ExtHostLanguageF return this._withAdapter(handle, RenameAdapter, adapter => adapter.resolveRenameLocation(URI.revive(resource), position, token), undefined, token); } + registerNewSymbolNamesProvider(extension: IExtensionDescription, selector: vscode.DocumentSelector, provider: vscode.NewSymbolNamesProvider): vscode.Disposable { + const handle = this._addNewAdapter(new NewSymbolNamesAdapter(this._documents, provider, this._logService), extension); + this._proxy.$registerNewSymbolNamesProvider(handle, this._transformDocumentSelector(selector, extension)); + return this._createDisposable(handle); + } + + $provideNewSymbolNames(handle: number, resource: UriComponents, range: IRange, token: CancellationToken): Promise { + return this._withAdapter(handle, NewSymbolNamesAdapter, adapter => adapter.provideNewSymbolNames(URI.revive(resource), range, token), undefined, token); + } + //#region semantic coloring registerDocumentSemanticTokensProvider(extension: IExtensionDescription, selector: vscode.DocumentSelector, provider: vscode.DocumentSemanticTokensProvider, legend: vscode.SemanticTokensLegend): vscode.Disposable { @@ -2363,9 +2504,9 @@ export class ExtHostLanguageFeatures implements extHostProtocol.ExtHostLanguageF }, undefined, undefined); } - $handleInlineCompletionPartialAccept(handle: number, pid: number, idx: number, acceptedCharacters: number): void { + $handleInlineCompletionPartialAccept(handle: number, pid: number, idx: number, acceptedCharacters: number, info: languages.PartialAcceptInfo): void { this._withAdapter(handle, InlineCompletionAdapterBase, async adapter => { - adapter.handlePartialAccept(pid, idx, acceptedCharacters); + adapter.handlePartialAccept(pid, idx, acceptedCharacters, info); }, undefined, undefined); } @@ -2373,6 +2514,23 @@ export class ExtHostLanguageFeatures implements extHostProtocol.ExtHostLanguageF this._withAdapter(handle, InlineCompletionAdapterBase, async adapter => { adapter.disposeCompletions(pid); }, undefined, undefined); } + // --- inline edit + + registerInlineEditProvider(extension: IExtensionDescription, selector: vscode.DocumentSelector, provider: vscode.InlineEditProvider): vscode.Disposable { + const adapter = new InlineEditAdapter(extension, this._documents, provider, this._commands.converter); + const handle = this._addNewAdapter(adapter, extension); + this._proxy.$registerInlineEditProvider(handle, this._transformDocumentSelector(selector, extension), extension.identifier); + return this._createDisposable(handle); + } + + $provideInlineEdit(handle: number, resource: UriComponents, context: languages.IInlineEditContext, token: CancellationToken): Promise { + return this._withAdapter(handle, InlineEditAdapter, adapter => adapter.provideInlineEdits(URI.revive(resource), context, token), undefined, token); + } + + $freeInlineEdit(handle: number, pid: number): void { + this._withAdapter(handle, InlineEditAdapter, async adapter => { adapter.disposeEdit(pid); }, undefined, undefined); + } + // --- parameter hints registerSignatureHelpProvider(extension: IExtensionDescription, selector: vscode.DocumentSelector, provider: vscode.SignatureHelpProvider, metadataOrTriggerChars: string[] | vscode.SignatureHelpProviderMetadata): vscode.Disposable { @@ -2548,13 +2706,12 @@ export class ExtHostLanguageFeatures implements extHostProtocol.ExtHostLanguageF const handle = this._nextHandle(); this._adapter.set(handle, new AdapterData(new DocumentOnDropEditAdapter(this._proxy, this._documents, provider, handle, extension), extension)); - const id = isProposedApiEnabled(extension, 'dropMetadata') && metadata ? DocumentOnDropEditAdapter.toInternalProviderId(extension.identifier.value, metadata.id) : undefined; - this._proxy.$registerDocumentOnDropEditProvider(handle, this._transformDocumentSelector(selector, extension), id, isProposedApiEnabled(extension, 'dropMetadata') ? metadata : undefined); + this._proxy.$registerDocumentOnDropEditProvider(handle, this._transformDocumentSelector(selector, extension), isProposedApiEnabled(extension, 'dropMetadata') ? metadata : undefined); return this._createDisposable(handle); } - $provideDocumentOnDropEdits(handle: number, requestId: number, resource: UriComponents, position: IPosition, dataTransferDto: extHostProtocol.DataTransferDTO, token: CancellationToken): Promise { + $provideDocumentOnDropEdits(handle: number, requestId: number, resource: UriComponents, position: IPosition, dataTransferDto: extHostProtocol.DataTransferDTO, token: CancellationToken): Promise { return this._withAdapter(handle, DocumentOnDropEditAdapter, adapter => Promise.resolve(adapter.provideDocumentOnDropEdits(requestId, URI.revive(resource), position, dataTransferDto, token)), undefined, undefined); } @@ -2577,10 +2734,11 @@ export class ExtHostLanguageFeatures implements extHostProtocol.ExtHostLanguageF registerDocumentPasteEditProvider(extension: IExtensionDescription, selector: vscode.DocumentSelector, provider: vscode.DocumentPasteEditProvider, metadata: vscode.DocumentPasteProviderMetadata): vscode.Disposable { const handle = this._nextHandle(); this._adapter.set(handle, new AdapterData(new DocumentPasteEditProvider(this._proxy, this._documents, provider, handle, extension), extension)); - const internalId = DocumentPasteEditProvider.toInternalProviderId(extension.identifier.value, metadata.id); - this._proxy.$registerPasteEditProvider(handle, this._transformDocumentSelector(selector, extension), internalId, { + this._proxy.$registerPasteEditProvider(handle, this._transformDocumentSelector(selector, extension), { supportsCopy: !!provider.prepareDocumentPaste, supportsPaste: !!provider.provideDocumentPasteEdits, + supportsResolve: !!provider.resolveDocumentPasteEdit, + providedPasteEditKinds: metadata.providedPasteEditKinds?.map(x => x.value), copyMimeTypes: metadata.copyMimeTypes, pasteMimeTypes: metadata.pasteMimeTypes, }); @@ -2591,8 +2749,16 @@ export class ExtHostLanguageFeatures implements extHostProtocol.ExtHostLanguageF return this._withAdapter(handle, DocumentPasteEditProvider, adapter => adapter.prepareDocumentPaste(URI.revive(resource), ranges, dataTransfer, token), undefined, token); } - $providePasteEdits(handle: number, requestId: number, resource: UriComponents, ranges: IRange[], dataTransferDto: extHostProtocol.DataTransferDTO, token: CancellationToken): Promise { - return this._withAdapter(handle, DocumentPasteEditProvider, adapter => adapter.providePasteEdits(requestId, URI.revive(resource), ranges, dataTransferDto, token), undefined, token); + $providePasteEdits(handle: number, requestId: number, resource: UriComponents, ranges: IRange[], dataTransferDto: extHostProtocol.DataTransferDTO, context: extHostProtocol.IDocumentPasteContextDto, token: CancellationToken): Promise { + return this._withAdapter(handle, DocumentPasteEditProvider, adapter => adapter.providePasteEdits(requestId, URI.revive(resource), ranges, dataTransferDto, context, token), undefined, token); + } + + $resolvePasteEdit(handle: number, id: extHostProtocol.ChainedCacheId, token: CancellationToken): Promise<{ additionalEdit?: extHostProtocol.IWorkspaceEditDto }> { + return this._withAdapter(handle, DocumentPasteEditProvider, adapter => adapter.resolvePasteEdit(id, token), {}, undefined); + } + + $releasePasteEdits(handle: number, cacheId: number): void { + this._withAdapter(handle, DocumentPasteEditProvider, adapter => Promise.resolve(adapter.releasePasteEdits(cacheId)), undefined, undefined); } // --- configuration diff --git a/src/vs/workbench/api/common/extHostLanguageModels.ts b/src/vs/workbench/api/common/extHostLanguageModels.ts new file mode 100644 index 0000000000000..8221534013c05 --- /dev/null +++ b/src/vs/workbench/api/common/extHostLanguageModels.ts @@ -0,0 +1,384 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { CancellationToken } from 'vs/base/common/cancellation'; +import { IDisposable, toDisposable } from 'vs/base/common/lifecycle'; +import { ExtHostLanguageModelsShape, MainContext, MainThreadLanguageModelsShape } from 'vs/workbench/api/common/extHost.protocol'; +import * as typeConvert from 'vs/workbench/api/common/extHostTypeConverters'; +import { LanguageModelError } from 'vs/workbench/api/common/extHostTypes'; +import type * as vscode from 'vscode'; +import { Progress } from 'vs/platform/progress/common/progress'; +import { IChatMessage, IChatResponseFragment, ILanguageModelChatMetadata } from 'vs/workbench/contrib/chat/common/languageModels'; +import { ExtensionIdentifier, ExtensionIdentifierMap, ExtensionIdentifierSet, IExtensionDescription } from 'vs/platform/extensions/common/extensions'; +import { AsyncIterableSource, Barrier } from 'vs/base/common/async'; +import { Emitter, Event } from 'vs/base/common/event'; +import { localize } from 'vs/nls'; +import { INTERNAL_AUTH_PROVIDER_PREFIX } from 'vs/workbench/services/authentication/common/authentication'; +import { CancellationError } from 'vs/base/common/errors'; +import { createDecorator } from 'vs/platform/instantiation/common/instantiation'; +import { IExtHostRpcService } from 'vs/workbench/api/common/extHostRpcService'; +import { IExtHostAuthentication } from 'vs/workbench/api/common/extHostAuthentication'; +import { ILogService } from 'vs/platform/log/common/log'; + +export interface IExtHostLanguageModels extends ExtHostLanguageModels { } + +export const IExtHostLanguageModels = createDecorator('IExtHostLanguageModels'); + +type LanguageModelData = { + readonly languageModelId: string; + readonly extension: ExtensionIdentifier; + readonly provider: vscode.ChatResponseProvider; +}; + +class LanguageModelResponseStream { + + readonly stream = new AsyncIterableSource(); + + constructor( + readonly option: number, + stream?: AsyncIterableSource + ) { + this.stream = stream ?? new AsyncIterableSource(); + } +} + +class LanguageModelResponse { + + readonly apiObject: vscode.LanguageModelChatResponse; + + private readonly _responseStreams = new Map(); + private readonly _defaultStream = new AsyncIterableSource(); + private _isDone: boolean = false; + private _isStreaming: boolean = false; + + constructor() { + + const that = this; + this.apiObject = { + // result: promise, + stream: that._defaultStream.asyncIterable, + // streams: AsyncIterable[] // FUTURE responses per N + }; + } + + private * _streams() { + if (this._responseStreams.size > 0) { + for (const [, value] of this._responseStreams) { + yield value.stream; + } + } else { + yield this._defaultStream; + } + } + + handleFragment(fragment: IChatResponseFragment): void { + if (this._isDone) { + return; + } + this._isStreaming = true; + let res = this._responseStreams.get(fragment.index); + if (!res) { + if (this._responseStreams.size === 0) { + // the first response claims the default response + res = new LanguageModelResponseStream(fragment.index, this._defaultStream); + } else { + res = new LanguageModelResponseStream(fragment.index); + } + this._responseStreams.set(fragment.index, res); + } + res.stream.emitOne(fragment.part); + } + + get isStreaming(): boolean { + return this._isStreaming; + } + + reject(err: Error): void { + this._isDone = true; + for (const stream of this._streams()) { + stream.reject(err); + } + } + + resolve(): void { + this._isDone = true; + for (const stream of this._streams()) { + stream.resolve(); + } + } + +} + +export class ExtHostLanguageModels implements ExtHostLanguageModelsShape { + + declare _serviceBrand: undefined; + + private static _idPool = 1; + + private readonly _proxy: MainThreadLanguageModelsShape; + private readonly _onDidChangeModelAccess = new Emitter<{ from: ExtensionIdentifier; to: ExtensionIdentifier }>(); + private readonly _onDidChangeProviders = new Emitter(); + readonly onDidChangeProviders = this._onDidChangeProviders.event; + + private readonly _languageModels = new Map(); + private readonly _allLanguageModelData = new Map(); // these are ALL models, not just the one in this EH + private readonly _modelAccessList = new ExtensionIdentifierMap(); + private readonly _pendingRequest = new Map(); + + constructor( + @IExtHostRpcService extHostRpc: IExtHostRpcService, + @ILogService private readonly _logService: ILogService, + @IExtHostAuthentication private readonly _extHostAuthentication: IExtHostAuthentication, + ) { + this._proxy = extHostRpc.getProxy(MainContext.MainThreadLanguageModels); + } + + dispose(): void { + this._onDidChangeModelAccess.dispose(); + this._onDidChangeProviders.dispose(); + } + + registerLanguageModel(extension: IExtensionDescription, identifier: string, provider: vscode.ChatResponseProvider, metadata: vscode.ChatResponseProviderMetadata): IDisposable { + + const handle = ExtHostLanguageModels._idPool++; + this._languageModels.set(handle, { extension: extension.identifier, provider, languageModelId: identifier }); + let auth; + if (metadata.auth) { + auth = { + providerLabel: extension.displayName || extension.name, + accountLabel: typeof metadata.auth === 'object' ? metadata.auth.label : undefined + }; + } + this._proxy.$registerLanguageModelProvider(handle, identifier, { + extension: extension.identifier, + identifier: identifier, + model: metadata.name ?? '', + auth + }); + + return toDisposable(() => { + this._languageModels.delete(handle); + this._proxy.$unregisterProvider(handle); + }); + } + + async $provideLanguageModelResponse(handle: number, requestId: number, from: ExtensionIdentifier, messages: IChatMessage[], options: { [name: string]: any }, token: CancellationToken): Promise { + const data = this._languageModels.get(handle); + if (!data) { + return; + } + const progress = new Progress(async fragment => { + if (token.isCancellationRequested) { + this._logService.warn(`[CHAT](${data.extension.value}) CANNOT send progress because the REQUEST IS CANCELLED`); + return; + } + this._proxy.$handleProgressChunk(requestId, { index: fragment.index, part: fragment.part }); + }); + + return data.provider.provideLanguageModelResponse2(messages.map(typeConvert.LanguageModelMessage.to), options, ExtensionIdentifier.toKey(from), progress, token); + } + + //#region --- making request + + $updateLanguageModels(data: { added?: ILanguageModelChatMetadata[] | undefined; removed?: string[] | undefined }): void { + const added: string[] = []; + const removed: string[] = []; + if (data.added) { + for (const metadata of data.added) { + this._allLanguageModelData.set(metadata.identifier, metadata); + added.push(metadata.model); + } + } + if (data.removed) { + for (const id of data.removed) { + // clean up + this._allLanguageModelData.delete(id); + removed.push(id); + + // cancel pending requests for this model + for (const [key, value] of this._pendingRequest) { + if (value.languageModelId === id) { + value.res.reject(new CancellationError()); + this._pendingRequest.delete(key); + } + } + } + } + + this._onDidChangeProviders.fire(Object.freeze({ + added: Object.freeze(added), + removed: Object.freeze(removed) + })); + + // TODO@jrieken@TylerLeonhardt - this is a temporary hack to populate the auth providers + data.added?.forEach(this._fakeAuthPopulate, this); + } + + getLanguageModelIds(): string[] { + return Array.from(this._allLanguageModelData.keys()); + } + + $updateModelAccesslist(data: { from: ExtensionIdentifier; to: ExtensionIdentifier; enabled: boolean }[]): void { + const updated = new Array<{ from: ExtensionIdentifier; to: ExtensionIdentifier }>(); + for (const { from, to, enabled } of data) { + const set = this._modelAccessList.get(from) ?? new ExtensionIdentifierSet(); + const oldValue = set.has(to); + if (oldValue !== enabled) { + if (enabled) { + set.add(to); + } else { + set.delete(to); + } + this._modelAccessList.set(from, set); + const newItem = { from, to }; + updated.push(newItem); + this._onDidChangeModelAccess.fire(newItem); + } + } + } + + async sendChatRequest(extension: IExtensionDescription, languageModelId: string, messages: vscode.LanguageModelChatMessage[], options: vscode.LanguageModelChatRequestOptions, token: CancellationToken) { + + const from = extension.identifier; + const metadata = await this._proxy.$prepareChatAccess(from, languageModelId, options.justification); + + if (!metadata || !this._allLanguageModelData.has(languageModelId)) { + throw LanguageModelError.NotFound(`Language model '${languageModelId}' is unknown.`); + } + + if (this._isUsingAuth(from, metadata)) { + const success = await this._getAuthAccess(extension, { identifier: metadata.extension, displayName: metadata.auth.providerLabel }, options.justification, options.silent); + + if (!success || !this._modelAccessList.get(from)?.has(metadata.extension)) { + throw LanguageModelError.NoPermissions(`Language model '${languageModelId}' cannot be used by '${from.value}'.`); + } + } + + const requestId = (Math.random() * 1e6) | 0; + const requestPromise = this._proxy.$fetchResponse(from, languageModelId, requestId, messages.map(typeConvert.LanguageModelMessage.from), options.modelOptions ?? {}, token); + + const barrier = new Barrier(); + + const res = new LanguageModelResponse(); + this._pendingRequest.set(requestId, { languageModelId, res }); + + let error: Error | undefined; + + requestPromise.catch(err => { + if (barrier.isOpen()) { + // we received an error while streaming. this means we need to reject the "stream" + // because we have already returned the request object + res.reject(err); + } else { + error = err; + } + }).finally(() => { + this._pendingRequest.delete(requestId); + res.resolve(); + barrier.open(); + }); + + await barrier.wait(); + + if (error) { + throw new LanguageModelError( + `Language model '${languageModelId}' errored, check cause for more details`, + 'Unknown', + error + ); + } + + return res.apiObject; + } + + async $handleResponseFragment(requestId: number, chunk: IChatResponseFragment): Promise { + const data = this._pendingRequest.get(requestId);//.report(chunk); + if (data) { + data.res.handleFragment(chunk); + } + } + + // BIG HACK: Using AuthenticationProviders to check access to Language Models + private async _getAuthAccess(from: IExtensionDescription, to: { identifier: ExtensionIdentifier; displayName: string }, justification: string | undefined, silent: boolean | undefined): Promise { + // This needs to be done in both MainThread & ExtHost ChatProvider + const providerId = INTERNAL_AUTH_PROVIDER_PREFIX + to.identifier.value; + const session = await this._extHostAuthentication.getSession(from, providerId, [], { silent: true }); + + if (session) { + this.$updateModelAccesslist([{ from: from.identifier, to: to.identifier, enabled: true }]); + return true; + } + + if (silent) { + return false; + } + + try { + const detail = justification + ? localize('chatAccessWithJustification', "To allow access to the language models provided by {0}. Justification:\n\n{1}", to.displayName, justification) + : localize('chatAccess', "To allow access to the language models provided by {0}", to.displayName); + await this._extHostAuthentication.getSession(from, providerId, [], { forceNewSession: { detail } }); + this.$updateModelAccesslist([{ from: from.identifier, to: to.identifier, enabled: true }]); + return true; + + } catch (err) { + // ignore + return false; + } + } + + private _isUsingAuth(from: ExtensionIdentifier, toMetadata: ILanguageModelChatMetadata): toMetadata is ILanguageModelChatMetadata & { auth: NonNullable } { + // If the 'to' extension uses an auth check + return !!toMetadata.auth + // And we're asking from a different extension + && !ExtensionIdentifier.equals(toMetadata.extension, from); + } + + private async _fakeAuthPopulate(metadata: ILanguageModelChatMetadata): Promise { + + for (const from of this._languageAccessInformationExtensions) { + try { + await this._getAuthAccess(from, { identifier: metadata.extension, displayName: '' }, undefined, true); + } catch (err) { + this._logService.error('Fake Auth request failed'); + this._logService.error(err); + } + } + + } + + private readonly _languageAccessInformationExtensions = new Set>(); + + createLanguageModelAccessInformation(from: Readonly): vscode.LanguageModelAccessInformation { + + this._languageAccessInformationExtensions.add(from); + + const that = this; + const _onDidChangeAccess = Event.signal(Event.filter(this._onDidChangeModelAccess.event, e => ExtensionIdentifier.equals(e.from, from.identifier))); + const _onDidAddRemove = Event.signal(this._onDidChangeProviders.event); + + return { + get onDidChange() { + return Event.any(_onDidChangeAccess, _onDidAddRemove); + }, + canSendRequest(languageModelId: string): boolean | undefined { + + const data = that._allLanguageModelData.get(languageModelId); + if (!data) { + return undefined; + } + if (!that._isUsingAuth(from.identifier, data)) { + return true; + } + + const list = that._modelAccessList.get(from.identifier); + if (!list) { + return undefined; + } + return list.has(data.extension); + } + }; + } +} diff --git a/src/vs/workbench/api/common/extHostNotebook.ts b/src/vs/workbench/api/common/extHostNotebook.ts index f4a80653baa3f..75dc5a402ffc3 100644 --- a/src/vs/workbench/api/common/extHostNotebook.ts +++ b/src/vs/workbench/api/common/extHostNotebook.ts @@ -434,6 +434,17 @@ export class ExtHostNotebookController implements ExtHostNotebookShape { } finalMatchedTargets.add(uri); }); + }).catch(err => { + // temporary fix for https://github.com/microsoft/vscode/issues/205044: don't show notebook results for remotehub repos. + if (err.code === 'ENOENT') { + console.warn(`Could not find notebook search results, ignoring notebook results.`); + return { + limitHit: false, + messages: [], + }; + } else { + throw err; + } }); })) )); @@ -614,7 +625,7 @@ export class ExtHostNotebookController implements ExtHostNotebookShape { ); // add cell document as vscode.TextDocument - addedCellDocuments.push(...modelData.cells.map(cell => ExtHostCell.asModelAddData(document.apiNotebook, cell))); + addedCellDocuments.push(...modelData.cells.map(cell => ExtHostCell.asModelAddData(cell))); this._documents.get(uri)?.dispose(); this._documents.set(uri, document); diff --git a/src/vs/workbench/api/common/extHostNotebookDocument.ts b/src/vs/workbench/api/common/extHostNotebookDocument.ts index e8970f70dee6f..8f74a0a4b6937 100644 --- a/src/vs/workbench/api/common/extHostNotebookDocument.ts +++ b/src/vs/workbench/api/common/extHostNotebookDocument.ts @@ -7,7 +7,7 @@ import { Schemas } from 'vs/base/common/network'; import { URI } from 'vs/base/common/uri'; import * as extHostProtocol from 'vs/workbench/api/common/extHost.protocol'; import { ExtHostDocuments } from 'vs/workbench/api/common/extHostDocuments'; -import { ExtHostDocumentsAndEditors, IExtHostModelAddedData } from 'vs/workbench/api/common/extHostDocumentsAndEditors'; +import { ExtHostDocumentsAndEditors } from 'vs/workbench/api/common/extHostDocumentsAndEditors'; import * as extHostTypeConverters from 'vs/workbench/api/common/extHostTypeConverters'; import { NotebookRange } from 'vs/workbench/api/common/extHostTypes'; import * as notebookCommon from 'vs/workbench/contrib/notebook/common/notebookCommon'; @@ -33,15 +33,14 @@ class RawContentChangeEvent { export class ExtHostCell { - static asModelAddData(notebook: vscode.NotebookDocument, cell: extHostProtocol.NotebookCellDto): IExtHostModelAddedData { + static asModelAddData(cell: extHostProtocol.NotebookCellDto): extHostProtocol.IModelAddedData { return { EOL: cell.eol, lines: cell.source, languageId: cell.language, uri: cell.uri, isDirty: false, - versionId: 1, - notebook + versionId: 1 }; } @@ -356,7 +355,7 @@ export class ExtHostNotebookDocument { } const contentChangeEvents: RawContentChangeEvent[] = []; - const addedCellDocuments: IExtHostModelAddedData[] = []; + const addedCellDocuments: extHostProtocol.IModelAddedData[] = []; const removedCellDocuments: URI[] = []; splices.reverse().forEach(splice => { @@ -365,7 +364,7 @@ export class ExtHostNotebookDocument { const extCell = new ExtHostCell(this, this._textDocumentsAndEditors, cell); if (!initialization) { - addedCellDocuments.push(ExtHostCell.asModelAddData(this.apiNotebook, cell)); + addedCellDocuments.push(ExtHostCell.asModelAddData(cell)); } return extCell; }); diff --git a/src/vs/workbench/api/common/extHostNotebookDocumentSaveParticipant.ts b/src/vs/workbench/api/common/extHostNotebookDocumentSaveParticipant.ts index 1e6a706a3058f..0f031e2f76b7d 100644 --- a/src/vs/workbench/api/common/extHostNotebookDocumentSaveParticipant.ts +++ b/src/vs/workbench/api/common/extHostNotebookDocumentSaveParticipant.ts @@ -13,6 +13,7 @@ import { ExtHostNotebookController } from 'vs/workbench/api/common/extHostNotebo import { TextDocumentSaveReason, WorkspaceEdit as WorksapceEditConverter } from 'vs/workbench/api/common/extHostTypeConverters'; import { WorkspaceEdit } from 'vs/workbench/api/common/extHostTypes'; import { SaveReason } from 'vs/workbench/common/editor'; +import { SerializableObjectWithBuffers } from 'vs/workbench/services/extensions/common/proxyIdentifier'; import { NotebookDocumentWillSaveEvent } from 'vscode'; interface IExtensionListener { @@ -90,6 +91,6 @@ export class ExtHostNotebookDocumentSaveParticipant implements ExtHostNotebookDo dto.edits = dto.edits.concat(edits); } - return this._mainThreadBulkEdits.$tryApplyWorkspaceEdit(dto); + return this._mainThreadBulkEdits.$tryApplyWorkspaceEdit(new SerializableObjectWithBuffers(dto)); } } diff --git a/src/vs/workbench/api/common/extHostNotebookKernels.ts b/src/vs/workbench/api/common/extHostNotebookKernels.ts index 265b29c33d364..49dcbf40085f6 100644 --- a/src/vs/workbench/api/common/extHostNotebookKernels.ts +++ b/src/vs/workbench/api/common/extHostNotebookKernels.ts @@ -12,7 +12,7 @@ import { ResourceMap } from 'vs/base/common/map'; import { URI, UriComponents } from 'vs/base/common/uri'; import { ExtensionIdentifier, IExtensionDescription } from 'vs/platform/extensions/common/extensions'; import { ILogService } from 'vs/platform/log/common/log'; -import { ExtHostNotebookKernelsShape, ICellExecuteUpdateDto, IMainContext, INotebookKernelDto2, MainContext, MainThreadNotebookKernelsShape, NotebookOutputDto } from 'vs/workbench/api/common/extHost.protocol'; +import { ExtHostNotebookKernelsShape, ICellExecuteUpdateDto, IMainContext, INotebookKernelDto2, MainContext, MainThreadNotebookKernelsShape, NotebookOutputDto, VariablesResult } from 'vs/workbench/api/common/extHost.protocol'; import { ApiCommand, ApiCommandArgument, ApiCommandResult, ExtHostCommands } from 'vs/workbench/api/common/extHostCommands'; import { IExtHostInitDataService } from 'vs/workbench/api/common/extHostInitDataService'; import { ExtHostNotebookController } from 'vs/workbench/api/common/extHostNotebook'; @@ -90,7 +90,30 @@ export class ExtHostNotebookKernels implements ExtHostNotebookKernelsShape { }) ], ApiCommandResult.Void); + + const requestKernelVariablesApiCommand = new ApiCommand( + 'vscode.executeNotebookVariableProvider', + '_executeNotebookVariableProvider', + 'Execute notebook variable provider', + [ApiCommandArgument.Uri], + new ApiCommandResult('A promise that resolves to an array of variables', (value, apiArgs) => { + return value.map(variable => { + return { + variable: { + name: variable.name, + value: variable.value, + expression: variable.expression, + type: variable.type, + language: variable.language + }, + hasNamedChildren: variable.hasNamedChildren, + indexedChildrenCount: variable.indexedChildrenCount + }; + }); + }) + ); this._commands.registerApiCommand(selectKernelApiCommand); + this._commands.registerApiCommand(requestKernelVariablesApiCommand); } createNotebookController(extension: IExtensionDescription, id: string, viewType: string, label: string, handler?: (cells: vscode.NotebookCell[], notebook: vscode.NotebookDocument, controller: vscode.NotebookController) => void | Thenable, preloads?: vscode.NotebookRendererScript[]): vscode.NotebookController { @@ -457,8 +480,12 @@ export class ExtHostNotebookKernels implements ExtHostNotebookKernelsShape { name: result.variable.name, value: result.variable.value, type: result.variable.type, + interfaces: result.variable.interfaces, + language: result.variable.language, + expression: result.variable.expression, hasNamedChildren: result.hasNamedChildren, - indexedChildrenCount: result.indexedChildrenCount + indexedChildrenCount: result.indexedChildrenCount, + extensionId: obj.extensionId.value, }; this.variableStore[variable.id] = result.variable; this._proxy.$receiveVariable(requestId, variable); @@ -681,7 +708,7 @@ class NotebookCellExecutionTask extends Disposable { }); }, - end(success: boolean | undefined, endTime?: number): void { + end(success: boolean | undefined, endTime?: number, executionError?: vscode.CellExecutionError): void { if (that._state === NotebookCellExecutionTaskState.Resolved) { throw new Error('Cannot call resolve twice'); } @@ -693,9 +720,22 @@ class NotebookCellExecutionTask extends Disposable { // so we use updateSoon and immediately flush. that._collector.flush(); + const error = executionError ? { + message: executionError.message, + stack: executionError.stack, + location: executionError?.location ? { + startLineNumber: executionError.location.start.line, + startColumn: executionError.location.start.character, + endLineNumber: executionError.location.end.line, + endColumn: executionError.location.end.character + } : undefined, + uri: executionError.uri + } : undefined; + that._proxy.$completeExecution(that._handle, new SerializableObjectWithBuffers({ runEndTime: endTime, - lastRunSuccess: success + lastRunSuccess: success, + error })); }, diff --git a/src/vs/workbench/api/common/extHostSCM.ts b/src/vs/workbench/api/common/extHostSCM.ts index ee2be0b1bcc24..c746b79ed3556 100644 --- a/src/vs/workbench/api/common/extHostSCM.ts +++ b/src/vs/workbench/api/common/extHostSCM.ts @@ -559,7 +559,7 @@ class ExtHostSourceControl implements vscode.SourceControl { } private _historyProvider: vscode.SourceControlHistoryProvider | undefined; - private _historyProviderDisposable = new MutableDisposable(); + private readonly _historyProviderDisposable = new MutableDisposable(); private _historyProviderCurrentHistoryItemGroup: vscode.SourceControlHistoryItemGroup | undefined; get historyProvider(): vscode.SourceControlHistoryProvider | undefined { @@ -598,7 +598,7 @@ class ExtHostSourceControl implements vscode.SourceControl { this.#proxy.$updateSourceControl(this.handle, { commitTemplate }); } - private _acceptInputDisposables = new MutableDisposable(); + private readonly _acceptInputDisposables = new MutableDisposable(); private _acceptInputCommand: vscode.Command | undefined = undefined; get acceptInputCommand(): vscode.Command | undefined { @@ -614,7 +614,7 @@ class ExtHostSourceControl implements vscode.SourceControl { this.#proxy.$updateSourceControl(this.handle, { acceptInputCommand: internal }); } - private _actionButtonDisposables = new MutableDisposable(); + private readonly _actionButtonDisposables = new MutableDisposable(); private _actionButton: vscode.SourceControlActionButton | undefined; get actionButton(): vscode.SourceControlActionButton | undefined { checkProposedApiEnabled(this._extension, 'scmActionButton'); @@ -639,7 +639,7 @@ class ExtHostSourceControl implements vscode.SourceControl { } - private _statusBarDisposables = new MutableDisposable(); + private readonly _statusBarDisposables = new MutableDisposable(); private _statusBarCommands: vscode.Command[] | undefined = undefined; get statusBarCommands(): vscode.Command[] | undefined { diff --git a/src/vs/workbench/api/common/extHostSearch.ts b/src/vs/workbench/api/common/extHostSearch.ts index d0289669abf7e..c2e0b93f7b902 100644 --- a/src/vs/workbench/api/common/extHostSearch.ts +++ b/src/vs/workbench/api/common/extHostSearch.ts @@ -11,13 +11,14 @@ import { FileSearchManager } from 'vs/workbench/services/search/common/fileSearc import { IExtHostRpcService } from 'vs/workbench/api/common/extHostRpcService'; import { IURITransformerService } from 'vs/workbench/api/common/extHostUriTransformerService'; import { ILogService } from 'vs/platform/log/common/log'; -import { IRawFileQuery, ISearchCompleteStats, IFileQuery, IRawTextQuery, IRawQuery, ITextQuery, IFolderQuery } from 'vs/workbench/services/search/common/search'; +import { IRawFileQuery, ISearchCompleteStats, IFileQuery, IRawTextQuery, IRawQuery, ITextQuery, IFolderQuery, IRawAITextQuery, IAITextQuery } from 'vs/workbench/services/search/common/search'; import { URI, UriComponents } from 'vs/base/common/uri'; import { TextSearchManager } from 'vs/workbench/services/search/common/textSearchManager'; import { CancellationToken } from 'vs/base/common/cancellation'; export interface IExtHostSearch extends ExtHostSearchShape { registerTextSearchProvider(scheme: string, provider: vscode.TextSearchProvider): IDisposable; + registerAITextSearchProvider(scheme: string, provider: vscode.AITextSearchProvider): IDisposable; registerFileSearchProvider(scheme: string, provider: vscode.FileSearchProvider): IDisposable; doInternalFileSearchWithCustomCallback(query: IFileQuery, token: CancellationToken, handleFileMatch: (data: URI[]) => void): Promise; } @@ -31,6 +32,10 @@ export class ExtHostSearch implements ExtHostSearchShape { private readonly _textSearchProvider = new Map(); private readonly _textSearchUsedSchemes = new Set(); + + private readonly _aiTextSearchProvider = new Map(); + private readonly _aiTextSearchUsedSchemes = new Set(); + private readonly _fileSearchProvider = new Map(); private readonly _fileSearchUsedSchemes = new Set(); @@ -62,6 +67,22 @@ export class ExtHostSearch implements ExtHostSearchShape { }); } + registerAITextSearchProvider(scheme: string, provider: vscode.AITextSearchProvider): IDisposable { + if (this._aiTextSearchUsedSchemes.has(scheme)) { + throw new Error(`an AI text search provider for the scheme '${scheme}'is already registered`); + } + + this._aiTextSearchUsedSchemes.add(scheme); + const handle = this._handlePool++; + this._aiTextSearchProvider.set(handle, provider); + this._proxy.$registerAITextSearchProvider(handle, this._transformScheme(scheme)); + return toDisposable(() => { + this._aiTextSearchUsedSchemes.delete(scheme); + this._aiTextSearchProvider.delete(handle); + this._proxy.$unregisterProvider(handle); + }); + } + registerFileSearchProvider(scheme: string, provider: vscode.FileSearchProvider): IDisposable { if (this._fileSearchUsedSchemes.has(scheme)) { throw new Error(`a file search provider for the scheme '${scheme}' is already registered`); @@ -86,7 +107,7 @@ export class ExtHostSearch implements ExtHostSearchShape { this._proxy.$handleFileMatch(handle, session, batch.map(p => p.resource)); }, token); } else { - throw new Error('unknown provider: ' + handle); + throw new Error('3 unknown provider: ' + handle); } } @@ -103,7 +124,7 @@ export class ExtHostSearch implements ExtHostSearchShape { $provideTextSearchResults(handle: number, session: number, rawQuery: IRawTextQuery, token: vscode.CancellationToken): Promise { const provider = this._textSearchProvider.get(handle); if (!provider || !provider.provideTextSearchResults) { - throw new Error(`Unknown provider ${handle}`); + throw new Error(`2 Unknown provider ${handle}`); } const query = reviveQuery(rawQuery); @@ -111,17 +132,35 @@ export class ExtHostSearch implements ExtHostSearchShape { return engine.search(progress => this._proxy.$handleTextMatch(handle, session, progress), token); } + $provideAITextSearchResults(handle: number, session: number, rawQuery: IRawAITextQuery, token: vscode.CancellationToken): Promise { + const provider = this._aiTextSearchProvider.get(handle); + if (!provider || !provider.provideAITextSearchResults) { + throw new Error(`1 Unknown provider ${handle}`); + } + + const query = reviveQuery(rawQuery); + const engine = this.createAITextSearchManager(query, provider); + return engine.search(progress => this._proxy.$handleTextMatch(handle, session, progress), token); + } + $enableExtensionHostSearch(): void { } protected createTextSearchManager(query: ITextQuery, provider: vscode.TextSearchProvider): TextSearchManager { - return new TextSearchManager(query, provider, { - readdir: resource => Promise.resolve([]), // TODO@rob implement + return new TextSearchManager({ query, provider }, { + readdir: resource => Promise.resolve([]), toCanonicalName: encoding => encoding }, 'textSearchProvider'); } + + protected createAITextSearchManager(query: IAITextQuery, provider: vscode.AITextSearchProvider): TextSearchManager { + return new TextSearchManager({ query, provider }, { + readdir: resource => Promise.resolve([]), + toCanonicalName: encoding => encoding + }, 'aiTextSearchProvider'); + } } -export function reviveQuery(rawQuery: U): U extends IRawTextQuery ? ITextQuery : IFileQuery { +export function reviveQuery(rawQuery: U): U extends IRawTextQuery ? ITextQuery : U extends IRawAITextQuery ? IAITextQuery : IFileQuery { return { ...rawQuery, // TODO@rob ??? ...{ diff --git a/src/vs/workbench/api/common/extHostSpeech.ts b/src/vs/workbench/api/common/extHostSpeech.ts index 8d230fd19f2f9..9093f63e3abc5 100644 --- a/src/vs/workbench/api/common/extHostSpeech.ts +++ b/src/vs/workbench/api/common/extHostSpeech.ts @@ -24,7 +24,7 @@ export class ExtHostSpeech implements ExtHostSpeechShape { this.proxy = mainContext.getProxy(MainContext.MainThreadSpeech); } - async $createSpeechToTextSession(handle: number, session: number): Promise { + async $createSpeechToTextSession(handle: number, session: number, language?: string): Promise { const provider = this.providers.get(handle); if (!provider) { return; @@ -35,7 +35,7 @@ export class ExtHostSpeech implements ExtHostSpeechShape { const cts = new CancellationTokenSource(); this.sessions.set(session, cts); - const speechToTextSession = disposables.add(provider.provideSpeechToTextSession(cts.token)); + const speechToTextSession = disposables.add(provider.provideSpeechToTextSession(cts.token, language ? { language } : undefined)); disposables.add(speechToTextSession.onDidChange(e => { if (cts.token.isCancellationRequested) { return; diff --git a/src/vs/workbench/api/common/extHostStatusBar.ts b/src/vs/workbench/api/common/extHostStatusBar.ts index 7e8db34a59941..982cdc28c2065 100644 --- a/src/vs/workbench/api/common/extHostStatusBar.ts +++ b/src/vs/workbench/api/common/extHostStatusBar.ts @@ -46,6 +46,7 @@ export class ExtHostStatusBarEntry implements vscode.StatusBarItem { private _name?: string; private _color?: string | ThemeColor; private _backgroundColor?: ThemeColor; + // eslint-disable-next-line local/code-no-potentially-unsafe-disposables private _latestCommandRegistration?: DisposableStore; private readonly _staleCommandRegistrations = new DisposableStore(); private _command?: { diff --git a/src/vs/workbench/api/common/extHostTerminalService.ts b/src/vs/workbench/api/common/extHostTerminalService.ts index e004acba809ce..b870c3ab033e5 100644 --- a/src/vs/workbench/api/common/extHostTerminalService.ts +++ b/src/vs/workbench/api/common/extHostTerminalService.ts @@ -54,6 +54,8 @@ export interface IExtHostTerminalService extends ExtHostTerminalServiceShape, ID registerProfileProvider(extension: IExtensionDescription, id: string, provider: vscode.TerminalProfileProvider): vscode.Disposable; registerTerminalQuickFixProvider(id: string, extensionId: string, provider: vscode.TerminalQuickFixProvider): vscode.Disposable; getEnvironmentVariableCollection(extension: IExtensionDescription): IEnvironmentVariableCollection; + getTerminalById(id: number): ExtHostTerminal | null; + getTerminalIdByApiObject(apiTerminal: vscode.Terminal): number | null; } interface IEnvironmentVariableCollection extends vscode.EnvironmentVariableCollection { @@ -63,6 +65,7 @@ interface IEnvironmentVariableCollection extends vscode.EnvironmentVariableColle export interface ITerminalInternalOptions { cwd?: string | URI; isFeatureTerminal?: boolean; + forceShellIntegration?: boolean; useShellEnvironment?: boolean; resolvedExtHostIdentifier?: ExtHostTerminalIdentifier; /** @@ -74,7 +77,7 @@ export interface ITerminalInternalOptions { export const IExtHostTerminalService = createDecorator('IExtHostTerminalService'); -export class ExtHostTerminal { +export class ExtHostTerminal extends Disposable { private _disposed: boolean = false; private _pidPromise: Promise; private _cols: number | undefined; @@ -84,16 +87,23 @@ export class ExtHostTerminal { private _state: vscode.TerminalState = { isInteractedWith: false }; private _selection: string | undefined; + shellIntegration: vscode.TerminalShellIntegration | undefined; + public isOpen: boolean = false; readonly value: vscode.Terminal; + protected readonly _onWillDispose = this._register(new Emitter()); + readonly onWillDispose = this._onWillDispose.event; + constructor( private _proxy: MainThreadTerminalServiceShape, public _id: ExtHostTerminalIdentifier, private readonly _creationOptions: vscode.TerminalOptions | vscode.ExtensionTerminalOptions, private _name?: string, ) { + super(); + this._creationOptions = Object.freeze(this._creationOptions); this._pidPromise = new Promise(c => this._pidPromiseComplete = c); @@ -117,6 +127,9 @@ export class ExtHostTerminal { get selection(): string | undefined { return that._selection; }, + get shellIntegration(): vscode.TerminalShellIntegration | undefined { + return that.shellIntegration; + }, sendText(text: string, shouldExecute: boolean = true): void { that._checkDisposed(); that._proxy.$sendText(that._id, text, shouldExecute); @@ -147,6 +160,11 @@ export class ExtHostTerminal { }; } + override dispose(): void { + this._onWillDispose.fire(); + super.dispose(); + } + public async create( options: vscode.TerminalOptions, internalOptions?: ITerminalInternalOptions, @@ -165,6 +183,7 @@ export class ExtHostTerminal { initialText: options.message ?? undefined, strictEnv: options.strictEnv ?? undefined, hideFromUser: options.hideFromUser ?? undefined, + forceShellIntegration: internalOptions?.forceShellIntegration ?? undefined, isFeatureTerminal: internalOptions?.isFeatureTerminal ?? undefined, isExtensionOwnedTerminal: true, useShellEnvironment: internalOptions?.useShellEnvironment ?? undefined, @@ -374,7 +393,7 @@ export abstract class BaseExtHostTerminalService extends Disposable implements I protected _environmentVariableCollections: Map = new Map(); private _defaultProfile: ITerminalProfile | undefined; private _defaultAutomationProfile: ITerminalProfile | undefined; - private _lastQuickFixCommands: MutableDisposable = this._register(new MutableDisposable()); + private readonly _lastQuickFixCommands: MutableDisposable = this._register(new MutableDisposable()); private readonly _bufferer: TerminalDataBufferer; private readonly _linkProviders: Set = new Set(); @@ -423,7 +442,7 @@ export abstract class BaseExtHostTerminalService extends Disposable implements I processArgument: arg => { const deserialize = (arg: any) => { const cast = arg as ISerializedTerminalInstanceContext; - return this._getTerminalById(cast.instanceId)?.value; + return this.getTerminalById(cast.instanceId)?.value; }; switch (arg?.$mid) { case MarshalledId.TerminalContext: return deserialize(arg); @@ -496,7 +515,7 @@ export abstract class BaseExtHostTerminalService extends Disposable implements I } public attachPtyToTerminal(id: number, pty: vscode.Pseudoterminal): void { - const terminal = this._getTerminalById(id); + const terminal = this.getTerminalById(id); if (!terminal) { throw new Error(`Cannot resolve terminal with id ${id} for virtual process`); } @@ -514,7 +533,7 @@ export abstract class BaseExtHostTerminalService extends Disposable implements I } return; } - const terminal = this._getTerminalById(id); + const terminal = this.getTerminalById(id); if (terminal) { this._activeTerminal = terminal; if (original !== this._activeTerminal) { @@ -524,14 +543,14 @@ export abstract class BaseExtHostTerminalService extends Disposable implements I } public async $acceptTerminalProcessData(id: number, data: string): Promise { - const terminal = this._getTerminalById(id); + const terminal = this.getTerminalById(id); if (terminal) { this._onDidWriteTerminalData.fire({ terminal: terminal.value, data }); } } public async $acceptTerminalDimensions(id: number, cols: number, rows: number): Promise { - const terminal = this._getTerminalById(id); + const terminal = this.getTerminalById(id); if (terminal) { if (terminal.setDimensions(cols, rows)) { this._onDidChangeTerminalDimensions.fire({ @@ -543,7 +562,7 @@ export abstract class BaseExtHostTerminalService extends Disposable implements I } public async $acceptDidExecuteCommand(id: number, command: ITerminalCommandDto): Promise { - const terminal = this._getTerminalById(id); + const terminal = this.getTerminalById(id); if (terminal) { this._onDidExecuteCommand.fire({ terminal: terminal.value, ...command }); } @@ -556,7 +575,7 @@ export abstract class BaseExtHostTerminalService extends Disposable implements I } public async $acceptTerminalTitleChange(id: number, name: string): Promise { - const terminal = this._getTerminalById(id); + const terminal = this.getTerminalById(id); if (terminal) { terminal.name = name; } @@ -599,14 +618,14 @@ export abstract class BaseExtHostTerminalService extends Disposable implements I } public async $acceptTerminalProcessId(id: number, processId: number): Promise { - const terminal = this._getTerminalById(id); + const terminal = this.getTerminalById(id); terminal?._setProcessId(processId); } public async $startExtensionTerminal(id: number, initialDimensions: ITerminalDimensionsDto | undefined): Promise { // Make sure the ExtHostTerminal exists so onDidOpenTerminal has fired before we call // Pseudoterminal.start - const terminal = this._getTerminalById(id); + const terminal = this.getTerminalById(id); if (!terminal) { return { message: localize('launchFail.idMissingOnExtHost', "Could not find the terminal with id {0} on the extension host", id) }; } @@ -663,14 +682,14 @@ export abstract class BaseExtHostTerminalService extends Disposable implements I } public $acceptTerminalInteraction(id: number): void { - const terminal = this._getTerminalById(id); + const terminal = this.getTerminalById(id); if (terminal?.setInteractedWith()) { this._onDidChangeTerminalState.fire(terminal.value); } } public $acceptTerminalSelection(id: number, selection: string | undefined): void { - this._getTerminalById(id)?.setSelection(selection); + this.getTerminalById(id)?.setSelection(selection); } public $acceptProcessResize(id: number, cols: number, rows: number): void { @@ -793,7 +812,7 @@ export abstract class BaseExtHostTerminalService extends Disposable implements I } public async $provideLinks(terminalId: number, line: string): Promise { - const terminal = this._getTerminalById(terminalId); + const terminal = this.getTerminalById(terminalId); if (!terminal) { return []; } @@ -876,10 +895,17 @@ export abstract class BaseExtHostTerminalService extends Disposable implements I this._proxy.$sendProcessExit(id, exitCode); } - private _getTerminalById(id: number): ExtHostTerminal | null { + public getTerminalById(id: number): ExtHostTerminal | null { return this._getTerminalObjectById(this._terminals, id); } + public getTerminalIdByApiObject(terminal: vscode.Terminal): number | null { + const index = this._terminals.findIndex(item => { + return item.value === terminal; + }); + return index >= 0 ? index : null; + } + private _getTerminalObjectById(array: T[], id: number): T | null { const index = this._getTerminalObjectIndexById(array, id); return index !== null ? array[index] : null; @@ -889,10 +915,7 @@ export abstract class BaseExtHostTerminalService extends Disposable implements I const index = array.findIndex(item => { return item._id === id; }); - if (index === -1) { - return null; - } - return index; + return index >= 0 ? index : null; } public getEnvironmentVariableCollection(extension: IExtensionDescription): IEnvironmentVariableCollection { diff --git a/src/vs/workbench/api/common/extHostTerminalShellIntegration.ts b/src/vs/workbench/api/common/extHostTerminalShellIntegration.ts new file mode 100644 index 0000000000000..f6ad679961290 --- /dev/null +++ b/src/vs/workbench/api/common/extHostTerminalShellIntegration.ts @@ -0,0 +1,294 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import type * as vscode from 'vscode'; +import { Disposable, DisposableStore, toDisposable } from 'vs/base/common/lifecycle'; +import { createDecorator } from 'vs/platform/instantiation/common/instantiation'; +import { MainContext, type ExtHostTerminalShellIntegrationShape, type MainThreadTerminalShellIntegrationShape } from 'vs/workbench/api/common/extHost.protocol'; +import { IExtHostRpcService } from 'vs/workbench/api/common/extHostRpcService'; +import { IExtHostTerminalService } from 'vs/workbench/api/common/extHostTerminalService'; +import { Emitter, type Event } from 'vs/base/common/event'; +import { URI } from 'vs/base/common/uri'; +import { AsyncIterableObject, Barrier, type AsyncIterableEmitter } from 'vs/base/common/async'; + +export interface IExtHostTerminalShellIntegration extends ExtHostTerminalShellIntegrationShape { + readonly _serviceBrand: undefined; + + readonly onDidChangeTerminalShellIntegration: Event; + readonly onDidStartTerminalShellExecution: Event; + readonly onDidEndTerminalShellExecution: Event; +} +export const IExtHostTerminalShellIntegration = createDecorator('IExtHostTerminalShellIntegration'); + +export class ExtHostTerminalShellIntegration extends Disposable implements IExtHostTerminalShellIntegration { + + readonly _serviceBrand: undefined; + + protected _proxy: MainThreadTerminalShellIntegrationShape; + + private _activeShellIntegrations: Map = new Map(); + + protected readonly _onDidChangeTerminalShellIntegration = new Emitter(); + readonly onDidChangeTerminalShellIntegration = this._onDidChangeTerminalShellIntegration.event; + protected readonly _onDidStartTerminalShellExecution = new Emitter(); + readonly onDidStartTerminalShellExecution = this._onDidStartTerminalShellExecution.event; + protected readonly _onDidEndTerminalShellExecution = new Emitter(); + readonly onDidEndTerminalShellExecution = this._onDidEndTerminalShellExecution.event; + + constructor( + @IExtHostRpcService extHostRpc: IExtHostRpcService, + @IExtHostTerminalService private readonly _extHostTerminalService: IExtHostTerminalService, + ) { + super(); + + this._proxy = extHostRpc.getProxy(MainContext.MainThreadTerminalShellIntegration); + + // Clean up listeners + this._register(toDisposable(() => { + for (const [_, integration] of this._activeShellIntegrations) { + integration.dispose(); + } + this._activeShellIntegrations.clear(); + })); + + // Convenient test code: + // this.onDidChangeTerminalShellIntegration(e => { + // console.log('*** onDidChangeTerminalShellIntegration', e); + // }); + // this.onDidStartTerminalShellExecution(async e => { + // console.log('*** onDidStartTerminalShellExecution', e); + // // new Promise(r => { + // // (async () => { + // // for await (const d of e.createDataStream()) { + // // console.log('data2', d); + // // } + // // })(); + // // }); + // for await (const d of e.createDataStream()) { + // console.log('data', d); + // } + // }); + // this.onDidEndTerminalShellExecution(e => { + // console.log('*** onDidEndTerminalShellExecution', e); + // }); + // setTimeout(() => { + // console.log('before executeCommand(\"echo hello\")'); + // Array.from(this._activeShellIntegrations.values())[0].value.executeCommand('echo hello'); + // console.log('after executeCommand(\"echo hello\")'); + // }, 4000); + } + + public $shellIntegrationChange(instanceId: number): void { + const terminal = this._extHostTerminalService.getTerminalById(instanceId); + if (!terminal) { + return; + } + + const apiTerminal = terminal.value; + let shellIntegration = this._activeShellIntegrations.get(instanceId); + if (!shellIntegration) { + shellIntegration = new InternalTerminalShellIntegration(terminal.value, this._onDidStartTerminalShellExecution); + this._activeShellIntegrations.set(instanceId, shellIntegration); + shellIntegration.store.add(terminal.onWillDispose(() => this._activeShellIntegrations.get(instanceId)?.dispose())); + shellIntegration.store.add(shellIntegration.onDidRequestShellExecution(commandLine => this._proxy.$executeCommand(instanceId, commandLine))); + shellIntegration.store.add(shellIntegration.onDidRequestEndExecution(e => this._onDidEndTerminalShellExecution.fire(e))); + shellIntegration.store.add(shellIntegration.onDidRequestChangeShellIntegration(e => this._onDidChangeTerminalShellIntegration.fire(e))); + terminal.shellIntegration = shellIntegration.value; + } + this._onDidChangeTerminalShellIntegration.fire({ + terminal: apiTerminal, + shellIntegration: shellIntegration.value + }); + } + + public $shellExecutionStart(instanceId: number, commandLine: string, cwd: URI | undefined): void { + // Force shellIntegration creation if it hasn't been created yet, this could when events + // don't come through on startup + if (!this._activeShellIntegrations.has(instanceId)) { + this.$shellIntegrationChange(instanceId); + } + this._activeShellIntegrations.get(instanceId)?.startShellExecution(commandLine, cwd); + } + + public $shellExecutionEnd(instanceId: number, commandLine: string | undefined, exitCode: number | undefined): void { + this._activeShellIntegrations.get(instanceId)?.endShellExecution(commandLine, exitCode); + } + + public $shellExecutionData(instanceId: number, data: string): void { + this._activeShellIntegrations.get(instanceId)?.emitData(data); + } + + public $cwdChange(instanceId: number, cwd: URI | undefined): void { + this._activeShellIntegrations.get(instanceId)?.setCwd(cwd); + } + + public $closeTerminal(instanceId: number): void { + this._activeShellIntegrations.get(instanceId)?.dispose(); + this._activeShellIntegrations.delete(instanceId); + + } +} + +class InternalTerminalShellIntegration extends Disposable { + private _currentExecution: InternalTerminalShellExecution | undefined; + get currentExecution(): InternalTerminalShellExecution | undefined { return this._currentExecution; } + + private _ignoreNextExecution: boolean = false; + private _cwd: URI | undefined; + + readonly store: DisposableStore = this._register(new DisposableStore()); + + readonly value: vscode.TerminalShellIntegration; + + protected readonly _onDidRequestChangeShellIntegration = this._register(new Emitter()); + readonly onDidRequestChangeShellIntegration = this._onDidRequestChangeShellIntegration.event; + protected readonly _onDidRequestShellExecution = this._register(new Emitter()); + readonly onDidRequestShellExecution = this._onDidRequestShellExecution.event; + protected readonly _onDidRequestEndExecution = this._register(new Emitter()); + readonly onDidRequestEndExecution = this._onDidRequestEndExecution.event; + + constructor( + private readonly _terminal: vscode.Terminal, + private readonly _onDidStartTerminalShellExecution: Emitter + ) { + super(); + + const that = this; + this.value = { + get cwd(): URI | undefined { + return that._cwd; + }, + executeCommand(commandLine): vscode.TerminalShellExecution { + that._onDidRequestShellExecution.fire(commandLine); + // Fire the event in a microtask to allow the extension to use the execution before + // the start event fires + const execution = that.startShellExecution(commandLine, that._cwd, true).value; + that._ignoreNextExecution = true; + return execution; + } + }; + } + + startShellExecution(commandLine: string, cwd: URI | undefined, fireEventInMicrotask?: boolean): InternalTerminalShellExecution { + if (this._ignoreNextExecution && this._currentExecution) { + this._ignoreNextExecution = false; + } else { + if (this._currentExecution) { + this._currentExecution.endExecution(undefined, undefined); + this._onDidRequestEndExecution.fire({ execution: this._currentExecution.value, exitCode: undefined }); + } + const currentExecution = this._currentExecution = new InternalTerminalShellExecution(this._terminal, commandLine, cwd); + if (fireEventInMicrotask) { + queueMicrotask(() => this._onDidStartTerminalShellExecution.fire(currentExecution.value)); + } else { + this._onDidStartTerminalShellExecution.fire(this._currentExecution.value); + } + } + return this._currentExecution; + } + + emitData(data: string): void { + this.currentExecution?.emitData(data); + } + + endShellExecution(commandLine: string | undefined, exitCode: number | undefined): void { + if (this._currentExecution) { + this._currentExecution.endExecution(commandLine, exitCode); + this._onDidRequestEndExecution.fire({ execution: this._currentExecution.value, exitCode }); + this._currentExecution = undefined; + } + } + + setCwd(cwd: URI | undefined): void { + let wasChanged = false; + if (URI.isUri(this._cwd)) { + wasChanged = !URI.isUri(cwd) || this._cwd.toString() !== cwd.toString(); + } else if (this._cwd !== cwd) { + wasChanged = true; + } + if (wasChanged) { + this._cwd = cwd; + this._onDidRequestChangeShellIntegration.fire({ terminal: this._terminal, shellIntegration: this.value }); + } + } +} + +class InternalTerminalShellExecution { + private _dataStream: ShellExecutionDataStream | undefined; + + private _ended: boolean = false; + + readonly value: vscode.TerminalShellExecution; + + constructor( + readonly terminal: vscode.Terminal, + private _commandLine: string | undefined, + readonly cwd: URI | undefined, + ) { + const that = this; + this.value = { + get terminal(): vscode.Terminal { + return that.terminal; + }, + get commandLine(): string | undefined { + return that._commandLine; + }, + get cwd(): URI | undefined { + return that.cwd; + }, + readData(): AsyncIterable { + return that._createDataStream(); + } + }; + } + + private _createDataStream(): AsyncIterable { + if (!this._dataStream) { + if (this._ended) { + return AsyncIterableObject.EMPTY; + } + this._dataStream = new ShellExecutionDataStream(); + } + return this._dataStream.createIterable(); + } + + emitData(data: string): void { + this._dataStream?.emitData(data); + } + + endExecution(commandLine: string | undefined, exitCode: number | undefined): void { + if (commandLine) { + this._commandLine = commandLine; + } + this._dataStream?.endExecution(); + this._dataStream = undefined; + this._ended = true; + } +} + +class ShellExecutionDataStream extends Disposable { + private _barrier: Barrier | undefined; + private _emitters: AsyncIterableEmitter[] = []; + + createIterable(): AsyncIterable { + const barrier = this._barrier = new Barrier(); + const iterable = new AsyncIterableObject(async emitter => { + this._emitters.push(emitter); + await barrier.wait(); + }); + return iterable; + } + + emitData(data: string): void { + for (const emitter of this._emitters) { + emitter.emitOne(data); + } + } + + endExecution(): void { + this._barrier?.open(); + this._barrier = undefined; + } +} diff --git a/src/vs/workbench/api/common/extHostTesting.ts b/src/vs/workbench/api/common/extHostTesting.ts index 597865e61c003..0aaec347ee680 100644 --- a/src/vs/workbench/api/common/extHostTesting.ts +++ b/src/vs/workbench/api/common/extHostTesting.ts @@ -13,7 +13,6 @@ import { createSingleCallFunction } from 'vs/base/common/functional'; import { hash } from 'vs/base/common/hash'; import { Disposable, DisposableStore, toDisposable } from 'vs/base/common/lifecycle'; import { MarshalledId } from 'vs/base/common/marshallingIds'; -import { deepFreeze } from 'vs/base/common/objects'; import { isDefined } from 'vs/base/common/types'; import { generateUuid } from 'vs/base/common/uuid'; import { IExtensionDescription, IRelaxedExtensionDescription } from 'vs/platform/extensions/common/extensions'; @@ -28,8 +27,7 @@ import { TestRunProfileKind, TestRunRequest } from 'vs/workbench/api/common/extH import { TestCommandId } from 'vs/workbench/contrib/testing/common/constants'; import { TestId, TestIdPathParts, TestPosition } from 'vs/workbench/contrib/testing/common/testId'; import { InvalidTestItemError } from 'vs/workbench/contrib/testing/common/testItemCollection'; -import { AbstractIncrementalTestCollection, CoverageDetails, ICallProfileRunHandler, IFileCoverage, ISerializedTestResults, IStartControllerTests, IStartControllerTestsResult, ITestErrorMessage, ITestItem, ITestItemContext, ITestMessageMenuArgs, ITestRunProfile, IncrementalChangeCollector, IncrementalTestCollectionItem, InternalTestItem, KEEP_N_LAST_COVERAGE_REPORTS, TestResultState, TestRunProfileBitset, TestsDiff, TestsDiffOp, isStartControllerTests } from 'vs/workbench/contrib/testing/common/testTypes'; -import { checkProposedApiEnabled } from 'vs/workbench/services/extensions/common/extensions'; +import { AbstractIncrementalTestCollection, CoverageDetails, ICallProfileRunHandler, ISerializedTestResults, IStartControllerTests, IStartControllerTestsResult, ITestErrorMessage, ITestItem, ITestItemContext, ITestMessageMenuArgs, ITestRunProfile, IncrementalChangeCollector, IncrementalTestCollectionItem, InternalTestItem, TestResultState, TestRunProfileBitset, TestsDiff, TestsDiffOp, isStartControllerTests } from 'vs/workbench/contrib/testing/common/testTypes'; import type * as vscode from 'vscode'; interface ControllerInfo { @@ -74,9 +72,11 @@ export class ExtHostTesting extends Disposable implements ExtHostTestingShape { return controller?.collection.tree.get(targetTest)?.actual ?? toItemFromContext(arg); } case MarshalledId.TestMessageMenuArgs: { - const { extId, message } = arg as ITestMessageMenuArgs; + const { test, message } = arg as ITestMessageMenuArgs; + const extId = test.item.extId; return { - test: this.controllers.get(TestId.root(extId))?.collection.tree.get(extId)?.actual, + test: this.controllers.get(TestId.root(extId))?.collection.tree.get(extId)?.actual + ?? toItemFromContext({ $mid: MarshalledId.TestItemContext, tests: [test] }), message: Convert.TestMessage.to(message as ITestErrorMessage.Serialized), }; } @@ -154,7 +154,7 @@ export class ExtHostTesting extends Disposable implements ExtHostTestingShape { return new TestItemImpl(controllerId, id, label, uri); }, createTestRun: (request, name, persist = true) => { - return this.runTracker.createTestRun(extension, controllerId, collection, request, name, persist); + return this.runTracker.createTestRun(controllerId, collection, request, name, persist); }, invalidateTestResults: items => { if (items === undefined) { @@ -235,19 +235,16 @@ export class ExtHostTesting extends Disposable implements ExtHostTestingShape { /** * @inheritdoc */ - async $provideFileCoverage(runId: string, taskId: string, token: CancellationToken): Promise { - const coverage = this.runTracker.getCoverageReport(runId, taskId); - const fileCoverage = await coverage?.provideFileCoverage(token); - return fileCoverage ?? []; + async $getCoverageDetails(coverageId: string, token: CancellationToken): Promise { + const details = await this.runTracker.getCoverageDetails(coverageId, token); + return details?.map(Convert.TestCoverage.fromDetails); } /** * @inheritdoc */ - async $resolveFileCoverage(runId: string, taskId: string, fileIndex: number, token: CancellationToken): Promise { - const coverage = this.runTracker.getCoverageReport(runId, taskId); - const details = await coverage?.resolveFileCoverage(fileIndex, token); - return details ?? []; + async $disposeRun(runId: string) { + this.runTracker.disposeTestRun(runId); } /** @inheritdoc */ @@ -294,7 +291,7 @@ export class ExtHostTesting extends Disposable implements ExtHostTestingShape { public $publishTestResults(results: ISerializedTestResults[]): void { this.results = Object.freeze( results - .map(r => deepFreeze(Convert.TestResults.to(r))) + .map(Convert.TestResults.to) .concat(this.results) .sort((a, b) => b.completedAt - a.completedAt) .slice(0, 32), @@ -356,7 +353,7 @@ export class ExtHostTesting extends Disposable implements ExtHostTestingShape { return {}; } - const { collection, profiles, extension } = lookup; + const { collection, profiles } = lookup; const profile = profiles.get(req.profileId); if (!profile) { return {}; @@ -387,7 +384,7 @@ export class ExtHostTesting extends Disposable implements ExtHostTestingShape { const tracker = isStartControllerTests(req) && this.runTracker.prepareForMainThreadTestRun( publicReq, TestRunDto.fromInternal(req, lookup.collection), - extension, + profile, token, ); @@ -401,8 +398,6 @@ export class ExtHostTesting extends Disposable implements ExtHostTestingShape { if (tracker.hasRunningTasks && !token.isCancellationRequested) { await Event.toPromise(tracker.onEnd); } - - tracker.dispose(); } } } @@ -433,16 +428,13 @@ const enum TestRunTrackerState { class TestRunTracker extends Disposable { private state = TestRunTrackerState.Running; + private running = 0; private readonly tasks = new Map(); private readonly sharedTestIds = new Set(); private readonly cts: CancellationTokenSource; private readonly endEmitter = this._register(new Emitter()); - private readonly coverageEmitter = this._register(new Emitter<{ runId: string; taskId: string; coverage: TestRunCoverageBearer | undefined }>()); - - /** - * Fired when a coverage provider is added or removed from a task. - */ - public readonly onDidCoverage = this.coverageEmitter.event; + private readonly onDidDispose: Event; + private readonly publishedCoverage = new Map(); /** * Fires when a test ends, and no more tests are left running. @@ -453,7 +445,7 @@ class TestRunTracker extends Disposable { * Gets whether there are any tests running. */ public get hasRunningTasks() { - return this.tasks.size > 0; + return this.running > 0; } /** @@ -466,8 +458,8 @@ class TestRunTracker extends Disposable { constructor( private readonly dto: TestRunDto, private readonly proxy: MainThreadTestingShape, - private readonly extension: IRelaxedExtensionDescription, private readonly logService: ILogService, + private readonly profile: vscode.TestRunProfile | undefined, parentToken?: CancellationToken, ) { super(); @@ -475,6 +467,13 @@ class TestRunTracker extends Disposable { const forciblyEnd = this._register(new RunOnceScheduler(() => this.forciblyEndTasks(), RUN_CANCEL_DEADLINE)); this._register(this.cts.token.onCancellationRequested(() => forciblyEnd.schedule())); + + const didDisposeEmitter = new Emitter(); + this.onDidDispose = didDisposeEmitter.event; + this._register(toDisposable(() => { + didDisposeEmitter.fire(); + didDisposeEmitter.dispose(); + })); } /** Requests cancellation of the run. On the second call, forces cancellation. */ @@ -487,14 +486,27 @@ class TestRunTracker extends Disposable { } } + /** Gets details for a previously-emitted coverage object. */ + public getCoverageDetails(id: string, token: CancellationToken) { + const [, taskId, covId] = TestId.fromString(id).path; /** runId, taskId, URI */ + const coverage = this.publishedCoverage.get(covId); + if (!coverage) { + return []; + } + + const task = this.tasks.get(taskId); + if (!task) { + throw new Error('unreachable: run task was not found'); + } + + return this.profile?.loadDetailedCoverage?.(task.run, coverage, token) ?? []; + } + /** Creates the public test run interface to give to extensions. */ public createRun(name: string | undefined): vscode.TestRun { const runId = this.dto.id; const ctrlId = this.dto.controllerId; const taskId = generateUuid(); - const extension = this.extension; - const coverageEmitter = this.coverageEmitter; - let coverage: TestRunCoverageBearer | undefined; const guardTestMutation = (fn: (test: vscode.TestItem, ...args: Args) => void) => (test: vscode.TestItem, ...args: Args) => { @@ -531,13 +543,12 @@ class TestRunTracker extends Disposable { isPersisted: this.dto.isPersisted, token: this.cts.token, name, - get coverageProvider() { - return coverage?.provider; - }, - set coverageProvider(provider) { - checkProposedApiEnabled(extension, 'testCoverage'); - coverage = provider && new TestRunCoverageBearer(provider); - coverageEmitter.fire({ taskId, runId, coverage }); + onDidDispose: this.onDidDispose, + addCoverage: coverage => { + const uriStr = coverage.uri.toString(); + const id = new TestId([runId, taskId, uriStr]).toString(); + this.publishedCoverage.set(uriStr, coverage); + this.proxy.$appendCoverage(runId, taskId, Convert.TestCoverage.fromFile(id, coverage)); }, //#region state mutation enqueued: guardTestMutation(test => { @@ -589,13 +600,13 @@ class TestRunTracker extends Disposable { ended = true; this.proxy.$finishedTestRunTask(runId, taskId); - this.tasks.delete(taskId); - if (!this.tasks.size) { + if (!--this.running) { this.markEnded(); } } }; + this.running++; this.tasks.set(taskId, { run }); this.proxy.$startedTestRunTask(runId, { id: taskId, name, running: true }); @@ -651,18 +662,13 @@ class TestRunTracker extends Disposable { } } -interface CoverageReportRecord { - runId: string; - coverage: Map; -} - /** * Queues runs for a single extension and provides the currently-executing * run so that `createTestRun` can be properly correlated. */ export class TestRunCoordinator { private readonly tracked = new Map(); - private readonly coverageReports: CoverageReportRecord[] = []; + private readonly trackedById = new Map(); public get trackers() { return this.tracked.values(); @@ -676,10 +682,23 @@ export class TestRunCoordinator { /** * Gets a coverage report for a given run and task ID. */ - public getCoverageReport(runId: string, taskId: string) { - return this.coverageReports - .find(r => r.runId === runId) - ?.coverage.get(taskId); + public getCoverageDetails(id: string, token: vscode.CancellationToken) { + const runId = TestId.root(id); + return this.trackedById.get(runId)?.getCoverageDetails(id, token) || []; + } + + /** + * Disposes the test run, called when the main thread is no longer interested + * in associated data. + */ + public disposeTestRun(runId: string) { + this.trackedById.get(runId)?.dispose(); + this.trackedById.delete(runId); + for (const [req, { id }] of this.tracked) { + if (id === runId) { + this.tracked.delete(req); + } + } } /** @@ -687,20 +706,15 @@ export class TestRunCoordinator { * `$startedExtensionTestRun` is not invoked. The run must eventually * be cancelled manually. */ - public prepareForMainThreadTestRun(req: vscode.TestRunRequest, dto: TestRunDto, extension: Readonly, token: CancellationToken) { - return this.getTracker(req, dto, extension, token); + public prepareForMainThreadTestRun(req: vscode.TestRunRequest, dto: TestRunDto, profile: vscode.TestRunProfile, token: CancellationToken) { + return this.getTracker(req, dto, profile, token); } /** * Cancels an existing test run via its cancellation token. */ public cancelRunById(runId: string) { - for (const tracker of this.tracked.values()) { - if (tracker.id === runId) { - tracker.cancel(); - return; - } - } + this.trackedById.get(runId)?.cancel(); } /** @@ -712,11 +726,10 @@ export class TestRunCoordinator { } } - /** * Implements the public `createTestRun` API. */ - public createTestRun(extension: IRelaxedExtensionDescription, controllerId: string, collection: ExtHostTestItemCollection, request: vscode.TestRunRequest, name: string | undefined, persist: boolean): vscode.TestRun { + public createTestRun(controllerId: string, collection: ExtHostTestItemCollection, request: vscode.TestRunRequest, name: string | undefined, persist: boolean): vscode.TestRun { const existing = this.tracked.get(request); if (existing) { return existing.createRun(name); @@ -736,37 +749,18 @@ export class TestRunCoordinator { persist }); - const tracker = this.getTracker(request, dto, extension); + const tracker = this.getTracker(request, dto, request.profile); Event.once(tracker.onEnd)(() => { this.proxy.$finishedExtensionTestRun(dto.id); - tracker.dispose(); }); return tracker.createRun(name); } - private getTracker(req: vscode.TestRunRequest, dto: TestRunDto, extension: IRelaxedExtensionDescription, token?: CancellationToken) { - const tracker = new TestRunTracker(dto, this.proxy, extension, this.logService, token); + private getTracker(req: vscode.TestRunRequest, dto: TestRunDto, profile: vscode.TestRunProfile | undefined, token?: CancellationToken) { + const tracker = new TestRunTracker(dto, this.proxy, this.logService, profile, token); this.tracked.set(req, tracker); - - let coverageReports: CoverageReportRecord | undefined; - const coverageListener = tracker.onDidCoverage(({ runId, taskId, coverage }) => { - if (!coverageReports) { - coverageReports = { runId, coverage: new Map() }; - this.coverageReports.unshift(coverageReports); - if (this.coverageReports.length > KEEP_N_LAST_COVERAGE_REPORTS) { - this.coverageReports.pop(); - } - } - - coverageReports.coverage.set(taskId, coverage); - this.proxy.$signalCoverageAvailable(runId, taskId, !!coverage); - }); - - Event.once(tracker.onEnd)(() => { - this.tracked.delete(req); - coverageListener.dispose(); - }); + this.trackedById.set(tracker.id, tracker); return tracker; } } @@ -839,40 +833,6 @@ export class TestRunDto { } } -class TestRunCoverageBearer { - private fileCoverage?: Promise; - - constructor(public readonly provider: vscode.TestCoverageProvider) { } - - public async provideFileCoverage(token: CancellationToken): Promise { - if (!this.fileCoverage) { - this.fileCoverage = (async () => this.provider.provideFileCoverage(token))(); - } - - try { - const coverage = await this.fileCoverage; - return coverage?.map(Convert.TestCoverage.fromFile) ?? []; - } catch (e) { - this.fileCoverage = undefined; - throw e; - } - } - - public async resolveFileCoverage(index: number, token: CancellationToken): Promise { - const fileCoverage = await this.fileCoverage; - let file = fileCoverage?.[index]; - if (!this.provider || !fileCoverage || !file) { - return []; - } - - if (!file.detailedCoverage) { - file = fileCoverage[index] = await this.provider.resolveFileCoverage?.(file, token) ?? file; - } - - return file.detailedCoverage?.map(Convert.TestCoverage.fromDetailed) ?? []; - } -} - /** * @private */ diff --git a/src/vs/workbench/api/common/extHostTunnelService.ts b/src/vs/workbench/api/common/extHostTunnelService.ts index ccf5700c83b50..650b852e1e438 100644 --- a/src/vs/workbench/api/common/extHostTunnelService.ts +++ b/src/vs/workbench/api/common/extHostTunnelService.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { CancellationTokenSource } from 'vs/base/common/cancellation'; +import { CancellationToken } from 'vs/base/common/cancellation'; import { Emitter } from 'vs/base/common/event'; import { Disposable, IDisposable, toDisposable } from 'vs/base/common/lifecycle'; import * as nls from 'vs/nls'; @@ -103,6 +103,9 @@ export class ExtHostTunnelService extends Disposable implements IExtHostTunnelSe } registerPortsAttributesProvider(portSelector: PortAttributesSelector, provider: vscode.PortAttributesProvider): vscode.Disposable { + if (portSelector.portRange === undefined && portSelector.commandPattern === undefined) { + this.logService.error('PortAttributesProvider must specify either a portRange or a commandPattern'); + } const providerHandle = this.nextPortAttributesProviderHandle(); this._portAttributesProviders.set(providerHandle, { selector: portSelector, provider }); @@ -149,7 +152,7 @@ export class ExtHostTunnelService extends Disposable implements IExtHostTunnelSe throw new Error('A tunnel provider has already been registered. Only the first tunnel provider to be registered will be used.'); } this._forwardPortProvider = async (tunnelOptions: TunnelOptions, tunnelCreationOptions: TunnelCreationOptions) => { - const result = await provider.provideTunnel(tunnelOptions, tunnelCreationOptions, new CancellationTokenSource().token); + const result = await provider.provideTunnel(tunnelOptions, tunnelCreationOptions, CancellationToken.None); return result ?? undefined; }; diff --git a/src/vs/workbench/api/common/extHostTypeConverters.ts b/src/vs/workbench/api/common/extHostTypeConverters.ts index 42a57e47c136e..fb64c34d72a20 100644 --- a/src/vs/workbench/api/common/extHostTypeConverters.ts +++ b/src/vs/workbench/api/common/extHostTypeConverters.ts @@ -14,7 +14,9 @@ import { marked } from 'vs/base/common/marked/marked'; import { parse, revive } from 'vs/base/common/marshalling'; import { Mimes } from 'vs/base/common/mime'; import { cloneAndChange } from 'vs/base/common/objects'; -import { isEmptyObject, isNumber, isString, isUndefinedOrNull } from 'vs/base/common/types'; +import { IPrefixTreeNode, WellDefinedPrefixTree } from 'vs/base/common/prefixTree'; +import { basename } from 'vs/base/common/resources'; +import { isDefined, isEmptyObject, isNumber, isString, isUndefinedOrNull } from 'vs/base/common/types'; import { URI, UriComponents, isUriComponents } from 'vs/base/common/uri'; import { IURITransformer } from 'vs/base/common/uriIpc'; import { RenderLineNumbersType } from 'vs/editor/common/config/editorOptions'; @@ -31,26 +33,28 @@ import { IExtensionDescription } from 'vs/platform/extensions/common/extensions' import { IMarkerData, IRelatedInformation, MarkerSeverity, MarkerTag } from 'vs/platform/markers/common/markers'; import { ProgressLocation as MainProgressLocation } from 'vs/platform/progress/common/progress'; import * as extHostProtocol from 'vs/workbench/api/common/extHost.protocol'; +import { CommandsConverter } from 'vs/workbench/api/common/extHostCommands'; import { getPrivateApiFor } from 'vs/workbench/api/common/extHostTestingPrivateApi'; import { DEFAULT_EDITOR_ASSOCIATION, SaveReason } from 'vs/workbench/common/editor'; import { IViewBadge } from 'vs/workbench/common/views'; -import { IChatAgentRequest } from 'vs/workbench/contrib/chat/common/chatAgents'; -import * as chatProvider from 'vs/workbench/contrib/chat/common/chatProvider'; -import { IChatContentInlineReference, IChatContentReference, IChatFollowup, IChatMarkdownContent, IChatProgressMessage, IChatReplyFollowup, IChatResponseCommandFollowup, IChatTreeData } from 'vs/workbench/contrib/chat/common/chatService'; +import { ChatAgentLocation, IChatAgentRequest, IChatAgentResult } from 'vs/workbench/contrib/chat/common/chatAgents'; +import { IChatRequestVariableEntry } from 'vs/workbench/contrib/chat/common/chatModel'; +import { IChatCommandButton, IChatContentInlineReference, IChatContentReference, IChatFollowup, IChatMarkdownContent, IChatProgressMessage, IChatTextEdit, IChatTreeData, IChatUserActionEvent } from 'vs/workbench/contrib/chat/common/chatService'; import { IChatRequestVariableValue } from 'vs/workbench/contrib/chat/common/chatVariables'; -import { InlineChatResponseFeedbackKind } from 'vs/workbench/contrib/inlineChat/common/inlineChat'; +import * as chatProvider from 'vs/workbench/contrib/chat/common/languageModels'; +import { DebugTreeItemCollapsibleState, IDebugVisualizationTreeItem } from 'vs/workbench/contrib/debug/common/debug'; +import { IInlineChatCommandFollowup, IInlineChatFollowup, IInlineChatReplyFollowup, InlineChatResponseFeedbackKind } from 'vs/workbench/contrib/inlineChat/common/inlineChat'; import * as notebooks from 'vs/workbench/contrib/notebook/common/notebookCommon'; import { ICellRange } from 'vs/workbench/contrib/notebook/common/notebookRange'; import * as search from 'vs/workbench/contrib/search/common/search'; -import { TestId, TestPosition } from 'vs/workbench/contrib/testing/common/testId'; -import { CoverageDetails, DetailType, ICoveredCount, IFileCoverage, ISerializedTestResults, ITestErrorMessage, ITestItem, ITestTag, TestMessageType, TestResultItem, denamespaceTestTag, namespaceTestTag } from 'vs/workbench/contrib/testing/common/testTypes'; +import { TestId } from 'vs/workbench/contrib/testing/common/testId'; +import { CoverageDetails, DetailType, ICoverageCount, IFileCoverage, ISerializedTestResults, ITestErrorMessage, ITestItem, ITestTag, TestMessageType, TestResultItem, denamespaceTestTag, namespaceTestTag } from 'vs/workbench/contrib/testing/common/testTypes'; import { EditorGroupColumn } from 'vs/workbench/services/editor/common/editorGroupColumn'; import { ACTIVE_GROUP, SIDE_GROUP } from 'vs/workbench/services/editor/common/editorService'; import { checkProposedApiEnabled } from 'vs/workbench/services/extensions/common/extensions'; import { Dto } from 'vs/workbench/services/extensions/common/proxyIdentifier'; import type * as vscode from 'vscode'; import * as types from './extHostTypes'; -import { basename } from 'vs/base/common/resources'; export namespace Command { @@ -1953,18 +1957,15 @@ export namespace TestTag { } export namespace TestResults { - const convertTestResultItem = (item: TestResultItem.Serialized, byInternalId: Map): vscode.TestResultSnapshot => { - const children: TestResultItem.Serialized[] = []; - for (const [id, item] of byInternalId) { - if (TestId.compare(item.item.extId, id) === TestPosition.IsChild) { - byInternalId.delete(id); - children.push(item); - } + const convertTestResultItem = (node: IPrefixTreeNode, parent?: vscode.TestResultSnapshot): vscode.TestResultSnapshot | undefined => { + const item = node.value; + if (!item) { + return undefined; // should be unreachable } const snapshot: vscode.TestResultSnapshot = ({ ...TestItem.toPlain(item.item), - parent: undefined, + parent, taskStates: item.tasks.map(t => ({ state: t.state as number as types.TestResultState, duration: t.duration, @@ -1972,36 +1973,49 @@ export namespace TestResults { .filter((m): m is ITestErrorMessage.Serialized => m.type === TestMessageType.Error) .map(TestMessage.to), })), - children: children.map(c => convertTestResultItem(c, byInternalId)) + children: [], }); - for (const child of snapshot.children) { - (child as any).parent = snapshot; + if (node.children) { + for (const child of node.children.values()) { + const c = convertTestResultItem(child, snapshot); + if (c) { + snapshot.children.push(c); + } + } } return snapshot; }; export function to(serialized: ISerializedTestResults): vscode.TestRunResult { - const roots: TestResultItem.Serialized[] = []; - const byInternalId = new Map(); + const tree = new WellDefinedPrefixTree(); for (const item of serialized.items) { - byInternalId.set(item.item.extId, item); - const controllerId = TestId.root(item.item.extId); - if (serialized.request.targets.some(t => t.controllerId === controllerId && t.testIds.includes(item.item.extId))) { - roots.push(item); + tree.insert(TestId.fromString(item.item.extId).path, item); + } + + // Get the first node with a value in each subtree of IDs. + const queue = [tree.nodes]; + const roots: IPrefixTreeNode[] = []; + while (queue.length) { + for (const node of queue.pop()!) { + if (node.value) { + roots.push(node); + } else if (node.children) { + queue.push(node.children.values()); + } } } return { completedAt: serialized.completedAt, - results: roots.map(r => convertTestResultItem(r, byInternalId)), + results: roots.map(r => convertTestResultItem(r)).filter(isDefined), }; } } export namespace TestCoverage { - function fromCoveredCount(count: vscode.CoveredCount): ICoveredCount { + function fromCoverageCount(count: vscode.TestCoverageCount): ICoverageCount { return { covered: count.covered, total: count.total }; } @@ -2009,7 +2023,11 @@ export namespace TestCoverage { return 'line' in location ? Position.from(location) : Range.from(location); } - export function fromDetailed(coverage: vscode.DetailedCoverage): CoverageDetails.Serialized { + export function fromDetails(coverage: vscode.FileCoverageDetail): CoverageDetails.Serialized { + if (typeof coverage.executed === 'number' && coverage.executed < 0) { + throw new Error(`Invalid coverage count ${coverage.executed}`); + } + if ('branches' in coverage) { return { count: coverage.executed, @@ -2021,7 +2039,7 @@ export namespace TestCoverage { }; } else { return { - type: DetailType.Function, + type: DetailType.Declaration, name: coverage.name, count: coverage.executed, location: fromLocation(coverage.location), @@ -2029,13 +2047,17 @@ export namespace TestCoverage { } } - export function fromFile(coverage: vscode.FileCoverage): IFileCoverage.Serialized { + export function fromFile(id: string, coverage: vscode.FileCoverage): IFileCoverage.Serialized { + types.validateTestCoverageCount(coverage.statementCoverage); + types.validateTestCoverageCount(coverage.branchCoverage); + types.validateTestCoverageCount(coverage.declarationCoverage); + return { + id, uri: coverage.uri, - statement: fromCoveredCount(coverage.statementCoverage), - branch: coverage.branchCoverage && fromCoveredCount(coverage.branchCoverage), - function: coverage.functionCoverage && fromCoveredCount(coverage.functionCoverage), - details: coverage.detailedCoverage?.map(fromDetailed), + statement: fromCoverageCount(coverage.statementCoverage), + branch: coverage.branchCoverage && fromCoverageCount(coverage.branchCoverage), + declaration: coverage.declarationCoverage && fromCoverageCount(coverage.declarationCoverage), }; } } @@ -2191,70 +2213,68 @@ export namespace DataTransfer { } } -export namespace ChatReplyFollowup { - export function from(followup: vscode.ChatAgentReplyFollowup | vscode.InteractiveEditorReplyFollowup): IChatReplyFollowup { +export namespace ChatFollowup { + export function from(followup: vscode.ChatFollowup, request: IChatAgentRequest | undefined): IChatFollowup { return { kind: 'reply', - message: followup.message, - title: followup.title, - tooltip: followup.tooltip, + agentId: followup.participant ?? request?.agentId ?? '', + subCommand: followup.command ?? request?.command, + message: followup.prompt, + title: followup.label + }; + } + + export function to(followup: IChatFollowup): vscode.ChatFollowup { + return { + prompt: followup.message, + label: followup.title, + participant: followup.agentId, + command: followup.subCommand, }; } } -export namespace ChatFollowup { - export function from(followup: string | vscode.ChatAgentFollowup): IChatFollowup { - if (typeof followup === 'string') { - return { title: followup, message: followup, kind: 'reply' }; - } else if ('commandId' in followup) { - return { +export namespace ChatInlineFollowup { + export function from(followup: vscode.InteractiveEditorFollowup): IInlineChatFollowup { + if ('commandId' in followup) { + return { kind: 'command', title: followup.title ?? '', commandId: followup.commandId ?? '', when: followup.when ?? '', args: followup.args - }; + } satisfies IInlineChatCommandFollowup; } else { - return ChatReplyFollowup.from(followup); + return { + kind: 'reply', + message: followup.message, + title: followup.title, + tooltip: followup.tooltip, + } satisfies IInlineChatReplyFollowup; } - } -} - -export namespace ChatMessage { - export function to(message: chatProvider.IChatMessage): vscode.ChatMessage { - const res = new types.ChatMessage(ChatMessageRole.to(message.role), message.content); - res.name = message.name; - return res; - } - - export function from(message: vscode.ChatMessage): chatProvider.IChatMessage { - return { - role: ChatMessageRole.from(message.role), - content: message.content, - name: message.name - }; } } +export namespace LanguageModelMessage { -export namespace ChatMessageRole { - - export function to(role: chatProvider.ChatMessageRole): vscode.ChatMessageRole { - switch (role) { - case chatProvider.ChatMessageRole.System: return types.ChatMessageRole.System; - case chatProvider.ChatMessageRole.User: return types.ChatMessageRole.User; - case chatProvider.ChatMessageRole.Assistant: return types.ChatMessageRole.Assistant; + export function to(message: chatProvider.IChatMessage): vscode.LanguageModelChatMessage { + switch (message.role) { + case chatProvider.ChatMessageRole.System: return new types.LanguageModelChatSystemMessage(message.content); + case chatProvider.ChatMessageRole.User: return new types.LanguageModelChatUserMessage(message.content); + case chatProvider.ChatMessageRole.Assistant: return new types.LanguageModelChatAssistantMessage(message.content); } } - export function from(role: vscode.ChatMessageRole): chatProvider.ChatMessageRole { - switch (role) { - case types.ChatMessageRole.System: return chatProvider.ChatMessageRole.System; - case types.ChatMessageRole.Assistant: return chatProvider.ChatMessageRole.Assistant; - case types.ChatMessageRole.User: - default: - return chatProvider.ChatMessageRole.User; + export function from(message: vscode.LanguageModelChatMessage): chatProvider.IChatMessage { + if (message instanceof types.LanguageModelChatSystemMessage) { + return { role: chatProvider.ChatMessageRole.System, content: message.content }; + } else if (message instanceof types.LanguageModelChatUserMessage) { + return { role: chatProvider.ChatMessageRole.User, content: message.content }; + } else if (message instanceof types.LanguageModelChatAssistantMessage) { + return { role: chatProvider.ChatMessageRole.Assistant, content: message.content }; + } else { + throw new Error('Invalid LanguageModelMessage'); } } } @@ -2329,32 +2349,20 @@ export namespace InteractiveEditorResponseFeedbackKind { } } -export namespace ChatResponseTextPart { - export function to(part: vscode.ChatResponseTextPart): Dto { - return { - kind: 'markdownContent', - content: MarkdownString.from(new types.MarkdownString().appendText(part.value)) - }; - } - export function from(part: Dto): vscode.ChatResponseTextPart { - return new types.ChatResponseTextPart(part.content.value); - } -} - export namespace ChatResponseMarkdownPart { - export function to(part: vscode.ChatResponseMarkdownPart): Dto { + export function from(part: vscode.ChatResponseMarkdownPart): Dto { return { kind: 'markdownContent', content: MarkdownString.from(part.value) }; } - export function from(part: Dto): vscode.ChatResponseMarkdownPart { + export function to(part: Dto): vscode.ChatResponseMarkdownPart { return new types.ChatResponseMarkdownPart(MarkdownString.to(part.content)); } } export namespace ChatResponseFilesPart { - export function to(part: vscode.ChatResponseFileTreePart): IChatTreeData { + export function from(part: vscode.ChatResponseFileTreePart): IChatTreeData { const { value, baseUri } = part; function convert(items: vscode.ChatResponseFileTree[], baseUri: URI): extHostProtocol.IChatResponseProgressFileTreeData[] { return items.map(item => { @@ -2375,8 +2383,8 @@ export namespace ChatResponseFilesPart { } }; } - export function from(part: Dto): vscode.ChatResponseFileTreePart { - const { treeData } = revive(part.treeData); + export function to(part: Dto): vscode.ChatResponseFileTreePart { + const treeData = revive(part.treeData); function convert(items: extHostProtocol.IChatResponseProgressFileTreeData[]): vscode.ChatResponseFileTree[] { return items.map(item => { return { @@ -2393,7 +2401,7 @@ export namespace ChatResponseFilesPart { } export namespace ChatResponseAnchorPart { - export function to(part: vscode.ChatResponseAnchorPart): Dto { + export function from(part: vscode.ChatResponseAnchorPart): Dto { return { kind: 'inlineReference', name: part.title, @@ -2401,7 +2409,7 @@ export namespace ChatResponseAnchorPart { }; } - export function from(part: Dto): vscode.ChatResponseAnchorPart { + export function to(part: Dto): vscode.ChatResponseAnchorPart { const value = revive(part); return new types.ChatResponseAnchorPart( URI.isUri(value.inlineReference) ? value.inlineReference : Location.to(value.inlineReference), @@ -2411,45 +2419,101 @@ export namespace ChatResponseAnchorPart { } export namespace ChatResponseProgressPart { - export function to(part: vscode.ChatResponseProgressPart): Dto { + export function from(part: vscode.ChatResponseProgressPart): Dto { return { kind: 'progressMessage', content: MarkdownString.from(part.value) }; } - export function from(part: Dto): vscode.ChatResponseProgressPart { + export function to(part: Dto): vscode.ChatResponseProgressPart { return new types.ChatResponseProgressPart(part.content.value); } } +export namespace ChatResponseCommandButtonPart { + export function from(part: vscode.ChatResponseCommandButtonPart, commandsConverter: CommandsConverter, commandDisposables: DisposableStore): Dto { + // If the command isn't in the converter, then this session may have been restored, and the command args don't exist anymore + const command = commandsConverter.toInternal(part.value, commandDisposables) ?? { command: part.value.command, title: part.value.title }; + return { + kind: 'command', + command + }; + } + export function to(part: Dto, commandsConverter: CommandsConverter): vscode.ChatResponseCommandButtonPart { + // If the command isn't in the converter, then this session may have been restored, and the command args don't exist anymore + return new types.ChatResponseCommandButtonPart(commandsConverter.fromInternal(part.command) ?? { command: part.command.id, title: part.command.title }); + } +} + +export namespace ChatResponseTextEditPart { + export function from(part: vscode.ChatResponseTextEditPart): Dto { + return { + kind: 'textEdit', + uri: part.uri, + edits: part.edits.map(e => TextEdit.from(e)) + }; + } + export function to(part: Dto): vscode.ChatResponseTextEditPart { + return new types.ChatResponseTextEditPart(URI.revive(part.uri), part.edits.map(e => TextEdit.to(e))); + } + +} + export namespace ChatResponseReferencePart { - export function to(part: vscode.ChatResponseReferencePart): Dto { + export function from(part: vscode.ChatResponseReferencePart): Dto { + if ('variableName' in part.value) { + return { + kind: 'reference', + reference: { + variableName: part.value.variableName, + value: URI.isUri(part.value.value) || !part.value.value ? + part.value.value : + Location.from(part.value.value as vscode.Location) + } + }; + } + return { kind: 'reference', - reference: !URI.isUri(part.value) ? Location.from(part.value) : part.value + reference: URI.isUri(part.value) ? + part.value : + Location.from(part.value) }; } - export function from(part: Dto): vscode.ChatResponseReferencePart { + export function to(part: Dto): vscode.ChatResponseReferencePart { const value = revive(part); + + const mapValue = (value: URI | languages.Location): vscode.Uri | vscode.Location => URI.isUri(value) ? + value : + Location.to(value); + return new types.ChatResponseReferencePart( - URI.isUri(value.reference) ? value.reference : Location.to(value.reference) + 'variableName' in value.reference ? { + variableName: value.reference.variableName, + value: value.reference.value && mapValue(value.reference.value) + } : + mapValue(value.reference) ); } } export namespace ChatResponsePart { - export function to(part: vscode.ChatResponsePart): extHostProtocol.IChatProgressDto { + export function from(part: vscode.ChatResponsePart | vscode.ChatResponseTextEditPart, commandsConverter: CommandsConverter, commandDisposables: DisposableStore): extHostProtocol.IChatProgressDto { if (part instanceof types.ChatResponseMarkdownPart) { - return ChatResponseMarkdownPart.to(part); + return ChatResponseMarkdownPart.from(part); } else if (part instanceof types.ChatResponseAnchorPart) { - return ChatResponseAnchorPart.to(part); + return ChatResponseAnchorPart.from(part); } else if (part instanceof types.ChatResponseReferencePart) { - return ChatResponseReferencePart.to(part); + return ChatResponseReferencePart.from(part); } else if (part instanceof types.ChatResponseProgressPart) { - return ChatResponseProgressPart.to(part); + return ChatResponseProgressPart.from(part); } else if (part instanceof types.ChatResponseFileTreePart) { - return ChatResponseFilesPart.to(part); + return ChatResponseFilesPart.from(part); + } else if (part instanceof types.ChatResponseCommandButtonPart) { + return ChatResponseCommandButtonPart.from(part, commandsConverter, commandDisposables); + } else if (part instanceof types.ChatResponseTextEditPart) { + return ChatResponseTextEditPart.from(part); } return { kind: 'content', @@ -2458,26 +2522,40 @@ export namespace ChatResponsePart { } - export function from(part: extHostProtocol.IChatProgressDto): vscode.ChatResponsePart { + export function to(part: extHostProtocol.IChatProgressDto, commandsConverter: CommandsConverter): vscode.ChatResponsePart | undefined { switch (part.kind) { - case 'markdownContent': return ChatResponseMarkdownPart.from(part); - case 'inlineReference': return ChatResponseAnchorPart.from(part); - case 'reference': return ChatResponseReferencePart.from(part); - case 'progressMessage': return ChatResponseProgressPart.from(part); - case 'treeData': return ChatResponseFilesPart.from(part); + case 'reference': return ChatResponseReferencePart.to(part); + case 'markdownContent': + case 'inlineReference': + case 'progressMessage': + case 'treeData': + case 'command': + return toContent(part, commandsConverter); } - return new types.ChatResponseTextPart(''); + return undefined; + } + + export function toContent(part: extHostProtocol.IChatContentProgressDto, commandsConverter: CommandsConverter): vscode.ChatResponseMarkdownPart | vscode.ChatResponseFileTreePart | vscode.ChatResponseAnchorPart | vscode.ChatResponseCommandButtonPart | undefined { + switch (part.kind) { + case 'markdownContent': return ChatResponseMarkdownPart.to(part); + case 'inlineReference': return ChatResponseAnchorPart.to(part); + case 'progressMessage': return undefined; + case 'treeData': return ChatResponseFilesPart.to(part); + case 'command': return ChatResponseCommandButtonPart.to(part, commandsConverter); + } + + return undefined; } } export namespace ChatResponseProgress { - export function from(extension: IExtensionDescription, progress: vscode.ChatAgentExtendedProgress): extHostProtocol.IChatProgressDto | undefined { + export function from(extension: IExtensionDescription, progress: vscode.ChatExtendedProgress): extHostProtocol.IChatProgressDto | undefined { if ('markdownContent' in progress) { - checkProposedApiEnabled(extension, 'chatAgents2Additions'); + checkProposedApiEnabled(extension, 'chatParticipantAdditions'); return { content: MarkdownString.from(progress.markdownContent), kind: 'markdownContent' }; } else if ('content' in progress) { if ('vulnerabilities' in progress && progress.vulnerabilities) { - checkProposedApiEnabled(extension, 'chatAgents2Additions'); + checkProposedApiEnabled(extension, 'chatParticipantAdditions'); return { content: progress.content, vulnerabilities: progress.vulnerabilities, kind: 'vulnerability' }; } @@ -2485,7 +2563,7 @@ export namespace ChatResponseProgress { return { content: progress.content, kind: 'content' }; } - checkProposedApiEnabled(extension, 'chatAgents2Additions'); + checkProposedApiEnabled(extension, 'chatParticipantAdditions'); return { content: MarkdownString.from(progress.content), kind: 'markdownContent' }; } else if ('documents' in progress) { return { @@ -2515,11 +2593,9 @@ export namespace ChatResponseProgress { name: progress.title, kind: 'inlineReference' }; - } else if ('agentName' in progress) { - checkProposedApiEnabled(extension, 'chatAgents2Additions'); - return { agentName: progress.agentName, command: progress.command, kind: 'agentDetection' }; - } else if ('treeData' in progress) { - return { treeData: progress.treeData, kind: 'treeData' }; + } else if ('participant' in progress) { + checkProposedApiEnabled(extension, 'chatParticipantAdditions'); + return { agentId: progress.participant, command: progress.command, kind: 'agentDetection' }; } else if ('message' in progress) { return { content: MarkdownString.from(progress.message), kind: 'progressMessage' }; } else { @@ -2527,37 +2603,7 @@ export namespace ChatResponseProgress { } } - export function to(progress: extHostProtocol.IChatProgressDto): vscode.ChatAgentProgress | undefined { - switch (progress.kind) { - case 'markdownContent': - case 'inlineReference': - case 'treeData': - return ChatResponseProgress.to(progress); - case 'content': - return { content: progress.content }; - case 'usedContext': - return { documents: progress.documents.map(d => ({ uri: URI.revive(d.uri), version: d.version, ranges: d.ranges.map(r => Range.to(r)) })) }; - case 'reference': - return { - reference: - isUriComponents(progress.reference) ? - URI.revive(progress.reference) : - Location.to(progress.reference) - }; - case 'agentDetection': - // For simplicity, don't sent back the 'extended' types - return undefined; - case 'progressMessage': - return { message: progress.content.value }; - case 'vulnerability': - return { content: progress.content, vulnerabilities: progress.vulnerabilities }; - default: - // Unknown type, eg something in history that was removed? Ignore - return undefined; - } - } - - export function toProgressContent(progress: extHostProtocol.IChatContentProgressDto): vscode.ChatAgentContentProgress | undefined { + export function toProgressContent(progress: extHostProtocol.IChatContentProgressDto, commandsConverter: Command.ICommandsConverter): vscode.ChatContentProgress | undefined { switch (progress.kind) { case 'markdownContent': // For simplicity, don't sent back the 'extended' types, so downgrade markdown to just some text @@ -2570,8 +2616,11 @@ export namespace ChatResponseProgress { Location.to(progress.inlineReference), title: progress.name }; - case 'treeData': - return { treeData: revive(progress.treeData) }; + case 'command': + // If the command isn't in the converter, then this session may have been restored, and the command args don't exist anymore + return { + command: commandsConverter.fromInternal(progress.command) ?? { command: progress.command.id, title: progress.command.title }, + }; default: // Unknown type, eg something in history that was removed? Ignore return undefined; @@ -2580,18 +2629,39 @@ export namespace ChatResponseProgress { } export namespace ChatAgentRequest { - export function to(request: IChatAgentRequest): vscode.ChatAgentRequest { + export function to(request: IChatAgentRequest): vscode.ChatRequest { return { prompt: request.message, - variables: ChatVariable.objectTo(request.variables), - subCommand: request.command, - agentId: request.agentId, + command: request.command, + variables: request.variables.variables.map(ChatAgentResolvedVariable.to), + location: ChatLocation.to(request.location), + }; + } +} + +export namespace ChatLocation { + export function to(loc: ChatAgentLocation): types.ChatLocation { + switch (loc) { + case ChatAgentLocation.Notebook: return types.ChatLocation.Notebook; + case ChatAgentLocation.Terminal: return types.ChatLocation.Terminal; + case ChatAgentLocation.Panel: return types.ChatLocation.Panel; + case ChatAgentLocation.Editor: return types.ChatLocation.Editor; + } + } +} + +export namespace ChatAgentResolvedVariable { + export function to(request: IChatRequestVariableEntry): vscode.ChatResolvedVariable { + return { + name: request.name, + range: request.range && [request.range.start, request.range.endExclusive], + values: request.values.map(ChatVariable.to) }; } } export namespace ChatAgentCompletionItem { - export function from(item: vscode.ChatAgentCompletionItem): extHostProtocol.IChatAgentCompletionItem { + export function from(item: vscode.ChatCompletionItem): extHostProtocol.IChatAgentCompletionItem { return { label: item.label, values: item.values.map(ChatVariable.from), @@ -2602,6 +2672,35 @@ export namespace ChatAgentCompletionItem { } } +export namespace ChatAgentResult { + export function to(result: IChatAgentResult): vscode.ChatResult { + return { + errorDetails: result.errorDetails, + metadata: result.metadata, + }; + } +} + +export namespace ChatAgentUserActionEvent { + export function to(result: IChatAgentResult, event: IChatUserActionEvent, commandsConverter: CommandsConverter): vscode.ChatUserActionEvent | undefined { + if (event.action.kind === 'vote') { + // Is the "feedback" type + return; + } + + const ehResult = ChatAgentResult.to(result); + if (event.action.kind === 'command') { + const commandAction: vscode.ChatCommandAction = { kind: 'command', commandButton: ChatResponseProgress.toProgressContent(event.action.commandButton, commandsConverter) as vscode.ChatCommandButton }; + return { action: commandAction, result: ehResult }; + } else if (event.action.kind === 'followUp') { + const followupAction: vscode.ChatFollowupAction = { kind: 'followUp', followup: ChatFollowup.to(event.action.followup) }; + return { action: followupAction, result: ehResult }; + } else { + return { action: event.action, result: ehResult }; + } + } +} + export namespace TerminalQuickFix { export function from(quickFix: vscode.TerminalQuickFixTerminalCommand | vscode.TerminalQuickFixOpener | vscode.Command, converter: Command.ICommandsConverter, disposables: DisposableStore): extHostProtocol.ITerminalQuickFixTerminalCommandDto | extHostProtocol.ITerminalQuickFixOpenerDto | extHostProtocol.ICommandDto | undefined { @@ -2614,3 +2713,39 @@ export namespace TerminalQuickFix { return converter.toInternal(quickFix, disposables); } } + +export namespace PartialAcceptInfo { + export function to(info: languages.PartialAcceptInfo): types.PartialAcceptInfo { + return { + kind: PartialAcceptTriggerKind.to(info.kind), + }; + } +} + +export namespace PartialAcceptTriggerKind { + export function to(kind: languages.PartialAcceptTriggerKind): types.PartialAcceptTriggerKind { + switch (kind) { + case languages.PartialAcceptTriggerKind.Word: + return types.PartialAcceptTriggerKind.Word; + case languages.PartialAcceptTriggerKind.Line: + return types.PartialAcceptTriggerKind.Line; + case languages.PartialAcceptTriggerKind.Suggest: + return types.PartialAcceptTriggerKind.Suggest; + default: + return types.PartialAcceptTriggerKind.Unknown; + } + } +} + +export namespace DebugTreeItem { + export function from(item: vscode.DebugTreeItem, id: number): IDebugVisualizationTreeItem { + return { + id, + label: item.label, + description: item.description, + canEdit: item.canEdit, + collapsibleState: (item.collapsibleState || DebugTreeItemCollapsibleState.None) as DebugTreeItemCollapsibleState, + contextValue: item.contextValue, + }; + } +} diff --git a/src/vs/workbench/api/common/extHostTypes.ts b/src/vs/workbench/api/common/extHostTypes.ts index b0269443d64d0..addaa76b68373 100644 --- a/src/vs/workbench/api/common/extHostTypes.ts +++ b/src/vs/workbench/api/common/extHostTypes.ts @@ -1795,6 +1795,17 @@ export class InlineSuggestionList implements vscode.InlineCompletionList { } } +export interface PartialAcceptInfo { + kind: PartialAcceptTriggerKind; +} + +export enum PartialAcceptTriggerKind { + Unknown = 0, + Word = 1, + Line = 2, + Suggest = 3, +} + export enum ViewColumn { Active = -1, Beside = -2, @@ -2775,27 +2786,63 @@ export class DataTransfer implements vscode.DataTransfer { @es5ClassCompat export class DocumentDropEdit { + title?: string; + id: string | undefined; insertText: string | SnippetString; additionalEdit?: WorkspaceEdit; - constructor(insertText: string | SnippetString) { + kind?: DocumentPasteEditKind; + + constructor(insertText: string | SnippetString, title?: string, kind?: DocumentPasteEditKind) { this.insertText = insertText; + this.title = title; + this.kind = kind; + } +} + +export enum DocumentPasteTriggerKind { + Automatic = 0, + PasteAs = 1, +} + +export class DocumentPasteEditKind { + static Empty: DocumentPasteEditKind; + + private static sep = '.'; + + constructor( + public readonly value: string + ) { } + + public append(...parts: string[]): DocumentPasteEditKind { + return new DocumentPasteEditKind((this.value ? [this.value, ...parts] : parts).join(DocumentPasteEditKind.sep)); + } + + public intersects(other: DocumentPasteEditKind): boolean { + return this.contains(other) || other.contains(this); + } + + public contains(other: DocumentPasteEditKind): boolean { + return this.value === other.value || other.value.startsWith(this.value + DocumentPasteEditKind.sep); } } +DocumentPasteEditKind.Empty = new DocumentPasteEditKind(''); @es5ClassCompat export class DocumentPasteEdit { - label: string; + title: string; insertText: string | SnippetString; additionalEdit?: WorkspaceEdit; + kind: DocumentPasteEditKind; - constructor(insertText: string | SnippetString, label: string) { - this.label = label; + constructor(insertText: string | SnippetString, title: string, kind: DocumentPasteEditKind) { + this.title = title; this.insertText = insertText; + this.kind = kind; } } @@ -2918,8 +2965,9 @@ export class Breakpoint { readonly condition?: string; readonly hitCondition?: string; readonly logMessage?: string; + readonly mode?: string; - protected constructor(enabled?: boolean, condition?: string, hitCondition?: string, logMessage?: string) { + protected constructor(enabled?: boolean, condition?: string, hitCondition?: string, logMessage?: string, mode?: string) { this.enabled = typeof enabled === 'boolean' ? enabled : true; if (typeof condition === 'string') { this.condition = condition; @@ -2930,6 +2978,9 @@ export class Breakpoint { if (typeof logMessage === 'string') { this.logMessage = logMessage; } + if (typeof mode === 'string') { + this.mode = mode; + } } get id(): string { @@ -2944,8 +2995,8 @@ export class Breakpoint { export class SourceBreakpoint extends Breakpoint { readonly location: Location; - constructor(location: Location, enabled?: boolean, condition?: string, hitCondition?: string, logMessage?: string) { - super(enabled, condition, hitCondition, logMessage); + constructor(location: Location, enabled?: boolean, condition?: string, hitCondition?: string, logMessage?: string, mode?: string) { + super(enabled, condition, hitCondition, logMessage, mode); if (location === null) { throw illegalArgument('location'); } @@ -2957,8 +3008,8 @@ export class SourceBreakpoint extends Breakpoint { export class FunctionBreakpoint extends Breakpoint { readonly functionName: string; - constructor(functionName: string, enabled?: boolean, condition?: string, hitCondition?: string, logMessage?: string) { - super(enabled, condition, hitCondition, logMessage); + constructor(functionName: string, enabled?: boolean, condition?: string, hitCondition?: string, logMessage?: string, mode?: string) { + super(enabled, condition, hitCondition, logMessage, mode); this.functionName = functionName; } } @@ -2969,8 +3020,8 @@ export class DataBreakpoint extends Breakpoint { readonly dataId: string; readonly canPersist: boolean; - constructor(label: string, dataId: string, canPersist: boolean, enabled?: boolean, condition?: string, hitCondition?: string, logMessage?: string) { - super(enabled, condition, hitCondition, logMessage); + constructor(label: string, dataId: string, canPersist: boolean, enabled?: boolean, condition?: string, hitCondition?: string, logMessage?: string, mode?: string) { + super(enabled, condition, hitCondition, logMessage, mode); if (!dataId) { throw illegalArgument('dataId'); } @@ -3020,23 +3071,20 @@ export class DebugAdapterInlineImplementation implements vscode.DebugAdapterInli } -@es5ClassCompat -export class StackFrameFocus { +export class StackFrame implements vscode.StackFrame { constructor( public readonly session: vscode.DebugSession, - readonly threadId?: number, - readonly frameId?: number) { } + readonly threadId: number, + readonly frameId: number) { } } -@es5ClassCompat -export class ThreadFocus { +export class Thread implements vscode.Thread { constructor( public readonly session: vscode.DebugSession, - readonly threadId?: number) { } + readonly threadId: number) { } } - @es5ClassCompat export class EvaluatableExpression implements vscode.EvaluatableExpression { readonly range: vscode.Range; @@ -3100,6 +3148,23 @@ export class InlineValueContext implements vscode.InlineValueContext { } } +export enum NewSymbolNameTag { + AIGenerated = 1 +} + +export class NewSymbolName implements vscode.NewSymbolName { + readonly newSymbolName: string; + readonly tags?: readonly vscode.NewSymbolNameTag[] | undefined; + + constructor( + newSymbolName: string, + tags?: readonly NewSymbolNameTag[] + ) { + this.newSymbolName = newSymbolName; + this.tags = tags; + } +} + //#region file api export enum FileChangeType { @@ -3207,6 +3272,11 @@ export enum CommentThreadState { Resolved = 1 } +export enum CommentThreadApplicability { + Current = 0, + Outdated = 1 +} + //#endregion //#region Semantic Coloring @@ -3955,22 +4025,31 @@ export class TestTag implements vscode.TestTag { //#endregion //#region Test Coverage -export class CoveredCount implements vscode.CoveredCount { +export class TestCoverageCount implements vscode.TestCoverageCount { constructor(public covered: number, public total: number) { + validateTestCoverageCount(this); } } -const validateCC = (cc?: vscode.CoveredCount) => { - if (cc && cc.covered > cc.total) { +export function validateTestCoverageCount(cc?: vscode.TestCoverageCount) { + if (!cc) { + return; + } + + if (cc.covered > cc.total) { throw new Error(`The total number of covered items (${cc.covered}) cannot be greater than the total (${cc.total})`); } -}; + + if (cc.total < 0) { + throw new Error(`The number of covered items (${cc.total}) cannot be negative`); + } +} export class FileCoverage implements vscode.FileCoverage { - public static fromDetails(uri: vscode.Uri, details: vscode.DetailedCoverage[]): vscode.FileCoverage { - const statements = new CoveredCount(0, 0); - const branches = new CoveredCount(0, 0); - const fn = new CoveredCount(0, 0); + public static fromDetails(uri: vscode.Uri, details: vscode.FileCoverageDetail[]): vscode.FileCoverage { + const statements = new TestCoverageCount(0, 0); + const branches = new TestCoverageCount(0, 0); + const decl = new TestCoverageCount(0, 0); for (const detail of details) { if ('branches' in detail) { @@ -3982,8 +4061,8 @@ export class FileCoverage implements vscode.FileCoverage { branches.covered += branch.executed ? 1 : 0; } } else { - fn.total += 1; - fn.covered += detail.executed ? 1 : 0; + decl.total += 1; + decl.covered += detail.executed ? 1 : 0; } } @@ -3991,7 +4070,7 @@ export class FileCoverage implements vscode.FileCoverage { uri, statements, branches.total > 0 ? branches : undefined, - fn.total > 0 ? fn : undefined, + decl.total > 0 ? decl : undefined, ); coverage.detailedCoverage = details; @@ -3999,17 +4078,14 @@ export class FileCoverage implements vscode.FileCoverage { return coverage; } - detailedCoverage?: vscode.DetailedCoverage[]; + detailedCoverage?: vscode.FileCoverageDetail[]; constructor( public readonly uri: vscode.Uri, - public statementCoverage: vscode.CoveredCount, - public branchCoverage?: vscode.CoveredCount, - public functionCoverage?: vscode.CoveredCount, + public statementCoverage: vscode.TestCoverageCount, + public branchCoverage?: vscode.TestCoverageCount, + public declarationCoverage?: vscode.TestCoverageCount, ) { - validateCC(statementCoverage); - validateCC(branchCoverage); - validateCC(functionCoverage); } } @@ -4037,7 +4113,7 @@ export class BranchCoverage implements vscode.BranchCoverage { ) { } } -export class FunctionCoverage implements vscode.FunctionCoverage { +export class DeclarationCoverage implements vscode.DeclarationCoverage { // back compat until finalization: get executionCount() { return +this.executed; } set executionCount(n: number) { this.executed = n; } @@ -4134,6 +4210,10 @@ export class InteractiveWindowInput { export class ChatEditorTabInput { constructor(readonly providerId: string) { } } + +export class TextMultiDiffTabInput { + constructor(readonly textDiffs: TextDiffTabInput[]) { } +} //#endregion //#region Chat @@ -4143,7 +4223,7 @@ export enum InteractiveSessionVoteDirection { Up = 1 } -export enum ChatAgentCopyKind { +export enum ChatCopyKind { Action = 1, Toolbar = 2 } @@ -4154,7 +4234,7 @@ export enum ChatVariableLevel { Full = 3 } -export class ChatAgentCompletionItem implements vscode.ChatAgentCompletionItem { +export class ChatCompletionItem implements vscode.ChatCompletionItem { label: string | CompletionItemLabel; insertText?: string; values: vscode.ChatVariableValue[]; @@ -4179,37 +4259,11 @@ export enum InteractiveEditorResponseFeedbackKind { Bug = 4 } -export enum ChatMessageRole { - System = 0, - User = 1, - Assistant = 2, -} - -export class ChatMessage implements vscode.ChatMessage { - - role: ChatMessageRole; - content: string; - name?: string; - - constructor(role: ChatMessageRole, content: string) { - this.role = role; - this.content = content; - } -} - -export enum ChatAgentResultFeedbackKind { +export enum ChatResultFeedbackKind { Unhelpful = 0, Helpful = 1, } - -export class ChatResponseTextPart { - value: string; - constructor(value: string) { - this.value = value; - } -} - export class ChatResponseMarkdownPart { value: vscode.MarkdownString; constructor(value: string | vscode.MarkdownString) { @@ -4242,13 +4296,102 @@ export class ChatResponseProgressPart { } } +export class ChatResponseCommandButtonPart { + value: vscode.Command; + constructor(value: vscode.Command) { + this.value = value; + } +} + export class ChatResponseReferencePart { - value: vscode.Uri | vscode.Location; - constructor(value: vscode.Uri | vscode.Location) { + value: vscode.Uri | vscode.Location | { variableName: string; value?: vscode.Uri | vscode.Location }; + constructor(value: vscode.Uri | vscode.Location | { variableName: string; value?: vscode.Uri | vscode.Location }) { this.value = value; } } +export class ChatResponseTextEditPart { + uri: vscode.Uri; + edits: vscode.TextEdit[]; + constructor(uri: vscode.Uri, edits: vscode.TextEdit | vscode.TextEdit[]) { + this.uri = uri; + this.edits = Array.isArray(edits) ? edits : [edits]; + } +} + +export class ChatRequestTurn implements vscode.ChatRequestTurn { + constructor( + readonly prompt: string, + readonly command: string | undefined, + readonly variables: vscode.ChatResolvedVariable[], + readonly participant: string, + ) { } +} + +export class ChatResponseTurn implements vscode.ChatResponseTurn { + + constructor( + readonly response: ReadonlyArray, + readonly result: vscode.ChatResult, + readonly participant: string, + readonly command?: string + ) { } +} + +export enum ChatLocation { + Panel = 1, + Terminal = 2, + Notebook = 3, + Editor = 4, +} + +export class LanguageModelChatSystemMessage { + content: string; + + constructor(content: string) { + this.content = content; + } +} + +export class LanguageModelChatUserMessage { + content: string; + name: string | undefined; + + constructor(content: string, name?: string) { + this.content = content; + this.name = name; + } +} + +export class LanguageModelChatAssistantMessage { + content: string; + name?: string; + + constructor(content: string, name?: string) { + this.content = content; + this.name = name; + } +} + +export class LanguageModelError extends Error { + + static NotFound(message?: string): LanguageModelError { + return new LanguageModelError(message, LanguageModelError.NotFound.name); + } + + static NoPermissions(message?: string): LanguageModelError { + return new LanguageModelError(message, LanguageModelError.NoPermissions.name); + } + + readonly code: string; + + constructor(message?: string, code?: string, cause?: Error) { + super(message, { cause }); + this.name = 'LanguageModelError'; + this.code = code ?? ''; + } + +} //#endregion @@ -4269,7 +4412,8 @@ export enum SpeechToTextStatus { Started = 1, Recognizing = 2, Recognized = 3, - Stopped = 4 + Stopped = 4, + Error = 5 } export enum KeywordRecognitionStatus { @@ -4278,3 +4422,19 @@ export enum KeywordRecognitionStatus { } //#endregion + +//#region InlineEdit + +export class InlineEdit implements vscode.InlineEdit { + constructor( + public readonly text: string, + public readonly range: Range, + ) { } +} + +export enum InlineEditTriggerKind { + Invoke = 0, + Automatic = 1, +} + +//#endregion diff --git a/src/vs/workbench/api/common/extHostWorkspace.ts b/src/vs/workbench/api/common/extHostWorkspace.ts index 95a60bcba8bb9..d240127a4d456 100644 --- a/src/vs/workbench/api/common/extHostWorkspace.ts +++ b/src/vs/workbench/api/common/extHostWorkspace.ts @@ -28,7 +28,7 @@ import { IExtHostRpcService } from 'vs/workbench/api/common/extHostRpcService'; import { GlobPattern } from 'vs/workbench/api/common/extHostTypeConverters'; import { Range } from 'vs/workbench/api/common/extHostTypes'; import { IURITransformerService } from 'vs/workbench/api/common/extHostUriTransformerService'; -import { ITextQueryBuilderOptions } from 'vs/workbench/services/search/common/queryBuilder'; +import { IFileQueryBuilderOptions, ITextQueryBuilderOptions } from 'vs/workbench/services/search/common/queryBuilder'; import { IRawFileMatch2, ITextSearchResult, resultIsMatch } from 'vs/workbench/services/search/common/search'; import * as vscode from 'vscode'; import { ExtHostWorkspaceShape, IRelativePatternDto, IWorkspaceData, MainContext, MainThreadMessageOptions, MainThreadMessageServiceShape, MainThreadWorkspaceShape } from './extHost.protocol'; @@ -446,27 +446,74 @@ export class ExtHostWorkspace implements ExtHostWorkspaceShape, IExtHostWorkspac findFiles(include: vscode.GlobPattern | undefined, exclude: vscode.GlobPattern | null | undefined, maxResults: number | undefined, extensionId: ExtensionIdentifier, token: vscode.CancellationToken = CancellationToken.None): Promise { this._logService.trace(`extHostWorkspace#findFiles: fileSearch, extension: ${extensionId.value}, entryPoint: findFiles`); - let excludePatternOrDisregardExcludes: string | false | undefined = undefined; + let excludeString: string = ''; + let useFileExcludes = true; if (exclude === null) { - excludePatternOrDisregardExcludes = false; - } else if (exclude) { + useFileExcludes = false; + } else if (exclude !== undefined) { if (typeof exclude === 'string') { - excludePatternOrDisregardExcludes = exclude; + excludeString = exclude; } else { - excludePatternOrDisregardExcludes = exclude.pattern; + excludeString = exclude.pattern; } } - + return this._findFilesImpl(include, undefined, { + exclude: excludeString, + maxResults, + useDefaultExcludes: useFileExcludes, + useDefaultSearchExcludes: false, + useIgnoreFiles: false + }, token); + } + + findFiles2(filePattern: vscode.GlobPattern | undefined, + options: vscode.FindFiles2Options = {}, + extensionId: ExtensionIdentifier, + token: vscode.CancellationToken = CancellationToken.None): Promise { + this._logService.trace(`extHostWorkspace#findFiles2: fileSearch, extension: ${extensionId.value}, entryPoint: findFiles2`); + return this._findFilesImpl(undefined, filePattern, options, token); + } + + private async _findFilesImpl( + // the old `findFiles` used `include` to query, but the new `findFiles2` uses `filePattern` to query. + // `filePattern` is the proper way to handle this, since it takes less precedence than the ignore files. + include: vscode.GlobPattern | undefined, + filePattern: vscode.GlobPattern | undefined, + options: vscode.FindFiles2Options, + token: vscode.CancellationToken = CancellationToken.None): Promise { if (token && token.isCancellationRequested) { return Promise.resolve([]); } - const { includePattern, folder } = parseSearchInclude(GlobPattern.from(include)); + const excludePattern = (typeof options.exclude === 'string') ? options.exclude : + options.exclude ? options.exclude.pattern : undefined; + + const fileQueries = { + ignoreSymlinks: typeof options.followSymlinks === 'boolean' ? !options.followSymlinks : undefined, + disregardIgnoreFiles: typeof options.useIgnoreFiles === 'boolean' ? !options.useIgnoreFiles : undefined, + disregardGlobalIgnoreFiles: typeof options.useGlobalIgnoreFiles === 'boolean' ? !options.useGlobalIgnoreFiles : undefined, + disregardParentIgnoreFiles: typeof options.useParentIgnoreFiles === 'boolean' ? !options.useParentIgnoreFiles : undefined, + disregardExcludeSettings: typeof options.useDefaultExcludes === 'boolean' ? !options.useDefaultExcludes : false, + disregardSearchExcludeSettings: typeof options.useDefaultSearchExcludes === 'boolean' ? !options.useDefaultSearchExcludes : false, + maxResults: options.maxResults, + excludePattern: excludePattern, + shouldGlobSearch: typeof options.fuzzy === 'boolean' ? !options.fuzzy : true, + _reason: 'startFileSearch' + }; + let folderToUse: URI | undefined; + if (include) { + const { includePattern, folder } = parseSearchInclude(GlobPattern.from(include)); + folderToUse = folder; + fileQueries.includePattern = includePattern; + } else { + const { includePattern, folder } = parseSearchInclude(GlobPattern.from(filePattern)); + folderToUse = folder; + fileQueries.filePattern = includePattern; + } + return this._proxy.$startFileSearch( - includePattern ?? null, - folder ?? null, - excludePatternOrDisregardExcludes ?? null, - maxResults ?? null, + folderToUse ?? null, + fileQueries, token ) .then(data => Array.isArray(data) ? data.map(d => URI.revive(d)) : []); diff --git a/src/vs/workbench/api/common/jsonValidationExtensionPoint.ts b/src/vs/workbench/api/common/jsonValidationExtensionPoint.ts index 94a2f00ff588c..1b82d305f1968 100644 --- a/src/vs/workbench/api/common/jsonValidationExtensionPoint.ts +++ b/src/vs/workbench/api/common/jsonValidationExtensionPoint.ts @@ -12,6 +12,7 @@ import { Extensions, IExtensionFeatureTableRenderer, IExtensionFeaturesRegistry, import { IExtensionManifest } from 'vs/platform/extensions/common/extensions'; import { Registry } from 'vs/platform/registry/common/platform'; import { SyncDescriptor } from 'vs/platform/instantiation/common/descriptors'; +import { MarkdownString } from 'vs/base/common/htmlContent'; interface IJSONValidationExtensionPoint { fileMatch: string | string[]; @@ -109,7 +110,7 @@ class JSONValidationDataRenderer extends Disposable implements IExtensionFeature const rows: IRowData[][] = contrib.map(v => { return [ - { data: Array.isArray(v.fileMatch) ? v.fileMatch.join(', ') : v.fileMatch, type: 'code' }, + new MarkdownString().appendMarkdown(`\`${Array.isArray(v.fileMatch) ? v.fileMatch.join(', ') : v.fileMatch}\``), v.url, ]; }); diff --git a/src/vs/workbench/api/node/extHostDebugService.ts b/src/vs/workbench/api/node/extHostDebugService.ts index 1ccfd2101daad..995d4f8a17b3a 100644 --- a/src/vs/workbench/api/node/extHostDebugService.ts +++ b/src/vs/workbench/api/node/extHostDebugService.ts @@ -27,6 +27,7 @@ import { ExtensionDescriptionRegistry } from 'vs/workbench/services/extensions/c import type * as vscode from 'vscode'; import { ExtHostConfigProvider, IExtHostConfiguration } from '../common/extHostConfiguration'; import { IExtHostCommands } from 'vs/workbench/api/common/extHostCommands'; +import { createHash } from 'crypto'; export class ExtHostDebugService extends ExtHostDebugServiceBase { @@ -89,8 +90,8 @@ export class ExtHostDebugService extends ExtHostDebugServiceBase { const terminalName = args.title || nls.localize('debug.terminal.title', "Debug Process"); - const shellConfig = JSON.stringify({ shell, shellArgs }); - let terminal = await this._integratedTerminalInstances.checkout(shellConfig, terminalName); + const termKey = createKeyForShell(shell, shellArgs, args); + let terminal = await this._integratedTerminalInstances.checkout(termKey, terminalName, true); let cwdForPrepareCommand: string | undefined; let giveShellTimeToInitialize = false; @@ -102,13 +103,17 @@ export class ExtHostDebugService extends ExtHostDebugServiceBase { cwd: args.cwd, name: terminalName, iconPath: new ThemeIcon('debug'), + env: args.env, }; giveShellTimeToInitialize = true; terminal = this._terminalService.createTerminalFromOptions(options, { isFeatureTerminal: true, + // Since debug termnials are REPLs, we want shell integration to be enabled. + // Ignore isFeatureTerminal when evaluating shell integration enablement. + forceShellIntegration: true, useShellEnvironment: true }); - this._integratedTerminalInstances.insert(terminal, shellConfig); + this._integratedTerminalInstances.insert(terminal, termKey); } else { cwdForPrepareCommand = args.cwd; @@ -122,6 +127,10 @@ export class ExtHostDebugService extends ExtHostDebugServiceBase { // give a new terminal some time to initialize the shell await new Promise(resolve => setTimeout(resolve, 1000)); } else { + if (terminal.state.isInteractedWith) { + terminal.sendText('\u0003'); // Ctrl+C for #106743. Not part of the same command for #107969 + } + if (configProvider.getConfiguration('debug.terminal').get('clearBeforeReusing')) { // clear terminal before reusing it if (shell.indexOf('powershell') >= 0 || shell.indexOf('pwsh') >= 0 || shell.indexOf('cmd.exe') >= 0) { @@ -136,7 +145,7 @@ export class ExtHostDebugService extends ExtHostDebugServiceBase { } } - const command = prepareCommand(shell, args.args, !!args.argsCanBeInterpretedByShell, cwdForPrepareCommand, args.env); + const command = prepareCommand(shell, args.args, !!args.argsCanBeInterpretedByShell, cwdForPrepareCommand); terminal.sendText(command); // Mark terminal as unused when its session ends, see #112055 @@ -156,6 +165,14 @@ export class ExtHostDebugService extends ExtHostDebugServiceBase { } } +/** Creates a key that determines how terminals get reused */ +function createKeyForShell(shell: string, shellArgs: string | string[], args: DebugProtocol.RunInTerminalRequestArguments) { + const hash = createHash('sha256'); + hash.update(JSON.stringify({ shell, shellArgs })); + hash.update(JSON.stringify(Object.entries(args.env || {}).sort(([k1], [k2]) => k1.localeCompare(k2)))); + return hash.digest('base64'); +} + let externalTerminalService: IExternalTerminalService | undefined = undefined; function runInExternalTerminal(args: DebugProtocol.RunInTerminalRequestArguments, configProvider: ExtHostConfigProvider): Promise { @@ -182,7 +199,7 @@ class DebugTerminalCollection { private _terminalInstances = new Map(); - public async checkout(config: string, name: string) { + public async checkout(config: string, name: string, cleanupOthersByName = false) { const entries = [...this._terminalInstances.entries()]; const promises = entries.map(([terminal, termInfo]) => createCancelablePromise(async ct => { @@ -202,6 +219,9 @@ class DebugTerminalCollection { } if (termInfo.config !== config) { + if (cleanupOthersByName) { + terminal.dispose(); + } return null; } diff --git a/src/vs/workbench/api/test/browser/extHostAuthentication.integrationTest.ts b/src/vs/workbench/api/test/browser/extHostAuthentication.integrationTest.ts index 25455816095fe..dd77886bbf05b 100644 --- a/src/vs/workbench/api/test/browser/extHostAuthentication.integrationTest.ts +++ b/src/vs/workbench/api/test/browser/extHostAuthentication.integrationTest.ts @@ -19,14 +19,18 @@ import { ExtHostContext, MainContext } from 'vs/workbench/api/common/extHost.pro import { ExtHostAuthentication } from 'vs/workbench/api/common/extHostAuthentication'; import { IActivityService } from 'vs/workbench/services/activity/common/activity'; import { AuthenticationService } from 'vs/workbench/services/authentication/browser/authenticationService'; -import { IAuthenticationService } from 'vs/workbench/services/authentication/common/authentication'; +import { IAuthenticationExtensionsService, IAuthenticationService } from 'vs/workbench/services/authentication/common/authentication'; import { IExtensionService, nullExtensionDescription as extensionDescription } from 'vs/workbench/services/extensions/common/extensions'; import { IRemoteAgentService } from 'vs/workbench/services/remote/common/remoteAgentService'; import { TestRPCProtocol } from 'vs/workbench/api/test/common/testRPCProtocol'; import { TestEnvironmentService, TestQuickInputService, TestRemoteAgentService } from 'vs/workbench/test/browser/workbenchTestServices'; -import { TestActivityService, TestExtensionService, TestStorageService } from 'vs/workbench/test/common/workbenchTestServices'; +import { TestActivityService, TestExtensionService, TestProductService, TestStorageService } from 'vs/workbench/test/common/workbenchTestServices'; import type { AuthenticationProvider, AuthenticationSession } from 'vscode'; import { IBrowserWorkbenchEnvironmentService } from 'vs/workbench/services/environment/browser/environmentService'; +import { IProductService } from 'vs/platform/product/common/productService'; +import { AuthenticationAccessService, IAuthenticationAccessService } from 'vs/workbench/services/authentication/browser/authenticationAccessService'; +import { AuthenticationUsageService, IAuthenticationUsageService } from 'vs/workbench/services/authentication/browser/authenticationUsageService'; +import { AuthenticationExtensionsService } from 'vs/workbench/services/authentication/browser/authenticationExtensionsService'; class AuthQuickPick { private listener: ((e: IQuickPickDidAcceptEvent) => any) | undefined; @@ -111,9 +115,13 @@ suite('ExtHostAuthentication', () => { instantiationService.stub(INotificationService, new TestNotificationService()); instantiationService.stub(ITelemetryService, NullTelemetryService); instantiationService.stub(IBrowserWorkbenchEnvironmentService, TestEnvironmentService); + instantiationService.stub(IProductService, TestProductService); + instantiationService.stub(IAuthenticationAccessService, instantiationService.createInstance(AuthenticationAccessService)); + instantiationService.stub(IAuthenticationUsageService, instantiationService.createInstance(AuthenticationUsageService)); const rpcProtocol = new TestRPCProtocol(); instantiationService.stub(IAuthenticationService, instantiationService.createInstance(AuthenticationService)); + instantiationService.stub(IAuthenticationExtensionsService, instantiationService.createInstance(AuthenticationExtensionsService)); rpcProtocol.set(MainContext.MainThreadAuthentication, instantiationService.createInstance(MainThreadAuthentication, rpcProtocol)); extHostAuthentication = new ExtHostAuthentication(rpcProtocol); rpcProtocol.set(ExtHostContext.ExtHostAuthentication, extHostAuthentication); diff --git a/src/vs/workbench/api/test/browser/extHostBulkEdits.test.ts b/src/vs/workbench/api/test/browser/extHostBulkEdits.test.ts index f17a6c5b0e96c..045f3d77cc136 100644 --- a/src/vs/workbench/api/test/browser/extHostBulkEdits.test.ts +++ b/src/vs/workbench/api/test/browser/extHostBulkEdits.test.ts @@ -13,6 +13,7 @@ import { NullLogService } from 'vs/platform/log/common/log'; import { ExtHostBulkEdits } from 'vs/workbench/api/common/extHostBulkEdits'; import { nullExtensionDescription } from 'vs/workbench/services/extensions/common/extensions'; import { ensureNoDisposablesAreLeakedInTestSuite } from 'vs/base/test/common/utils'; +import { SerializableObjectWithBuffers } from 'vs/workbench/services/extensions/common/proxyIdentifier'; suite('ExtHostBulkEdits.applyWorkspaceEdit', () => { @@ -25,8 +26,8 @@ suite('ExtHostBulkEdits.applyWorkspaceEdit', () => { const rpcProtocol = new TestRPCProtocol(); rpcProtocol.set(MainContext.MainThreadBulkEdits, new class extends mock() { - override $tryApplyWorkspaceEdit(_workspaceResourceEdits: IWorkspaceEditDto): Promise { - workspaceResourceEdits = _workspaceResourceEdits; + override $tryApplyWorkspaceEdit(_workspaceResourceEdits: SerializableObjectWithBuffers): Promise { + workspaceResourceEdits = _workspaceResourceEdits.value; return Promise.resolve(true); } }); diff --git a/src/vs/workbench/api/test/browser/extHostDocumentSaveParticipant.test.ts b/src/vs/workbench/api/test/browser/extHostDocumentSaveParticipant.test.ts index 59c02e4d3b1d6..632487d43c0a3 100644 --- a/src/vs/workbench/api/test/browser/extHostDocumentSaveParticipant.test.ts +++ b/src/vs/workbench/api/test/browser/extHostDocumentSaveParticipant.test.ts @@ -16,6 +16,7 @@ import { mock } from 'vs/base/test/common/mock'; import { NullLogService } from 'vs/platform/log/common/log'; import { nullExtensionDescription } from 'vs/workbench/services/extensions/common/extensions'; import { ensureNoDisposablesAreLeakedInTestSuite } from 'vs/base/test/common/utils'; +import { SerializableObjectWithBuffers } from 'vs/workbench/services/extensions/common/proxyIdentifier'; function timeout(n: number) { return new Promise(resolve => setTimeout(resolve, n)); @@ -257,8 +258,8 @@ suite('ExtHostDocumentSaveParticipant', () => { let dto: IWorkspaceEditDto; const participant = new ExtHostDocumentSaveParticipant(nullLogService, documents, new class extends mock() { - $tryApplyWorkspaceEdit(_edits: IWorkspaceEditDto) { - dto = _edits; + $tryApplyWorkspaceEdit(_edits: SerializableObjectWithBuffers) { + dto = _edits.value; return Promise.resolve(true); } }); @@ -281,8 +282,8 @@ suite('ExtHostDocumentSaveParticipant', () => { let edits: IWorkspaceEditDto; const participant = new ExtHostDocumentSaveParticipant(nullLogService, documents, new class extends mock() { - $tryApplyWorkspaceEdit(_edits: IWorkspaceEditDto) { - edits = _edits; + $tryApplyWorkspaceEdit(_edits: SerializableObjectWithBuffers) { + edits = _edits.value; return Promise.resolve(true); } }); @@ -318,9 +319,9 @@ suite('ExtHostDocumentSaveParticipant', () => { test('event delivery, two listeners -> two document states', () => { const participant = new ExtHostDocumentSaveParticipant(nullLogService, documents, new class extends mock() { - $tryApplyWorkspaceEdit(dto: IWorkspaceEditDto) { + $tryApplyWorkspaceEdit(dto: SerializableObjectWithBuffers) { - for (const edit of dto.edits) { + for (const edit of dto.value.edits) { const uri = URI.revive((edit).resource); const { text, range } = (edit).textEdit; diff --git a/src/vs/workbench/api/test/browser/extHostTesting.test.ts b/src/vs/workbench/api/test/browser/extHostTesting.test.ts index f7e03c8d80fe2..9a3e9ad49806e 100644 --- a/src/vs/workbench/api/test/browser/extHostTesting.test.ts +++ b/src/vs/workbench/api/test/browser/extHostTesting.test.ts @@ -5,6 +5,7 @@ import * as assert from 'assert'; import * as sinon from 'sinon'; +import { timeout } from 'vs/base/common/async'; import { VSBuffer } from 'vs/base/common/buffer'; import { CancellationTokenSource } from 'vs/base/common/cancellation'; import { Event } from 'vs/base/common/event'; @@ -13,7 +14,7 @@ import { URI } from 'vs/base/common/uri'; import { mock, mockObject, MockObject } from 'vs/base/test/common/mock'; import { ensureNoDisposablesAreLeakedInTestSuite } from 'vs/base/test/common/utils'; import * as editorRange from 'vs/editor/common/core/range'; -import { ExtensionIdentifier, IRelaxedExtensionDescription } from 'vs/platform/extensions/common/extensions'; +import { ExtensionIdentifier } from 'vs/platform/extensions/common/extensions'; import { NullLogService } from 'vs/platform/log/common/log'; import { MainThreadTestingShape } from 'vs/workbench/api/common/extHost.protocol'; import { ExtHostCommands } from 'vs/workbench/api/common/extHostCommands'; @@ -85,11 +86,14 @@ suite('ExtHost Testing', () => { const ds = ensureNoDisposablesAreLeakedInTestSuite(); let single: TestExtHostTestItemCollection; + let resolveCalls: (string | undefined)[] = []; setup(() => { + resolveCalls = []; single = ds.add(new TestExtHostTestItemCollection('ctrlId', 'root', { getDocument: () => undefined, } as Partial as ExtHostDocumentsAndEditors)); single.resolveHandler = item => { + resolveCalls.push(item?.id); if (item === undefined) { const a = new TestItemImpl('ctrlId', 'id-a', 'a', URI.file('/')); a.canResolveChildren = true; @@ -305,7 +309,7 @@ suite('ExtHost Testing', () => { assert.deepStrictEqual(single.collectDiff(), [ { op: TestDiffOpType.Update, - item: { extId: new TestId(['ctrlId', 'id-a']).toString(), expand: TestItemExpandState.Expanded, item: { label: 'Hello world' } }, + item: { extId: new TestId(['ctrlId', 'id-a']).toString(), item: { label: 'Hello world' } }, }, { op: TestDiffOpType.DocumentSynced, @@ -326,6 +330,40 @@ suite('ExtHost Testing', () => { assert.deepStrictEqual(single.collectDiff(), []); }); + suite('expandibility restoration', () => { + const doReplace = async (canResolveChildren = true) => { + const uri = single.root.children.get('id-a')!.uri; + const newA = new TestItemImpl('ctrlId', 'id-a', 'Hello world', uri); + newA.canResolveChildren = canResolveChildren; + single.root.children.replace([ + newA, + new TestItemImpl('ctrlId', 'id-b', single.root.children.get('id-b')!.label, uri), + ]); + await timeout(0); // drain microtasks + }; + + test('does not restore an unexpanded state', async () => { + await single.expand(single.root.id, 0); + assert.deepStrictEqual(resolveCalls, [undefined]); + await doReplace(); + assert.deepStrictEqual(resolveCalls, [undefined]); + }); + + test('restores resolve state on replacement', async () => { + await single.expand(single.root.id, Infinity); + assert.deepStrictEqual(resolveCalls, [undefined, 'id-a']); + await doReplace(); + assert.deepStrictEqual(resolveCalls, [undefined, 'id-a', 'id-a']); + }); + + test('does not expand if new child is not expandable', async () => { + await single.expand(single.root.id, Infinity); + assert.deepStrictEqual(resolveCalls, [undefined, 'id-a']); + await doReplace(false); + assert.deepStrictEqual(resolveCalls, [undefined, 'id-a']); + }); + }); + test('treats in-place replacement as mutation deeply', () => { single.expand(single.root.id, Infinity); single.collectDiff(); @@ -340,10 +378,6 @@ suite('ExtHost Testing', () => { single.root.children.replace([newA, single.root.children.get('id-b')!]); assert.deepStrictEqual(single.collectDiff(), [ - { - op: TestDiffOpType.Update, - item: { extId: new TestId(['ctrlId', 'id-a']).toString(), expand: TestItemExpandState.Expanded }, - }, { op: TestDiffOpType.Update, item: { extId: TestId.fromExtHostTestItem(oldAB, 'ctrlId').toString(), item: { label: 'Hello world' } }, @@ -603,7 +637,12 @@ suite('ExtHost Testing', () => { let req: TestRunRequest; let dto: TestRunDto; - const ext: IRelaxedExtensionDescription = {} as any; + + teardown(() => { + for (const { id } of c.trackers) { + c.disposeTestRun(id); + } + }); setup(async () => { proxy = mockObject()(); @@ -631,11 +670,11 @@ suite('ExtHost Testing', () => { }); test('tracks a run started from a main thread request', () => { - const tracker = ds.add(c.prepareForMainThreadTestRun(req, dto, ext, cts.token)); + const tracker = ds.add(c.prepareForMainThreadTestRun(req, dto, configuration, cts.token)); assert.strictEqual(tracker.hasRunningTasks, false); - const task1 = c.createTestRun(ext, 'ctrl', single, req, 'run1', true); - const task2 = c.createTestRun(ext, 'ctrl', single, req, 'run2', true); + const task1 = c.createTestRun('ctrl', single, req, 'run1', true); + const task2 = c.createTestRun('ctrl', single, req, 'run2', true); assert.strictEqual(proxy.$startedExtensionTestRun.called, false); assert.strictEqual(tracker.hasRunningTasks, true); @@ -656,8 +695,8 @@ suite('ExtHost Testing', () => { test('run cancel force ends after a timeout', () => { const clock = sinon.useFakeTimers(); try { - const tracker = ds.add(c.prepareForMainThreadTestRun(req, dto, ext, cts.token)); - const task = c.createTestRun(ext, 'ctrl', single, req, 'run1', true); + const tracker = ds.add(c.prepareForMainThreadTestRun(req, dto, configuration, cts.token)); + const task = c.createTestRun('ctrl', single, req, 'run1', true); const onEnded = sinon.stub(); ds.add(tracker.onEnd(onEnded)); @@ -681,8 +720,8 @@ suite('ExtHost Testing', () => { }); test('run cancel force ends on second cancellation request', () => { - const tracker = ds.add(c.prepareForMainThreadTestRun(req, dto, ext, cts.token)); - const task = c.createTestRun(ext, 'ctrl', single, req, 'run1', true); + const tracker = ds.add(c.prepareForMainThreadTestRun(req, dto, configuration, cts.token)); + const task = c.createTestRun('ctrl', single, req, 'run1', true); const onEnded = sinon.stub(); ds.add(tracker.onEnd(onEnded)); @@ -700,7 +739,7 @@ suite('ExtHost Testing', () => { }); test('tracks a run started from an extension request', () => { - const task1 = c.createTestRun(ext, 'ctrl', single, req, 'hello world', false); + const task1 = c.createTestRun('ctrl', single, req, 'hello world', false); const tracker = Iterable.first(c.trackers)!; assert.strictEqual(tracker.hasRunningTasks, true); @@ -716,8 +755,8 @@ suite('ExtHost Testing', () => { }] ]); - const task2 = c.createTestRun(ext, 'ctrl', single, req, 'run2', true); - const task3Detached = c.createTestRun(ext, 'ctrl', single, { ...req }, 'task3Detached', true); + const task2 = c.createTestRun('ctrl', single, req, 'run2', true); + const task3Detached = c.createTestRun('ctrl', single, { ...req }, 'task3Detached', true); task1.end(); assert.strictEqual(proxy.$finishedExtensionTestRun.called, false); @@ -731,7 +770,7 @@ suite('ExtHost Testing', () => { }); test('adds tests to run smartly', () => { - const task1 = c.createTestRun(ext, 'ctrlId', single, req, 'hello world', false); + const task1 = c.createTestRun('ctrlId', single, req, 'hello world', false); const tracker = Iterable.first(c.trackers)!; const expectedArgs: unknown[][] = []; assert.deepStrictEqual(proxy.$addTestsToRun.args, expectedArgs); @@ -770,7 +809,7 @@ suite('ExtHost Testing', () => { const test2 = new TestItemImpl('ctrlId', 'id-d', 'test d', URI.file('/testd.txt')); test1.range = test2.range = new Range(new Position(0, 0), new Position(1, 0)); single.root.children.replace([test1, test2]); - const task = c.createTestRun(ext, 'ctrlId', single, req, 'hello world', false); + const task = c.createTestRun('ctrlId', single, req, 'hello world', false); const message1 = new TestMessage('some message'); message1.location = new Location(URI.file('/a.txt'), new Position(0, 0)); @@ -811,7 +850,7 @@ suite('ExtHost Testing', () => { }); test('guards calls after runs are ended', () => { - const task = c.createTestRun(ext, 'ctrl', single, req, 'hello world', false); + const task = c.createTestRun('ctrl', single, req, 'hello world', false); task.end(); task.failed(single.root, new TestMessage('some message')); @@ -823,7 +862,7 @@ suite('ExtHost Testing', () => { }); test('excludes tests outside tree or explicitly excluded', () => { - const task = c.createTestRun(ext, 'ctrlId', single, { + const task = c.createTestRun('ctrlId', single, { profile: configuration, include: [single.root.children.get('id-a')!], exclude: [single.root.children.get('id-a')!.children.get('id-aa')!], @@ -852,7 +891,7 @@ suite('ExtHost Testing', () => { const childB = new TestItemImpl('ctrlId', 'id-child', 'child', undefined); testB!.children.replace([childB]); - const task1 = c.createTestRun(ext, 'ctrl', single, new TestRunRequestImpl(), 'hello world', false); + const task1 = c.createTestRun('ctrl', single, new TestRunRequestImpl(), 'hello world', false); const tracker = Iterable.first(c.trackers)!; task1.passed(childA); diff --git a/src/vs/workbench/api/test/browser/extHostTypeConverter.test.ts b/src/vs/workbench/api/test/browser/extHostTypeConverter.test.ts index 727c4dea88099..ab38b202b6e45 100644 --- a/src/vs/workbench/api/test/browser/extHostTypeConverter.test.ts +++ b/src/vs/workbench/api/test/browser/extHostTypeConverter.test.ts @@ -90,7 +90,7 @@ suite('ExtHostTypeConverter', function () { const d = new extHostTypes.NotebookData([]); d.cells.push(new extHostTypes.NotebookCellData(extHostTypes.NotebookCellKind.Code, 'hello', 'fooLang')); - d.metadata = { custom: { foo: 'bar', bar: 123 } }; + d.metadata = { foo: 'bar', bar: 123 }; const dto = NotebookData.from(d); diff --git a/src/vs/workbench/api/test/browser/extHostWorkspace.test.ts b/src/vs/workbench/api/test/browser/extHostWorkspace.test.ts index da33adc7de709..6abca46d3ff11 100644 --- a/src/vs/workbench/api/test/browser/extHostWorkspace.test.ts +++ b/src/vs/workbench/api/test/browser/extHostWorkspace.test.ts @@ -18,7 +18,7 @@ import { mock } from 'vs/base/test/common/mock'; import { TestRPCProtocol } from 'vs/workbench/api/test/common/testRPCProtocol'; import { ExtHostRpcService } from 'vs/workbench/api/common/extHostRpcService'; import { IExtHostInitDataService } from 'vs/workbench/api/common/extHostInitDataService'; -import { ITextQueryBuilderOptions } from 'vs/workbench/services/search/common/queryBuilder'; +import { IFileQueryBuilderOptions, ITextQueryBuilderOptions } from 'vs/workbench/services/search/common/queryBuilder'; import { IPatternInfo } from 'vs/workbench/services/search/common/search'; import { isLinux, isWindows } from 'vs/base/common/platform'; import { IExtHostFileSystemInfo } from 'vs/workbench/api/common/extHostFileSystemInfo'; @@ -583,12 +583,13 @@ suite('ExtHostWorkspace', function () { let mainThreadCalled = false; rpcProtocol.set(MainContext.MainThreadWorkspace, new class extends mock() { - override $startFileSearch(includePattern: string, _includeFolder: UriComponents | null, excludePatternOrDisregardExcludes: string | false, maxResults: number, token: CancellationToken): Promise { + override $startFileSearch(_includeFolder: UriComponents | null, options: IFileQueryBuilderOptions, token: CancellationToken): Promise { mainThreadCalled = true; - assert.strictEqual(includePattern, 'foo'); + assert.strictEqual(options.includePattern, 'foo'); assert.strictEqual(_includeFolder, null); - assert.strictEqual(excludePatternOrDisregardExcludes, null); - assert.strictEqual(maxResults, 10); + assert.strictEqual(options.excludePattern, ''); + assert.strictEqual(options.disregardExcludeSettings, false); + assert.strictEqual(options.maxResults, 10); return Promise.resolve(null); } }); @@ -605,11 +606,12 @@ suite('ExtHostWorkspace', function () { let mainThreadCalled = false; rpcProtocol.set(MainContext.MainThreadWorkspace, new class extends mock() { - override $startFileSearch(includePattern: string, _includeFolder: UriComponents | null, excludePatternOrDisregardExcludes: string | false, maxResults: number, token: CancellationToken): Promise { + override $startFileSearch(_includeFolder: UriComponents | null, options: IFileQueryBuilderOptions, token: CancellationToken): Promise { mainThreadCalled = true; - assert.strictEqual(includePattern, 'glob/**'); + assert.strictEqual(options.includePattern, 'glob/**'); assert.deepStrictEqual(_includeFolder ? URI.from(_includeFolder).toJSON() : null, URI.file('/other/folder').toJSON()); - assert.strictEqual(excludePatternOrDisregardExcludes, null); + assert.strictEqual(options.excludePattern, ''); + assert.strictEqual(options.disregardExcludeSettings, false); return Promise.resolve(null); } }); @@ -634,11 +636,12 @@ suite('ExtHostWorkspace', function () { let mainThreadCalled = false; rpcProtocol.set(MainContext.MainThreadWorkspace, new class extends mock() { - override $startFileSearch(includePattern: string, _includeFolder: UriComponents | null, excludePatternOrDisregardExcludes: string | false, maxResults: number, token: CancellationToken): Promise { + override $startFileSearch(_includeFolder: UriComponents | null, options: IFileQueryBuilderOptions, token: CancellationToken): Promise { mainThreadCalled = true; - assert.strictEqual(includePattern, 'glob/**'); + assert.strictEqual(options.includePattern, 'glob/**'); assert.deepStrictEqual(URI.revive(_includeFolder!).toString(), URI.file('/other/folder').toString()); - assert.strictEqual(excludePatternOrDisregardExcludes, false); + assert.strictEqual(options.excludePattern, ''); + assert.strictEqual(options.disregardExcludeSettings, true); return Promise.resolve(null); } }); @@ -655,7 +658,7 @@ suite('ExtHostWorkspace', function () { let mainThreadCalled = false; rpcProtocol.set(MainContext.MainThreadWorkspace, new class extends mock() { - override $startFileSearch(includePattern: string, _includeFolder: UriComponents | null, excludePatternOrDisregardExcludes: string | false, maxResults: number, token: CancellationToken): Promise { + override $startFileSearch(_includeFolder: UriComponents | null, options: IFileQueryBuilderOptions, token: CancellationToken): Promise { mainThreadCalled = true; return Promise.resolve(null); } @@ -675,9 +678,10 @@ suite('ExtHostWorkspace', function () { let mainThreadCalled = false; rpcProtocol.set(MainContext.MainThreadWorkspace, new class extends mock() { - override $startFileSearch(includePattern: string, _includeFolder: UriComponents | null, excludePatternOrDisregardExcludes: string | false, maxResults: number, token: CancellationToken): Promise { + override $startFileSearch(_includeFolder: UriComponents | null, options: IFileQueryBuilderOptions, token: CancellationToken): Promise { mainThreadCalled = true; - assert(excludePatternOrDisregardExcludes, 'glob/**'); // Note that the base portion is ignored, see #52651 + assert.strictEqual(options.disregardExcludeSettings, false); + assert.strictEqual(options.excludePattern, 'glob/**'); // Note that the base portion is ignored, see #52651 return Promise.resolve(null); } }); @@ -687,6 +691,163 @@ suite('ExtHostWorkspace', function () { assert(mainThreadCalled, 'mainThreadCalled'); }); }); + test('findFiles2 - string include', () => { + const root = '/project/foo'; + const rpcProtocol = new TestRPCProtocol(); + + let mainThreadCalled = false; + rpcProtocol.set(MainContext.MainThreadWorkspace, new class extends mock() { + override $startFileSearch(_includeFolder: UriComponents | null, options: IFileQueryBuilderOptions, token: CancellationToken): Promise { + mainThreadCalled = true; + assert.strictEqual(options.filePattern, 'foo'); + assert.strictEqual(options.includePattern, undefined); + assert.strictEqual(_includeFolder, null); + assert.strictEqual(options.excludePattern, undefined); + assert.strictEqual(options.disregardExcludeSettings, false); + assert.strictEqual(options.maxResults, 10); + return Promise.resolve(null); + } + }); + + const ws = createExtHostWorkspace(rpcProtocol, { id: 'foo', folders: [aWorkspaceFolderData(URI.file(root), 0)], name: 'Test' }, new NullLogService()); + return ws.findFiles2('foo', { maxResults: 10, useDefaultExcludes: true }, new ExtensionIdentifier('test')).then(() => { + assert(mainThreadCalled, 'mainThreadCalled'); + }); + }); + + function testFindFiles2Include(pattern: RelativePattern) { + const root = '/project/foo'; + const rpcProtocol = new TestRPCProtocol(); + + let mainThreadCalled = false; + rpcProtocol.set(MainContext.MainThreadWorkspace, new class extends mock() { + override $startFileSearch(_includeFolder: UriComponents | null, options: IFileQueryBuilderOptions, token: CancellationToken): Promise { + mainThreadCalled = true; + assert.strictEqual(options.filePattern, 'glob/**'); + assert.strictEqual(options.includePattern, undefined); + assert.deepStrictEqual(_includeFolder ? URI.from(_includeFolder).toJSON() : null, URI.file('/other/folder').toJSON()); + assert.strictEqual(options.excludePattern, undefined); + assert.strictEqual(options.disregardExcludeSettings, false); + return Promise.resolve(null); + } + }); + + const ws = createExtHostWorkspace(rpcProtocol, { id: 'foo', folders: [aWorkspaceFolderData(URI.file(root), 0)], name: 'Test' }, new NullLogService()); + return ws.findFiles2(pattern, { maxResults: 10 }, new ExtensionIdentifier('test')).then(() => { + assert(mainThreadCalled, 'mainThreadCalled'); + }); + } + + test('findFiles2 - RelativePattern include (string)', () => { + return testFindFiles2Include(new RelativePattern('/other/folder', 'glob/**')); + }); + + test('findFiles2 - RelativePattern include (URI)', () => { + return testFindFiles2Include(new RelativePattern(URI.file('/other/folder'), 'glob/**')); + }); + + test('findFiles2 - no excludes', () => { + const root = '/project/foo'; + const rpcProtocol = new TestRPCProtocol(); + + let mainThreadCalled = false; + rpcProtocol.set(MainContext.MainThreadWorkspace, new class extends mock() { + override $startFileSearch(_includeFolder: UriComponents | null, options: IFileQueryBuilderOptions, token: CancellationToken): Promise { + mainThreadCalled = true; + assert.strictEqual(options.filePattern, 'glob/**'); + assert.strictEqual(options.includePattern, undefined); + assert.deepStrictEqual(URI.revive(_includeFolder!).toString(), URI.file('/other/folder').toString()); + assert.strictEqual(options.excludePattern, undefined); + assert.strictEqual(options.disregardExcludeSettings, false); + return Promise.resolve(null); + } + }); + + const ws = createExtHostWorkspace(rpcProtocol, { id: 'foo', folders: [aWorkspaceFolderData(URI.file(root), 0)], name: 'Test' }, new NullLogService()); + return ws.findFiles2(new RelativePattern('/other/folder', 'glob/**'), {}, new ExtensionIdentifier('test')).then(() => { + assert(mainThreadCalled, 'mainThreadCalled'); + }); + }); + + test('findFiles2 - with cancelled token', () => { + const root = '/project/foo'; + const rpcProtocol = new TestRPCProtocol(); + + let mainThreadCalled = false; + rpcProtocol.set(MainContext.MainThreadWorkspace, new class extends mock() { + override $startFileSearch(_includeFolder: UriComponents | null, options: IFileQueryBuilderOptions, token: CancellationToken): Promise { + mainThreadCalled = true; + return Promise.resolve(null); + } + }); + + const ws = createExtHostWorkspace(rpcProtocol, { id: 'foo', folders: [aWorkspaceFolderData(URI.file(root), 0)], name: 'Test' }, new NullLogService()); + + const token = CancellationToken.Cancelled; + return ws.findFiles2(new RelativePattern('/other/folder', 'glob/**'), {}, new ExtensionIdentifier('test'), token).then(() => { + assert(!mainThreadCalled, '!mainThreadCalled'); + }); + }); + + test('findFiles2 - RelativePattern exclude', () => { + const root = '/project/foo'; + const rpcProtocol = new TestRPCProtocol(); + + let mainThreadCalled = false; + rpcProtocol.set(MainContext.MainThreadWorkspace, new class extends mock() { + override $startFileSearch(_includeFolder: UriComponents | null, options: IFileQueryBuilderOptions, token: CancellationToken): Promise { + mainThreadCalled = true; + assert.strictEqual(options.disregardExcludeSettings, false); + assert.strictEqual(options.excludePattern, 'glob/**'); // Note that the base portion is ignored, see #52651 + return Promise.resolve(null); + } + }); + + const ws = createExtHostWorkspace(rpcProtocol, { id: 'foo', folders: [aWorkspaceFolderData(URI.file(root), 0)], name: 'Test' }, new NullLogService()); + return ws.findFiles2('', { exclude: new RelativePattern(root, 'glob/**') }, new ExtensionIdentifier('test')).then(() => { + assert(mainThreadCalled, 'mainThreadCalled'); + }); + }); + test('findFiles2 - useIgnoreFiles', () => { + const root = '/project/foo'; + const rpcProtocol = new TestRPCProtocol(); + + let mainThreadCalled = false; + rpcProtocol.set(MainContext.MainThreadWorkspace, new class extends mock() { + override $startFileSearch(_includeFolder: UriComponents | null, options: IFileQueryBuilderOptions, token: CancellationToken): Promise { + mainThreadCalled = true; + assert.strictEqual(options.disregardExcludeSettings, false); + assert.strictEqual(options.disregardIgnoreFiles, false); + assert.strictEqual(options.disregardGlobalIgnoreFiles, false); + assert.strictEqual(options.disregardParentIgnoreFiles, false); + return Promise.resolve(null); + } + }); + + const ws = createExtHostWorkspace(rpcProtocol, { id: 'foo', folders: [aWorkspaceFolderData(URI.file(root), 0)], name: 'Test' }, new NullLogService()); + return ws.findFiles2('', { useIgnoreFiles: true, useParentIgnoreFiles: true, useGlobalIgnoreFiles: true }, new ExtensionIdentifier('test')).then(() => { + assert(mainThreadCalled, 'mainThreadCalled'); + }); + }); + + test('findFiles2 - use symlinks', () => { + const root = '/project/foo'; + const rpcProtocol = new TestRPCProtocol(); + + let mainThreadCalled = false; + rpcProtocol.set(MainContext.MainThreadWorkspace, new class extends mock() { + override $startFileSearch(_includeFolder: UriComponents | null, options: IFileQueryBuilderOptions, token: CancellationToken): Promise { + mainThreadCalled = true; + assert.strictEqual(options.ignoreSymlinks, false); + return Promise.resolve(null); + } + }); + + const ws = createExtHostWorkspace(rpcProtocol, { id: 'foo', folders: [aWorkspaceFolderData(URI.file(root), 0)], name: 'Test' }, new NullLogService()); + return ws.findFiles2('', { followSymlinks: true }, new ExtensionIdentifier('test')).then(() => { + assert(mainThreadCalled, 'mainThreadCalled'); + }); + }); test('findTextInFiles - no include', async () => { const root = '/project/foo'; diff --git a/src/vs/workbench/api/test/browser/mainThreadEditors.test.ts b/src/vs/workbench/api/test/browser/mainThreadEditors.test.ts index 78d484a667db8..9809814a6c032 100644 --- a/src/vs/workbench/api/test/browser/mainThreadEditors.test.ts +++ b/src/vs/workbench/api/test/browser/mainThreadEditors.test.ts @@ -48,6 +48,7 @@ import { BulkEditService } from 'vs/workbench/contrib/bulkEdit/browser/bulkEditS import { IEditorGroupsService } from 'vs/workbench/services/editor/common/editorGroupsService'; import { IEditorService } from 'vs/workbench/services/editor/common/editorService'; import { IWorkbenchEnvironmentService } from 'vs/workbench/services/environment/common/environmentService'; +import { SerializableObjectWithBuffers } from 'vs/workbench/services/extensions/common/proxyIdentifier'; import { LabelService } from 'vs/workbench/services/label/common/labelService'; import { ILifecycleService } from 'vs/workbench/services/lifecycle/common/lifecycle'; import { IPaneCompositePartService } from 'vs/workbench/services/panecomposite/browser/panecomposite'; @@ -204,7 +205,7 @@ suite('MainThreadEditors', () => { // Act as if the user edited the model model.applyEdits([EditOperation.insert(new Position(0, 0), 'something')]); - return bulkEdits.$tryApplyWorkspaceEdit({ edits: [workspaceResourceEdit] }).then((result) => { + return bulkEdits.$tryApplyWorkspaceEdit(new SerializableObjectWithBuffers({ edits: [workspaceResourceEdit] })).then((result) => { assert.strictEqual(result, false); }); }); @@ -230,11 +231,11 @@ suite('MainThreadEditors', () => { } }; - const p1 = bulkEdits.$tryApplyWorkspaceEdit({ edits: [workspaceResourceEdit1] }).then((result) => { + const p1 = bulkEdits.$tryApplyWorkspaceEdit(new SerializableObjectWithBuffers({ edits: [workspaceResourceEdit1] })).then((result) => { // first edit request succeeds assert.strictEqual(result, true); }); - const p2 = bulkEdits.$tryApplyWorkspaceEdit({ edits: [workspaceResourceEdit2] }).then((result) => { + const p2 = bulkEdits.$tryApplyWorkspaceEdit(new SerializableObjectWithBuffers({ edits: [workspaceResourceEdit2] })).then((result) => { // second edit request fails assert.strictEqual(result, false); }); @@ -242,13 +243,13 @@ suite('MainThreadEditors', () => { }); test(`applyWorkspaceEdit with only resource edit`, () => { - return bulkEdits.$tryApplyWorkspaceEdit({ + return bulkEdits.$tryApplyWorkspaceEdit(new SerializableObjectWithBuffers({ edits: [ { oldResource: resource, newResource: resource, options: undefined }, { oldResource: undefined, newResource: resource, options: undefined }, { oldResource: resource, newResource: undefined, options: undefined } ] - }).then((result) => { + })).then((result) => { assert.strictEqual(result, true); assert.strictEqual(movedResources.get(resource), resource); assert.strictEqual(createdResources.has(resource), true); diff --git a/src/vs/workbench/api/test/browser/mainThreadWorkspace.test.ts b/src/vs/workbench/api/test/browser/mainThreadWorkspace.test.ts index 7000d9edb5b4e..5234d7abb3427 100644 --- a/src/vs/workbench/api/test/browser/mainThreadWorkspace.test.ts +++ b/src/vs/workbench/api/test/browser/mainThreadWorkspace.test.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import * as assert from 'assert'; -import { CancellationTokenSource } from 'vs/base/common/cancellation'; +import { CancellationToken } from 'vs/base/common/cancellation'; import { ensureNoDisposablesAreLeakedInTestSuite } from 'vs/base/test/common/utils'; import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; import { TestConfigurationService } from 'vs/platform/configuration/test/common/testConfigurationService'; @@ -41,7 +41,7 @@ suite('MainThreadWorkspace', () => { }); const mtw = disposables.add(instantiationService.createInstance(MainThreadWorkspace, SingleProxyRPCProtocol({ $initializeWorkspace: () => { } }))); - return mtw.$startFileSearch('foo', null, null, 10, new CancellationTokenSource().token); + return mtw.$startFileSearch(null, { maxResults: 10, includePattern: 'foo', disregardSearchExcludeSettings: true }, CancellationToken.None); }); test('exclude defaults', () => { @@ -63,7 +63,7 @@ suite('MainThreadWorkspace', () => { }); const mtw = disposables.add(instantiationService.createInstance(MainThreadWorkspace, SingleProxyRPCProtocol({ $initializeWorkspace: () => { } }))); - return mtw.$startFileSearch('', null, null, 10, new CancellationTokenSource().token); + return mtw.$startFileSearch(null, { maxResults: 10, includePattern: '', disregardSearchExcludeSettings: true }, CancellationToken.None); }); test('disregard excludes', () => { @@ -76,7 +76,7 @@ suite('MainThreadWorkspace', () => { instantiationService.stub(ISearchService, { fileSearch(query: IFileQuery) { - assert.strictEqual(query.folderQueries[0].excludePattern, undefined); + assert.deepStrictEqual(query.folderQueries[0].excludePattern, undefined); assert.deepStrictEqual(query.excludePattern, undefined); return Promise.resolve({ results: [], messages: [] }); @@ -84,7 +84,29 @@ suite('MainThreadWorkspace', () => { }); const mtw = disposables.add(instantiationService.createInstance(MainThreadWorkspace, SingleProxyRPCProtocol({ $initializeWorkspace: () => { } }))); - return mtw.$startFileSearch('', null, false, 10, new CancellationTokenSource().token); + return mtw.$startFileSearch(null, { maxResults: 10, includePattern: '', disregardSearchExcludeSettings: true, disregardExcludeSettings: true }, CancellationToken.None); + }); + + test('do not disregard anything if disregardExcludeSettings is true', () => { + configService.setUserConfiguration('search', { + 'exclude': { 'searchExclude': true } + }); + configService.setUserConfiguration('files', { + 'exclude': { 'filesExclude': true } + }); + + instantiationService.stub(ISearchService, { + fileSearch(query: IFileQuery) { + assert.strictEqual(query.folderQueries.length, 1); + assert.strictEqual(query.folderQueries[0].disregardIgnoreFiles, true); + assert.deepStrictEqual(query.folderQueries[0].excludePattern, undefined); + + return Promise.resolve({ results: [], messages: [] }); + } + }); + + const mtw = disposables.add(instantiationService.createInstance(MainThreadWorkspace, SingleProxyRPCProtocol({ $initializeWorkspace: () => { } }))); + return mtw.$startFileSearch(null, { maxResults: 10, includePattern: '', disregardExcludeSettings: true, disregardSearchExcludeSettings: false }, CancellationToken.None); }); test('exclude string', () => { @@ -98,6 +120,6 @@ suite('MainThreadWorkspace', () => { }); const mtw = disposables.add(instantiationService.createInstance(MainThreadWorkspace, SingleProxyRPCProtocol({ $initializeWorkspace: () => { } }))); - return mtw.$startFileSearch('', null, 'exclude/**', 10, new CancellationTokenSource().token); + return mtw.$startFileSearch(null, { maxResults: 10, includePattern: '', excludePattern: 'exclude/**', disregardSearchExcludeSettings: true }, CancellationToken.None); }); }); diff --git a/src/vs/workbench/api/test/node/extHostSearch.test.ts b/src/vs/workbench/api/test/node/extHostSearch.test.ts index 1d903c4081517..1502ef3f565ae 100644 --- a/src/vs/workbench/api/test/node/extHostSearch.test.ts +++ b/src/vs/workbench/api/test/node/extHostSearch.test.ts @@ -43,6 +43,10 @@ class MockMainThreadSearch implements MainThreadSearchShape { this.lastHandle = handle; } + $registerAITextSearchProvider(handle: number, scheme: string): void { + this.lastHandle = handle; + } + $unregisterProvider(handle: number): void { } diff --git a/src/vs/workbench/browser/actions.ts b/src/vs/workbench/browser/actions.ts index 71fea8cc6d3b1..1e166016ed363 100644 --- a/src/vs/workbench/browser/actions.ts +++ b/src/vs/workbench/browser/actions.ts @@ -23,7 +23,7 @@ class MenuActions extends Disposable { private readonly _onDidChange = this._register(new Emitter()); readonly onDidChange = this._onDidChange.event; - private disposables = this._register(new DisposableStore()); + private readonly disposables = this._register(new DisposableStore()); constructor( menuId: MenuId, diff --git a/src/vs/workbench/browser/actions/layoutActions.ts b/src/vs/workbench/browser/actions/layoutActions.ts index 765693c16c8a0..77574a6338d79 100644 --- a/src/vs/workbench/browser/actions/layoutActions.ts +++ b/src/vs/workbench/browser/actions/layoutActions.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { localize, localize2 } from 'vs/nls'; +import { ILocalizedString, localize, localize2 } from 'vs/nls'; import { MenuId, MenuRegistry, registerAction2, Action2 } from 'vs/platform/actions/common/actions'; import { Categories } from 'vs/platform/action/common/actionCommonCategories'; import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; @@ -447,12 +447,13 @@ registerAction2(ToggleStatusbarVisibilityAction); abstract class AbstractSetShowTabsAction extends Action2 { - constructor(private readonly settingName: string, private readonly value: string, title: ICommandActionTitle, id: string, precondition: ContextKeyExpression) { + constructor(private readonly settingName: string, private readonly value: string, title: ICommandActionTitle, id: string, precondition: ContextKeyExpression, description: string | ILocalizedString | undefined) { super({ id, title, category: Categories.View, precondition, + metadata: description ? { description } : undefined, f1: true }); } @@ -472,7 +473,7 @@ export class HideEditorTabsAction extends AbstractSetShowTabsAction { constructor() { const precondition = ContextKeyExpr.and(ContextKeyExpr.equals(`config.${LayoutSettings.EDITOR_TABS_MODE}`, EditorTabsMode.NONE).negate(), InEditorZenModeContext.negate())!; const title = localize2('hideEditorTabs', 'Hide Editor Tabs'); - super(LayoutSettings.EDITOR_TABS_MODE, EditorTabsMode.NONE, title, HideEditorTabsAction.ID, precondition); + super(LayoutSettings.EDITOR_TABS_MODE, EditorTabsMode.NONE, title, HideEditorTabsAction.ID, precondition, localize2('hideEditorTabsDescription', "Hide Tab Bar")); } } @@ -483,7 +484,7 @@ export class ZenHideEditorTabsAction extends AbstractSetShowTabsAction { constructor() { const precondition = ContextKeyExpr.and(ContextKeyExpr.equals(`config.${ZenModeSettings.SHOW_TABS}`, EditorTabsMode.NONE).negate(), InEditorZenModeContext)!; const title = localize2('hideEditorTabsZenMode', 'Hide Editor Tabs in Zen Mode'); - super(ZenModeSettings.SHOW_TABS, EditorTabsMode.NONE, title, ZenHideEditorTabsAction.ID, precondition); + super(ZenModeSettings.SHOW_TABS, EditorTabsMode.NONE, title, ZenHideEditorTabsAction.ID, precondition, localize2('hideEditorTabsZenModeDescription', "Hide Tab Bar in Zen Mode")); } } @@ -497,7 +498,7 @@ export class ShowMultipleEditorTabsAction extends AbstractSetShowTabsAction { const precondition = ContextKeyExpr.and(ContextKeyExpr.equals(`config.${LayoutSettings.EDITOR_TABS_MODE}`, EditorTabsMode.MULTIPLE).negate(), InEditorZenModeContext.negate())!; const title = localize2('showMultipleEditorTabs', 'Show Multiple Editor Tabs'); - super(LayoutSettings.EDITOR_TABS_MODE, EditorTabsMode.MULTIPLE, title, ShowMultipleEditorTabsAction.ID, precondition); + super(LayoutSettings.EDITOR_TABS_MODE, EditorTabsMode.MULTIPLE, title, ShowMultipleEditorTabsAction.ID, precondition, localize2('showMultipleEditorTabsDescription', "Show Tab Bar with multiple tabs")); } } @@ -509,7 +510,7 @@ export class ZenShowMultipleEditorTabsAction extends AbstractSetShowTabsAction { const precondition = ContextKeyExpr.and(ContextKeyExpr.equals(`config.${ZenModeSettings.SHOW_TABS}`, EditorTabsMode.MULTIPLE).negate(), InEditorZenModeContext)!; const title = localize2('showMultipleEditorTabsZenMode', 'Show Multiple Editor Tabs in Zen Mode'); - super(ZenModeSettings.SHOW_TABS, EditorTabsMode.MULTIPLE, title, ZenShowMultipleEditorTabsAction.ID, precondition); + super(ZenModeSettings.SHOW_TABS, EditorTabsMode.MULTIPLE, title, ZenShowMultipleEditorTabsAction.ID, precondition, localize2('showMultipleEditorTabsZenModeDescription', "Show Tab Bar in Zen Mode")); } } @@ -523,7 +524,7 @@ export class ShowSingleEditorTabAction extends AbstractSetShowTabsAction { const precondition = ContextKeyExpr.and(ContextKeyExpr.equals(`config.${LayoutSettings.EDITOR_TABS_MODE}`, EditorTabsMode.SINGLE).negate(), InEditorZenModeContext.negate())!; const title = localize2('showSingleEditorTab', 'Show Single Editor Tab'); - super(LayoutSettings.EDITOR_TABS_MODE, EditorTabsMode.SINGLE, title, ShowSingleEditorTabAction.ID, precondition); + super(LayoutSettings.EDITOR_TABS_MODE, EditorTabsMode.SINGLE, title, ShowSingleEditorTabAction.ID, precondition, localize2('showSingleEditorTabDescription', "Show Tab Bar with one Tab")); } } @@ -535,7 +536,7 @@ export class ZenShowSingleEditorTabAction extends AbstractSetShowTabsAction { const precondition = ContextKeyExpr.and(ContextKeyExpr.equals(`config.${ZenModeSettings.SHOW_TABS}`, EditorTabsMode.SINGLE).negate(), InEditorZenModeContext)!; const title = localize2('showSingleEditorTabZenMode', 'Show Single Editor Tab in Zen Mode'); - super(ZenModeSettings.SHOW_TABS, EditorTabsMode.SINGLE, title, ZenShowSingleEditorTabAction.ID, precondition); + super(ZenModeSettings.SHOW_TABS, EditorTabsMode.SINGLE, title, ZenShowSingleEditorTabAction.ID, precondition, localize2('showSingleEditorTabZenModeDescription', "Show Tab Bar in Zen Mode with one Tab")); } } @@ -576,6 +577,7 @@ export class EditorActionsTitleBarAction extends Action2 { title: localize2('moveEditorActionsToTitleBar', "Move Editor Actions to Title Bar"), category: Categories.View, precondition: ContextKeyExpr.equals(`config.${LayoutSettings.EDITOR_ACTIONS_LOCATION}`, EditorActionsLocation.TITLEBAR).negate(), + metadata: { description: localize2('moveEditorActionsToTitleBarDescription', "Move Editor Actions from the tab bar to the title bar") }, f1: true }); } @@ -602,6 +604,7 @@ export class EditorActionsDefaultAction extends Action2 { ContextKeyExpr.equals(`config.${LayoutSettings.EDITOR_ACTIONS_LOCATION}`, EditorActionsLocation.DEFAULT).negate(), ContextKeyExpr.equals(`config.${LayoutSettings.EDITOR_TABS_MODE}`, EditorTabsMode.NONE).negate(), ), + metadata: { description: localize2('moveEditorActionsToTabBarDescription', "Move Editor Actions from the title bar to the tab bar") }, f1: true }); } @@ -625,6 +628,7 @@ export class HideEditorActionsAction extends Action2 { title: localize2('hideEditorActons', "Hide Editor Actions"), category: Categories.View, precondition: ContextKeyExpr.equals(`config.${LayoutSettings.EDITOR_ACTIONS_LOCATION}`, EditorActionsLocation.HIDDEN).negate(), + metadata: { description: localize2('hideEditorActonsDescription', "Hide Editor Actions in the tab and title bar") }, f1: true }); } @@ -648,6 +652,7 @@ export class ShowEditorActionsAction extends Action2 { title: localize2('showEditorActons', "Show Editor Actions"), category: Categories.View, precondition: ContextKeyExpr.equals(`config.${LayoutSettings.EDITOR_ACTIONS_LOCATION}`, EditorActionsLocation.HIDDEN), + metadata: { description: localize2('showEditorActonsDescription', "Make Editor Actions visible.") }, f1: true }); } @@ -678,6 +683,7 @@ registerAction2(class extends Action2 { title: localize2('toggleSeparatePinnedEditorTabs', "Separate Pinned Editor Tabs"), category: Categories.View, precondition: ContextKeyExpr.equals(`config.${LayoutSettings.EDITOR_TABS_MODE}`, EditorTabsMode.MULTIPLE), + metadata: { description: localize2('toggleSeparatePinnedEditorTabsDescription', "Toggle whether pinned editor tabs are shown on a separate row above unpinned tabs.") }, f1: true }); } diff --git a/src/vs/workbench/browser/actions/listCommands.ts b/src/vs/workbench/browser/actions/listCommands.ts index dffef61a01569..41fce8a721b59 100644 --- a/src/vs/workbench/browser/actions/listCommands.ts +++ b/src/vs/workbench/browser/actions/listCommands.ts @@ -917,6 +917,7 @@ registerAction2(class ToggleStickyScroll extends Action2 { mnemonicTitle: localize({ key: 'mitoggleTreeStickyScroll', comment: ['&& denotes a mnemonic'] }, "&&Toggle Tree Sticky Scroll"), }, category: 'View', + metadata: { description: localize('toggleTreeStickyScrollDescription', "Toggles Sticky Scroll widget at the top of tree structures such as the File Explorer and Debug variables View.") }, f1: true }); } diff --git a/src/vs/workbench/browser/actions/widgetNavigationCommands.ts b/src/vs/workbench/browser/actions/widgetNavigationCommands.ts index bc2cb028dd295..1657bf72263ff 100644 --- a/src/vs/workbench/browser/actions/widgetNavigationCommands.ts +++ b/src/vs/workbench/browser/actions/widgetNavigationCommands.ts @@ -10,6 +10,8 @@ import { WorkbenchListFocusContextKey, WorkbenchListScrollAtBottomContextKey, Wo import { Event } from 'vs/base/common/event'; import { combinedDisposable, toDisposable, IDisposable, Disposable } from 'vs/base/common/lifecycle'; import { WorkbenchPhase, registerWorkbenchContribution2 } from 'vs/workbench/common/contributions'; +import { ILogService } from 'vs/platform/log/common/log'; +import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; /** INavigableContainer represents a logical container composed of widgets that can be navigated back and forth with key shortcuts */ @@ -25,6 +27,7 @@ interface INavigableContainer { * focused, and blurred if all parts being blurred. */ readonly focusNotifiers: readonly IFocusNotifier[]; + readonly name?: string; // for debugging focusPreviousWidget(): void; focusNextWidget(): void; } @@ -34,16 +37,18 @@ interface IFocusNotifier { readonly onDidBlur: Event; } -function handleFocusEventsGroup(group: readonly IFocusNotifier[], handler: (isFocus: boolean) => void): IDisposable { +function handleFocusEventsGroup(group: readonly IFocusNotifier[], handler: (isFocus: boolean) => void, onPartFocusChange?: (index: number, state: string) => void): IDisposable { const focusedIndices = new Set(); return combinedDisposable(...group.map((events, index) => combinedDisposable( events.onDidFocus(() => { + onPartFocusChange?.(index, 'focus'); if (!focusedIndices.size) { handler(true); } focusedIndices.add(index); }), events.onDidBlur(() => { + onPartFocusChange?.(index, 'blur'); focusedIndices.delete(index); if (!focusedIndices.size) { handler(false); @@ -65,7 +70,10 @@ class NavigableContainerManager implements IDisposable { private focused: IContextKey; - constructor(@IContextKeyService contextKeyService: IContextKeyService) { + constructor( + @IContextKeyService contextKeyService: IContextKeyService, + @ILogService private logService: ILogService, + @IConfigurationService private configurationService: IConfigurationService) { this.focused = NavigableContainerFocusedContextKey.bindTo(contextKeyService); NavigableContainerManager.INSTANCE = this; } @@ -76,25 +84,43 @@ class NavigableContainerManager implements IDisposable { NavigableContainerManager.INSTANCE = undefined; } + private get debugEnabled(): boolean { + return this.configurationService.getValue('workbench.navigibleContainer.enableDebug'); + } + + private log(msg: string, ...args: any[]): void { + if (this.debugEnabled) { + this.logService.debug(msg, ...args); + } + } + static register(container: INavigableContainer): IDisposable { const instance = this.INSTANCE; if (!instance) { return Disposable.None; } instance.containers.add(container); + instance.log('NavigableContainerManager.register', container.name); return combinedDisposable( handleFocusEventsGroup(container.focusNotifiers, (isFocus) => { if (isFocus) { + instance.log('NavigableContainerManager.focus', container.name); instance.focused.set(true); instance.lastContainer = container; - } else if (instance.lastContainer === container) { - instance.focused.set(false); - instance.lastContainer = undefined; + } else { + instance.log('NavigableContainerManager.blur', container.name, instance.lastContainer?.name); + if (instance.lastContainer === container) { + instance.focused.set(false); + instance.lastContainer = undefined; + } } + }, (index: number, event: string) => { + instance.log('NavigableContainerManager.partFocusChange', container.name, index, event); }), toDisposable(() => { instance.containers.delete(container); + instance.log('NavigableContainerManager.unregister', container.name, instance.lastContainer?.name); if (instance.lastContainer === container) { instance.focused.set(false); instance.lastContainer = undefined; diff --git a/src/vs/workbench/browser/codeeditor.ts b/src/vs/workbench/browser/codeeditor.ts index 33358b10156d5..dcce3224ba991 100644 --- a/src/vs/workbench/browser/codeeditor.ts +++ b/src/vs/workbench/browser/codeeditor.ts @@ -9,7 +9,7 @@ import { Disposable, DisposableStore } from 'vs/base/common/lifecycle'; import { isEqual } from 'vs/base/common/resources'; import { URI } from 'vs/base/common/uri'; import { ICodeEditor, IOverlayWidget, IOverlayWidgetPosition, OverlayWidgetPositionPreference, isCodeEditor, isCompositeEditor } from 'vs/editor/browser/editorBrowser'; -import { EmbeddedCodeEditorWidget } from 'vs/editor/browser/widget/embeddedCodeEditorWidget'; +import { EmbeddedCodeEditorWidget } from 'vs/editor/browser/widget/codeEditor/embeddedCodeEditorWidget'; import { EditorOption } from 'vs/editor/common/config/editorOptions'; import { IRange } from 'vs/editor/common/core/range'; import { CursorChangeReason, ICursorPositionChangedEvent } from 'vs/editor/common/cursorEvents'; diff --git a/src/vs/workbench/browser/composite.ts b/src/vs/workbench/browser/composite.ts index ab28dbb003b30..d56a31212ed71 100644 --- a/src/vs/workbench/browser/composite.ts +++ b/src/vs/workbench/browser/composite.ts @@ -10,13 +10,14 @@ import { IComposite, ICompositeControl } from 'vs/workbench/common/composite'; import { Event, Emitter } from 'vs/base/common/event'; import { IThemeService } from 'vs/platform/theme/common/themeService'; import { IConstructorSignature, IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; -import { trackFocus, Dimension, IDomPosition, focusWindow } from 'vs/base/browser/dom'; +import { trackFocus, Dimension, IDomPosition } from 'vs/base/browser/dom'; import { IStorageService } from 'vs/platform/storage/common/storage'; import { Disposable } from 'vs/base/common/lifecycle'; import { assertIsDefined } from 'vs/base/common/types'; import { IActionViewItem } from 'vs/base/browser/ui/actionbar/actionbar'; import { MenuId } from 'vs/platform/actions/common/actions'; import { IBoundarySashes } from 'vs/base/browser/ui/sash/sash'; +import { IBaseActionViewItemOptions } from 'vs/base/browser/ui/actionbar/actionViewItems'; /** * Composites are layed out in the sidebar and panel part of the workbench. At a time only one composite @@ -35,7 +36,7 @@ export abstract class Composite extends Component implements IComposite { private readonly _onTitleAreaUpdate = this._register(new Emitter()); readonly onTitleAreaUpdate = this._onTitleAreaUpdate.event; - private _onDidFocus: Emitter | undefined; + protected _onDidFocus: Emitter | undefined; get onDidFocus(): Event { if (!this._onDidFocus) { this._onDidFocus = this.registerFocusTrackEvents().onDidFocus; @@ -44,10 +45,6 @@ export abstract class Composite extends Component implements IComposite { return this._onDidFocus.event; } - protected fireOnDidFocus(): void { - this._onDidFocus?.fire(); - } - private _onDidBlur: Emitter | undefined; get onDidBlur(): Event { if (!this._onDidBlur) { @@ -85,22 +82,16 @@ export abstract class Composite extends Component implements IComposite { protected actionRunner: IActionRunner | undefined; - private _telemetryService: ITelemetryService; - protected get telemetryService(): ITelemetryService { return this._telemetryService; } - - private visible: boolean; + private visible = false; private parent: HTMLElement | undefined; constructor( id: string, - telemetryService: ITelemetryService, + protected readonly telemetryService: ITelemetryService, themeService: IThemeService, storageService: IStorageService ) { super(id, themeService, storageService); - - this._telemetryService = telemetryService; - this.visible = false; } getTitle(): string | undefined { @@ -148,13 +139,7 @@ export abstract class Composite extends Component implements IComposite { * Called when this composite should receive keyboard focus. */ focus(): void { - const container = this.getContainer(); - if (container) { - // Make sure to focus the window of the container - // because it is possible that the composite is - // opened in a auxiliary window that is not focused. - focusWindow(container); - } + // Subclasses can implement } /** @@ -204,7 +189,7 @@ export abstract class Composite extends Component implements IComposite { * of an action. Returns undefined to indicate that the action is not rendered through * an action item. */ - getActionViewItem(action: IAction): IActionViewItem | undefined { + getActionViewItem(action: IAction, options: IBaseActionViewItemOptions): IActionViewItem | undefined { return undefined; } diff --git a/src/vs/workbench/browser/contextkeys.ts b/src/vs/workbench/browser/contextkeys.ts index a1663743d762b..71ec7e272327a 100644 --- a/src/vs/workbench/browser/contextkeys.ts +++ b/src/vs/workbench/browser/contextkeys.ts @@ -7,7 +7,7 @@ import { Event } from 'vs/base/common/event'; import { Disposable } from 'vs/base/common/lifecycle'; import { IContextKeyService, IContextKey, setConstant as setConstantContextKey } from 'vs/platform/contextkey/common/contextkey'; import { InputFocusedContext, IsMacContext, IsLinuxContext, IsWindowsContext, IsWebContext, IsMacNativeContext, IsDevelopmentContext, IsIOSContext, ProductQualityContext, IsMobileContext } from 'vs/platform/contextkey/common/contextkeys'; -import { SplitEditorsVertically, InEditorZenModeContext, ActiveEditorCanRevertContext, ActiveEditorGroupLockedContext, ActiveEditorCanSplitInGroupContext, SideBySideEditorActiveContext, AuxiliaryBarVisibleContext, SideBarVisibleContext, PanelAlignmentContext, PanelMaximizedContext, PanelVisibleContext, ActiveEditorContext, EditorsVisibleContext, TextCompareEditorVisibleContext, TextCompareEditorActiveContext, ActiveEditorGroupEmptyContext, EmbedderIdentifierContext, EditorTabsVisibleContext, IsMainEditorCenteredLayoutContext, ActiveEditorGroupIndexContext, ActiveEditorGroupLastContext, ActiveEditorReadonlyContext, MainEditorAreaVisibleContext, ActiveEditorAvailableEditorIdsContext, DirtyWorkingCopiesContext, EmptyWorkspaceSupportContext, EnterMultiRootWorkspaceSupportContext, HasWebFileSystemAccess, IsMainWindowFullscreenContext, OpenFolderWorkspaceSupportContext, RemoteNameContext, VirtualWorkspaceContext, WorkbenchStateContext, WorkspaceFolderCountContext, PanelPositionContext, TemporaryWorkspaceContext, ActiveEditorCanToggleReadonlyContext, applyAvailableEditorIds, TitleBarVisibleContext, TitleBarStyleContext, MultipleEditorGroupsContext, IsAuxiliaryWindowFocusedContext, ActiveCompareEditorOriginalWriteableContext } from 'vs/workbench/common/contextkeys'; +import { SplitEditorsVertically, InEditorZenModeContext, ActiveEditorCanRevertContext, ActiveEditorGroupLockedContext, ActiveEditorCanSplitInGroupContext, SideBySideEditorActiveContext, AuxiliaryBarVisibleContext, SideBarVisibleContext, PanelAlignmentContext, PanelMaximizedContext, PanelVisibleContext, ActiveEditorContext, EditorsVisibleContext, TextCompareEditorVisibleContext, TextCompareEditorActiveContext, ActiveEditorGroupEmptyContext, EmbedderIdentifierContext, EditorTabsVisibleContext, IsMainEditorCenteredLayoutContext, ActiveEditorGroupIndexContext, ActiveEditorGroupLastContext, ActiveEditorReadonlyContext, MainEditorAreaVisibleContext, ActiveEditorAvailableEditorIdsContext, DirtyWorkingCopiesContext, EmptyWorkspaceSupportContext, EnterMultiRootWorkspaceSupportContext, HasWebFileSystemAccess, IsMainWindowFullscreenContext, OpenFolderWorkspaceSupportContext, RemoteNameContext, VirtualWorkspaceContext, WorkbenchStateContext, WorkspaceFolderCountContext, PanelPositionContext, TemporaryWorkspaceContext, ActiveEditorCanToggleReadonlyContext, applyAvailableEditorIds, TitleBarVisibleContext, TitleBarStyleContext, MultipleEditorGroupsContext, IsAuxiliaryWindowFocusedContext, ActiveCompareEditorCanSwapContext } from 'vs/workbench/common/contextkeys'; import { TEXT_DIFF_EDITOR_ID, EditorInputCapabilities, SIDE_BY_SIDE_EDITOR_ID, EditorResourceAccessor, SideBySideEditor } from 'vs/workbench/common/editor'; import { trackFocus, addDisposableListener, EventType, onDidRegisterWindow, getActiveWindow } from 'vs/base/browser/dom'; import { preferredSideBySideGroupDirection, GroupDirection, IEditorGroupsService } from 'vs/workbench/services/editor/common/editorGroupsService'; @@ -29,6 +29,7 @@ import { getTitleBarStyle } from 'vs/platform/window/common/window'; import { mainWindow } from 'vs/base/browser/window'; import { DiffEditorInput } from 'vs/workbench/common/editor/diffEditorInput'; import { isFullscreen, onDidChangeFullscreen } from 'vs/base/browser/browser'; +import { Schemas } from 'vs/base/common/network'; export class WorkbenchContextKeysHandler extends Disposable { private inputFocusedContext: IContextKey; @@ -41,7 +42,7 @@ export class WorkbenchContextKeysHandler extends Disposable { private activeEditorAvailableEditorIds: IContextKey; private activeEditorIsReadonly: IContextKey; - private activeCompareEditorOriginalWritable: IContextKey; + private activeCompareEditorCanSwap: IContextKey; private activeEditorCanToggleReadonly: IContextKey; private activeEditorGroupEmpty: IContextKey; @@ -130,7 +131,7 @@ export class WorkbenchContextKeysHandler extends Disposable { // Editors this.activeEditorContext = ActiveEditorContext.bindTo(this.contextKeyService); this.activeEditorIsReadonly = ActiveEditorReadonlyContext.bindTo(this.contextKeyService); - this.activeCompareEditorOriginalWritable = ActiveCompareEditorOriginalWriteableContext.bindTo(this.contextKeyService); + this.activeCompareEditorCanSwap = ActiveCompareEditorCanSwapContext.bindTo(this.contextKeyService); this.activeEditorCanToggleReadonly = ActiveEditorCanToggleReadonlyContext.bindTo(this.contextKeyService); this.activeEditorCanRevert = ActiveEditorCanRevertContext.bindTo(this.contextKeyService); this.activeEditorCanSplitInGroup = ActiveEditorCanSplitInGroupContext.bindTo(this.contextKeyService); @@ -318,13 +319,14 @@ export class WorkbenchContextKeysHandler extends Disposable { this.activeEditorCanSplitInGroup.set(activeEditorPane.input.hasCapability(EditorInputCapabilities.CanSplitInGroup)); applyAvailableEditorIds(this.activeEditorAvailableEditorIds, activeEditorPane.input, this.editorResolverService); this.activeEditorIsReadonly.set(!!activeEditorPane.input.isReadonly()); - this.activeCompareEditorOriginalWritable.set(activeEditorPane.input instanceof DiffEditorInput && !activeEditorPane.input.original.isReadonly()); const primaryEditorResource = EditorResourceAccessor.getOriginalUri(activeEditorPane.input, { supportSideBySide: SideBySideEditor.PRIMARY }); + const secondaryEditorResource = EditorResourceAccessor.getOriginalUri(activeEditorPane.input, { supportSideBySide: SideBySideEditor.SECONDARY }); + this.activeCompareEditorCanSwap.set(activeEditorPane.input instanceof DiffEditorInput && !activeEditorPane.input.original.isReadonly() && !!primaryEditorResource && (this.fileService.hasProvider(primaryEditorResource) || primaryEditorResource.scheme === Schemas.untitled) && !!secondaryEditorResource && (this.fileService.hasProvider(secondaryEditorResource) || secondaryEditorResource.scheme === Schemas.untitled)); this.activeEditorCanToggleReadonly.set(!!primaryEditorResource && this.fileService.hasProvider(primaryEditorResource) && !this.fileService.hasCapability(primaryEditorResource, FileSystemProviderCapabilities.Readonly)); } else { this.activeEditorContext.reset(); this.activeEditorIsReadonly.reset(); - this.activeCompareEditorOriginalWritable.reset(); + this.activeCompareEditorCanSwap.reset(); this.activeEditorCanToggleReadonly.reset(); this.activeEditorCanRevert.reset(); this.activeEditorCanSplitInGroup.reset(); diff --git a/src/vs/workbench/browser/editor.ts b/src/vs/workbench/browser/editor.ts index 7d0b333bece2e..d5e1a7f339152 100644 --- a/src/vs/workbench/browser/editor.ts +++ b/src/vs/workbench/browser/editor.ts @@ -59,23 +59,23 @@ export class EditorPaneDescriptor implements IEditorPaneDescriptor { static readonly onWillInstantiateEditorPane = EditorPaneDescriptor._onWillInstantiateEditorPane.event; static create( - ctor: { new(...services: Services): EditorPane }, + ctor: { new(group: IEditorGroup, ...services: Services): EditorPane }, typeId: string, name: string ): EditorPaneDescriptor { - return new EditorPaneDescriptor(ctor as IConstructorSignature, typeId, name); + return new EditorPaneDescriptor(ctor as IConstructorSignature, typeId, name); } private constructor( - private readonly ctor: IConstructorSignature, + private readonly ctor: IConstructorSignature, readonly typeId: string, readonly name: string ) { } - instantiate(instantiationService: IInstantiationService): EditorPane { + instantiate(instantiationService: IInstantiationService, group: IEditorGroup): EditorPane { EditorPaneDescriptor._onWillInstantiateEditorPane.fire({ typeId: this.typeId }); - const pane = instantiationService.createInstance(this.ctor); + const pane = instantiationService.createInstance(this.ctor, group); EditorPaneDescriptor.instantiatedEditorPanes.add(this.typeId); return pane; diff --git a/src/vs/workbench/browser/labels.ts b/src/vs/workbench/browser/labels.ts index 0bb8699861a56..f36a00e795d55 100644 --- a/src/vs/workbench/browser/labels.ts +++ b/src/vs/workbench/browser/labels.ts @@ -291,7 +291,7 @@ class ResourceLabelWidget extends IconLabel { readonly onDidRender = this._onDidRender.event; private label: IResourceLabelProps | undefined = undefined; - private decoration = this._register(new MutableDisposable()); + private readonly decoration = this._register(new MutableDisposable()); private options: IResourceLabelOptions | undefined = undefined; private computedIconClasses: string[] | undefined = undefined; @@ -574,7 +574,8 @@ class ResourceLabelWidget extends IconLabel { separator: this.options?.separator, domId: this.options?.domId, disabledCommand: this.options?.disabledCommand, - labelEscapeNewLines: this.options?.labelEscapeNewLines + labelEscapeNewLines: this.options?.labelEscapeNewLines, + descriptionTitle: this.options?.descriptionTitle, }; const resource = toResource(this.label); diff --git a/src/vs/workbench/browser/layout.ts b/src/vs/workbench/browser/layout.ts index a2b6007fb797c..94ec1087453c1 100644 --- a/src/vs/workbench/browser/layout.ts +++ b/src/vs/workbench/browser/layout.ts @@ -5,7 +5,7 @@ import { Disposable, DisposableMap, DisposableStore, IDisposable, toDisposable } from 'vs/base/common/lifecycle'; import { Event, Emitter } from 'vs/base/common/event'; -import { EventType, addDisposableListener, getClientArea, position, size, IDimension, isAncestorUsingFlowTo, computeScreenAwareSize, getActiveDocument, getWindows, getActiveWindow, focusWindow, isActiveDocument, getWindow, getWindowId, getActiveElement } from 'vs/base/browser/dom'; +import { EventType, addDisposableListener, getClientArea, position, size, IDimension, isAncestorUsingFlowTo, computeScreenAwareSize, getActiveDocument, getWindows, getActiveWindow, isActiveDocument, getWindow, getWindowId, getActiveElement } from 'vs/base/browser/dom'; import { onDidChangeFullscreen, isFullscreen, isWCOEnabled } from 'vs/base/browser/browser'; import { IWorkingCopyBackupService } from 'vs/workbench/services/workingCopy/common/workingCopyBackup'; import { isWindows, isLinux, isMacintosh, isWeb, isIOS } from 'vs/base/common/platform'; @@ -47,7 +47,7 @@ import { IPaneCompositePartService } from 'vs/workbench/services/panecomposite/b import { AuxiliaryBarPart } from 'vs/workbench/browser/parts/auxiliarybar/auxiliaryBarPart'; import { ITelemetryService } from 'vs/platform/telemetry/common/telemetry'; import { IAuxiliaryWindowService } from 'vs/workbench/services/auxiliaryWindow/browser/auxiliaryWindowService'; -import { mainWindow } from 'vs/base/browser/window'; +import { CodeWindow, mainWindow } from 'vs/base/browser/window'; import { CustomTitleBarVisibility } from '../../platform/window/common/window'; //#region Layout Implementation @@ -194,6 +194,11 @@ export abstract class Layout extends Disposable implements IWorkbenchLayoutServi } } + private readonly containerStylesLoaded = new Map>(); + whenContainerStylesLoaded(window: CodeWindow): Promise | undefined { + return this.containerStylesLoaded.get(window.vscodeWindowId); + } + private _mainContainerDimension!: IDimension; get mainContainerDimension(): IDimension { return this._mainContainerDimension; } @@ -219,11 +224,6 @@ export abstract class Layout extends Disposable implements IWorkbenchLayoutServi return this.computeContainerOffset(getWindow(this.activeContainer)); } - get whenActiveContainerStylesLoaded() { - const active = this.activeContainer; - return this.auxWindowStylesLoaded.get(active) || Promise.resolve(); - } - private computeContainerOffset(targetWindow: Window) { let top = 0; let quickPickTop = 0; @@ -252,7 +252,6 @@ export abstract class Layout extends Disposable implements IWorkbenchLayoutServi //#endregion private readonly parts = new Map(); - private readonly auxWindowStylesLoaded = new Map>(); private initialized = false; private workbenchGrid!: SerializableGrid; @@ -359,9 +358,15 @@ export abstract class Layout extends Disposable implements IWorkbenchLayoutServi LegacyWorkbenchLayoutSettings.STATUSBAR_VISIBLE, ].some(setting => e.affectsConfiguration(setting))) { // Show Custom TitleBar if actions moved to the titlebar - const activityBarMovedToTop = e.affectsConfiguration(LayoutSettings.ACTIVITY_BAR_LOCATION) && this.configurationService.getValue(LayoutSettings.ACTIVITY_BAR_LOCATION) === ActivityBarPosition.TOP; const editorActionsMovedToTitlebar = e.affectsConfiguration(LayoutSettings.EDITOR_ACTIONS_LOCATION) && this.configurationService.getValue(LayoutSettings.EDITOR_ACTIONS_LOCATION) === EditorActionsLocation.TITLEBAR; - if (activityBarMovedToTop || editorActionsMovedToTitlebar) { + + let activityBarMovedToTopOrBottom = false; + if (e.affectsConfiguration(LayoutSettings.ACTIVITY_BAR_LOCATION)) { + const activityBarPosition = this.configurationService.getValue(LayoutSettings.ACTIVITY_BAR_LOCATION); + activityBarMovedToTopOrBottom = activityBarPosition === ActivityBarPosition.TOP || activityBarPosition === ActivityBarPosition.BOTTOM; + } + + if (activityBarMovedToTopOrBottom || editorActionsMovedToTitlebar) { if (this.configurationService.getValue(TitleBarSetting.CUSTOM_TITLE_BAR_VISIBILITY) === CustomTitleBarVisibility.NEVER) { this.configurationService.updateValue(TitleBarSetting.CUSTOM_TITLE_BAR_VISIBILITY, CustomTitleBarVisibility.AUTO); } @@ -402,12 +407,15 @@ export abstract class Layout extends Disposable implements IWorkbenchLayoutServi // Auxiliary windows this._register(this.auxiliaryWindowService.onDidOpenAuxiliaryWindow(({ window, disposables }) => { + const windowId = window.window.vscodeWindowId; + this.containerStylesLoaded.set(windowId, window.whenStylesHaveLoaded); + window.whenStylesHaveLoaded.then(() => this.containerStylesLoaded.delete(windowId)); + disposables.add(toDisposable(() => this.containerStylesLoaded.delete(windowId))); + const eventDisposables = disposables.add(new DisposableStore()); - this.auxWindowStylesLoaded.set(window.container, window.whenStylesHaveLoaded); this._onDidAddContainer.fire({ container: window.container, disposables: eventDisposables }); disposables.add(window.onDidLayout(dimension => this.handleContainerDidLayout(window.container, dimension))); - disposables.add(toDisposable(() => this.auxWindowStylesLoaded.delete(window.container))); })); } @@ -671,7 +679,7 @@ export abstract class Layout extends Disposable implements IWorkbenchLayoutServi // Only restore last viewlet if window was reloaded or we are in development mode let viewContainerToRestore: string | undefined; - if (!this.environmentService.isBuilt || lifecycleService.startupKind === StartupKind.ReloadedWindow || isWeb) { + if (!this.environmentService.isBuilt || lifecycleService.startupKind === StartupKind.ReloadedWindow) { viewContainerToRestore = this.storageService.get(SidebarPart.activeViewletSettingsKey, StorageScope.WORKSPACE, this.viewDescriptorService.getDefaultViewContainer(ViewContainerLocation.Sidebar)?.id); } else { viewContainerToRestore = this.viewDescriptorService.getDefaultViewContainer(ViewContainerLocation.Sidebar)?.id; @@ -1089,8 +1097,11 @@ export abstract class Layout extends Disposable implements IWorkbenchLayoutServi }); } - registerPart(part: Part): void { - this.parts.set(part.getId(), part); + registerPart(part: Part): IDisposable { + const id = part.getId(); + this.parts.set(id, part); + + return toDisposable(() => this.parts.delete(id)); } protected getPart(key: Parts): Part { @@ -1124,9 +1135,6 @@ export abstract class Layout extends Disposable implements IWorkbenchLayoutServi focusPart(part: SINGLE_WINDOW_PARTS): void; focusPart(part: Parts, targetWindow: Window = mainWindow): void { const container = this.getContainer(targetWindow, part) ?? this.mainContainer; - if (container) { - focusWindow(container); - } switch (part) { case Parts.EDITOR_PART: @@ -1541,7 +1549,10 @@ export abstract class Layout extends Disposable implements IWorkbenchLayoutServi layout(): void { if (!this.disposed) { - this._mainContainerDimension = getClientArea(this.parent); + this._mainContainerDimension = getClientArea(this.state.runtime.mainWindowFullscreen ? + mainWindow.document.body : // in fullscreen mode, make sure to use element because + this.parent // in that case the workbench will span the entire site + ); this.logService.trace(`Layout#layout, height: ${this._mainContainerDimension.height}, width: ${this._mainContainerDimension.width}`); position(this.mainContainer, 0, 0, 0, 0, 'relative'); @@ -2392,11 +2403,11 @@ export abstract class Layout extends Disposable implements IWorkbenchLayoutServi type StartupLayoutEventClassification = { owner: 'sbatten'; comment: 'Information about the layout of the workbench during statup'; - activityBarVisible: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Whether or the not the activity bar is visible' }; - sideBarVisible: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Whether or the not the primary side bar is visible' }; - auxiliaryBarVisible: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Whether or the not the secondary side bar is visible' }; - panelVisible: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Whether or the not the panel is visible' }; - statusbarVisible: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Whether or the not the status bar is visible' }; + activityBarVisible: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether or the not the activity bar is visible' }; + sideBarVisible: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether or the not the primary side bar is visible' }; + auxiliaryBarVisible: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether or the not the secondary side bar is visible' }; + panelVisible: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether or the not the panel is visible' }; + statusbarVisible: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether or the not the status bar is visible' }; sideBarPosition: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether the primary side bar is on the left or right' }; panelPosition: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether the panel is on the bottom, left, or right' }; }; @@ -2608,7 +2619,7 @@ class LayoutStateModel extends Disposable { LayoutStateKeys.GRID_SIZE.defaultValue = { height: workbenchDimensions.height, width: workbenchDimensions.width }; LayoutStateKeys.SIDEBAR_SIZE.defaultValue = Math.min(300, workbenchDimensions.width / 4); LayoutStateKeys.AUXILIARYBAR_SIZE.defaultValue = Math.min(300, workbenchDimensions.width / 4); - LayoutStateKeys.PANEL_SIZE.defaultValue = (this.stateCache.get(LayoutStateKeys.PANEL_POSITION.name) ?? LayoutStateKeys.PANEL_POSITION.defaultValue) === 'bottom' ? workbenchDimensions.height / 3 : workbenchDimensions.width / 4; + LayoutStateKeys.PANEL_SIZE.defaultValue = (this.stateCache.get(LayoutStateKeys.PANEL_POSITION.name) ?? LayoutStateKeys.PANEL_POSITION.defaultValue) === Position.BOTTOM ? workbenchDimensions.height / 3 : workbenchDimensions.width / 4; LayoutStateKeys.SIDEBAR_HIDDEN.defaultValue = this.contextService.getWorkbenchState() === WorkbenchState.EMPTY; // Apply all defaults @@ -2698,7 +2709,7 @@ class LayoutStateModel extends Disposable { if (oldValue !== undefined) { return !oldValue; } - return this.configurationService.getValue(LayoutSettings.ACTIVITY_BAR_LOCATION) !== ActivityBarPosition.SIDE; + return this.configurationService.getValue(LayoutSettings.ACTIVITY_BAR_LOCATION) !== ActivityBarPosition.DEFAULT; } private setRuntimeValueAndFire(key: RuntimeStateKey, value: T): void { diff --git a/src/vs/workbench/browser/media/part.css b/src/vs/workbench/browser/media/part.css index 17182b6fa91f4..f17465a5e4a2b 100644 --- a/src/vs/workbench/browser/media/part.css +++ b/src/vs/workbench/browser/media/part.css @@ -22,11 +22,13 @@ z-index: 12; } -.monaco-workbench .part > .title { - display: none; /* Parts have to opt in to show title area */ +.monaco-workbench .part > .title, +.monaco-workbench .part > .header-or-footer { + display: none; /* Parts have to opt in to show area */ } -.monaco-workbench .part > .title { +.monaco-workbench .part > .title, +.monaco-workbench .part > .header-or-footer { height: 35px; display: flex; box-sizing: border-box; diff --git a/src/vs/workbench/browser/media/style.css b/src/vs/workbench/browser/media/style.css index 262745f0ed2cd..9299856d6ffe2 100644 --- a/src/vs/workbench/browser/media/style.css +++ b/src/vs/workbench/browser/media/style.css @@ -87,6 +87,11 @@ body.web { text-decoration: none; } +.monaco-workbench.hc-black p > a, +.monaco-workbench.hc-light p > a { + text-decoration: underline !important; +} + .monaco-workbench a:active { color: inherit; background-color: inherit; @@ -109,6 +114,16 @@ body.web { font-size: 100%; } +.monaco-workbench table { + /* + * Somehow this is required when tables show in floating windows + * to override the user-agent style which sets a specific color + * and font-size + */ + color: inherit; + font-size: inherit; +} + .monaco-workbench input::placeholder { color: var(--vscode-input-placeholderForeground); } .monaco-workbench input::-webkit-input-placeholder { color: var(--vscode-input-placeholderForeground); } .monaco-workbench input::-moz-placeholder { color: var(--vscode-input-placeholderForeground); } diff --git a/src/vs/workbench/browser/panecomposite.ts b/src/vs/workbench/browser/panecomposite.ts index b829c9f3d7761..89e701e2d0bec 100644 --- a/src/vs/workbench/browser/panecomposite.ts +++ b/src/vs/workbench/browser/panecomposite.ts @@ -22,6 +22,7 @@ import { IView } from 'vs/workbench/common/views'; import { IExtensionService } from 'vs/workbench/services/extensions/common/extensions'; import { VIEWPANE_FILTER_ACTION } from 'vs/workbench/browser/parts/views/viewPane'; import { IBoundarySashes } from 'vs/base/browser/ui/sash/sash'; +import { IBaseActionViewItemOptions } from 'vs/base/browser/ui/actionbar/actionViewItems'; export abstract class PaneComposite extends Composite implements IPaneComposite { @@ -140,8 +141,8 @@ export abstract class PaneComposite extends Composite implements IPaneComposite return menuActions.length ? menuActions : viewPaneActions; } - override getActionViewItem(action: IAction): IActionViewItem | undefined { - return this.viewPaneContainer?.getActionViewItem(action); + override getActionViewItem(action: IAction, options: IBaseActionViewItemOptions): IActionViewItem | undefined { + return this.viewPaneContainer?.getActionViewItem(action, options); } override getTitle(): string { diff --git a/src/vs/workbench/browser/part.ts b/src/vs/workbench/browser/part.ts index 7ae271c65cc2f..086dcb51b2ce8 100644 --- a/src/vs/workbench/browser/part.ts +++ b/src/vs/workbench/browser/part.ts @@ -6,7 +6,7 @@ import 'vs/css!./media/part'; import { Component } from 'vs/workbench/common/component'; import { IThemeService, IColorTheme } from 'vs/platform/theme/common/themeService'; -import { Dimension, size, IDimension, getActiveDocument } from 'vs/base/browser/dom'; +import { Dimension, size, IDimension, getActiveDocument, prepend, IDomPosition } from 'vs/base/browser/dom'; import { IStorageService } from 'vs/platform/storage/common/storage'; import { ISerializableView, IViewSize } from 'vs/base/browser/ui/grid/grid'; import { Event, Emitter } from 'vs/base/common/event'; @@ -20,8 +20,10 @@ export interface IPartOptions { } export interface ILayoutContentResult { + readonly headerSize: IDimension; readonly titleSize: IDimension; readonly contentSize: IDimension; + readonly footerSize: IDimension; } /** @@ -33,12 +35,17 @@ export abstract class Part extends Component implements ISerializableView { private _dimension: Dimension | undefined; get dimension(): Dimension | undefined { return this._dimension; } + private _contentPosition: IDomPosition | undefined; + get contentPosition(): IDomPosition | undefined { return this._contentPosition; } + protected _onDidVisibilityChange = this._register(new Emitter()); readonly onDidVisibilityChange = this._onDidVisibilityChange.event; private parent: HTMLElement | undefined; + private headerArea: HTMLElement | undefined; private titleArea: HTMLElement | undefined; private contentArea: HTMLElement | undefined; + private footerArea: HTMLElement | undefined; private partLayout: PartLayout | undefined; constructor( @@ -50,7 +57,7 @@ export abstract class Part extends Component implements ISerializableView { ) { super(id, themeService, storageService); - layoutService.registerPart(this); + this._register(layoutService.registerPart(this)); } protected override onThemeChange(theme: IColorTheme): void { @@ -61,10 +68,6 @@ export abstract class Part extends Component implements ISerializableView { } } - override updateStyles(): void { - super.updateStyles(); - } - /** * Note: Clients should not call this method, the workbench calls this * method. Calling it otherwise may result in unexpected behavior. @@ -116,6 +119,77 @@ export abstract class Part extends Component implements ISerializableView { return this.contentArea; } + /** + * Sets the header area + */ + protected setHeaderArea(headerContainer: HTMLElement): void { + if (this.headerArea) { + throw new Error('Header already exists'); + } + + if (!this.parent || !this.titleArea) { + return; + } + + prepend(this.parent, headerContainer); + headerContainer.classList.add('header-or-footer'); + headerContainer.classList.add('header'); + + this.headerArea = headerContainer; + this.partLayout?.setHeaderVisibility(true); + this.relayout(); + } + + /** + * Sets the footer area + */ + protected setFooterArea(footerContainer: HTMLElement): void { + if (this.footerArea) { + throw new Error('Footer already exists'); + } + + if (!this.parent || !this.titleArea) { + return; + } + + this.parent.appendChild(footerContainer); + footerContainer.classList.add('header-or-footer'); + footerContainer.classList.add('footer'); + + this.footerArea = footerContainer; + this.partLayout?.setFooterVisibility(true); + this.relayout(); + } + + /** + * removes the header area + */ + protected removeHeaderArea(): void { + if (this.headerArea) { + this.headerArea.remove(); + this.headerArea = undefined; + this.partLayout?.setHeaderVisibility(false); + this.relayout(); + } + } + + /** + * removes the footer area + */ + protected removeFooterArea(): void { + if (this.footerArea) { + this.footerArea.remove(); + this.footerArea = undefined; + this.partLayout?.setFooterVisibility(false); + this.relayout(); + } + } + + private relayout() { + if (this.dimension && this.contentPosition) { + this.layout(this.dimension.width, this.dimension.height, this.contentPosition.top, this.contentPosition.left); + } + } /** * Layout title and content area in the given dimension. */ @@ -137,8 +211,9 @@ export abstract class Part extends Component implements ISerializableView { abstract minimumHeight: number; abstract maximumHeight: number; - layout(width: number, height: number, _top: number, _left: number): void { + layout(width: number, height: number, top: number, left: number): void { this._dimension = new Dimension(width, height); + this._contentPosition = { top, left }; } setVisible(visible: boolean) { @@ -152,7 +227,12 @@ export abstract class Part extends Component implements ISerializableView { class PartLayout { + private static readonly HEADER_HEIGHT = 35; private static readonly TITLE_HEIGHT = 35; + private static readonly Footer_HEIGHT = 35; + + private headerVisible: boolean = false; + private footerVisible: boolean = false; constructor(private options: IPartOptions, private contentArea: HTMLElement | undefined) { } @@ -166,20 +246,44 @@ class PartLayout { titleSize = Dimension.None; } + // Header Size: Width (Fill), Height (Variable) + let headerSize: Dimension; + if (this.headerVisible) { + headerSize = new Dimension(width, Math.min(height, PartLayout.HEADER_HEIGHT)); + } else { + headerSize = Dimension.None; + } + + // Footer Size: Width (Fill), Height (Variable) + let footerSize: Dimension; + if (this.footerVisible) { + footerSize = new Dimension(width, Math.min(height, PartLayout.Footer_HEIGHT)); + } else { + footerSize = Dimension.None; + } + let contentWidth = width; if (this.options && typeof this.options.borderWidth === 'function') { contentWidth -= this.options.borderWidth(); // adjust for border size } // Content Size: Width (Fill), Height (Variable) - const contentSize = new Dimension(contentWidth, height - titleSize.height); + const contentSize = new Dimension(contentWidth, height - titleSize.height - headerSize.height - footerSize.height); // Content if (this.contentArea) { size(this.contentArea, contentSize.width, contentSize.height); } - return { titleSize, contentSize }; + return { headerSize, titleSize, contentSize, footerSize }; + } + + setFooterVisibility(visible: boolean): void { + this.footerVisible = visible; + } + + setHeaderVisibility(visible: boolean): void { + this.headerVisible = visible; } } @@ -197,7 +301,7 @@ export abstract class MultiWindowParts extends Compo registerPart(part: T): IDisposable { this._parts.add(part); - return this._register(toDisposable(() => this.unregisterPart(part))); + return toDisposable(() => this.unregisterPart(part)); } protected unregisterPart(part: T): void { diff --git a/src/vs/workbench/browser/parts/activitybar/activitybarPart.ts b/src/vs/workbench/browser/parts/activitybar/activitybarPart.ts index 2e2047e8f1de1..15e58a5817990 100644 --- a/src/vs/workbench/browser/parts/activitybar/activitybarPart.ts +++ b/src/vs/workbench/browser/parts/activitybar/activitybarPart.ts @@ -378,26 +378,26 @@ export class ActivityBarCompositeBar extends PaneCompositeBar { registerAction2(class extends Action2 { constructor() { super({ - id: 'workbench.action.activityBarLocation.side', + id: 'workbench.action.activityBarLocation.default', title: { - ...localize2('positionActivityBarSide', 'Move Activity Bar to Side'), - mnemonicTitle: localize({ key: 'miSideActivityBar', comment: ['&& denotes a mnemonic'] }, "&&Side"), + ...localize2('positionActivityBarDefault', 'Move Activity Bar to Side'), + mnemonicTitle: localize({ key: 'miDefaultActivityBar', comment: ['&& denotes a mnemonic'] }, "&&Default"), }, - shortTitle: localize('side', "Side"), + shortTitle: localize('default', "Default"), category: Categories.View, - toggled: ContextKeyExpr.equals(`config.${LayoutSettings.ACTIVITY_BAR_LOCATION}`, ActivityBarPosition.SIDE), + toggled: ContextKeyExpr.equals(`config.${LayoutSettings.ACTIVITY_BAR_LOCATION}`, ActivityBarPosition.DEFAULT), menu: [{ id: MenuId.ActivityBarPositionMenu, order: 1 }, { id: MenuId.CommandPalette, - when: ContextKeyExpr.notEquals(`config.${LayoutSettings.ACTIVITY_BAR_LOCATION}`, ActivityBarPosition.SIDE), + when: ContextKeyExpr.notEquals(`config.${LayoutSettings.ACTIVITY_BAR_LOCATION}`, ActivityBarPosition.DEFAULT), }] }); } run(accessor: ServicesAccessor): void { const configurationService = accessor.get(IConfigurationService); - configurationService.updateValue(LayoutSettings.ACTIVITY_BAR_LOCATION, ActivityBarPosition.SIDE); + configurationService.updateValue(LayoutSettings.ACTIVITY_BAR_LOCATION, ActivityBarPosition.DEFAULT); } }); @@ -427,6 +427,32 @@ registerAction2(class extends Action2 { } }); +registerAction2(class extends Action2 { + constructor() { + super({ + id: 'workbench.action.activityBarLocation.bottom', + title: { + ...localize2('positionActivityBarBottom', 'Move Activity Bar to Bottom'), + mnemonicTitle: localize({ key: 'miBottomActivityBar', comment: ['&& denotes a mnemonic'] }, "&&Bottom"), + }, + shortTitle: localize('bottom', "Bottom"), + category: Categories.View, + toggled: ContextKeyExpr.equals(`config.${LayoutSettings.ACTIVITY_BAR_LOCATION}`, ActivityBarPosition.BOTTOM), + menu: [{ + id: MenuId.ActivityBarPositionMenu, + order: 3 + }, { + id: MenuId.CommandPalette, + when: ContextKeyExpr.notEquals(`config.${LayoutSettings.ACTIVITY_BAR_LOCATION}`, ActivityBarPosition.BOTTOM), + }] + }); + } + run(accessor: ServicesAccessor): void { + const configurationService = accessor.get(IConfigurationService); + configurationService.updateValue(LayoutSettings.ACTIVITY_BAR_LOCATION, ActivityBarPosition.BOTTOM); + } +}); + registerAction2(class extends Action2 { constructor() { super({ @@ -440,7 +466,7 @@ registerAction2(class extends Action2 { toggled: ContextKeyExpr.equals(`config.${LayoutSettings.ACTIVITY_BAR_LOCATION}`, ActivityBarPosition.HIDDEN), menu: [{ id: MenuId.ActivityBarPositionMenu, - order: 3 + order: 4 }, { id: MenuId.CommandPalette, when: ContextKeyExpr.notEquals(`config.${LayoutSettings.ACTIVITY_BAR_LOCATION}`, ActivityBarPosition.HIDDEN), diff --git a/src/vs/workbench/browser/parts/activitybar/media/activityaction.css b/src/vs/workbench/browser/parts/activitybar/media/activityaction.css index 99df125f3a8e1..a47bbe794a0f4 100644 --- a/src/vs/workbench/browser/parts/activitybar/media/activityaction.css +++ b/src/vs/workbench/browser/parts/activitybar/media/activityaction.css @@ -107,6 +107,11 @@ border-left: 2px solid; } +.monaco-workbench.hc-black .activitybar > .content :not(.monaco-menu) > .monaco-action-bar .action-item.checked .active-item-indicator:before, +.monaco-workbench.hc-black .activitybar > .content :not(.monaco-menu) > .monaco-action-bar .action-item:focus .active-item-indicator:before { + border-color: var(--vscode-focusBorder); +} + .monaco-workbench .activitybar > .content :not(.monaco-menu) > .monaco-action-bar .action-item.checked .active-item-indicator:before { top: 0; height: 100%; diff --git a/src/vs/workbench/browser/parts/auxiliarybar/auxiliaryBarPart.ts b/src/vs/workbench/browser/parts/auxiliarybar/auxiliaryBarPart.ts index 5ad1c648d6fce..4cbc1def33568 100644 --- a/src/vs/workbench/browser/parts/auxiliarybar/auxiliaryBarPart.ts +++ b/src/vs/workbench/browser/parts/auxiliarybar/auxiliaryBarPart.ts @@ -14,21 +14,28 @@ import { IStorageService } from 'vs/platform/storage/common/storage'; import { contrastBorder } from 'vs/platform/theme/common/colorRegistry'; import { IThemeService } from 'vs/platform/theme/common/themeService'; import { ActiveAuxiliaryContext, AuxiliaryBarFocusContext } from 'vs/workbench/common/contextkeys'; -import { ACTIVITY_BAR_BADGE_BACKGROUND, ACTIVITY_BAR_BADGE_FOREGROUND, PANEL_ACTIVE_TITLE_BORDER, PANEL_ACTIVE_TITLE_FOREGROUND, PANEL_DRAG_AND_DROP_BORDER, PANEL_INACTIVE_TITLE_FOREGROUND, SIDE_BAR_BACKGROUND, SIDE_BAR_BORDER, SIDE_BAR_FOREGROUND } from 'vs/workbench/common/theme'; +import { ACTIVITY_BAR_BADGE_BACKGROUND, ACTIVITY_BAR_BADGE_FOREGROUND, ACTIVITY_BAR_TOP_ACTIVE_BORDER, ACTIVITY_BAR_TOP_DRAG_AND_DROP_BORDER, ACTIVITY_BAR_TOP_FOREGROUND, ACTIVITY_BAR_TOP_INACTIVE_FOREGROUND, PANEL_ACTIVE_TITLE_BORDER, PANEL_ACTIVE_TITLE_FOREGROUND, PANEL_DRAG_AND_DROP_BORDER, PANEL_INACTIVE_TITLE_FOREGROUND, SIDE_BAR_BACKGROUND, SIDE_BAR_BORDER, SIDE_BAR_FOREGROUND } from 'vs/workbench/common/theme'; import { IViewDescriptorService } from 'vs/workbench/common/views'; import { IExtensionService } from 'vs/workbench/services/extensions/common/extensions'; -import { IWorkbenchLayoutService, Parts, Position } from 'vs/workbench/services/layout/browser/layoutService'; +import { ActivityBarPosition, IWorkbenchLayoutService, LayoutSettings, Parts, Position } from 'vs/workbench/services/layout/browser/layoutService'; import { HoverPosition } from 'vs/base/browser/ui/hover/hoverWidget'; -import { IAction, Separator, toAction } from 'vs/base/common/actions'; +import { IAction, Separator, SubmenuAction, toAction } from 'vs/base/common/actions'; import { ToggleAuxiliaryBarAction } from 'vs/workbench/browser/parts/auxiliarybar/auxiliaryBarActions'; import { assertIsDefined } from 'vs/base/common/types'; import { LayoutPriority } from 'vs/base/browser/ui/splitview/splitview'; import { ToggleSidebarPositionAction } from 'vs/workbench/browser/actions/layoutActions'; import { ICommandService } from 'vs/platform/commands/common/commands'; -import { AbstractPaneCompositePart } from 'vs/workbench/browser/parts/paneCompositePart'; -import { ActionsOrientation } from 'vs/base/browser/ui/actionbar/actionbar'; +import { AbstractPaneCompositePart, CompositeBarPosition } from 'vs/workbench/browser/parts/paneCompositePart'; +import { ActionsOrientation, IActionViewItem, prepareActions } from 'vs/base/browser/ui/actionbar/actionbar'; import { IPaneCompositeBarOptions } from 'vs/workbench/browser/parts/paneCompositeBar'; -import { IMenuService } from 'vs/platform/actions/common/actions'; +import { IMenuService, MenuId } from 'vs/platform/actions/common/actions'; +import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; +import { createAndFillInContextMenuActions } from 'vs/platform/actions/browser/menuEntryActionViewItem'; +import { $ } from 'vs/base/browser/dom'; +import { HiddenItemStrategy, WorkbenchToolBar } from 'vs/platform/actions/browser/toolbar'; +import { ActionViewItem, IActionViewItemOptions } from 'vs/base/browser/ui/actionbar/actionViewItems'; +import { CompositeMenuActions } from 'vs/workbench/browser/actions'; +import { IHoverService } from 'vs/platform/hover/browser/hover'; export class AuxiliaryBarPart extends AbstractPaneCompositePart { @@ -72,6 +79,7 @@ export class AuxiliaryBarPart extends AbstractPaneCompositePart { @IContextMenuService contextMenuService: IContextMenuService, @IWorkbenchLayoutService layoutService: IWorkbenchLayoutService, @IKeybindingService keybindingService: IKeybindingService, + @IHoverService hoverService: IHoverService, @IInstantiationService instantiationService: IInstantiationService, @IThemeService themeService: IThemeService, @IViewDescriptorService viewDescriptorService: IViewDescriptorService, @@ -79,6 +87,7 @@ export class AuxiliaryBarPart extends AbstractPaneCompositePart { @IExtensionService extensionService: IExtensionService, @ICommandService private commandService: ICommandService, @IMenuService menuService: IMenuService, + @IConfigurationService private readonly configurationService: IConfigurationService ) { super( Parts.AUXILIARYBAR_PART, @@ -97,6 +106,7 @@ export class AuxiliaryBarPart extends AbstractPaneCompositePart { contextMenuService, layoutService, keybindingService, + hoverService, instantiationService, themeService, viewDescriptorService, @@ -104,6 +114,21 @@ export class AuxiliaryBarPart extends AbstractPaneCompositePart { extensionService, menuService, ); + + this._register(configurationService.onDidChangeConfiguration(e => { + if (e.affectsConfiguration(LayoutSettings.ACTIVITY_BAR_LOCATION)) { + this.onDidChangeActivityBarLocation(); + } + })); + } + + private onDidChangeActivityBarLocation(): void { + this.updateCompositeBar(); + + const id = this.getActiveComposite()?.getId(); + if (id) { + this.onTitleAreaUpdate(id); + } } override updateStyles(): void { @@ -127,6 +152,7 @@ export class AuxiliaryBarPart extends AbstractPaneCompositePart { } protected getCompositeBarOptions(): IPaneCompositeBarOptions { + const $this = this; return { partContainerClass: 'auxiliarybar', pinnedViewContainersKey: AuxiliaryBarPart.pinnedPanelsKey, @@ -136,21 +162,22 @@ export class AuxiliaryBarPart extends AbstractPaneCompositePart { orientation: ActionsOrientation.HORIZONTAL, recomputeSizes: true, activityHoverOptions: { - position: () => HoverPosition.BELOW, + position: () => this.getCompositeBarPosition() === CompositeBarPosition.BOTTOM ? HoverPosition.ABOVE : HoverPosition.BELOW, }, fillExtraContextMenuActions: actions => this.fillExtraContextMenuActions(actions), compositeSize: 0, iconSize: 16, - overflowActionSize: 44, + // Add 10px spacing if the overflow action is visible to no confuse the user with ... between the toolbars + get overflowActionSize() { return $this.getCompositeBarPosition() === CompositeBarPosition.TITLE ? 40 : 30; }, colors: theme => ({ activeBackgroundColor: theme.getColor(SIDE_BAR_BACKGROUND), inactiveBackgroundColor: theme.getColor(SIDE_BAR_BACKGROUND), - activeBorderBottomColor: theme.getColor(PANEL_ACTIVE_TITLE_BORDER), - activeForegroundColor: theme.getColor(PANEL_ACTIVE_TITLE_FOREGROUND), - inactiveForegroundColor: theme.getColor(PANEL_INACTIVE_TITLE_FOREGROUND), + get activeBorderBottomColor() { return $this.getCompositeBarPosition() === CompositeBarPosition.TITLE ? theme.getColor(PANEL_ACTIVE_TITLE_BORDER) : theme.getColor(ACTIVITY_BAR_TOP_ACTIVE_BORDER); }, + get activeForegroundColor() { return $this.getCompositeBarPosition() === CompositeBarPosition.TITLE ? theme.getColor(PANEL_ACTIVE_TITLE_FOREGROUND) : theme.getColor(ACTIVITY_BAR_TOP_FOREGROUND); }, + get inactiveForegroundColor() { return $this.getCompositeBarPosition() === CompositeBarPosition.TITLE ? theme.getColor(PANEL_INACTIVE_TITLE_FOREGROUND) : theme.getColor(ACTIVITY_BAR_TOP_INACTIVE_FOREGROUND); }, badgeBackground: theme.getColor(ACTIVITY_BAR_BADGE_BACKGROUND), badgeForeground: theme.getColor(ACTIVITY_BAR_BADGE_FOREGROUND), - dragAndDropBorder: theme.getColor(PANEL_DRAG_AND_DROP_BORDER) + get dragAndDropBorder() { return $this.getCompositeBarPosition() === CompositeBarPosition.TITLE ? theme.getColor(PANEL_DRAG_AND_DROP_BORDER) : theme.getColor(ACTIVITY_BAR_TOP_DRAG_AND_DROP_BORDER); } }), compact: true }; @@ -163,15 +190,70 @@ export class AuxiliaryBarPart extends AbstractPaneCompositePart { actions.push(new Separator()); actions.push(viewsSubmenuAction); } + + const activityBarPositionMenu = this.menuService.createMenu(MenuId.ActivityBarPositionMenu, this.contextKeyService); + const positionActions: IAction[] = []; + createAndFillInContextMenuActions(activityBarPositionMenu, { shouldForwardArgs: true, renderShortTitle: true }, { primary: [], secondary: positionActions }); + activityBarPositionMenu.dispose(); + actions.push(...[ new Separator(), + new SubmenuAction('workbench.action.panel.position', localize('activity bar position', "Activity Bar Position"), positionActions), toAction({ id: ToggleSidebarPositionAction.ID, label: currentPositionRight ? localize('move second side bar left', "Move Secondary Side Bar Left") : localize('move second side bar right', "Move Secondary Side Bar Right"), run: () => this.commandService.executeCommand(ToggleSidebarPositionAction.ID) }), toAction({ id: ToggleAuxiliaryBarAction.ID, label: localize('hide second side bar', "Hide Secondary Side Bar"), run: () => this.commandService.executeCommand(ToggleAuxiliaryBarAction.ID) }) ]); } protected shouldShowCompositeBar(): boolean { - return true; + return this.configurationService.getValue(LayoutSettings.ACTIVITY_BAR_LOCATION) !== ActivityBarPosition.HIDDEN; + } + + // TODO@benibenj chache this + protected getCompositeBarPosition(): CompositeBarPosition { + const activityBarPosition = this.configurationService.getValue(LayoutSettings.ACTIVITY_BAR_LOCATION); + switch (activityBarPosition) { + case ActivityBarPosition.TOP: return CompositeBarPosition.TOP; + case ActivityBarPosition.BOTTOM: return CompositeBarPosition.BOTTOM; + case ActivityBarPosition.HIDDEN: return CompositeBarPosition.TITLE; + case ActivityBarPosition.DEFAULT: return CompositeBarPosition.TITLE; + default: return CompositeBarPosition.TITLE; + } + } + + protected override createHeaderArea() { + const headerArea = super.createHeaderArea(); + const globalHeaderContainer = $('.auxiliary-bar-global-header'); + + // Add auxillary header action + const menu = this.headerFooterCompositeBarDispoables.add(this.instantiationService.createInstance(CompositeMenuActions, MenuId.AuxiliaryBarHeader, undefined, undefined)); + + const toolBar = this.headerFooterCompositeBarDispoables.add(this.instantiationService.createInstance(WorkbenchToolBar, globalHeaderContainer, { + actionViewItemProvider: (action, options) => this.headerActionViewItemProvider(action, options), + orientation: ActionsOrientation.HORIZONTAL, + hiddenItemStrategy: HiddenItemStrategy.NoHide, + getKeyBinding: action => this.keybindingService.lookupKeybinding(action.id), + })); + + toolBar.setActions(prepareActions(menu.getPrimaryActions())); + this.headerFooterCompositeBarDispoables.add(menu.onDidChange(() => toolBar.setActions(prepareActions(menu.getPrimaryActions())))); + + headerArea.appendChild(globalHeaderContainer); + return headerArea; + } + + protected override getToolbarWidth(): number { + if (this.getCompositeBarPosition() === CompositeBarPosition.TOP) { + return 22; + } + return super.getToolbarWidth(); + } + + private headerActionViewItemProvider(action: IAction, options: IActionViewItemOptions): IActionViewItem | undefined { + if (action.id === ToggleAuxiliaryBarAction.ID) { + return this.instantiationService.createInstance(ActionViewItem, undefined, action, options); + } + + return undefined; } override toJSON(): object { diff --git a/src/vs/workbench/browser/parts/auxiliarybar/media/auxiliaryBarPart.css b/src/vs/workbench/browser/parts/auxiliarybar/media/auxiliaryBarPart.css index 3d382b3e39d88..b1e39af1e5898 100644 --- a/src/vs/workbench/browser/parts/auxiliarybar/media/auxiliaryBarPart.css +++ b/src/vs/workbench/browser/parts/auxiliarybar/media/auxiliaryBarPart.css @@ -14,30 +14,80 @@ background-color: var(--vscode-sideBar-background); } +.monaco-workbench .part.auxiliarybar .title-actions .actions-container { + justify-content: flex-end; +} + +.monaco-workbench .part.auxiliarybar .title-actions .action-item { + margin-right: 4px; +} + +.monaco-workbench .part.auxiliarybar > .title { + background-color: var(--vscode-sideBarTitle-background); +} + +.monaco-workbench .part.auxiliarybar > .title > .title-label { + flex: 1; +} + +.monaco-workbench .part.auxiliarybar > .title > .title-label h2 { + text-transform: uppercase; +} + .monaco-workbench .part.auxiliarybar > .title > .composite-bar-container { flex: 1; } +.monaco-workbench .part.auxiliarybar > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item:focus, +.monaco-workbench .part.auxiliarybar > .header-or-footer > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item:focus { + outline: 0 !important; /* activity bar indicates focus custom */ +} + +.monaco-workbench .part.auxiliarybar > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item .action-label, +.monaco-workbench .part.auxiliarybar > .header-or-footer > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item .action-label { + border-radius: 0px; + outline-offset: 2px; +} + +.monaco-workbench .part.auxiliarybar > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item .action-label::before, +.monaco-workbench .part.auxiliarybar > .header-or-footer > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item .action-label::before { + position: absolute; + left: 6px; /* place icon in center */ +} + .monaco-workbench .part.auxiliarybar > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item.checked:not(:focus) .active-item-indicator:before, .monaco-workbench .part.auxiliarybar > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item.checked.clicked:focus .active-item-indicator:before { border-top-color: var(--vscode-panelTitle-activeBorder) !important; } +.monaco-workbench .part.auxiliarybar > .header-or-footer > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item.checked:not(:focus) .active-item-indicator:before, +.monaco-workbench .part.auxiliarybar > .header-or-footer > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item.checked.clicked:focus .active-item-indicator:before { + border-top-color: var(--vscode-activityBarTop-activeBorder) !important; +} + .monaco-workbench .part.auxiliarybar > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item:hover .action-label, .monaco-workbench .part.auxiliarybar > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item:focus .action-label { color: var(--vscode-sideBarTitle-foreground) !important; } +.monaco-workbench .part.auxiliarybar > .header-or-footer > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item:hover .action-label, +.monaco-workbench .part.auxiliarybar > .header-or-footer > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item:focus .action-label { + color: var(--vscode-activityBarTop-foreground) !important; +} + +.monaco-workbench .part.auxiliarybar > .header-or-footer > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item.checked .action-label, +.monaco-workbench .part.auxiliarybar > .header-or-footer > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item:hover .action-label, .monaco-workbench .part.auxiliarybar > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item.checked .action-label, .monaco-workbench .part.auxiliarybar > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item:hover .action-label { outline: var(--vscode-contrastActiveBorder, unset) solid 1px !important; } +.monaco-workbench .part.auxiliarybar > .header-or-footer > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item:not(.checked):hover .action-label, .monaco-workbench .part.auxiliarybar > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item:not(.checked):hover .action-label { outline: var(--vscode-contrastActiveBorder, unset) dashed 1px !important; } -.monaco-workbench .auxiliarybar.part.pane-composite-part > .composite.title.has-composite-bar > .title-actions { +.monaco-workbench .auxiliarybar.part.pane-composite-part > .composite.title > .title-actions { flex: inherit; } diff --git a/src/vs/workbench/browser/parts/banner/bannerPart.ts b/src/vs/workbench/browser/parts/banner/bannerPart.ts index 3725e594c9733..dda7c882d5ec8 100644 --- a/src/vs/workbench/browser/parts/banner/bannerPart.ts +++ b/src/vs/workbench/browser/parts/banner/bannerPart.ts @@ -86,7 +86,7 @@ export class BannerPart extends Part implements IBannerService { })); // Track focus - const scopedContextKeyService = this.contextKeyService.createScoped(this.element); + const scopedContextKeyService = this._register(this.contextKeyService.createScoped(this.element)); BannerFocused.bindTo(scopedContextKeyService).set(true); return this.element; @@ -222,13 +222,12 @@ export class BannerPart extends Part implements IBannerService { } // Action - if (!item.disableCloseAction) { - const actionBarContainer = append(this.element, $('div.action-container')); - this.actionBar = this._register(new ActionBar(actionBarContainer)); - const closeAction = this._register(new Action('banner.close', 'Close Banner', ThemeIcon.asClassName(widgetClose), true, () => this.close(item))); - this.actionBar.push(closeAction, { icon: true, label: false }); - this.actionBar.setFocusable(false); - } + const actionBarContainer = append(this.element, $('div.action-container')); + this.actionBar = this._register(new ActionBar(actionBarContainer)); + const label = item.closeLabel ?? 'Close Banner'; + const closeAction = this._register(new Action('banner.close', label, ThemeIcon.asClassName(widgetClose), true, () => this.close(item))); + this.actionBar.push(closeAction, { icon: true, label: false }); + this.actionBar.setFocusable(false); this.setVisibility(true); this.item = item; diff --git a/src/vs/workbench/browser/parts/compositeBar.ts b/src/vs/workbench/browser/parts/compositeBar.ts index a2007e1829909..a9fc39c84ed4a 100644 --- a/src/vs/workbench/browser/parts/compositeBar.ts +++ b/src/vs/workbench/browser/parts/compositeBar.ts @@ -37,6 +37,7 @@ export class CompositeDragAndDrop implements ICompositeDragAndDrop { constructor( private viewDescriptorService: IViewDescriptorService, private targetContainerLocation: ViewContainerLocation, + private orientation: ActionsOrientation, private openComposite: (id: string, focus?: boolean) => Promise, private moveComposite: (from: string, to: string, before?: Before2D) => void, private getItems: () => ICompositeBarItem[] @@ -93,7 +94,7 @@ export class CompositeDragAndDrop implements ICompositeDragAndDrop { } const items = this.getItems(); - const before = this.targetContainerLocation === ViewContainerLocation.Panel ? before2d?.horizontallyBefore : before2d?.verticallyBefore; + const before = this.orientation === ActionsOrientation.HORIZONTAL ? before2d?.horizontallyBefore : before2d?.verticallyBefore; return items.filter(item => item.visible).findIndex(item => item.id === targetId) + (before ? 0 : 1); } @@ -201,14 +202,14 @@ export class CompositeBar extends Widget implements ICompositeBar { create(parent: HTMLElement): HTMLElement { const actionBarDiv = parent.appendChild($('.composite-bar')); this.compositeSwitcherBar = this._register(new ActionBar(actionBarDiv, { - actionViewItemProvider: action => { + actionViewItemProvider: (action, options) => { if (action instanceof CompositeOverflowActivityAction) { return this.compositeOverflowActionViewItem; } const item = this.model.findItem(action.id); return item && this.instantiationService.createInstance( CompositeActionViewItem, - { draggable: true, colors: this.options.colors, icon: this.options.icon, hoverOptions: this.options.activityHoverOptions, compact: this.options.compact }, + { ...options, draggable: true, colors: this.options.colors, icon: this.options.icon, hoverOptions: this.options.activityHoverOptions, compact: this.options.compact }, action as CompositeBarAction, item.pinnedAction, item.toggleBadgeAction, @@ -221,7 +222,6 @@ export class CompositeBar extends Widget implements ICompositeBar { orientation: this.options.orientation, ariaLabel: localize('activityBarAriaLabel', "Active View Switcher"), ariaRole: 'tablist', - animated: false, preventLoopNavigation: this.options.preventLoopNavigation, triggerKeys: { keyDown: true } })); diff --git a/src/vs/workbench/browser/parts/compositeBarActions.ts b/src/vs/workbench/browser/parts/compositeBarActions.ts index e1945a1cbc2a7..de10721d07cac 100644 --- a/src/vs/workbench/browser/parts/compositeBarActions.ts +++ b/src/vs/workbench/browser/parts/compositeBarActions.ts @@ -26,7 +26,7 @@ import { IConfigurationService } from 'vs/platform/configuration/common/configur import { HoverPosition } from 'vs/base/browser/ui/hover/hoverWidget'; import { URI } from 'vs/base/common/uri'; import { badgeBackground, badgeForeground, contrastBorder } from 'vs/platform/theme/common/colorRegistry'; -import { IHoverWidget } from 'vs/base/browser/ui/iconLabel/iconHoverDelegate'; +import type { IHoverWidget } from 'vs/base/browser/ui/hover/hover'; export interface ICompositeBar { @@ -144,7 +144,7 @@ export interface ICompositeBarActionViewItemOptions extends IActionViewItemOptio readonly compact?: boolean; } -export class CompoisteBarActionViewItem extends BaseActionViewItem { +export class CompositeBarActionViewItem extends BaseActionViewItem { private static hoverLeaveTime = 0; @@ -394,7 +394,7 @@ export class CompoisteBarActionViewItem extends BaseActionViewItem { this.hoverDisposables.add(addDisposableListener(this.container, EventType.MOUSE_OVER, () => { if (!this.showHoverScheduler.isScheduled()) { - if (Date.now() - CompoisteBarActionViewItem.hoverLeaveTime < 200) { + if (Date.now() - CompositeBarActionViewItem.hoverLeaveTime < 200) { this.showHover(true); } else { this.showHoverScheduler.schedule(this.configurationService.getValue('workbench.hover.delay')); @@ -404,7 +404,7 @@ export class CompoisteBarActionViewItem extends BaseActionViewItem { this.hoverDisposables.add(addDisposableListener(this.container, EventType.MOUSE_LEAVE, e => { if (e.target === this.container) { - CompoisteBarActionViewItem.hoverLeaveTime = Date.now(); + CompositeBarActionViewItem.hoverLeaveTime = Date.now(); this.hoverService.hideHover(); this.showHoverScheduler.cancel(); } @@ -467,7 +467,7 @@ export class CompositeOverflowActivityAction extends CompositeBarAction { } } -export class CompositeOverflowActivityActionViewItem extends CompoisteBarActionViewItem { +export class CompositeOverflowActivityActionViewItem extends CompositeBarActionViewItem { constructor( action: CompositeBarAction, @@ -529,7 +529,7 @@ class ManageExtensionAction extends Action { } } -export class CompositeActionViewItem extends CompoisteBarActionViewItem { +export class CompositeActionViewItem extends CompositeBarActionViewItem { private static manageExtensionAction: ManageExtensionAction; @@ -706,12 +706,12 @@ export class CompositeActionViewItem extends CompoisteBarActionViewItem { protected override updateChecked(): void { if (this.action.checked) { this.container.classList.add('checked'); - this.container.setAttribute('aria-label', this.container.title); + this.container.setAttribute('aria-label', this.getTooltip() ?? this.container.title); this.container.setAttribute('aria-expanded', 'true'); this.container.setAttribute('aria-selected', 'true'); } else { this.container.classList.remove('checked'); - this.container.setAttribute('aria-label', this.container.title); + this.container.setAttribute('aria-label', this.getTooltip() ?? this.container.title); this.container.setAttribute('aria-expanded', 'false'); this.container.setAttribute('aria-selected', 'false'); } diff --git a/src/vs/workbench/browser/parts/compositePart.ts b/src/vs/workbench/browser/parts/compositePart.ts index de055f7b032ba..3ea632e64d5d1 100644 --- a/src/vs/workbench/browser/parts/compositePart.ts +++ b/src/vs/workbench/browser/parts/compositePart.ts @@ -32,6 +32,10 @@ import { AbstractProgressScope, ScopedProgressIndicator } from 'vs/workbench/ser import { WorkbenchToolBar } from 'vs/platform/actions/browser/toolbar'; import { defaultProgressBarStyles } from 'vs/platform/theme/browser/defaultStyles'; import { IBoundarySashes } from 'vs/base/browser/ui/sash/sash'; +import { IBaseActionViewItemOptions } from 'vs/base/browser/ui/actionbar/actionViewItems'; +import { IHoverDelegate } from 'vs/base/browser/ui/hover/hoverDelegate'; +import { createInstantHoverDelegate, getDefaultHoverDelegate } from 'vs/base/browser/ui/hover/hoverDelegateFactory'; +import type { IHoverService } from 'vs/platform/hover/browser/hover'; export interface ICompositeTitleLabel { @@ -59,13 +63,14 @@ export abstract class CompositePart extends Part { protected toolBar: WorkbenchToolBar | undefined; protected titleLabelElement: HTMLElement | undefined; + protected readonly toolbarHoverDelegate: IHoverDelegate; private readonly mapCompositeToCompositeContainer = new Map(); private readonly mapActionsBindingToComposite = new Map void>(); private activeComposite: Composite | undefined; private lastActiveCompositeId: string; private readonly instantiatedCompositeItems = new Map(); - private titleLabel: ICompositeTitleLabel | undefined; + protected titleLabel: ICompositeTitleLabel | undefined; private progressBar: ProgressBar | undefined; private contentAreaSize: Dimension | undefined; private readonly actionsListener = this._register(new MutableDisposable()); @@ -78,6 +83,7 @@ export abstract class CompositePart extends Part { protected readonly contextMenuService: IContextMenuService, layoutService: IWorkbenchLayoutService, protected readonly keybindingService: IKeybindingService, + private readonly hoverService: IHoverService, protected readonly instantiationService: IInstantiationService, themeService: IThemeService, protected readonly registry: CompositeRegistry, @@ -92,6 +98,7 @@ export abstract class CompositePart extends Part { super(id, options, themeService, storageService, layoutService); this.lastActiveCompositeId = storageService.get(activeCompositeSettingsKey, StorageScope.WORKSPACE, this.defaultCompositeId); + this.toolbarHoverDelegate = this._register(createInstantHoverDelegate()); } protected openComposite(id: string, focus?: boolean): Composite | undefined { @@ -396,12 +403,13 @@ export abstract class CompositePart extends Part { // Toolbar this.toolBar = this._register(this.instantiationService.createInstance(WorkbenchToolBar, titleActionsContainer, { - actionViewItemProvider: action => this.actionViewItemProvider(action), + actionViewItemProvider: (action, options) => this.actionViewItemProvider(action, options), orientation: ActionsOrientation.HORIZONTAL, getKeyBinding: action => this.keybindingService.lookupKeybinding(action.id), anchorAlignmentProvider: () => this.getTitleAreaDropDownAnchorAlignment(), toggleMenuTitle: localize('viewsAndMoreActions', "Views and More Actions..."), - telemetrySource: this.nameForTelemetry + telemetrySource: this.nameForTelemetry, + hoverDelegate: this.toolbarHoverDelegate })); this.collectCompositeActions()(); @@ -413,6 +421,7 @@ export abstract class CompositePart extends Part { const titleContainer = append(parent, $('.title-label')); const titleLabel = append(titleContainer, $('h2')); this.titleLabelElement = titleLabel; + const hover = this._register(this.hoverService.setupUpdatableHover(getDefaultHoverDelegate('mouse'), titleLabel, '')); const $this = this; return { @@ -420,7 +429,7 @@ export abstract class CompositePart extends Part { // The title label is shared for all composites in the base CompositePart if (!this.activeComposite || this.activeComposite.getId() === id) { titleLabel.innerText = title; - titleLabel.title = keybinding ? localize('titleTooltip', "{0} ({1})", title, keybinding) : title; + hover.update(keybinding ? localize('titleTooltip', "{0} ({1})", title, keybinding) : title); } }, @@ -430,6 +439,14 @@ export abstract class CompositePart extends Part { }; } + protected createHeaderArea(): HTMLElement { + return $('.composite'); + } + + protected createFooterArea(): HTMLElement { + return $('.composite'); + } + override updateStyles(): void { super.updateStyles(); @@ -438,14 +455,14 @@ export abstract class CompositePart extends Part { titleLabel.updateStyles(); } - protected actionViewItemProvider(action: IAction): IActionViewItem | undefined { + protected actionViewItemProvider(action: IAction, options: IBaseActionViewItemOptions): IActionViewItem | undefined { // Check Active Composite if (this.activeComposite) { - return this.activeComposite.getActionViewItem(action); + return this.activeComposite.getActionViewItem(action, options); } - return createActionViewItem(this.instantiationService, action); + return createActionViewItem(this.instantiationService, action, options); } protected actionsContextProvider(): unknown { diff --git a/src/vs/workbench/browser/parts/editor/auxiliaryEditorPart.ts b/src/vs/workbench/browser/parts/editor/auxiliaryEditorPart.ts index 7e4dd2aeddf83..f6f50db50417c 100644 --- a/src/vs/workbench/browser/parts/editor/auxiliaryEditorPart.ts +++ b/src/vs/workbench/browser/parts/editor/auxiliaryEditorPart.ts @@ -171,6 +171,22 @@ export class AuxiliaryEditorPart { disposables.dispose(); })); disposables.add(Event.once(this.lifecycleService.onDidShutdown)(() => disposables.dispose())); + disposables.add(auxiliaryWindow.onBeforeUnload(event => { + for (const group of editorPart.groups) { + for (const editor of group.editors) { + // Closing an auxiliary window with opened editors + // will move the editors to the main window. As such, + // we need to validate that we can move and otherwise + // prevent the window from closing. + const canMoveVeto = editor.canMove(group.id, this.editorPartsView.mainPart.activeGroup.id); + if (typeof canMoveVeto === 'string') { + group.openEditor(editor); + event.veto(canMoveVeto); + break; + } + } + } + })); // Layout: specifically `onWillLayout` to have a chance // to build the aux editor part before other components diff --git a/src/vs/workbench/browser/parts/editor/binaryDiffEditor.ts b/src/vs/workbench/browser/parts/editor/binaryDiffEditor.ts index bcd00491191f7..f4314ae0dbb91 100644 --- a/src/vs/workbench/browser/parts/editor/binaryDiffEditor.ts +++ b/src/vs/workbench/browser/parts/editor/binaryDiffEditor.ts @@ -13,7 +13,7 @@ import { BaseBinaryResourceEditor } from 'vs/workbench/browser/parts/editor/bina import { IStorageService } from 'vs/platform/storage/common/storage'; import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; import { ITextResourceConfigurationService } from 'vs/editor/common/services/textResourceConfiguration'; -import { IEditorGroupsService } from 'vs/workbench/services/editor/common/editorGroupsService'; +import { IEditorGroup, IEditorGroupsService } from 'vs/workbench/services/editor/common/editorGroupsService'; import { IEditorService } from 'vs/workbench/services/editor/common/editorService'; /** @@ -24,6 +24,7 @@ export class BinaryResourceDiffEditor extends SideBySideEditor { static override readonly ID = BINARY_DIFF_EDITOR_ID; constructor( + group: IEditorGroup, @ITelemetryService telemetryService: ITelemetryService, @IInstantiationService instantiationService: IInstantiationService, @IThemeService themeService: IThemeService, @@ -33,7 +34,7 @@ export class BinaryResourceDiffEditor extends SideBySideEditor { @IEditorService editorService: IEditorService, @IEditorGroupsService editorGroupService: IEditorGroupsService ) { - super(telemetryService, instantiationService, themeService, storageService, configurationService, textResourceConfigurationService, editorService, editorGroupService); + super(group, telemetryService, instantiationService, themeService, storageService, configurationService, textResourceConfigurationService, editorService, editorGroupService); } getMetadata(): string | undefined { diff --git a/src/vs/workbench/browser/parts/editor/binaryEditor.ts b/src/vs/workbench/browser/parts/editor/binaryEditor.ts index 0fb42552e45bd..52a0190881832 100644 --- a/src/vs/workbench/browser/parts/editor/binaryEditor.ts +++ b/src/vs/workbench/browser/parts/editor/binaryEditor.ts @@ -13,6 +13,7 @@ import { IStorageService } from 'vs/platform/storage/common/storage'; import { ByteSize } from 'vs/platform/files/common/files'; import { IEditorOptions } from 'vs/platform/editor/common/editor'; import { EditorPlaceholder, IEditorPlaceholderContents } from 'vs/workbench/browser/parts/editor/editorPlaceholder'; +import { IEditorGroup } from 'vs/workbench/services/editor/common/editorGroupsService'; export interface IOpenCallbacks { openInternal: (input: EditorInput, options: IEditorOptions | undefined) => Promise; @@ -33,12 +34,13 @@ export abstract class BaseBinaryResourceEditor extends EditorPlaceholder { constructor( id: string, + group: IEditorGroup, private readonly callbacks: IOpenCallbacks, telemetryService: ITelemetryService, themeService: IThemeService, @IStorageService storageService: IStorageService ) { - super(id, telemetryService, themeService, storageService); + super(id, group, telemetryService, themeService, storageService); } override getTitle(): string { @@ -46,7 +48,7 @@ export abstract class BaseBinaryResourceEditor extends EditorPlaceholder { } protected async getContents(input: EditorInput, options: IEditorOptions): Promise { - const model = await input.resolve(options); + const model = await input.resolve(); // Assert Model instance if (!(model instanceof BinaryEditorModel)) { diff --git a/src/vs/workbench/browser/parts/editor/breadcrumbsControl.ts b/src/vs/workbench/browser/parts/editor/breadcrumbsControl.ts index 0eb27fcfcb660..38dfa709081b1 100644 --- a/src/vs/workbench/browser/parts/editor/breadcrumbsControl.ts +++ b/src/vs/workbench/browser/parts/editor/breadcrumbsControl.ts @@ -6,7 +6,6 @@ import * as dom from 'vs/base/browser/dom'; import { StandardMouseEvent } from 'vs/base/browser/mouseEvent'; import { BreadcrumbsItem, BreadcrumbsWidget, IBreadcrumbsItemEvent, IBreadcrumbsWidgetStyles } from 'vs/base/browser/ui/breadcrumbs/breadcrumbsWidget'; -import { tail } from 'vs/base/common/arrays'; import { timeout } from 'vs/base/common/async'; import { KeyCode, KeyMod } from 'vs/base/common/keyCodes'; import { combinedDisposable, DisposableStore, MutableDisposable, toDisposable } from 'vs/base/common/lifecycle'; @@ -40,6 +39,8 @@ import { registerIcon } from 'vs/platform/theme/common/iconRegistry'; import { Codicon } from 'vs/base/common/codicons'; import { defaultBreadcrumbsWidgetStyles } from 'vs/platform/theme/browser/defaultStyles'; import { Emitter } from 'vs/base/common/event'; +import { IHoverDelegate } from 'vs/base/browser/ui/hover/hoverDelegate'; +import { getDefaultHoverDelegate } from 'vs/base/browser/ui/hover/hoverDelegateFactory'; class OutlineItem extends BreadcrumbsItem { @@ -108,7 +109,8 @@ class FileItem extends BreadcrumbsItem { readonly model: BreadcrumbsModel, readonly element: FileElement, readonly options: IBreadcrumbsControlOptions, - private readonly _labels: ResourceLabels + private readonly _labels: ResourceLabels, + private readonly _hoverDelegate: IHoverDelegate ) { super(); } @@ -129,7 +131,7 @@ class FileItem extends BreadcrumbsItem { render(container: HTMLElement): void { // file/folder - const label = this._labels.create(container); + const label = this._labels.create(container, { hoverDelegate: this._hoverDelegate }); label.setFile(this.element.uri, { hidePath: true, hideIcon: this.element.kind === FileKind.FOLDER || !this.options.showFileIcons, @@ -186,6 +188,8 @@ export class BreadcrumbsControl { private _breadcrumbsPickerShowing = false; private _breadcrumbsPickerIgnoreOnceItem: BreadcrumbsItem | undefined; + private readonly _hoverDelegate: IHoverDelegate; + private readonly _onDidVisibilityChange = this._disposables.add(new Emitter()); get onDidVisibilityChange() { return this._onDidVisibilityChange.event; } @@ -201,7 +205,7 @@ export class BreadcrumbsControl { @IEditorService private readonly _editorService: IEditorService, @ILabelService private readonly _labelService: ILabelService, @IConfigurationService configurationService: IConfigurationService, - @IBreadcrumbsService breadcrumbsService: IBreadcrumbsService, + @IBreadcrumbsService breadcrumbsService: IBreadcrumbsService ) { this.domNode = document.createElement('div'); this.domNode.classList.add('breadcrumbs-control'); @@ -224,6 +228,8 @@ export class BreadcrumbsControl { this._ckBreadcrumbsVisible = BreadcrumbsControl.CK_BreadcrumbsVisible.bindTo(this._contextKeyService); this._ckBreadcrumbsActive = BreadcrumbsControl.CK_BreadcrumbsActive.bindTo(this._contextKeyService); + this._hoverDelegate = getDefaultHoverDelegate('mouse'); + this._disposables.add(breadcrumbsService.register(this._editorGroup.id, this._widget)); this.hide(); } @@ -321,7 +327,7 @@ export class BreadcrumbsControl { showFileIcons: this._options.showFileIcons && showIcons, showSymbolIcons: this._options.showSymbolIcons && showIcons }; - const items = model.getElements().map(element => element instanceof FileElement ? new FileItem(model, element, options, this._labels) : new OutlineItem(model, element, options)); + const items = model.getElements().map(element => element instanceof FileElement ? new FileItem(model, element, options, this._labels, this._hoverDelegate) : new OutlineItem(model, element, options)); if (items.length === 0) { this._widget.setEnabled(false); this._widget.setItems([new class extends BreadcrumbsItem { @@ -510,7 +516,7 @@ export class BreadcrumbsControl { this._widget.setSelection(items[idx + 1], BreadcrumbsControl.Payload_Pick); } } else { - element.outline.reveal(element, { pinned }, group === SIDE_GROUP); + element.outline.reveal(element, { pinned }, group === SIDE_GROUP, false); } } @@ -604,14 +610,15 @@ registerAction2(class ToggleBreadcrumb extends Action2 { category: Categories.View, toggled: { condition: ContextKeyExpr.equals('config.breadcrumbs.enabled', true), - title: localize('cmd.toggle2', "Breadcrumbs"), - mnemonicTitle: localize({ key: 'miBreadcrumbs2', comment: ['&& denotes a mnemonic'] }, "&&Breadcrumbs") + title: localize('cmd.toggle2', "Toggle Breadcrumbs"), + mnemonicTitle: localize({ key: 'miBreadcrumbs2', comment: ['&& denotes a mnemonic'] }, "Toggle &&Breadcrumbs") }, menu: [ { id: MenuId.CommandPalette }, { id: MenuId.MenubarAppearanceMenu, group: '4_editor', order: 2 }, { id: MenuId.NotebookToolbar, group: 'notebookLayout', order: 2 }, - { id: MenuId.StickyScrollContext } + { id: MenuId.StickyScrollContext }, + { id: MenuId.NotebookStickyScrollContext, group: 'notebookView', order: 2 } ] }); } @@ -631,7 +638,7 @@ function focusAndSelectHandler(accessor: ServicesAccessor, select: boolean): voi const breadcrumbs = accessor.get(IBreadcrumbsService); const widget = breadcrumbs.getWidget(groups.activeGroup.id); if (widget) { - const item = tail(widget.getItems()); + const item = widget.getItems().at(-1); widget.setFocused(item); if (select) { widget.setSelection(item, BreadcrumbsControl.Payload_Pick); @@ -852,7 +859,7 @@ KeybindingsRegistry.registerCommandAndKeybindingRule({ return (>input).reveal(element, { pinned: true, preserveFocus: false - }, true); + }, true, false); } } }); diff --git a/src/vs/workbench/browser/parts/editor/breadcrumbsPicker.ts b/src/vs/workbench/browser/parts/editor/breadcrumbsPicker.ts index 9ca0b4310a585..13c2df311198b 100644 --- a/src/vs/workbench/browser/parts/editor/breadcrumbsPicker.ts +++ b/src/vs/workbench/browser/parts/editor/breadcrumbsPicker.ts @@ -507,7 +507,7 @@ export class BreadcrumbsOutlinePicker extends BreadcrumbsPicker { protected async _revealElement(element: any, options: IEditorOptions, sideBySide: boolean): Promise { this._onWillPickElement.fire(); const outline: IOutline = this._tree.getInput(); - await outline.reveal(element, options, sideBySide); + await outline.reveal(element, options, sideBySide, false); return true; } } diff --git a/src/vs/workbench/browser/parts/editor/diffEditorCommands.ts b/src/vs/workbench/browser/parts/editor/diffEditorCommands.ts new file mode 100644 index 0000000000000..7a782b9ee3e9c --- /dev/null +++ b/src/vs/workbench/browser/parts/editor/diffEditorCommands.ts @@ -0,0 +1,232 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { KeyMod, KeyCode } from 'vs/base/common/keyCodes'; +import { localize2, localize } from 'vs/nls'; +import { MenuRegistry, MenuId } from 'vs/platform/actions/common/actions'; +import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; +import { ContextKeyExpr } from 'vs/platform/contextkey/common/contextkey'; +import { ServicesAccessor } from 'vs/platform/instantiation/common/instantiation'; +import { KeybindingsRegistry, KeybindingWeight } from 'vs/platform/keybinding/common/keybindingsRegistry'; +import { TextDiffEditor } from 'vs/workbench/browser/parts/editor/textDiffEditor'; +import { TextCompareEditorVisibleContext, TextCompareEditorActiveContext, ActiveCompareEditorCanSwapContext } from 'vs/workbench/common/contextkeys'; +import { DiffEditorInput } from 'vs/workbench/common/editor/diffEditorInput'; +import { IEditorService } from 'vs/workbench/services/editor/common/editorService'; + +export const TOGGLE_DIFF_SIDE_BY_SIDE = 'toggle.diff.renderSideBySide'; +export const GOTO_NEXT_CHANGE = 'workbench.action.compareEditor.nextChange'; +export const GOTO_PREVIOUS_CHANGE = 'workbench.action.compareEditor.previousChange'; +export const DIFF_FOCUS_PRIMARY_SIDE = 'workbench.action.compareEditor.focusPrimarySide'; +export const DIFF_FOCUS_SECONDARY_SIDE = 'workbench.action.compareEditor.focusSecondarySide'; +export const DIFF_FOCUS_OTHER_SIDE = 'workbench.action.compareEditor.focusOtherSide'; +export const DIFF_OPEN_SIDE = 'workbench.action.compareEditor.openSide'; +export const TOGGLE_DIFF_IGNORE_TRIM_WHITESPACE = 'toggle.diff.ignoreTrimWhitespace'; +export const DIFF_SWAP_SIDES = 'workbench.action.compareEditor.swapSides'; + +export function registerDiffEditorCommands(): void { + KeybindingsRegistry.registerCommandAndKeybindingRule({ + id: GOTO_NEXT_CHANGE, + weight: KeybindingWeight.WorkbenchContrib, + when: TextCompareEditorVisibleContext, + primary: KeyMod.Alt | KeyCode.F5, + handler: accessor => navigateInDiffEditor(accessor, true) + }); + + MenuRegistry.appendMenuItem(MenuId.CommandPalette, { + command: { + id: GOTO_NEXT_CHANGE, + title: localize2('compare.nextChange', 'Go to Next Change'), + } + }); + + KeybindingsRegistry.registerCommandAndKeybindingRule({ + id: GOTO_PREVIOUS_CHANGE, + weight: KeybindingWeight.WorkbenchContrib, + when: TextCompareEditorVisibleContext, + primary: KeyMod.Alt | KeyMod.Shift | KeyCode.F5, + handler: accessor => navigateInDiffEditor(accessor, false) + }); + + MenuRegistry.appendMenuItem(MenuId.CommandPalette, { + command: { + id: GOTO_PREVIOUS_CHANGE, + title: localize2('compare.previousChange', 'Go to Previous Change'), + } + }); + + function getActiveTextDiffEditor(accessor: ServicesAccessor): TextDiffEditor | undefined { + const editorService = accessor.get(IEditorService); + + for (const editor of [editorService.activeEditorPane, ...editorService.visibleEditorPanes]) { + if (editor instanceof TextDiffEditor) { + return editor; + } + } + + return undefined; + } + + function navigateInDiffEditor(accessor: ServicesAccessor, next: boolean): void { + const activeTextDiffEditor = getActiveTextDiffEditor(accessor); + + if (activeTextDiffEditor) { + activeTextDiffEditor.getControl()?.goToDiff(next ? 'next' : 'previous'); + } + } + + enum FocusTextDiffEditorMode { + Original, + Modified, + Toggle + } + + function focusInDiffEditor(accessor: ServicesAccessor, mode: FocusTextDiffEditorMode): void { + const activeTextDiffEditor = getActiveTextDiffEditor(accessor); + + if (activeTextDiffEditor) { + switch (mode) { + case FocusTextDiffEditorMode.Original: + activeTextDiffEditor.getControl()?.getOriginalEditor().focus(); + break; + case FocusTextDiffEditorMode.Modified: + activeTextDiffEditor.getControl()?.getModifiedEditor().focus(); + break; + case FocusTextDiffEditorMode.Toggle: + if (activeTextDiffEditor.getControl()?.getModifiedEditor().hasWidgetFocus()) { + return focusInDiffEditor(accessor, FocusTextDiffEditorMode.Original); + } else { + return focusInDiffEditor(accessor, FocusTextDiffEditorMode.Modified); + } + } + } + } + + function toggleDiffSideBySide(accessor: ServicesAccessor): void { + const configurationService = accessor.get(IConfigurationService); + + const newValue = !configurationService.getValue('diffEditor.renderSideBySide'); + configurationService.updateValue('diffEditor.renderSideBySide', newValue); + } + + function toggleDiffIgnoreTrimWhitespace(accessor: ServicesAccessor): void { + const configurationService = accessor.get(IConfigurationService); + + const newValue = !configurationService.getValue('diffEditor.ignoreTrimWhitespace'); + configurationService.updateValue('diffEditor.ignoreTrimWhitespace', newValue); + } + + async function swapDiffSides(accessor: ServicesAccessor): Promise { + const editorService = accessor.get(IEditorService); + + const diffEditor = getActiveTextDiffEditor(accessor); + const activeGroup = diffEditor?.group; + const diffInput = diffEditor?.input; + if (!diffEditor || typeof activeGroup === 'undefined' || !(diffInput instanceof DiffEditorInput) || !diffInput.modified.resource) { + return; + } + + const untypedDiffInput = diffInput.toUntyped({ preserveViewState: activeGroup.id, preserveResource: true }); + if (!untypedDiffInput) { + return; + } + + // Since we are about to replace the diff editor, make + // sure to first open the modified side if it is not + // yet opened. This ensures that the swapping is not + // bringing up a confirmation dialog to save. + if (diffInput.modified.isModified() && editorService.findEditors({ resource: diffInput.modified.resource, typeId: diffInput.modified.typeId, editorId: diffInput.modified.editorId }).length === 0) { + await editorService.openEditor({ + ...untypedDiffInput.modified, + options: { + ...untypedDiffInput.modified.options, + pinned: true, + inactive: true + } + }, activeGroup); + } + + // Replace the input with the swapped variant + await editorService.replaceEditors([ + { + editor: diffInput, + replacement: { + ...untypedDiffInput, + original: untypedDiffInput.modified, + modified: untypedDiffInput.original, + options: { + ...untypedDiffInput.options, + pinned: true + } + } + } + ], activeGroup); + } + + KeybindingsRegistry.registerCommandAndKeybindingRule({ + id: TOGGLE_DIFF_SIDE_BY_SIDE, + weight: KeybindingWeight.WorkbenchContrib, + when: undefined, + primary: undefined, + handler: accessor => toggleDiffSideBySide(accessor) + }); + + KeybindingsRegistry.registerCommandAndKeybindingRule({ + id: DIFF_FOCUS_PRIMARY_SIDE, + weight: KeybindingWeight.WorkbenchContrib, + when: undefined, + primary: undefined, + handler: accessor => focusInDiffEditor(accessor, FocusTextDiffEditorMode.Modified) + }); + + KeybindingsRegistry.registerCommandAndKeybindingRule({ + id: DIFF_FOCUS_SECONDARY_SIDE, + weight: KeybindingWeight.WorkbenchContrib, + when: undefined, + primary: undefined, + handler: accessor => focusInDiffEditor(accessor, FocusTextDiffEditorMode.Original) + }); + + KeybindingsRegistry.registerCommandAndKeybindingRule({ + id: DIFF_FOCUS_OTHER_SIDE, + weight: KeybindingWeight.WorkbenchContrib, + when: undefined, + primary: undefined, + handler: accessor => focusInDiffEditor(accessor, FocusTextDiffEditorMode.Toggle) + }); + + KeybindingsRegistry.registerCommandAndKeybindingRule({ + id: TOGGLE_DIFF_IGNORE_TRIM_WHITESPACE, + weight: KeybindingWeight.WorkbenchContrib, + when: undefined, + primary: undefined, + handler: accessor => toggleDiffIgnoreTrimWhitespace(accessor) + }); + + KeybindingsRegistry.registerCommandAndKeybindingRule({ + id: DIFF_SWAP_SIDES, + weight: KeybindingWeight.WorkbenchContrib, + when: undefined, + primary: undefined, + handler: accessor => swapDiffSides(accessor) + }); + + MenuRegistry.appendMenuItem(MenuId.CommandPalette, { + command: { + id: TOGGLE_DIFF_SIDE_BY_SIDE, + title: localize2('toggleInlineView', "Toggle Inline View"), + category: localize('compare', "Compare") + }, + when: TextCompareEditorActiveContext + }); + + MenuRegistry.appendMenuItem(MenuId.CommandPalette, { + command: { + id: DIFF_SWAP_SIDES, + title: localize2('swapDiffSides', "Swap Left and Right Editor Side"), + category: localize('compare', "Compare") + }, + when: ContextKeyExpr.and(TextCompareEditorActiveContext, ActiveCompareEditorCanSwapContext) + }); +} diff --git a/src/vs/workbench/browser/parts/editor/editor.contribution.ts b/src/vs/workbench/browser/parts/editor/editor.contribution.ts index ca21ad138646a..1611076ea5100 100644 --- a/src/vs/workbench/browser/parts/editor/editor.contribution.ts +++ b/src/vs/workbench/browser/parts/editor/editor.contribution.ts @@ -11,7 +11,7 @@ import { TextCompareEditorActiveContext, ActiveEditorPinnedContext, EditorGroupEditorsCountContext, ActiveEditorStickyContext, ActiveEditorAvailableEditorIdsContext, EditorPartMultipleEditorGroupsContext, ActiveEditorDirtyContext, ActiveEditorGroupLockedContext, ActiveEditorCanSplitInGroupContext, SideBySideEditorActiveContext, EditorTabsVisibleContext, ActiveEditorLastInGroupContext, EditorPartMaximizedEditorGroupContext, MultipleEditorGroupsContext, InEditorZenModeContext, - IsAuxiliaryEditorPartContext, ActiveCompareEditorOriginalWriteableContext + IsAuxiliaryEditorPartContext, ActiveCompareEditorCanSwapContext } from 'vs/workbench/common/contextkeys'; import { SideBySideEditorInput, SideBySideEditorInputSerializer } from 'vs/workbench/common/editor/sideBySideEditorInput'; import { TextResourceEditor } from 'vs/workbench/browser/parts/editor/textResourceEditor'; @@ -47,12 +47,13 @@ import { } from 'vs/workbench/browser/parts/editor/editorActions'; import { CLOSE_EDITORS_AND_GROUP_COMMAND_ID, CLOSE_EDITORS_IN_GROUP_COMMAND_ID, CLOSE_EDITORS_TO_THE_RIGHT_COMMAND_ID, CLOSE_EDITOR_COMMAND_ID, CLOSE_EDITOR_GROUP_COMMAND_ID, CLOSE_OTHER_EDITORS_IN_GROUP_COMMAND_ID, - CLOSE_PINNED_EDITOR_COMMAND_ID, CLOSE_SAVED_EDITORS_COMMAND_ID, GOTO_NEXT_CHANGE, GOTO_PREVIOUS_CHANGE, KEEP_EDITOR_COMMAND_ID, PIN_EDITOR_COMMAND_ID, SHOW_EDITORS_IN_GROUP, SPLIT_EDITOR_DOWN, SPLIT_EDITOR_LEFT, - SPLIT_EDITOR_RIGHT, SPLIT_EDITOR_UP, TOGGLE_DIFF_IGNORE_TRIM_WHITESPACE, TOGGLE_DIFF_SIDE_BY_SIDE, TOGGLE_KEEP_EDITORS_COMMAND_ID, UNPIN_EDITOR_COMMAND_ID, setup as registerEditorCommands, REOPEN_WITH_COMMAND_ID, + CLOSE_PINNED_EDITOR_COMMAND_ID, CLOSE_SAVED_EDITORS_COMMAND_ID, KEEP_EDITOR_COMMAND_ID, PIN_EDITOR_COMMAND_ID, SHOW_EDITORS_IN_GROUP, SPLIT_EDITOR_DOWN, SPLIT_EDITOR_LEFT, + SPLIT_EDITOR_RIGHT, SPLIT_EDITOR_UP, TOGGLE_KEEP_EDITORS_COMMAND_ID, UNPIN_EDITOR_COMMAND_ID, setup as registerEditorCommands, REOPEN_WITH_COMMAND_ID, TOGGLE_LOCK_GROUP_COMMAND_ID, UNLOCK_GROUP_COMMAND_ID, SPLIT_EDITOR_IN_GROUP, JOIN_EDITOR_IN_GROUP, FOCUS_FIRST_SIDE_EDITOR, FOCUS_SECOND_SIDE_EDITOR, TOGGLE_SPLIT_EDITOR_IN_GROUP_LAYOUT, LOCK_GROUP_COMMAND_ID, SPLIT_EDITOR, TOGGLE_MAXIMIZE_EDITOR_GROUP, MOVE_EDITOR_INTO_NEW_WINDOW_COMMAND_ID, COPY_EDITOR_INTO_NEW_WINDOW_COMMAND_ID, MOVE_EDITOR_GROUP_INTO_NEW_WINDOW_COMMAND_ID, COPY_EDITOR_GROUP_INTO_NEW_WINDOW_COMMAND_ID, - NEW_EMPTY_EDITOR_WINDOW_COMMAND_ID, DIFF_SWAP_SIDES + NEW_EMPTY_EDITOR_WINDOW_COMMAND_ID } from 'vs/workbench/browser/parts/editor/editorCommands'; +import { GOTO_NEXT_CHANGE, GOTO_PREVIOUS_CHANGE, TOGGLE_DIFF_IGNORE_TRIM_WHITESPACE, TOGGLE_DIFF_SIDE_BY_SIDE, DIFF_SWAP_SIDES } from './diffEditorCommands'; import { inQuickPickContext, getQuickNavigateHandler } from 'vs/workbench/browser/quickaccess'; import { KeybindingsRegistry, KeybindingWeight } from 'vs/platform/keybinding/common/keybindingsRegistry'; import { ContextKeyExpr, ContextKeyExpression } from 'vs/platform/contextkey/common/contextkey'; @@ -570,11 +571,8 @@ appendEditorToolItem( CLOSE_ORDER - 1, // immediately to the left of close action ); -const previousChangeIcon = registerIcon('diff-editor-previous-change', Codicon.arrowUp, localize('previousChangeIcon', 'Icon for the previous change action in the diff editor.')); -const nextChangeIcon = registerIcon('diff-editor-next-change', Codicon.arrowDown, localize('nextChangeIcon', 'Icon for the next change action in the diff editor.')); -const toggleWhitespace = registerIcon('diff-editor-toggle-whitespace', Codicon.whitespace, localize('toggleWhitespace', 'Icon for the toggle whitespace action in the diff editor.')); - // Diff Editor Title Menu: Previous Change +const previousChangeIcon = registerIcon('diff-editor-previous-change', Codicon.arrowUp, localize('previousChangeIcon', 'Icon for the previous change action in the diff editor.')); appendEditorToolItem( { id: GOTO_PREVIOUS_CHANGE, @@ -588,6 +586,7 @@ appendEditorToolItem( ); // Diff Editor Title Menu: Next Change +const nextChangeIcon = registerIcon('diff-editor-next-change', Codicon.arrowDown, localize('nextChangeIcon', 'Icon for the next change action in the diff editor.')); appendEditorToolItem( { id: GOTO_NEXT_CHANGE, @@ -607,12 +606,13 @@ appendEditorToolItem( title: localize('swapDiffSides', "Swap Left and Right Side"), icon: Codicon.arrowSwap }, - ContextKeyExpr.and(TextCompareEditorActiveContext, ActiveCompareEditorOriginalWriteableContext), + ContextKeyExpr.and(TextCompareEditorActiveContext, ActiveCompareEditorCanSwapContext), 15, undefined, undefined ); +const toggleWhitespace = registerIcon('diff-editor-toggle-whitespace', Codicon.whitespace, localize('toggleWhitespace', 'Icon for the toggle whitespace action in the diff editor.')); MenuRegistry.appendMenuItem(MenuId.EditorTitle, { command: { id: TOGGLE_DIFF_IGNORE_TRIM_WHITESPACE, diff --git a/src/vs/workbench/browser/parts/editor/editorAutoSave.ts b/src/vs/workbench/browser/parts/editor/editorAutoSave.ts index ddcce4134c0d5..2b3de674cb990 100644 --- a/src/vs/workbench/browser/parts/editor/editorAutoSave.ts +++ b/src/vs/workbench/browser/parts/editor/editorAutoSave.ts @@ -29,7 +29,7 @@ export class EditorAutoSave extends Disposable implements IWorkbenchContribution // Auto save: focus change & window change private lastActiveEditor: EditorInput | undefined = undefined; private lastActiveGroupId: GroupIdentifier | undefined = undefined; - private lastActiveEditorControlDisposable = this._register(new DisposableStore()); + private readonly lastActiveEditorControlDisposable = this._register(new DisposableStore()); // Auto save: waiting on specific condition private readonly waitingOnConditionAutoSaveWorkingCopies = new ResourceMap<{ readonly workingCopy: IWorkingCopy; readonly reason: SaveReason; condition: AutoSaveDisabledReason }>(resource => this.uriIdentityService.extUri.getComparisonKey(resource)); @@ -80,7 +80,7 @@ export class EditorAutoSave extends Disposable implements IWorkbenchContribution if (workingCopyResult?.condition === condition) { if ( workingCopyResult.workingCopy.isDirty() && - this.filesConfigurationService.getAutoSaveMode(workingCopyResult.workingCopy.resource).mode !== AutoSaveMode.OFF + this.filesConfigurationService.getAutoSaveMode(workingCopyResult.workingCopy.resource, workingCopyResult.reason).mode !== AutoSaveMode.OFF ) { this.discardAutoSave(workingCopyResult.workingCopy); @@ -96,7 +96,7 @@ export class EditorAutoSave extends Disposable implements IWorkbenchContribution editorResult?.condition === condition && !editorResult.editor.editor.isDisposed() && editorResult.editor.editor.isDirty() && - this.filesConfigurationService.getAutoSaveMode(editorResult.editor.editor).mode !== AutoSaveMode.OFF + this.filesConfigurationService.getAutoSaveMode(editorResult.editor.editor, editorResult.reason).mode !== AutoSaveMode.OFF ) { this.waitingOnConditionAutoSaveEditors.delete(resource); @@ -151,7 +151,7 @@ export class EditorAutoSave extends Disposable implements IWorkbenchContribution return; // no auto save for non-dirty, readonly or untitled editors } - const autoSaveMode = this.filesConfigurationService.getAutoSaveMode(editorIdentifier.editor); + const autoSaveMode = this.filesConfigurationService.getAutoSaveMode(editorIdentifier.editor, reason); if (autoSaveMode.mode !== AutoSaveMode.OFF) { // Determine if we need to save all. In case of a window focus change we also save if // auto save mode is configured to be ON_FOCUS_CHANGE (editor focus change) @@ -198,7 +198,7 @@ export class EditorAutoSave extends Disposable implements IWorkbenchContribution continue; // we never auto save untitled working copies } - const autoSaveMode = this.filesConfigurationService.getAutoSaveMode(workingCopy.resource); + const autoSaveMode = this.filesConfigurationService.getAutoSaveMode(workingCopy.resource, reason); if (autoSaveMode.mode !== AutoSaveMode.OFF) { workingCopy.save({ reason }); } else if (autoSaveMode.reason === AutoSaveDisabledReason.ERRORS || autoSaveMode.reason === AutoSaveDisabledReason.DISABLED) { @@ -257,12 +257,13 @@ export class EditorAutoSave extends Disposable implements IWorkbenchContribution // Save if dirty and unless prevented by other conditions such as error markers if (workingCopy.isDirty()) { - const autoSaveMode = this.filesConfigurationService.getAutoSaveMode(workingCopy.resource); + const reason = SaveReason.AUTO; + const autoSaveMode = this.filesConfigurationService.getAutoSaveMode(workingCopy.resource, reason); if (autoSaveMode.mode !== AutoSaveMode.OFF) { this.logService.trace(`[editor auto save] running auto save`, workingCopy.resource.toString(), workingCopy.typeId); - workingCopy.save({ reason: SaveReason.AUTO }); + workingCopy.save({ reason }); } else if (autoSaveMode.reason === AutoSaveDisabledReason.ERRORS || autoSaveMode.reason === AutoSaveDisabledReason.DISABLED) { - this.waitingOnConditionAutoSaveWorkingCopies.set(workingCopy.resource, { workingCopy, reason: SaveReason.AUTO, condition: autoSaveMode.reason }); + this.waitingOnConditionAutoSaveWorkingCopies.set(workingCopy.resource, { workingCopy, reason, condition: autoSaveMode.reason }); } } }, autoSaveAfterDelay); diff --git a/src/vs/workbench/browser/parts/editor/editorCommands.ts b/src/vs/workbench/browser/parts/editor/editorCommands.ts index 68cd3a6b32822..89795c710574f 100644 --- a/src/vs/workbench/browser/parts/editor/editorCommands.ts +++ b/src/vs/workbench/browser/parts/editor/editorCommands.ts @@ -16,7 +16,7 @@ import { isDiffEditor } from 'vs/editor/browser/editorBrowser'; import { EditorContextKeys } from 'vs/editor/common/editorContextKeys'; import { localize, localize2 } from 'vs/nls'; import { Categories } from 'vs/platform/action/common/actionCommonCategories'; -import { Action2, MenuId, MenuRegistry, registerAction2 } from 'vs/platform/actions/common/actions'; +import { Action2, registerAction2 } from 'vs/platform/actions/common/actions'; import { CommandsRegistry, ICommandHandler, ICommandService } from 'vs/platform/commands/common/commands'; import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; import { ContextKeyExpr } from 'vs/platform/contextkey/common/contextkey'; @@ -30,7 +30,7 @@ import { ITelemetryService } from 'vs/platform/telemetry/common/telemetry'; import { ActiveGroupEditorsByMostRecentlyUsedQuickAccess } from 'vs/workbench/browser/parts/editor/editorQuickAccess'; import { SideBySideEditor } from 'vs/workbench/browser/parts/editor/sideBySideEditor'; import { TextDiffEditor } from 'vs/workbench/browser/parts/editor/textDiffEditor'; -import { ActiveEditorCanSplitInGroupContext, ActiveEditorGroupEmptyContext, ActiveEditorGroupLockedContext, ActiveEditorStickyContext, MultipleEditorGroupsContext, SideBySideEditorActiveContext, TextCompareEditorActiveContext, TextCompareEditorVisibleContext } from 'vs/workbench/common/contextkeys'; +import { ActiveEditorCanSplitInGroupContext, ActiveEditorGroupEmptyContext, ActiveEditorGroupLockedContext, ActiveEditorStickyContext, MultipleEditorGroupsContext, SideBySideEditorActiveContext, TextCompareEditorActiveContext } from 'vs/workbench/common/contextkeys'; import { CloseDirection, EditorInputCapabilities, EditorsOrder, IEditorCommandsContext, IEditorIdentifier, IResourceDiffEditorInput, IUntitledTextResourceEditorInput, IVisibleEditorPane, isEditorIdentifier, isEditorInputWithOptionsAndGroup } from 'vs/workbench/common/editor'; import { DiffEditorInput } from 'vs/workbench/common/editor/diffEditorInput'; import { EditorInput } from 'vs/workbench/common/editor/editorInput'; @@ -41,6 +41,7 @@ import { IEditorResolverService } from 'vs/workbench/services/editor/common/edit import { IEditorService, SIDE_GROUP } from 'vs/workbench/services/editor/common/editorService'; import { IPathService } from 'vs/workbench/services/path/common/pathService'; import { IUntitledTextEditorService } from 'vs/workbench/services/untitled/common/untitledTextEditorService'; +import { DIFF_FOCUS_OTHER_SIDE, DIFF_FOCUS_PRIMARY_SIDE, DIFF_FOCUS_SECONDARY_SIDE, DIFF_OPEN_SIDE, registerDiffEditorCommands } from './diffEditorCommands'; export const CLOSE_SAVED_EDITORS_COMMAND_ID = 'workbench.action.closeUnmodifiedEditors'; export const CLOSE_EDITORS_IN_GROUP_COMMAND_ID = 'workbench.action.closeEditorsInGroup'; @@ -65,16 +66,6 @@ export const REOPEN_WITH_COMMAND_ID = 'workbench.action.reopenWithEditor'; export const PIN_EDITOR_COMMAND_ID = 'workbench.action.pinEditor'; export const UNPIN_EDITOR_COMMAND_ID = 'workbench.action.unpinEditor'; -export const TOGGLE_DIFF_SIDE_BY_SIDE = 'toggle.diff.renderSideBySide'; -export const GOTO_NEXT_CHANGE = 'workbench.action.compareEditor.nextChange'; -export const GOTO_PREVIOUS_CHANGE = 'workbench.action.compareEditor.previousChange'; -export const DIFF_FOCUS_PRIMARY_SIDE = 'workbench.action.compareEditor.focusPrimarySide'; -export const DIFF_FOCUS_SECONDARY_SIDE = 'workbench.action.compareEditor.focusSecondarySide'; -export const DIFF_FOCUS_OTHER_SIDE = 'workbench.action.compareEditor.focusOtherSide'; -export const DIFF_OPEN_SIDE = 'workbench.action.compareEditor.openSide'; -export const TOGGLE_DIFF_IGNORE_TRIM_WHITESPACE = 'toggle.diff.ignoreTrimWhitespace'; -export const DIFF_SWAP_SIDES = 'workbench.action.compareEditor.swapSides'; - export const SPLIT_EDITOR = 'workbench.action.splitEditor'; export const SPLIT_EDITOR_UP = 'workbench.action.splitEditorUp'; export const SPLIT_EDITOR_DOWN = 'workbench.action.splitEditorDown'; @@ -373,212 +364,6 @@ function registerEditorGroupsLayoutCommands(): void { }); } -function registerDiffEditorCommands(): void { - KeybindingsRegistry.registerCommandAndKeybindingRule({ - id: GOTO_NEXT_CHANGE, - weight: KeybindingWeight.WorkbenchContrib, - when: TextCompareEditorVisibleContext, - primary: KeyMod.Alt | KeyCode.F5, - handler: accessor => navigateInDiffEditor(accessor, true) - }); - - MenuRegistry.appendMenuItem(MenuId.CommandPalette, { - command: { - id: GOTO_NEXT_CHANGE, - title: localize2('compare.nextChange', 'Go to Next Change'), - } - }); - - KeybindingsRegistry.registerCommandAndKeybindingRule({ - id: GOTO_PREVIOUS_CHANGE, - weight: KeybindingWeight.WorkbenchContrib, - when: TextCompareEditorVisibleContext, - primary: KeyMod.Alt | KeyMod.Shift | KeyCode.F5, - handler: accessor => navigateInDiffEditor(accessor, false) - }); - - MenuRegistry.appendMenuItem(MenuId.CommandPalette, { - command: { - id: GOTO_PREVIOUS_CHANGE, - title: localize2('compare.previousChange', 'Go to Previous Change'), - } - }); - - function getActiveTextDiffEditor(accessor: ServicesAccessor): TextDiffEditor | undefined { - const editorService = accessor.get(IEditorService); - - for (const editor of [editorService.activeEditorPane, ...editorService.visibleEditorPanes]) { - if (editor instanceof TextDiffEditor) { - return editor; - } - } - - return undefined; - } - - function navigateInDiffEditor(accessor: ServicesAccessor, next: boolean): void { - const activeTextDiffEditor = getActiveTextDiffEditor(accessor); - - if (activeTextDiffEditor) { - activeTextDiffEditor.getControl()?.goToDiff(next ? 'next' : 'previous'); - } - } - - enum FocusTextDiffEditorMode { - Original, - Modified, - Toggle - } - - function focusInDiffEditor(accessor: ServicesAccessor, mode: FocusTextDiffEditorMode): void { - const activeTextDiffEditor = getActiveTextDiffEditor(accessor); - - if (activeTextDiffEditor) { - switch (mode) { - case FocusTextDiffEditorMode.Original: - activeTextDiffEditor.getControl()?.getOriginalEditor().focus(); - break; - case FocusTextDiffEditorMode.Modified: - activeTextDiffEditor.getControl()?.getModifiedEditor().focus(); - break; - case FocusTextDiffEditorMode.Toggle: - if (activeTextDiffEditor.getControl()?.getModifiedEditor().hasWidgetFocus()) { - return focusInDiffEditor(accessor, FocusTextDiffEditorMode.Original); - } else { - return focusInDiffEditor(accessor, FocusTextDiffEditorMode.Modified); - } - } - } - } - - function toggleDiffSideBySide(accessor: ServicesAccessor): void { - const configurationService = accessor.get(IConfigurationService); - - const newValue = !configurationService.getValue('diffEditor.renderSideBySide'); - configurationService.updateValue('diffEditor.renderSideBySide', newValue); - } - - function toggleDiffIgnoreTrimWhitespace(accessor: ServicesAccessor): void { - const configurationService = accessor.get(IConfigurationService); - - const newValue = !configurationService.getValue('diffEditor.ignoreTrimWhitespace'); - configurationService.updateValue('diffEditor.ignoreTrimWhitespace', newValue); - } - - async function swapDiffSides(accessor: ServicesAccessor): Promise { - const editorService = accessor.get(IEditorService); - - const diffEditor = getActiveTextDiffEditor(accessor); - const activeGroup = diffEditor?.group; - const diffInput = diffEditor?.input; - if (!diffEditor || typeof activeGroup === 'undefined' || !(diffInput instanceof DiffEditorInput) || !diffInput.modified.resource) { - return; - } - - const untypedDiffInput = diffInput.toUntyped({ preserveViewState: activeGroup.id, preserveResource: true }); - if (!untypedDiffInput) { - return; - } - - // Since we are about to replace the diff editor, make - // sure to first open the modified side if it is not - // yet opened. This ensures that the swapping is not - // bringing up a confirmation dialog to save. - if (diffInput.modified.isModified() && editorService.findEditors({ resource: diffInput.modified.resource, typeId: diffInput.modified.typeId, editorId: diffInput.modified.editorId }).length === 0) { - await editorService.openEditor({ - ...untypedDiffInput.modified, - options: { - ...untypedDiffInput.modified.options, - pinned: true, - inactive: true - } - }, activeGroup); - } - - // Replace the input with the swapped variant - await editorService.replaceEditors([ - { - editor: diffInput, - replacement: { - ...untypedDiffInput, - original: untypedDiffInput.modified, - modified: untypedDiffInput.original, - options: { - ...untypedDiffInput.options, - pinned: true - } - } - } - ], activeGroup); - } - - KeybindingsRegistry.registerCommandAndKeybindingRule({ - id: TOGGLE_DIFF_SIDE_BY_SIDE, - weight: KeybindingWeight.WorkbenchContrib, - when: undefined, - primary: undefined, - handler: accessor => toggleDiffSideBySide(accessor) - }); - - KeybindingsRegistry.registerCommandAndKeybindingRule({ - id: DIFF_FOCUS_PRIMARY_SIDE, - weight: KeybindingWeight.WorkbenchContrib, - when: undefined, - primary: undefined, - handler: accessor => focusInDiffEditor(accessor, FocusTextDiffEditorMode.Modified) - }); - - KeybindingsRegistry.registerCommandAndKeybindingRule({ - id: DIFF_FOCUS_SECONDARY_SIDE, - weight: KeybindingWeight.WorkbenchContrib, - when: undefined, - primary: undefined, - handler: accessor => focusInDiffEditor(accessor, FocusTextDiffEditorMode.Original) - }); - - KeybindingsRegistry.registerCommandAndKeybindingRule({ - id: DIFF_FOCUS_OTHER_SIDE, - weight: KeybindingWeight.WorkbenchContrib, - when: undefined, - primary: undefined, - handler: accessor => focusInDiffEditor(accessor, FocusTextDiffEditorMode.Toggle) - }); - - KeybindingsRegistry.registerCommandAndKeybindingRule({ - id: TOGGLE_DIFF_IGNORE_TRIM_WHITESPACE, - weight: KeybindingWeight.WorkbenchContrib, - when: undefined, - primary: undefined, - handler: accessor => toggleDiffIgnoreTrimWhitespace(accessor) - }); - - KeybindingsRegistry.registerCommandAndKeybindingRule({ - id: DIFF_SWAP_SIDES, - weight: KeybindingWeight.WorkbenchContrib, - when: undefined, - primary: undefined, - handler: accessor => swapDiffSides(accessor) - }); - - MenuRegistry.appendMenuItem(MenuId.CommandPalette, { - command: { - id: TOGGLE_DIFF_SIDE_BY_SIDE, - title: localize2('toggleInlineView', "Toggle Inline View"), - category: localize('compare', "Compare") - }, - when: TextCompareEditorActiveContext - }); - - MenuRegistry.appendMenuItem(MenuId.CommandPalette, { - command: { - id: DIFF_SWAP_SIDES, - title: localize2('swapDiffSides', "Swap Left and Right Editor Side"), - category: localize('compare', "Compare") - }, - when: TextCompareEditorActiveContext - }); -} - function registerOpenEditorAPICommands(): void { function mixinContext(context: IOpenEvent | undefined, options: ITextEditorOptions | undefined, column: EditorGroupColumn | undefined): [ITextEditorOptions | undefined, EditorGroupColumn | undefined] { diff --git a/src/vs/workbench/browser/parts/editor/editorConfiguration.ts b/src/vs/workbench/browser/parts/editor/editorConfiguration.ts index 9f3008f7385e8..0df198dd6de29 100644 --- a/src/vs/workbench/browser/parts/editor/editorConfiguration.ts +++ b/src/vs/workbench/browser/parts/editor/editorConfiguration.ts @@ -93,7 +93,7 @@ export class DynamicEditorConfigurations extends Disposable implements IWorkbenc private registerListeners(): void { // Registered editors (debounced to reduce perf overhead) - Event.debounce(this.editorResolverService.onDidChangeEditorRegistrations, (_, e) => e)(() => this.updateDynamicEditorConfigurations()); + this._register(Event.debounce(this.editorResolverService.onDidChangeEditorRegistrations, (_, e) => e)(() => this.updateDynamicEditorConfigurations())); } private updateDynamicEditorConfigurations(): void { diff --git a/src/vs/workbench/browser/parts/editor/editorGroupView.ts b/src/vs/workbench/browser/parts/editor/editorGroupView.ts index f0bdfdd5bc9f8..16b04556d217d 100644 --- a/src/vs/workbench/browser/parts/editor/editorGroupView.ts +++ b/src/vs/workbench/browser/parts/editor/editorGroupView.ts @@ -11,7 +11,7 @@ import { EditorInput } from 'vs/workbench/common/editor/editorInput'; import { SideBySideEditorInput } from 'vs/workbench/common/editor/sideBySideEditorInput'; import { Emitter, Relay } from 'vs/base/common/event'; import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; -import { Dimension, trackFocus, addDisposableListener, EventType, EventHelper, findParentWithClass, isAncestor, IDomNodePagePosition, isMouseEvent, isActiveElement, focusWindow, getWindow, getActiveElement } from 'vs/base/browser/dom'; +import { Dimension, trackFocus, addDisposableListener, EventType, EventHelper, findParentWithClass, isAncestor, IDomNodePagePosition, isMouseEvent, isActiveElement, getWindow, getActiveElement } from 'vs/base/browser/dom'; import { ServiceCollection } from 'vs/platform/instantiation/common/serviceCollection'; import { IContextKeyService } from 'vs/platform/contextkey/common/contextkey'; import { ProgressBar } from 'vs/base/browser/ui/progressbar/progressbar'; @@ -42,7 +42,7 @@ import { getMimeTypes } from 'vs/editor/common/services/languagesAssociations'; import { extname, isEqual } from 'vs/base/common/resources'; import { Schemas } from 'vs/base/common/network'; import { EditorActivation, IEditorOptions } from 'vs/platform/editor/common/editor'; -import { IFileDialogService, ConfirmResult } from 'vs/platform/dialogs/common/dialogs'; +import { IFileDialogService, ConfirmResult, IDialogService } from 'vs/platform/dialogs/common/dialogs'; import { IFilesConfigurationService, AutoSaveMode } from 'vs/workbench/services/filesConfiguration/common/filesConfigurationService'; import { URI } from 'vs/base/common/uri'; import { IUriIdentityService } from 'vs/platform/uriIdentity/common/uriIdentity'; @@ -156,7 +156,8 @@ export class EditorGroupView extends Themable implements IEditorGroupView { @IUriIdentityService private readonly uriIdentityService: IUriIdentityService, @ILogService private readonly logService: ILogService, @IEditorResolverService private readonly editorResolverService: IEditorResolverService, - @IHostService private readonly hostService: IHostService + @IHostService private readonly hostService: IHostService, + @IDialogService private readonly dialogService: IDialogService ) { super(themeService); @@ -544,6 +545,9 @@ export class EditorGroupView extends Themable implements IEditorGroupView { // Visibility this._register(this.groupsView.onDidVisibilityChange(e => this.onDidVisibilityChange(e))); + + // Focus + this._register(this.onDidFocus(() => this.onDidGainFocus())); } private onDidGroupModelChange(e: IGroupModelChangeEvent): void { @@ -578,6 +582,9 @@ export class EditorGroupView extends Themable implements IEditorGroupView { case GroupModelChangeKind.EDITOR_DIRTY: this.onDidChangeEditorDirty(e.editor); break; + case GroupModelChangeKind.EDITOR_TRANSIENT: + this.onDidChangeEditorTransient(e.editor); + break; case GroupModelChangeKind.EDITOR_LABEL: this.onDidChangeEditorLabel(e.editor); break; @@ -653,17 +660,27 @@ export class EditorGroupView extends Themable implements IEditorGroupView { return true; } + private toResourceTelemetryDescriptor(resource: URI): object | undefined { + if (!resource) { + return undefined; + } + const path = resource ? resource.scheme === Schemas.file ? resource.fsPath : resource.path : undefined; + if (!path) { + return undefined; + } + let resourceExt = extname(resource); + // Remove query parameters from the resource extension + const queryStringLocation = resourceExt.indexOf('?'); + resourceExt = queryStringLocation !== -1 ? resourceExt.substr(0, queryStringLocation) : resourceExt; + return { mimeType: new TelemetryTrustedValue(getMimeTypes(resource).join(', ')), scheme: resource.scheme, ext: resourceExt, path: hash(path) }; + } + private toEditorTelemetryDescriptor(editor: EditorInput): object { const descriptor = editor.getTelemetryDescriptor(); - const resource = EditorResourceAccessor.getOriginalUri(editor); - const path = resource ? resource.scheme === Schemas.file ? resource.fsPath : resource.path : undefined; - if (resource && path) { - let resourceExt = extname(resource); - // Remove query parameters from the resource extension - const queryStringLocation = resourceExt.indexOf('?'); - resourceExt = queryStringLocation !== -1 ? resourceExt.substr(0, queryStringLocation) : resourceExt; - descriptor['resource'] = { mimeType: new TelemetryTrustedValue(getMimeTypes(resource).join(', ')), scheme: resource.scheme, ext: resourceExt, path: hash(path) }; + const resource = EditorResourceAccessor.getOriginalUri(editor, { supportSideBySide: SideBySideEditor.BOTH }); + if (URI.isUri(resource)) { + descriptor['resource'] = this.toResourceTelemetryDescriptor(resource); /* __GDPR__FRAGMENT__ "EditorTelemetryDescriptor" : { @@ -671,6 +688,20 @@ export class EditorGroupView extends Themable implements IEditorGroupView { } */ return descriptor; + } else if (resource) { + if (resource.primary) { + descriptor['resource'] = this.toResourceTelemetryDescriptor(resource.primary); + } + if (resource.secondary) { + descriptor['resourceSecondary'] = this.toResourceTelemetryDescriptor(resource.secondary); + } + /* __GDPR__FRAGMENT__ + "EditorTelemetryDescriptor" : { + "resource": { "${inline}": [ "${URIDescriptor}" ] }, + "resourceSecondary": { "${inline}": [ "${URIDescriptor}" ] } + } + */ + return descriptor; } return descriptor; @@ -714,7 +745,7 @@ export class EditorGroupView extends Themable implements IEditorGroupView { // Close active one last if (activeEditor) { - this.doCloseEditor(activeEditor); + this.doCloseEditor(activeEditor, true); } } @@ -762,6 +793,17 @@ export class EditorGroupView extends Themable implements IEditorGroupView { this.titleControl.updateEditorDirty(editor); } + private onDidChangeEditorTransient(editor: EditorInput): void { + const transient = this.model.isTransient(editor); + + // Transient state overrides the `enablePreview` setting, + // so when an editor leaves the transient state, we have + // to ensure its preview state is also cleared. + if (!transient && !this.groupsView.partOptions.enablePreview) { + this.pinEditor(editor); + } + } + private onDidChangeEditorLabel(editor: EditorInput): void { // Forward to title control @@ -774,6 +816,18 @@ export class EditorGroupView extends Themable implements IEditorGroupView { this.editorPane.setVisible(visible); } + private onDidGainFocus(): void { + if (this.activeEditor) { + + // We aggressively clear the transient state of editors + // as soon as the group gains focus. This is to ensure + // that the transient state is not staying around when + // the user interacts with the editor. + + this.model.setTransient(this.activeEditor, false); + } + } + //#endregion //#region IEditorGroupView @@ -886,6 +940,10 @@ export class EditorGroupView extends Themable implements IEditorGroupView { return this.model.isSticky(editorOrIndex); } + isTransient(editorOrIndex: EditorInput | number): boolean { + return this.model.isTransient(editorOrIndex); + } + isActive(editor: EditorInput | IUntypedEditorInput): boolean { return this.model.isActive(editor); } @@ -943,9 +1001,6 @@ export class EditorGroupView extends Themable implements IEditorGroupView { focus(): void { - // Ensure window focus - focusWindow(this.element); - // Pass focus to editor panes if (this.activeEditorPane) { this.activeEditorPane.focus(); @@ -1033,7 +1088,7 @@ export class EditorGroupView extends Themable implements IEditorGroupView { // Determine options const pinned = options?.sticky - || !this.groupsView.partOptions.enablePreview + || (!this.groupsView.partOptions.enablePreview && !options?.transient) || editor.isDirty() || (options?.pinned ?? typeof options?.index === 'number' /* unless specified, prefer to pin when opening with index */) || (typeof options?.index === 'number' && this.model.isSticky(options.index)) @@ -1042,6 +1097,7 @@ export class EditorGroupView extends Themable implements IEditorGroupView { index: options ? options.index : undefined, pinned, sticky: options?.sticky || (typeof options?.index === 'number' && this.model.isSticky(options.index)), + transient: !!options?.transient, active: this.count === 0 || !options || !options.inactive, supportSideBySide: internalOptions?.supportSideBySide }; @@ -1215,7 +1271,7 @@ export class EditorGroupView extends Themable implements IEditorGroupView { //#region moveEditor() - moveEditors(editors: { editor: EditorInput; options?: IEditorOptions }[], target: EditorGroupView): void { + moveEditors(editors: { editor: EditorInput; options?: IEditorOptions }[], target: EditorGroupView): boolean { // Optimization: knowing that we move many editors, we // delay the title update to a later point for this group @@ -1226,29 +1282,38 @@ export class EditorGroupView extends Themable implements IEditorGroupView { skipTitleUpdate: this !== target }; + let moveFailed = false; + + const movedEditors = new Set(); for (const { editor, options } of editors) { - this.moveEditor(editor, target, options, internalOptions); + if (this.moveEditor(editor, target, options, internalOptions)) { + movedEditors.add(editor); + } else { + moveFailed = true; + } } // Update the title control all at once with all editors // in source and target if the title update was skipped if (internalOptions.skipTitleUpdate) { - const movedEditors = editors.map(({ editor }) => editor); - target.titleControl.openEditors(movedEditors); - this.titleControl.closeEditors(movedEditors); + target.titleControl.openEditors(Array.from(movedEditors)); + this.titleControl.closeEditors(Array.from(movedEditors)); } + + return !moveFailed; } - moveEditor(editor: EditorInput, target: EditorGroupView, options?: IEditorOptions, internalOptions?: IInternalMoveCopyOptions): void { + moveEditor(editor: EditorInput, target: EditorGroupView, options?: IEditorOptions, internalOptions?: IInternalMoveCopyOptions): boolean { // Move within same group if (this === target) { this.doMoveEditorInsideGroup(editor, options); + return true; } // Move across groups else { - this.doMoveOrCopyEditorAcrossGroups(editor, target, options, { ...internalOptions, keepCopy: false }); + return this.doMoveOrCopyEditorAcrossGroups(editor, target, options, { ...internalOptions, keepCopy: false }); } } @@ -1289,9 +1354,19 @@ export class EditorGroupView extends Themable implements IEditorGroupView { } } - private doMoveOrCopyEditorAcrossGroups(editor: EditorInput, target: EditorGroupView, openOptions?: IEditorOpenOptions, internalOptions?: IInternalMoveCopyOptions): void { + private doMoveOrCopyEditorAcrossGroups(editor: EditorInput, target: EditorGroupView, openOptions?: IEditorOpenOptions, internalOptions?: IInternalMoveCopyOptions): boolean { const keepCopy = internalOptions?.keepCopy; + // Validate that we can move + if (!keepCopy || editor.hasCapability(EditorInputCapabilities.Singleton) /* singleton editors will always move */) { + const canMoveVeto = editor.canMove(this.id, target.id); + if (typeof canMoveVeto === 'string') { + this.dialogService.error(canMoveVeto, localize('moveErrorDetails', "Try saving or reverting the editor first and then try again.")); + + return false; + } + } + // When moving/copying an editor, try to preserve as much view state as possible // by checking for the editor to be a text editor and creating the options accordingly // if so @@ -1317,6 +1392,8 @@ export class EditorGroupView extends Themable implements IEditorGroupView { if (!keepCopy) { this.doCloseEditor(editor, true /* do not focus next one behind if any */, { ...internalOptions, context: EditorCloseContext.MOVE }); } + + return true; } //#endregion diff --git a/src/vs/workbench/browser/parts/editor/editorGroupWatermark.ts b/src/vs/workbench/browser/parts/editor/editorGroupWatermark.ts index 3daa20701a79d..77d8a4458579c 100644 --- a/src/vs/workbench/browser/parts/editor/editorGroupWatermark.ts +++ b/src/vs/workbench/browser/parts/editor/editorGroupWatermark.ts @@ -62,6 +62,7 @@ export class EditorGroupWatermark extends Disposable { private readonly transientDisposables = this._register(new DisposableStore()); private enabled: boolean = false; private workbenchState: WorkbenchState; + private keybindingLabel?: KeybindingLabel; constructor( container: HTMLElement, @@ -145,8 +146,9 @@ export class EditorGroupWatermark extends Disposable { const dt = append(dl, $('dt')); dt.textContent = entry.text; const dd = append(dl, $('dd')); - const keybinding = new KeybindingLabel(dd, OS, { renderUnboundKeybindings: true, ...defaultKeybindingLabelStyles }); - keybinding.set(keys); + this.keybindingLabel?.dispose(); + this.keybindingLabel = new KeybindingLabel(dd, OS, { renderUnboundKeybindings: true, ...defaultKeybindingLabelStyles }); + this.keybindingLabel.set(keys); } }; @@ -162,5 +164,6 @@ export class EditorGroupWatermark extends Disposable { override dispose(): void { super.dispose(); this.clear(); + this.keybindingLabel?.dispose(); } } diff --git a/src/vs/workbench/browser/parts/editor/editorPane.ts b/src/vs/workbench/browser/parts/editor/editorPane.ts index 0f26fa1aa2061..7a73fe46fc531 100644 --- a/src/vs/workbench/browser/parts/editor/editorPane.ts +++ b/src/vs/workbench/browser/parts/editor/editorPane.ts @@ -24,6 +24,7 @@ import { IContextKeyService } from 'vs/platform/contextkey/common/contextkey'; import { IEditorOptions } from 'vs/platform/editor/common/editor'; import { ITextResourceConfigurationChangeEvent, ITextResourceConfigurationService } from 'vs/editor/common/services/textResourceConfiguration'; import { IBoundarySashes } from 'vs/base/browser/ui/sash/sash'; +import { getWindowById } from 'vs/base/browser/dom'; /** * The base class of editors in the workbench. Editors register themselves for specific editor inputs. @@ -70,8 +71,7 @@ export abstract class EditorPane extends Composite implements IEditorPane { protected _options: IEditorOptions | undefined; get options(): IEditorOptions | undefined { return this._options; } - private _group: IEditorGroup | undefined; - get group(): IEditorGroup | undefined { return this._group; } + get window() { return getWindowById(this.group.windowId, true).window; } /** * Should be overridden by editors that have their own ScopedContextKeyService @@ -80,6 +80,7 @@ export abstract class EditorPane extends Composite implements IEditorPane { constructor( id: string, + readonly group: IEditorGroup, telemetryService: ITelemetryService, themeService: IThemeService, storageService: IStorageService @@ -145,22 +146,20 @@ export abstract class EditorPane extends Composite implements IEditorPane { this._options = options; } - override setVisible(visible: boolean, group?: IEditorGroup): void { + override setVisible(visible: boolean): void { super.setVisible(visible); // Propagate to Editor - this.setEditorVisible(visible, group); + this.setEditorVisible(visible); } /** - * Indicates that the editor control got visible or hidden in a specific group. A - * editor instance will only ever be visible in one editor group. + * Indicates that the editor control got visible or hidden. * * @param visible the state of visibility of this editor - * @param group the editor group this editor is in. */ - protected setEditorVisible(visible: boolean, group: IEditorGroup | undefined): void { - this._group = group; + protected setEditorVisible(visible: boolean): void { + // Subclasses can implement } setBoundarySashes(_sashes: IBoundarySashes) { diff --git a/src/vs/workbench/browser/parts/editor/editorPanes.ts b/src/vs/workbench/browser/parts/editor/editorPanes.ts index 1094f3158425f..5d638871273ab 100644 --- a/src/vs/workbench/browser/parts/editor/editorPanes.ts +++ b/src/vs/workbench/browser/parts/editor/editorPanes.ts @@ -4,13 +4,13 @@ *--------------------------------------------------------------------------------------------*/ import { localize } from 'vs/nls'; -import { IAction, toAction } from 'vs/base/common/actions'; +import { IAction } from 'vs/base/common/actions'; import { Emitter } from 'vs/base/common/event'; import Severity from 'vs/base/common/severity'; import { Disposable, DisposableStore } from 'vs/base/common/lifecycle'; -import { EditorExtensions, EditorInputCapabilities, IEditorOpenContext, IVisibleEditorPane, createEditorOpenError, isEditorOpenError } from 'vs/workbench/common/editor'; +import { EditorExtensions, EditorInputCapabilities, IEditorOpenContext, IVisibleEditorPane, isEditorOpenError } from 'vs/workbench/common/editor'; import { EditorInput } from 'vs/workbench/common/editor/editorInput'; -import { Dimension, show, hide, IDomNodePagePosition, isAncestor, getWindow, getActiveElement } from 'vs/base/browser/dom'; +import { Dimension, show, hide, IDomNodePagePosition, isAncestor, getActiveElement, getWindowById } from 'vs/base/browser/dom'; import { Registry } from 'vs/platform/registry/common/platform'; import { IEditorPaneRegistry, IEditorPaneDescriptor } from 'vs/workbench/browser/editor'; import { IWorkbenchLayoutService } from 'vs/workbench/services/layout/browser/layoutService'; @@ -28,7 +28,6 @@ import { ILogService } from 'vs/platform/log/common/log'; import { IDialogService, IPromptButton, IPromptCancelButton } from 'vs/platform/dialogs/common/dialogs'; import { IBoundarySashes } from 'vs/base/browser/ui/sash/sash'; import { IHostService } from 'vs/workbench/services/host/browser/host'; -import { mainWindow } from 'vs/base/browser/window'; export interface IOpenEditorResult { @@ -129,22 +128,7 @@ export class EditorPanes extends Disposable { async openEditor(editor: EditorInput, options: IEditorOptions | undefined, internalOptions: IInternalEditorOpenOptions | undefined, context: IEditorOpenContext = Object.create(null)): Promise { try { - - // Assert the `EditorInputCapabilities.AuxWindowUnsupported` condition - if (getWindow(this.editorGroupParent) !== mainWindow && editor.hasCapability(EditorInputCapabilities.AuxWindowUnsupported)) { - return await this.doShowError(createEditorOpenError(localize('editorUnsupportedInAuxWindow', "This type of editor cannot be opened in other windows yet."), [ - toAction({ - id: 'workbench.editor.action.closeEditor', label: localize('openFolder', "Close Editor"), run: async () => { - return this.groupView.closeEditor(editor); - } - }) - ], { forceMessage: true, forceSeverity: Severity.Warning }), editor, options, internalOptions, context); - } - - // Open editor normally - else { - return await this.doOpenEditor(this.getEditorPaneDescriptor(editor), editor, options, internalOptions, context); - } + return await this.doOpenEditor(this.getEditorPaneDescriptor(editor), editor, options, internalOptions, context); } catch (error) { // First check if caller instructed us to ignore error handling @@ -277,7 +261,7 @@ export class EditorPanes extends Disposable { if (focus && this.shouldRestoreFocus(activeElement)) { pane.focus(); } else if (!internalOptions?.preserveWindowOrder) { - this.hostService.moveTop(getWindow(this.editorGroupParent)); + this.hostService.moveTop(getWindowById(this.groupView.windowId, true).window); } } @@ -353,7 +337,7 @@ export class EditorPanes extends Disposable { show(container); // Indicate to editor that it is now visible - editorPane.setVisible(true, this.groupView); + editorPane.setVisible(true); // Layout if (this.pagePosition) { @@ -378,6 +362,11 @@ export class EditorPanes extends Disposable { const editorPaneContainer = document.createElement('div'); editorPaneContainer.classList.add('editor-instance'); + // It is cruicial to append the container to its parent before + // passing on to the create() method of the pane so that the + // right `window` can be determined in floating window cases. + this.editorPanesParent.appendChild(editorPaneContainer); + editorPane.create(editorPaneContainer); } @@ -393,7 +382,7 @@ export class EditorPanes extends Disposable { } // Otherwise instantiate new - const editorPane = this._register(descriptor.instantiate(this.instantiationService)); + const editorPane = this._register(descriptor.instantiate(this.instantiationService, this.groupView)); this.editorPanes.push(editorPane); return editorPane; @@ -472,7 +461,7 @@ export class EditorPanes extends Disposable { // the DOM to give a chance to persist certain state that // might depend on still being the active DOM element. this.safeRun(() => this._activeEditorPane?.clearInput()); - this.safeRun(() => this._activeEditorPane?.setVisible(false, this.groupView)); + this.safeRun(() => this._activeEditorPane?.setVisible(false)); // Remove editor pane from parent const editorPaneContainer = this._activeEditorPane.getContainer(); @@ -492,7 +481,7 @@ export class EditorPanes extends Disposable { } setVisible(visible: boolean): void { - this.safeRun(() => this._activeEditorPane?.setVisible(visible, this.groupView)); + this.safeRun(() => this._activeEditorPane?.setVisible(visible)); } layout(pagePosition: IDomNodePagePosition): void { diff --git a/src/vs/workbench/browser/parts/editor/editorPlaceholder.ts b/src/vs/workbench/browser/parts/editor/editorPlaceholder.ts index 417314bc1f7a3..def52c5b61ea8 100644 --- a/src/vs/workbench/browser/parts/editor/editorPlaceholder.ts +++ b/src/vs/workbench/browser/parts/editor/editorPlaceholder.ts @@ -29,6 +29,7 @@ import { SimpleIconLabel } from 'vs/base/browser/ui/iconLabel/simpleIconLabel'; import { FileChangeType, FileOperationError, FileOperationResult, IFileService } from 'vs/platform/files/common/files'; import { toErrorMessage } from 'vs/base/common/errorMessage'; import { IDialogService } from 'vs/platform/dialogs/common/dialogs'; +import { IEditorGroup } from 'vs/workbench/services/editor/common/editorGroupsService'; export interface IEditorPlaceholderContents { icon: string; @@ -51,15 +52,16 @@ export abstract class EditorPlaceholder extends EditorPane { private container: HTMLElement | undefined; private scrollbar: DomScrollableElement | undefined; - private inputDisposable = this._register(new MutableDisposable()); + private readonly inputDisposable = this._register(new MutableDisposable()); constructor( id: string, + group: IEditorGroup, @ITelemetryService telemetryService: ITelemetryService, @IThemeService themeService: IThemeService, @IStorageService storageService: IStorageService ) { - super(id, telemetryService, themeService, storageService); + super(id, group, telemetryService, themeService, storageService); } protected createEditor(parent: HTMLElement): void { @@ -100,7 +102,7 @@ export abstract class EditorPlaceholder extends EditorPane { // Icon const iconContainer = container.appendChild($('.editor-placeholder-icon-container')); - const iconWidget = new SimpleIconLabel(iconContainer); + const iconWidget = disposables.add(new SimpleIconLabel(iconContainer)); iconWidget.text = icon; // Label @@ -186,13 +188,14 @@ export class WorkspaceTrustRequiredPlaceholderEditor extends EditorPlaceholder { static readonly DESCRIPTOR = EditorPaneDescriptor.create(WorkspaceTrustRequiredPlaceholderEditor, WorkspaceTrustRequiredPlaceholderEditor.ID, WorkspaceTrustRequiredPlaceholderEditor.LABEL); constructor( + group: IEditorGroup, @ITelemetryService telemetryService: ITelemetryService, @IThemeService themeService: IThemeService, @ICommandService private readonly commandService: ICommandService, @IWorkspaceContextService private readonly workspaceService: IWorkspaceContextService, @IStorageService storageService: IStorageService ) { - super(WorkspaceTrustRequiredPlaceholderEditor.ID, telemetryService, themeService, storageService); + super(WorkspaceTrustRequiredPlaceholderEditor.ID, group, telemetryService, themeService, storageService); } override getTitle(): string { @@ -223,18 +226,18 @@ export class ErrorPlaceholderEditor extends EditorPlaceholder { static readonly DESCRIPTOR = EditorPaneDescriptor.create(ErrorPlaceholderEditor, ErrorPlaceholderEditor.ID, ErrorPlaceholderEditor.LABEL); constructor( + group: IEditorGroup, @ITelemetryService telemetryService: ITelemetryService, @IThemeService themeService: IThemeService, @IStorageService storageService: IStorageService, @IFileService private readonly fileService: IFileService, @IDialogService private readonly dialogService: IDialogService ) { - super(ErrorPlaceholderEditor.ID, telemetryService, themeService, storageService); + super(ErrorPlaceholderEditor.ID, group, telemetryService, themeService, storageService); } protected async getContents(input: EditorInput, options: IErrorEditorPlaceholderOptions, disposables: DisposableStore): Promise { const resource = input.resource; - const group = this.group; const error = options.error; const isFileNotFound = (error)?.fileOperationResult === FileOperationResult.FILE_NOT_FOUND; @@ -274,20 +277,20 @@ export class ErrorPlaceholderEditor extends EditorPlaceholder { } }; }); - } else if (group) { + } else { actions = [ { label: localize('retry', "Try Again"), - run: () => group.openEditor(input, { ...options, source: EditorOpenSource.USER /* explicit user gesture */ }) + run: () => this.group.openEditor(input, { ...options, source: EditorOpenSource.USER /* explicit user gesture */ }) } ]; } // Auto-reload when file is added - if (group && isFileNotFound && resource && this.fileService.hasProvider(resource)) { + if (isFileNotFound && resource && this.fileService.hasProvider(resource)) { disposables.add(this.fileService.onDidFilesChange(e => { if (e.contains(resource, FileChangeType.ADDED, FileChangeType.UPDATED)) { - group.openEditor(input, options); + this.group.openEditor(input, options); } })); } diff --git a/src/vs/workbench/browser/parts/editor/editorStatus.ts b/src/vs/workbench/browser/parts/editor/editorStatus.ts index 0c08179077ac0..dc58c035fed62 100644 --- a/src/vs/workbench/browser/parts/editor/editorStatus.ts +++ b/src/vs/workbench/browser/parts/editor/editorStatus.ts @@ -293,8 +293,6 @@ class TabFocusMode extends Disposable { const tabFocusModeConfig = configurationService.getValue('editor.tabFocusMode') === true ? true : false; TabFocus.setTabFocusMode(tabFocusModeConfig); - - this._onDidChange.fire(tabFocusModeConfig); } private registerListeners(): void { @@ -328,13 +326,15 @@ class EditorStatus extends Disposable { private readonly eolElement = this._register(new MutableDisposable()); private readonly languageElement = this._register(new MutableDisposable()); private readonly metadataElement = this._register(new MutableDisposable()); - private readonly currentProblemStatus = this._register(this.instantiationService.createInstance(ShowCurrentMarkerInStatusbarContribution)); + + private readonly currentMarkerStatus = this._register(this.instantiationService.createInstance(ShowCurrentMarkerInStatusbarContribution)); + private readonly tabFocusMode = this._register(this.instantiationService.createInstance(TabFocusMode)); + private readonly state = new State(); + private toRender: StateChange | undefined = undefined; + private readonly activeEditorListeners = this._register(new DisposableStore()); private readonly delayedRender = this._register(new MutableDisposable()); - private readonly tabFocusMode = this.instantiationService.createInstance(TabFocusMode); - - private toRender: StateChange | undefined = undefined; constructor( private readonly targetWindowId: number, @@ -366,10 +366,9 @@ class EditorStatus extends Disposable { } private registerCommands(): void { - CommandsRegistry.registerCommand({ id: 'changeEditorIndentation', handler: () => this.showIndentationPicker() }); + this._register(CommandsRegistry.registerCommand({ id: `changeEditorIndentation${this.targetWindowId}`, handler: () => this.showIndentationPicker() })); } - private async showIndentationPicker(): Promise { const activeTextEditorControl = getCodeEditor(this.editorService.activeTextEditorControl); if (!activeTextEditorControl) { @@ -449,6 +448,12 @@ class EditorStatus extends Disposable { return; } + const editorURI = getCodeEditor(this.editorService.activeTextEditorControl)?.getModel()?.uri; + if (editorURI?.scheme === Schemas.vscodeNotebookCell) { + this.selectionElement.clear(); + return; + } + const props: IStatusbarEntry = { name: localize('status.editor.selection', "Editor Selection"), text, @@ -466,12 +471,18 @@ class EditorStatus extends Disposable { return; } + const editorURI = getCodeEditor(this.editorService.activeTextEditorControl)?.getModel()?.uri; + if (editorURI?.scheme === Schemas.vscodeNotebookCell) { + this.indentationElement.clear(); + return; + } + const props: IStatusbarEntry = { name: localize('status.editor.indentation', "Editor Indentation"), text, ariaLabel: text, tooltip: localize('selectIndentation', "Select Indentation"), - command: 'changeEditorIndentation' + command: `changeEditorIndentation${this.targetWindowId}` }; this.updateElement(this.indentationElement, props, 'status.editor.indentation', StatusbarAlignment.RIGHT, 100.4); @@ -623,7 +634,7 @@ class EditorStatus extends Disposable { this.onEncodingChange(activeEditorPane, activeCodeEditor); this.onIndentationChange(activeCodeEditor); this.onMetadataChange(activeEditorPane); - this.currentProblemStatus.update(activeCodeEditor); + this.currentMarkerStatus.update(activeCodeEditor); // Dispose old active editor listeners this.activeEditorListeners.clear(); @@ -651,7 +662,7 @@ class EditorStatus extends Disposable { // Hook Listener for Selection changes this.activeEditorListeners.add(Event.defer(activeCodeEditor.onDidChangeCursorPosition)(() => { this.onSelectionChange(activeCodeEditor); - this.currentProblemStatus.update(activeCodeEditor); + this.currentMarkerStatus.update(activeCodeEditor); })); // Hook Listener for language changes @@ -662,7 +673,7 @@ class EditorStatus extends Disposable { // Hook Listener for content changes this.activeEditorListeners.add(Event.accumulate(activeCodeEditor.onDidChangeModelContent)(e => { this.onEOLChange(activeCodeEditor); - this.currentProblemStatus.update(activeCodeEditor); + this.currentMarkerStatus.update(activeCodeEditor); const selections = activeCodeEditor.getSelections(); if (selections) { @@ -907,13 +918,16 @@ class ShowCurrentMarkerInStatusbarContribution extends Disposable { @IConfigurationService private readonly configurationService: IConfigurationService, ) { super(); + this.statusBarEntryAccessor = this._register(new MutableDisposable()); + this._register(markerService.onMarkerChanged(changedResources => this.onMarkerChanged(changedResources))); this._register(Event.filter(configurationService.onDidChangeConfiguration, e => e.affectsConfiguration('problems.showCurrentInStatus'))(() => this.updateStatus())); } update(editor: ICodeEditor | undefined): void { this.editor = editor; + this.updateMarkers(); this.updateStatus(); } @@ -1011,26 +1025,26 @@ class ShowCurrentMarkerInStatusbarContribution extends Disposable { resource: model.uri, severities: MarkerSeverity.Error | MarkerSeverity.Warning | MarkerSeverity.Info }); - this.markers.sort(compareMarker); + this.markers.sort(this.compareMarker); } else { this.markers = []; } this.updateStatus(); } -} -function compareMarker(a: IMarker, b: IMarker): number { - let res = compare(a.resource.toString(), b.resource.toString()); - if (res === 0) { - res = MarkerSeverity.compare(a.severity, b.severity); - } + private compareMarker(a: IMarker, b: IMarker): number { + let res = compare(a.resource.toString(), b.resource.toString()); + if (res === 0) { + res = MarkerSeverity.compare(a.severity, b.severity); + } - if (res === 0) { - res = Range.compareRangesUsingStarts(a, b); - } + if (res === 0) { + res = Range.compareRangesUsingStarts(a, b); + } - return res; + return res; + } } export class ShowLanguageExtensionsAction extends Action { diff --git a/src/vs/workbench/browser/parts/editor/editorTabsControl.ts b/src/vs/workbench/browser/parts/editor/editorTabsControl.ts index 3d202d8d48b1f..d7a82ed5c1690 100644 --- a/src/vs/workbench/browser/parts/editor/editorTabsControl.ts +++ b/src/vs/workbench/browser/parts/editor/editorTabsControl.ts @@ -25,7 +25,7 @@ import { IThemeService, Themable } from 'vs/platform/theme/common/themeService'; import { DraggedEditorGroupIdentifier, DraggedEditorIdentifier, fillEditorsDragData, isWindowDraggedOver } from 'vs/workbench/browser/dnd'; import { EditorPane } from 'vs/workbench/browser/parts/editor/editorPane'; import { IEditorGroupsView, IEditorGroupView, IEditorPartsView, IInternalEditorOpenOptions } from 'vs/workbench/browser/parts/editor/editor'; -import { IEditorCommandsContext, EditorResourceAccessor, IEditorPartOptions, SideBySideEditor, EditorsOrder, EditorInputCapabilities, IToolbarActions, GroupIdentifier } from 'vs/workbench/common/editor'; +import { IEditorCommandsContext, EditorResourceAccessor, IEditorPartOptions, SideBySideEditor, EditorsOrder, EditorInputCapabilities, IToolbarActions, GroupIdentifier, Verbosity } from 'vs/workbench/common/editor'; import { EditorInput } from 'vs/workbench/common/editor/editorInput'; import { ResourceContextKey, ActiveEditorPinnedContext, ActiveEditorStickyContext, ActiveEditorGroupLockedContext, ActiveEditorCanSplitInGroupContext, SideBySideEditorActiveContext, ActiveEditorFirstInGroupContext, ActiveEditorAvailableEditorIdsContext, applyAvailableEditorIds, ActiveEditorLastInGroupContext } from 'vs/workbench/common/contextkeys'; import { AnchorAlignment } from 'vs/base/browser/ui/contextview/contextview'; @@ -44,6 +44,9 @@ import { IAuxiliaryEditorPart, MergeGroupMode } from 'vs/workbench/services/edit import { isMacintosh } from 'vs/base/common/platform'; import { IHostService } from 'vs/workbench/services/host/browser/host'; import { ServiceCollection } from 'vs/platform/instantiation/common/serviceCollection'; +import { IHoverDelegate } from 'vs/base/browser/ui/hover/hoverDelegate'; +import { getDefaultHoverDelegate } from 'vs/base/browser/ui/hover/hoverDelegateFactory'; +import { IBaseActionViewItemOptions } from 'vs/base/browser/ui/actionbar/actionViewItems'; export class EditorCommandsContextActionRunner extends ActionRunner { @@ -121,6 +124,8 @@ export abstract class EditorTabsControl extends Themable implements IEditorTabsC private renderDropdownAsChildElement: boolean; + private readonly tabsHoverDelegate: IHoverDelegate; + constructor( protected readonly parent: HTMLElement, protected readonly editorPartsView: IEditorPartsView, @@ -135,7 +140,7 @@ export abstract class EditorTabsControl extends Themable implements IEditorTabsC @IQuickInputService protected quickInputService: IQuickInputService, @IThemeService themeService: IThemeService, @IEditorResolverService private readonly editorResolverService: IEditorResolverService, - @IHostService private readonly hostService: IHostService + @IHostService private readonly hostService: IHostService, ) { super(themeService); @@ -159,6 +164,8 @@ export abstract class EditorTabsControl extends Themable implements IEditorTabsC this.renderDropdownAsChildElement = false; + this.tabsHoverDelegate = getDefaultHoverDelegate('mouse'); + this.create(parent); } @@ -202,7 +209,7 @@ export abstract class EditorTabsControl extends Themable implements IEditorTabsC // Toolbar Widget this.editorActionsToolbar = this.editorActionsToolbarDisposables.add(this.instantiationService.createInstance(WorkbenchToolBar, container, { - actionViewItemProvider: action => this.actionViewItemProvider(action), + actionViewItemProvider: (action, options) => this.actionViewItemProvider(action, options), orientation: ActionsOrientation.HORIZONTAL, ariaLabel: localize('ariaLabelEditorActions', "Editor actions"), getKeyBinding: action => this.getKeybinding(action), @@ -228,12 +235,12 @@ export abstract class EditorTabsControl extends Themable implements IEditorTabsC })); } - private actionViewItemProvider(action: IAction): IActionViewItem | undefined { + private actionViewItemProvider(action: IAction, options: IBaseActionViewItemOptions): IActionViewItem | undefined { const activeEditorPane = this.groupView.activeEditorPane; // Check Active Editor if (activeEditorPane instanceof EditorPane) { - const result = activeEditorPane.getActionViewItem(action); + const result = activeEditorPane.getActionViewItem(action, options); if (result) { return result; @@ -241,7 +248,7 @@ export abstract class EditorTabsControl extends Themable implements IEditorTabsC } // Check extensions - return createActionViewItem(this.instantiationService, action, { menuAsChild: this.renderDropdownAsChildElement }); + return createActionViewItem(this.instantiationService, action, { ...options, menuAsChild: this.renderDropdownAsChildElement }); } protected updateEditorActionsToolbar(): void { @@ -444,6 +451,14 @@ export abstract class EditorTabsControl extends Themable implements IEditorTabsC return this.groupsView.partOptions.tabHeight !== 'compact' ? EditorTabsControl.EDITOR_TAB_HEIGHT.normal : EditorTabsControl.EDITOR_TAB_HEIGHT.compact; } + protected getHoverTitle(editor: EditorInput): string { + return editor.getTitle(Verbosity.LONG); + } + + protected getHoverDelegate(): IHoverDelegate { + return this.tabsHoverDelegate; + } + protected updateTabHeight(): void { this.parent.style.setProperty('--editor-group-tab-height', `${this.tabHeight}px`); } diff --git a/src/vs/workbench/browser/parts/editor/editorTitleControl.ts b/src/vs/workbench/browser/parts/editor/editorTitleControl.ts index 3f14abf5b9eff..5ff3995ffbdfe 100644 --- a/src/vs/workbench/browser/parts/editor/editorTitleControl.ts +++ b/src/vs/workbench/browser/parts/editor/editorTitleControl.ts @@ -36,10 +36,10 @@ export interface IEditorTitleControlDimensions { export class EditorTitleControl extends Themable { private editorTabsControl: IEditorTabsControl; - private editorTabsControlDisposable = this._register(new DisposableStore()); + private readonly editorTabsControlDisposable = this._register(new DisposableStore()); private breadcrumbsControlFactory: BreadcrumbsControlFactory | undefined; - private breadcrumbsControlDisposables = this._register(new DisposableStore()); + private readonly breadcrumbsControlDisposables = this._register(new DisposableStore()); private get breadcrumbsControl() { return this.breadcrumbsControlFactory?.control; } constructor( diff --git a/src/vs/workbench/browser/parts/editor/editorWithViewState.ts b/src/vs/workbench/browser/parts/editor/editorWithViewState.ts index f01bedd8f59e9..e31756007e9b9 100644 --- a/src/vs/workbench/browser/parts/editor/editorWithViewState.ts +++ b/src/vs/workbench/browser/parts/editor/editorWithViewState.ts @@ -31,6 +31,7 @@ export abstract class AbstractEditorWithViewState extends Edit constructor( id: string, + group: IEditorGroup, viewStateStorageKey: string, @ITelemetryService telemetryService: ITelemetryService, @IInstantiationService protected readonly instantiationService: IInstantiationService, @@ -40,17 +41,17 @@ export abstract class AbstractEditorWithViewState extends Edit @IEditorService protected readonly editorService: IEditorService, @IEditorGroupsService protected readonly editorGroupService: IEditorGroupsService ) { - super(id, telemetryService, themeService, storageService); + super(id, group, telemetryService, themeService, storageService); this.viewState = this.getEditorMemento(editorGroupService, textResourceConfigurationService, viewStateStorageKey, 100); } - protected override setEditorVisible(visible: boolean, group: IEditorGroup | undefined): void { + protected override setEditorVisible(visible: boolean): void { // Listen to close events to trigger `onWillCloseEditorInGroup` - this.groupListener.value = group?.onWillCloseEditor(e => this.onWillCloseEditor(e)); + this.groupListener.value = this.group.onWillCloseEditor(e => this.onWillCloseEditor(e)); - super.setEditorVisible(visible, group); + super.setEditorVisible(visible); } private onWillCloseEditor(e: IEditorCloseEvent): void { @@ -110,7 +111,7 @@ export abstract class AbstractEditorWithViewState extends Edit // - the user configured to not restore view state unless the editor is still opened in the group if ( (input.isDisposed() && !this.tracksDisposedEditorViewState()) || - (!this.shouldRestoreEditorViewState(input) && (!this.group || !this.group.contains(input))) + (!this.shouldRestoreEditorViewState(input) && !this.group.contains(input)) ) { this.clearEditorViewState(resource, this.group); } @@ -147,10 +148,6 @@ export abstract class AbstractEditorWithViewState extends Edit } private saveEditorViewState(resource: URI): void { - if (!this.group) { - return; - } - const editorViewState = this.computeEditorViewState(resource); if (!editorViewState) { return; @@ -160,7 +157,7 @@ export abstract class AbstractEditorWithViewState extends Edit } protected loadEditorViewState(input: EditorInput | undefined, context?: IEditorOpenContext): T | undefined { - if (!input || !this.group) { + if (!input) { return undefined; // we need valid input } diff --git a/src/vs/workbench/browser/parts/editor/media/multieditortabscontrol.css b/src/vs/workbench/browser/parts/editor/media/multieditortabscontrol.css index 8a883475400cf..f7d5341d2cf7f 100644 --- a/src/vs/workbench/browser/parts/editor/media/multieditortabscontrol.css +++ b/src/vs/workbench/browser/parts/editor/media/multieditortabscontrol.css @@ -106,6 +106,38 @@ padding-left: 10px; } +/* Tab Background Color in/active group/tab */ +.monaco-workbench .part.editor > .content .editor-group-container > .title .tabs-container > .tab { + background-color: var(--vscode-tab-unfocusedInactiveBackground); +} +.monaco-workbench .part.editor > .content .editor-group-container.active > .title .tabs-container > .tab { + background-color: var(--vscode-tab-inactiveBackground); +} +.monaco-workbench .part.editor > .content .editor-group-container > .title .tabs-container > .tab.active { + background-color: var(--vscode-tab-unfocusedActiveBackground); +} +.monaco-workbench .part.editor > .content .editor-group-container.active > .title .tabs-container > .tab.active { + background-color: var(--vscode-tab-activeBackground); +} + +/* Tab Foreground Color in/active group/tab */ +.monaco-workbench .part.editor > .content .editor-group-container > .title .tabs-container > .tab { + color: var(--vscode-tab-unfocusedInactiveForeground); +} +.monaco-workbench .part.editor > .content .editor-group-container.active > .title .tabs-container > .tab { + color: var(--vscode-tab-inactiveForeground); +} +.monaco-workbench .part.editor > .content .editor-group-container > .title .tabs-container > .tab.active { + color: var(--vscode-tab-unfocusedActiveForeground); +} +.monaco-workbench .part.editor > .content .editor-group-container.active > .title .tabs-container > .tab.active { + color: var(--vscode-tab-activeForeground); +} + +.monaco-workbench .part.editor > .content .editor-group-container > .title .tabs-container > .tab:not(.active) { + box-shadow: none; +} + .monaco-workbench .part.editor > .content .editor-group-container > .title > .tabs-and-actions-container.wrapping .tabs-container > .tab:last-child { margin-right: var(--last-tab-margin-right); /* when tabs wrap, we need a margin away from the absolute positioned editor actions */ } @@ -204,11 +236,14 @@ left: unset !important; } -.monaco-workbench .part.editor > .content .editor-group-container > .title .tabs-container > .tab.sizing-shrink.tab-actions-left::after, -.monaco-workbench .part.editor > .content .editor-group-container > .title .tabs-container > .tab.sizing-shrink.close-action-off::after, -.monaco-workbench .part.editor > .content .editor-group-container > .title .tabs-container > .tab.sizing-fixed.tab-actions-left::after, -.monaco-workbench .part.editor > .content .editor-group-container > .title .tabs-container > .tab.sizing-fixed.close-action-off::after { - content: ''; +.monaco-workbench .part.editor > .content .editor-group-container > .title .tabs-container > .tab .tab-fade-hider { + display: none; +} + +.monaco-workbench .part.editor > .content .editor-group-container > .title .tabs-container > .tab.sizing-shrink.tab-actions-left .tab-fade-hider, +.monaco-workbench .part.editor > .content .editor-group-container > .title .tabs-container > .tab.sizing-shrink.close-action-off .tab-fade-hider, +.monaco-workbench .part.editor > .content .editor-group-container > .title .tabs-container > .tab.sizing-fixed.tab-actions-left .tab-fade-hider, +.monaco-workbench .part.editor > .content .editor-group-container > .title .tabs-container > .tab.sizing-fixed.close-action-off .tab-fade-hider { display: flex; flex: 0; width: 5px; /* reserve space to hide tab fade when close button is left or off (fixes https://github.com/microsoft/vscode/issues/45728) */ @@ -386,11 +421,13 @@ .monaco-workbench .part.editor > .content .editor-group-container.active > .title .tabs-container > .tab.sticky.dirty > .tab-actions .action-label:not(:hover)::before, .monaco-workbench .part.editor > .content .editor-group-container > .title .tabs-container > .tab.sticky.dirty > .tab-actions .action-label:not(:hover)::before { content: "\ebb2"; /* use `pinned-dirty` icon unicode for sticky-dirty indication */ + font-family: 'codicon'; } .monaco-workbench .part.editor > .content .editor-group-container.active > .title .tabs-container > .tab.dirty > .tab-actions .action-label:not(:hover)::before, .monaco-workbench .part.editor > .content .editor-group-container > .title .tabs-container > .tab.dirty > .tab-actions .action-label:not(:hover)::before { content: "\ea71"; /* use `circle-filled` icon unicode for dirty indication */ + font-family: 'codicon'; } .monaco-workbench .part.editor > .content .editor-group-container > .title .tabs-container > .tab.active > .tab-actions .action-label, diff --git a/src/vs/workbench/browser/parts/editor/multiEditorTabsControl.ts b/src/vs/workbench/browser/parts/editor/multiEditorTabsControl.ts index 4372fd674a490..12d77fb90547c 100644 --- a/src/vs/workbench/browser/parts/editor/multiEditorTabsControl.ts +++ b/src/vs/workbench/browser/parts/editor/multiEditorTabsControl.ts @@ -26,7 +26,7 @@ import { ScrollableElement } from 'vs/base/browser/ui/scrollbar/scrollableElemen import { ScrollbarVisibility } from 'vs/base/common/scrollable'; import { getOrSet } from 'vs/base/common/map'; import { IThemeService, registerThemingParticipant } from 'vs/platform/theme/common/themeService'; -import { TAB_INACTIVE_BACKGROUND, TAB_ACTIVE_BACKGROUND, TAB_ACTIVE_FOREGROUND, TAB_INACTIVE_FOREGROUND, TAB_BORDER, EDITOR_DRAG_AND_DROP_BACKGROUND, TAB_UNFOCUSED_ACTIVE_FOREGROUND, TAB_UNFOCUSED_INACTIVE_FOREGROUND, TAB_UNFOCUSED_ACTIVE_BACKGROUND, TAB_UNFOCUSED_ACTIVE_BORDER, TAB_ACTIVE_BORDER, TAB_HOVER_BACKGROUND, TAB_HOVER_BORDER, TAB_UNFOCUSED_HOVER_BACKGROUND, TAB_UNFOCUSED_HOVER_BORDER, EDITOR_GROUP_HEADER_TABS_BACKGROUND, WORKBENCH_BACKGROUND, TAB_ACTIVE_BORDER_TOP, TAB_UNFOCUSED_ACTIVE_BORDER_TOP, TAB_ACTIVE_MODIFIED_BORDER, TAB_INACTIVE_MODIFIED_BORDER, TAB_UNFOCUSED_ACTIVE_MODIFIED_BORDER, TAB_UNFOCUSED_INACTIVE_MODIFIED_BORDER, TAB_UNFOCUSED_INACTIVE_BACKGROUND, TAB_HOVER_FOREGROUND, TAB_UNFOCUSED_HOVER_FOREGROUND, EDITOR_GROUP_HEADER_TABS_BORDER, TAB_LAST_PINNED_BORDER } from 'vs/workbench/common/theme'; +import { TAB_INACTIVE_BACKGROUND, TAB_ACTIVE_BACKGROUND, TAB_BORDER, EDITOR_DRAG_AND_DROP_BACKGROUND, TAB_UNFOCUSED_ACTIVE_BACKGROUND, TAB_UNFOCUSED_ACTIVE_BORDER, TAB_ACTIVE_BORDER, TAB_HOVER_BACKGROUND, TAB_HOVER_BORDER, TAB_UNFOCUSED_HOVER_BACKGROUND, TAB_UNFOCUSED_HOVER_BORDER, EDITOR_GROUP_HEADER_TABS_BACKGROUND, WORKBENCH_BACKGROUND, TAB_ACTIVE_BORDER_TOP, TAB_UNFOCUSED_ACTIVE_BORDER_TOP, TAB_ACTIVE_MODIFIED_BORDER, TAB_INACTIVE_MODIFIED_BORDER, TAB_UNFOCUSED_ACTIVE_MODIFIED_BORDER, TAB_UNFOCUSED_INACTIVE_MODIFIED_BORDER, TAB_UNFOCUSED_INACTIVE_BACKGROUND, TAB_HOVER_FOREGROUND, TAB_UNFOCUSED_HOVER_FOREGROUND, EDITOR_GROUP_HEADER_TABS_BORDER, TAB_LAST_PINNED_BORDER } from 'vs/workbench/common/theme'; import { activeContrastBorder, contrastBorder, editorBackground } from 'vs/platform/theme/common/colorRegistry'; import { ResourcesDropHandler, DraggedEditorIdentifier, DraggedEditorGroupIdentifier, extractTreeDropData, isWindowDraggedOver } from 'vs/workbench/browser/dnd'; import { Color } from 'vs/base/common/color'; @@ -149,7 +149,7 @@ export class MultiEditorTabsControl extends EditorTabsControl { @IPathService private readonly pathService: IPathService, @ITreeViewsDnDService private readonly treeViewsDragAndDropService: ITreeViewsDnDService, @IEditorResolverService editorResolverService: IEditorResolverService, - @IHostService hostService: IHostService + @IHostService hostService: IHostService, ) { super(parent, editorPartsView, groupsView, groupView, tabsModel, contextMenuService, instantiationService, contextKeyService, keybindingService, notificationService, quickInputService, themeService, editorResolverService, hostService); @@ -793,7 +793,7 @@ export class MultiEditorTabsControl extends EditorTabsControl { tabContainer.appendChild(tabBorderTopContainer); // Tab Editor Label - const editorLabel = this.tabResourceLabels.create(tabContainer); + const editorLabel = this.tabResourceLabels.create(tabContainer, { hoverDelegate: this.getHoverDelegate() }); // Tab Actions const tabActionsContainer = document.createElement('div'); @@ -815,6 +815,12 @@ export class MultiEditorTabsControl extends EditorTabsControl { const tabActionBarDisposable = combinedDisposable(tabActionBar, tabActionListener, toDisposable(insert(this.tabActionBars, tabActionBar))); + // Tab Fade Hider + // Hides the tab fade to the right when tab action left and sizing shrink/fixed, ::after, ::before are already used + const tabShadowHider = document.createElement('div'); + tabShadowHider.classList.add('tab-fade-hider'); + tabContainer.appendChild(tabShadowHider); + // Tab Border Bottom const tabBorderBottomContainer = document.createElement('div'); tabBorderBottomContainer.classList.add('tab-border-bottom-container'); @@ -1469,14 +1475,11 @@ export class MultiEditorTabsControl extends EditorTabsControl { tabContainer.setAttribute('aria-description', ''); } - const title = tabLabel.title || ''; - tabContainer.title = title; - // Label tabLabelWidget.setResource( { name, description, resource: EditorResourceAccessor.getOriginalUri(editor, { supportSideBySide: SideBySideEditor.BOTH }) }, { - title, + title: this.getHoverTitle(editor), extraClasses: coalesce(['tab-label', fileDecorationBadges ? 'tab-label-has-badge' : undefined].concat(editor.getLabelExtraClasses())), italic: !this.tabsModel.isPinned(editor), forceLabel, @@ -1507,55 +1510,23 @@ export class MultiEditorTabsControl extends EditorTabsControl { private doRedrawTabActive(isGroupActive: boolean, allowBorderTop: boolean, editor: EditorInput, tabContainer: HTMLElement, tabActionBar: ActionBar): void { - // Tab is active - if (this.tabsModel.isActive(editor)) { + const isActive = this.tabsModel.isActive(editor); - // Container - tabContainer.classList.add('active'); - tabContainer.setAttribute('aria-selected', 'true'); - tabContainer.tabIndex = 0; // Only active tab can be focused into - tabContainer.style.backgroundColor = this.getColor(isGroupActive ? TAB_ACTIVE_BACKGROUND : TAB_UNFOCUSED_ACTIVE_BACKGROUND) || ''; + tabContainer.classList.toggle('active', isActive); + tabContainer.setAttribute('aria-selected', isActive ? 'true' : 'false'); + tabContainer.tabIndex = isActive ? 0 : -1; // Only active tab can be focused into + tabActionBar.setFocusable(isActive); + if (isActive) { + // Set border BOTTOM if theme defined color const activeTabBorderColorBottom = this.getColor(isGroupActive ? TAB_ACTIVE_BORDER : TAB_UNFOCUSED_ACTIVE_BORDER); - if (activeTabBorderColorBottom) { - tabContainer.classList.add('tab-border-bottom'); - tabContainer.style.setProperty('--tab-border-bottom-color', activeTabBorderColorBottom.toString()); - } else { - tabContainer.classList.remove('tab-border-bottom'); - tabContainer.style.removeProperty('--tab-border-bottom-color'); - } + tabContainer.classList.toggle('tab-border-bottom', !!activeTabBorderColorBottom); + tabContainer.style.setProperty('--tab-border-bottom-color', activeTabBorderColorBottom?.toString() ?? ''); + // Set border TOP if theme defined color const activeTabBorderColorTop = allowBorderTop ? this.getColor(isGroupActive ? TAB_ACTIVE_BORDER_TOP : TAB_UNFOCUSED_ACTIVE_BORDER_TOP) : undefined; - if (activeTabBorderColorTop) { - tabContainer.classList.add('tab-border-top'); - tabContainer.style.setProperty('--tab-border-top-color', activeTabBorderColorTop.toString()); - } else { - tabContainer.classList.remove('tab-border-top'); - tabContainer.style.removeProperty('--tab-border-top-color'); - } - - // Label - tabContainer.style.color = this.getColor(isGroupActive ? TAB_ACTIVE_FOREGROUND : TAB_UNFOCUSED_ACTIVE_FOREGROUND) || ''; - - // Actions - tabActionBar.setFocusable(true); - } - - // Tab is inactive - else { - - // Container - tabContainer.classList.remove('active'); - tabContainer.setAttribute('aria-selected', 'false'); - tabContainer.tabIndex = -1; // Only active tab can be focused into - tabContainer.style.backgroundColor = this.getColor(isGroupActive ? TAB_INACTIVE_BACKGROUND : TAB_UNFOCUSED_INACTIVE_BACKGROUND) || ''; - tabContainer.style.boxShadow = ''; - - // Label - tabContainer.style.color = this.getColor(isGroupActive ? TAB_INACTIVE_FOREGROUND : TAB_UNFOCUSED_INACTIVE_FOREGROUND) || ''; - - // Actions - tabActionBar.setFocusable(false); + tabContainer.classList.toggle('tab-border-top', !!activeTabBorderColorTop); + tabContainer.style.setProperty('--tab-border-top-color', activeTabBorderColorTop?.toString() ?? ''); } } diff --git a/src/vs/workbench/browser/parts/editor/multiRowEditorTabsControl.ts b/src/vs/workbench/browser/parts/editor/multiRowEditorTabsControl.ts index 1a6b5ca985e65..24d85415eb848 100644 --- a/src/vs/workbench/browser/parts/editor/multiRowEditorTabsControl.ts +++ b/src/vs/workbench/browser/parts/editor/multiRowEditorTabsControl.ts @@ -36,19 +36,23 @@ export class MultiRowEditorControl extends Disposable implements IEditorTabsCont this.stickyEditorTabsControl = this._register(this.instantiationService.createInstance(MultiEditorTabsControl, this.parent, editorPartsView, this.groupsView, this.groupView, stickyModel)); this.unstickyEditorTabsControl = this._register(this.instantiationService.createInstance(MultiEditorTabsControl, this.parent, editorPartsView, this.groupsView, this.groupView, unstickyModel)); - this.handlePinnedTabsSeparateRowToolbars(); + this.handlePinnedTabsLayoutChange(); } - private handlePinnedTabsSeparateRowToolbars(): void { + private handlePinnedTabsLayoutChange(): void { if (this.groupView.count === 0) { // Do nothing as no tab bar is visible return; } + + const hadTwoTabBars = this.parent.classList.contains('two-tab-bars'); + const hasTwoTabBars = this.groupView.count !== this.groupView.stickyCount && this.groupView.stickyCount > 0; + // Ensure action toolbar is only visible once - if (this.groupView.count === this.groupView.stickyCount) { - this.parent.classList.toggle('two-tab-bars', false); - } else { - this.parent.classList.toggle('two-tab-bars', true); + this.parent.classList.toggle('two-tab-bars', hasTwoTabBars); + + if (hadTwoTabBars !== hasTwoTabBars) { + this.groupView.relayout(); } } @@ -85,7 +89,7 @@ export class MultiRowEditorControl extends Disposable implements IEditorTabsCont } private handleOpenedEditors(): void { - this.handlePinnedTabsSeparateRowToolbars(); + this.handlePinnedTabsLayoutChange(); } beforeCloseEditor(editor: EditorInput): void { @@ -111,7 +115,7 @@ export class MultiRowEditorControl extends Disposable implements IEditorTabsCont } private handleClosedEditors(): void { - this.handlePinnedTabsSeparateRowToolbars(); + this.handlePinnedTabsLayoutChange(); } moveEditor(editor: EditorInput, fromIndex: number, targetIndex: number, stickyStateChange: boolean): void { @@ -125,7 +129,7 @@ export class MultiRowEditorControl extends Disposable implements IEditorTabsCont this.unstickyEditorTabsControl.openEditor(editor); } - this.handlePinnedTabsSeparateRowToolbars(); + this.handlePinnedTabsLayoutChange(); } else { if (this.model.isSticky(editor)) { @@ -144,14 +148,14 @@ export class MultiRowEditorControl extends Disposable implements IEditorTabsCont this.unstickyEditorTabsControl.closeEditor(editor); this.stickyEditorTabsControl.openEditor(editor); - this.handlePinnedTabsSeparateRowToolbars(); + this.handlePinnedTabsLayoutChange(); } unstickEditor(editor: EditorInput): void { this.stickyEditorTabsControl.closeEditor(editor); this.unstickyEditorTabsControl.openEditor(editor); - this.handlePinnedTabsSeparateRowToolbars(); + this.handlePinnedTabsLayoutChange(); } setActive(isActive: boolean): void { diff --git a/src/vs/workbench/browser/parts/editor/sideBySideEditor.ts b/src/vs/workbench/browser/parts/editor/sideBySideEditor.ts index becfd13cac2d4..c3d7a0cce9dab 100644 --- a/src/vs/workbench/browser/parts/editor/sideBySideEditor.ts +++ b/src/vs/workbench/browser/parts/editor/sideBySideEditor.ts @@ -122,6 +122,7 @@ export class SideBySideEditor extends AbstractEditorWithViewState this.onTitleLabelClick(e))); // Breadcrumbs @@ -304,11 +304,6 @@ export class SingleEditorTabsControl extends EditorTabsControl { description = editor.getDescription(this.getVerbosity(labelFormat)) || ''; } - let title = editor.getTitle(Verbosity.LONG); - if (description === title) { - title = ''; // dont repeat what is already shown - } - editorLabel.setResource( { resource: EditorResourceAccessor.getOriginalUri(editor, { supportSideBySide: SideBySideEditor.BOTH }), @@ -316,7 +311,7 @@ export class SingleEditorTabsControl extends EditorTabsControl { description }, { - title, + title: this.getHoverTitle(editor), italic: !isEditorPinned, extraClasses: ['single-tab', 'title-label'].concat(editor.getLabelExtraClasses()), fileDecorations: { diff --git a/src/vs/workbench/browser/parts/editor/textCodeEditor.ts b/src/vs/workbench/browser/parts/editor/textCodeEditor.ts index 2efc763e041e5..c3846b6faf20a 100644 --- a/src/vs/workbench/browser/parts/editor/textCodeEditor.ts +++ b/src/vs/workbench/browser/parts/editor/textCodeEditor.ts @@ -12,12 +12,11 @@ import { IContextKeyService } from 'vs/platform/contextkey/common/contextkey'; import { ITextEditorOptions } from 'vs/platform/editor/common/editor'; import { isEqual } from 'vs/base/common/resources'; import { IEditorOptions as ICodeEditorOptions } from 'vs/editor/common/config/editorOptions'; -import { CodeEditorWidget, ICodeEditorWidgetOptions } from 'vs/editor/browser/widget/codeEditorWidget'; +import { CodeEditorWidget, ICodeEditorWidgetOptions } from 'vs/editor/browser/widget/codeEditor/codeEditorWidget'; import { IEditorViewState, ScrollType } from 'vs/editor/common/editorCommon'; import { ICodeEditor } from 'vs/editor/browser/editorBrowser'; import { AbstractTextEditor } from 'vs/workbench/browser/parts/editor/textEditor'; import { Dimension } from 'vs/base/browser/dom'; -import { IEditorGroup } from 'vs/workbench/services/editor/common/editorGroupsService'; /** * A text editor using the code editor widget. @@ -98,8 +97,8 @@ export abstract class AbstractTextCodeEditor extends return this.editorControl?.hasTextFocus() || super.hasFocus(); } - protected override setEditorVisible(visible: boolean, group: IEditorGroup | undefined): void { - super.setEditorVisible(visible, group); + protected override setEditorVisible(visible: boolean): void { + super.setEditorVisible(visible); if (visible) { this.editorControl?.onVisible(); diff --git a/src/vs/workbench/browser/parts/editor/textDiffEditor.ts b/src/vs/workbench/browser/parts/editor/textDiffEditor.ts index fa7c515d416a0..5743ac16f7a04 100644 --- a/src/vs/workbench/browser/parts/editor/textDiffEditor.ts +++ b/src/vs/workbench/browser/parts/editor/textDiffEditor.ts @@ -58,6 +58,7 @@ export class TextDiffEditor extends AbstractTextEditor imp } constructor( + group: IEditorGroup, @ITelemetryService telemetryService: ITelemetryService, @IInstantiationService instantiationService: IInstantiationService, @IStorageService storageService: IStorageService, @@ -68,7 +69,7 @@ export class TextDiffEditor extends AbstractTextEditor imp @IFileService fileService: IFileService, @IPreferencesService private readonly preferencesService: IPreferencesService ) { - super(TextDiffEditor.ID, telemetryService, instantiationService, storageService, configurationService, themeService, editorService, editorGroupService, fileService); + super(TextDiffEditor.ID, group, telemetryService, instantiationService, storageService, configurationService, themeService, editorService, editorGroupService, fileService); } override getTitle(): string { @@ -106,7 +107,7 @@ export class TextDiffEditor extends AbstractTextEditor imp await super.setInput(input, options, context, token); try { - const resolvedModel = await input.resolve(options); + const resolvedModel = await input.resolve(); // Check for cancellation if (token.isCancellationRequested) { @@ -171,7 +172,7 @@ export class TextDiffEditor extends AbstractTextEditor imp } // Handle case where a file is too large to open without confirmation - if ((error).fileOperationResult === FileOperationResult.FILE_TOO_LARGE && this.group) { + if ((error).fileOperationResult === FileOperationResult.FILE_TOO_LARGE) { let message: string; if (error instanceof TooLargeFileOperationError) { message = localize('fileTooLargeForHeapErrorWithSize', "At least one file is not displayed in the text compare editor because it is very large ({0}).", ByteSize.formatSize(error.size)); @@ -195,6 +196,10 @@ export class TextDiffEditor extends AbstractTextEditor imp control.restoreViewState(editorViewState); + if (options?.revealIfVisible) { + control.revealFirstDiff(); + } + return true; } @@ -218,7 +223,7 @@ export class TextDiffEditor extends AbstractTextEditor imp } // Replace this editor with the binary one - (this.group ?? this.editorGroupService.activeGroup).replaceEditors([{ + this.group.replaceEditors([{ editor: input, replacement: binaryDiffInput, options: { @@ -228,8 +233,8 @@ export class TextDiffEditor extends AbstractTextEditor imp // and do not control the initial intent that resulted // in us now opening as binary. activation: EditorActivation.PRESERVE, - pinned: this.group?.isPinned(input), - sticky: this.group?.isSticky(input) + pinned: this.group.isPinned(input), + sticky: this.group.isSticky(input) } }]); } @@ -336,7 +341,7 @@ export class TextDiffEditor extends AbstractTextEditor imp collapseUnchangedRegions: boolean; }, { owner: 'hediet'; - editorVisibleTimeMs: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Indicates the time the diff editor was visible to the user' }; + editorVisibleTimeMs: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Indicates the time the diff editor was visible to the user' }; languageId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Indicates for which language the diff editor was shown' }; collapseUnchangedRegions: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Indicates whether unchanged regions were collapsed' }; comment: 'This event gives insight about how long the diff editor was visible to the user.'; @@ -361,8 +366,8 @@ export class TextDiffEditor extends AbstractTextEditor imp return this.diffEditorControl?.hasTextFocus() || super.hasFocus(); } - protected override setEditorVisible(visible: boolean, group: IEditorGroup | undefined): void { - super.setEditorVisible(visible, group); + protected override setEditorVisible(visible: boolean): void { + super.setEditorVisible(visible); if (visible) { this.diffEditorControl?.onVisible(); diff --git a/src/vs/workbench/browser/parts/editor/textEditor.ts b/src/vs/workbench/browser/parts/editor/textEditor.ts index 563f12a1be7ad..629d430bbec72 100644 --- a/src/vs/workbench/browser/parts/editor/textEditor.ts +++ b/src/vs/workbench/browser/parts/editor/textEditor.ts @@ -10,7 +10,7 @@ import { Emitter, Event } from 'vs/base/common/event'; import { isObject, assertIsDefined } from 'vs/base/common/types'; import { MutableDisposable } from 'vs/base/common/lifecycle'; import { ICodeEditor } from 'vs/editor/browser/editorBrowser'; -import { IEditorOpenContext, IEditorPaneSelection, EditorPaneSelectionCompareResult, EditorPaneSelectionChangeReason, IEditorPaneWithSelection, IEditorPaneSelectionChangeEvent } from 'vs/workbench/common/editor'; +import { IEditorOpenContext, IEditorPaneSelection, EditorPaneSelectionCompareResult, EditorPaneSelectionChangeReason, IEditorPaneWithSelection, IEditorPaneSelectionChangeEvent, IEditorPaneScrollPosition, IEditorPaneWithScrolling } from 'vs/workbench/common/editor'; import { EditorInput } from 'vs/workbench/common/editor/editorInput'; import { computeEditorAriaLabel } from 'vs/workbench/browser/editor'; import { AbstractEditorWithViewState } from 'vs/workbench/browser/parts/editor/editorWithViewState'; @@ -22,7 +22,7 @@ import { ITelemetryService } from 'vs/platform/telemetry/common/telemetry'; import { IThemeService } from 'vs/platform/theme/common/themeService'; import { ITextResourceConfigurationChangeEvent, ITextResourceConfigurationService } from 'vs/editor/common/services/textResourceConfiguration'; import { IEditorOptions as ICodeEditorOptions } from 'vs/editor/common/config/editorOptions'; -import { IEditorGroupsService, IEditorGroup } from 'vs/workbench/services/editor/common/editorGroupsService'; +import { IEditorGroup, IEditorGroupsService } from 'vs/workbench/services/editor/common/editorGroupsService'; import { CancellationToken } from 'vs/base/common/cancellation'; import { IEditorService } from 'vs/workbench/services/editor/common/editorService'; import { IEditorOptions, ITextEditorOptions, TextEditorSelectionRevealType, TextEditorSelectionSource } from 'vs/platform/editor/common/editor'; @@ -46,13 +46,16 @@ export interface IEditorConfiguration { /** * The base class of editors that leverage any kind of text editor for the editing experience. */ -export abstract class AbstractTextEditor extends AbstractEditorWithViewState implements IEditorPaneWithSelection { +export abstract class AbstractTextEditor extends AbstractEditorWithViewState implements IEditorPaneWithSelection, IEditorPaneWithScrolling { private static readonly VIEW_STATE_PREFERENCE_KEY = 'textEditorViewState'; protected readonly _onDidChangeSelection = this._register(new Emitter()); readonly onDidChangeSelection = this._onDidChangeSelection.event; + protected readonly _onDidChangeScroll = this._register(new Emitter()); + readonly onDidChangeScroll = this._onDidChangeScroll.event; + private editorContainer: HTMLElement | undefined; private hasPendingConfigurationChange: boolean | undefined; @@ -62,6 +65,7 @@ export abstract class AbstractTextEditor extends Abs constructor( id: string, + group: IEditorGroup, @ITelemetryService telemetryService: ITelemetryService, @IInstantiationService instantiationService: IInstantiationService, @IStorageService storageService: IStorageService, @@ -71,7 +75,7 @@ export abstract class AbstractTextEditor extends Abs @IEditorGroupsService editorGroupService: IEditorGroupsService, @IFileService protected readonly fileService: IFileService ) { - super(id, AbstractTextEditor.VIEW_STATE_PREFERENCE_KEY, telemetryService, instantiationService, storageService, textResourceConfigurationService, themeService, editorService, editorGroupService); + super(id, group, AbstractTextEditor.VIEW_STATE_PREFERENCE_KEY, telemetryService, instantiationService, storageService, textResourceConfigurationService, themeService, editorService, editorGroupService); // Listen to configuration changes this._register(this.textResourceConfigurationService.onDidChangeConfiguration(e => this.handleConfigurationChangeEvent(e))); @@ -127,8 +131,8 @@ export abstract class AbstractTextEditor extends Abs return editorConfiguration; } - private computeAriaLabel(): string { - return this._input ? computeEditorAriaLabel(this._input, undefined, this.group, this.editorGroupService.count) : localize('editor', "Editor"); + protected computeAriaLabel(): string { + return this.input ? computeEditorAriaLabel(this.input, undefined, this.group, this.editorGroupService.count) : localize('editor', "Editor"); } private onDidChangeFileSystemProvider(scheme: string): void { @@ -185,6 +189,7 @@ export abstract class AbstractTextEditor extends Abs this._register(mainControl.onDidChangeModel(() => this.updateEditorConfiguration())); this._register(mainControl.onDidChangeCursorPosition(e => this._onDidChangeSelection.fire({ reason: this.toEditorPaneSelectionChangeReason(e) }))); this._register(mainControl.onDidChangeModelContent(() => this._onDidChangeSelection.fire({ reason: EditorPaneSelectionChangeReason.EDIT }))); + this._register(mainControl.onDidScrollChange(() => this._onDidChangeScroll.fire())); } } @@ -255,12 +260,37 @@ export abstract class AbstractTextEditor extends Abs super.clearInput(); } - protected override setEditorVisible(visible: boolean, group: IEditorGroup | undefined): void { + getScrollPosition(): IEditorPaneScrollPosition { + const editor = this.getMainControl(); + if (!editor) { + throw new Error('Control has not yet been initialized'); + } + + return { + // The top position can vary depending on the view zones (find widget for example) + scrollTop: editor.getScrollTop() - editor.getTopForLineNumber(1), + scrollLeft: editor.getScrollLeft(), + }; + } + + setScrollPosition(scrollPosition: IEditorPaneScrollPosition): void { + const editor = this.getMainControl(); + if (!editor) { + throw new Error('Control has not yet been initialized'); + } + + editor.setScrollTop(scrollPosition.scrollTop); + if (scrollPosition.scrollLeft) { + editor.setScrollLeft(scrollPosition.scrollLeft); + } + } + + protected override setEditorVisible(visible: boolean): void { if (visible) { this.consumePendingConfigurationChangeEvent(); } - super.setEditorVisible(visible, group); + super.setEditorVisible(visible); } protected override toEditorViewStateResource(input: EditorInput): URI | undefined { diff --git a/src/vs/workbench/browser/parts/editor/textResourceEditor.ts b/src/vs/workbench/browser/parts/editor/textResourceEditor.ts index 34762256f40ed..0a3b885e01db5 100644 --- a/src/vs/workbench/browser/parts/editor/textResourceEditor.ts +++ b/src/vs/workbench/browser/parts/editor/textResourceEditor.ts @@ -18,7 +18,7 @@ import { ITextResourceConfigurationService } from 'vs/editor/common/services/tex import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; import { IThemeService } from 'vs/platform/theme/common/themeService'; import { ScrollType, ICodeEditorViewState } from 'vs/editor/common/editorCommon'; -import { IEditorGroupsService } from 'vs/workbench/services/editor/common/editorGroupsService'; +import { IEditorGroup, IEditorGroupsService } from 'vs/workbench/services/editor/common/editorGroupsService'; import { CancellationToken } from 'vs/base/common/cancellation'; import { IEditorService } from 'vs/workbench/services/editor/common/editorService'; import { IModelService } from 'vs/editor/common/services/model'; @@ -37,6 +37,7 @@ export abstract class AbstractTextResourceEditor extends AbstractTextCodeEditor< constructor( id: string, + group: IEditorGroup, @ITelemetryService telemetryService: ITelemetryService, @IInstantiationService instantiationService: IInstantiationService, @IStorageService storageService: IStorageService, @@ -46,14 +47,14 @@ export abstract class AbstractTextResourceEditor extends AbstractTextCodeEditor< @IEditorService editorService: IEditorService, @IFileService fileService: IFileService ) { - super(id, telemetryService, instantiationService, storageService, textResourceConfigurationService, themeService, editorService, editorGroupService, fileService); + super(id, group, telemetryService, instantiationService, storageService, textResourceConfigurationService, themeService, editorService, editorGroupService, fileService); } override async setInput(input: AbstractTextResourceEditorInput, options: ITextEditorOptions | undefined, context: IEditorOpenContext, token: CancellationToken): Promise { // Set input and resolve await super.setInput(input, options, context, token); - const resolvedModel = await input.resolve(options); + const resolvedModel = await input.resolve(); // Check for cancellation if (token.isCancellationRequested) { @@ -130,6 +131,7 @@ export class TextResourceEditor extends AbstractTextResourceEditor { static readonly ID = 'workbench.editors.textResourceEditor'; constructor( + group: IEditorGroup, @ITelemetryService telemetryService: ITelemetryService, @IInstantiationService instantiationService: IInstantiationService, @IStorageService storageService: IStorageService, @@ -141,7 +143,7 @@ export class TextResourceEditor extends AbstractTextResourceEditor { @ILanguageService private readonly languageService: ILanguageService, @IFileService fileService: IFileService ) { - super(TextResourceEditor.ID, telemetryService, instantiationService, storageService, textResourceConfigurationService, themeService, editorGroupService, editorService, fileService); + super(TextResourceEditor.ID, group, telemetryService, instantiationService, storageService, textResourceConfigurationService, themeService, editorGroupService, editorService, fileService); } protected override createEditorControl(parent: HTMLElement, configuration: ICodeEditorOptions): void { diff --git a/src/vs/workbench/browser/parts/globalCompositeBar.ts b/src/vs/workbench/browser/parts/globalCompositeBar.ts index 50a63bb9e0021..8301e27c6435d 100644 --- a/src/vs/workbench/browser/parts/globalCompositeBar.ts +++ b/src/vs/workbench/browser/parts/globalCompositeBar.ts @@ -12,7 +12,7 @@ import { DisposableStore, Disposable } from 'vs/base/common/lifecycle'; import { IColorTheme, IThemeService } from 'vs/platform/theme/common/themeService'; import { IStorageService, StorageScope, StorageTarget } from 'vs/platform/storage/common/storage'; import { IExtensionService } from 'vs/workbench/services/extensions/common/extensions'; -import { CompoisteBarActionViewItem, CompositeBarAction, IActivityHoverOptions, ICompositeBarActionViewItemOptions, ICompositeBarColors } from 'vs/workbench/browser/parts/compositeBarActions'; +import { CompositeBarActionViewItem, CompositeBarAction, IActivityHoverOptions, ICompositeBarActionViewItemOptions, ICompositeBarColors } from 'vs/workbench/browser/parts/compositeBarActions'; import { Codicon } from 'vs/base/common/codicons'; import { ThemeIcon } from 'vs/base/common/themables'; import { registerIcon } from 'vs/platform/theme/common/iconRegistry'; @@ -42,6 +42,8 @@ import { DEFAULT_ICON } from 'vs/workbench/services/userDataProfile/common/userD import { isString } from 'vs/base/common/types'; import { KeyCode } from 'vs/base/common/keyCodes'; import { ACTIVITY_BAR_BADGE_BACKGROUND, ACTIVITY_BAR_BADGE_FOREGROUND } from 'vs/workbench/common/theme'; +import { IBaseActionViewItemOptions } from 'vs/base/browser/ui/actionbar/actionViewItems'; +import { ICommandService } from 'vs/platform/commands/common/commands'; export class GlobalCompositeBar extends Disposable { @@ -71,15 +73,16 @@ export class GlobalCompositeBar extends Disposable { anchorAxisAlignment: AnchorAxisAlignment.HORIZONTAL }); this.globalActivityActionBar = this._register(new ActionBar(this.element, { - actionViewItemProvider: action => { + actionViewItemProvider: (action, options) => { if (action.id === GLOBAL_ACTIVITY_ID) { - return this.instantiationService.createInstance(GlobalActivityActionViewItem, this.contextMenuActionsProvider, { colors: this.colors, hoverOptions: this.activityHoverOptions }, contextMenuAlignmentOptions); + return this.instantiationService.createInstance(GlobalActivityActionViewItem, this.contextMenuActionsProvider, { ...options, colors: this.colors, hoverOptions: this.activityHoverOptions }, contextMenuAlignmentOptions); } if (action.id === ACCOUNTS_ACTIVITY_ID) { return this.instantiationService.createInstance(AccountsActivityActionViewItem, this.contextMenuActionsProvider, { + ...options, colors: this.colors, hoverOptions: this.activityHoverOptions }, @@ -96,7 +99,6 @@ export class GlobalCompositeBar extends Disposable { }, orientation: ActionsOrientation.VERTICAL, ariaLabel: localize('manage', "Manage"), - animated: false, preventLoopNavigation: true })); @@ -153,7 +155,7 @@ export class GlobalCompositeBar extends Disposable { } } -abstract class AbstractGlobalActivityActionViewItem extends CompoisteBarActionViewItem { +abstract class AbstractGlobalActivityActionViewItem extends CompositeBarActionViewItem { constructor( private readonly menuId: MenuId, @@ -308,6 +310,7 @@ export class AccountsActivityActionViewItem extends AbstractGlobalActivityAction @ILogService private readonly logService: ILogService, @IActivityService activityService: IActivityService, @IInstantiationService instantiationService: IInstantiationService, + @ICommandService private readonly commandService: ICommandService ) { const action = instantiationService.createInstance(CompositeBarAction, { id: ACCOUNTS_ACTIVITY_ID, @@ -390,7 +393,7 @@ export class AccountsActivityActionViewItem extends AbstractGlobalActivityAction menus.push(noAccountsAvailableAction); break; } - const providerLabel = this.authenticationService.getLabel(providerId); + const providerLabel = this.authenticationService.getProvider(providerId).label; const accounts = this.groupedAccounts.get(providerId); if (!accounts) { if (this.problematicProviders.has(providerId)) { @@ -407,19 +410,22 @@ export class AccountsActivityActionViewItem extends AbstractGlobalActivityAction } for (const account of accounts) { - const manageExtensionsAction = disposables.add(new Action(`configureSessions${account.label}`, localize('manageTrustedExtensions', "Manage Trusted Extensions"), undefined, true, () => { - return this.authenticationService.manageTrustedExtensionsForAccount(providerId, account.label); - })); + const manageExtensionsAction = toAction({ + id: `configureSessions${account.label}`, + label: localize('manageTrustedExtensions', "Manage Trusted Extensions"), + enabled: true, + run: () => this.commandService.executeCommand('_manageTrustedExtensionsForAccount', { providerId, accountLabel: account.label }) + }); - const providerSubMenuActions: Action[] = [manageExtensionsAction]; + const providerSubMenuActions: IAction[] = [manageExtensionsAction]; if (account.canSignOut) { - const signOutAction = disposables.add(new Action('signOut', localize('signOut', "Sign Out"), undefined, true, async () => { - const allSessions = await this.authenticationService.getSessions(providerId); - const sessionsForAccount = allSessions.filter(s => s.account.label === account.label); - return await this.authenticationService.removeAccountSessions(providerId, account.label, sessionsForAccount); + providerSubMenuActions.push(toAction({ + id: 'signOut', + label: localize('signOut', "Sign Out"), + enabled: true, + run: () => this.commandService.executeCommand('_signOutOfAccount', { providerId, accountLabel: account.label }) })); - providerSubMenuActions.push(signOutAction); } const providerSubMenu = new SubmenuAction('activitybar.submenu', `${account.label} (${providerLabel})`, providerSubMenuActions); @@ -612,6 +618,7 @@ export class SimpleAccountActivityActionViewItem extends AccountsActivityActionV constructor( hoverOptions: IActivityHoverOptions, + options: IBaseActionViewItemOptions, @IThemeService themeService: IThemeService, @ILifecycleService lifecycleService: ILifecycleService, @IHoverService hoverService: IHoverService, @@ -626,16 +633,18 @@ export class SimpleAccountActivityActionViewItem extends AccountsActivityActionV @ISecretStorageService secretStorageService: ISecretStorageService, @ILogService logService: ILogService, @IActivityService activityService: IActivityService, - @IInstantiationService instantiationService: IInstantiationService + @IInstantiationService instantiationService: IInstantiationService, + @ICommandService commandService: ICommandService ) { super(() => [], { + ...options, colors: theme => ({ badgeBackground: theme.getColor(ACTIVITY_BAR_BADGE_BACKGROUND), badgeForeground: theme.getColor(ACTIVITY_BAR_BADGE_FOREGROUND), }), hoverOptions, compact: true, - }, () => undefined, actions => actions, themeService, lifecycleService, hoverService, contextMenuService, menuService, contextKeyService, authenticationService, environmentService, productService, configurationService, keybindingService, secretStorageService, logService, activityService, instantiationService); + }, () => undefined, actions => actions, themeService, lifecycleService, hoverService, contextMenuService, menuService, contextKeyService, authenticationService, environmentService, productService, configurationService, keybindingService, secretStorageService, logService, activityService, instantiationService, commandService); } } @@ -643,6 +652,7 @@ export class SimpleGlobalActivityActionViewItem extends GlobalActivityActionView constructor( hoverOptions: IActivityHoverOptions, + options: IBaseActionViewItemOptions, @IUserDataProfileService userDataProfileService: IUserDataProfileService, @IThemeService themeService: IThemeService, @IHoverService hoverService: IHoverService, @@ -656,6 +666,7 @@ export class SimpleGlobalActivityActionViewItem extends GlobalActivityActionView @IActivityService activityService: IActivityService, ) { super(() => [], { + ...options, colors: theme => ({ badgeBackground: theme.getColor(ACTIVITY_BAR_BADGE_BACKGROUND), badgeForeground: theme.getColor(ACTIVITY_BAR_BADGE_FOREGROUND), diff --git a/src/vs/workbench/browser/parts/media/compositepart.css b/src/vs/workbench/browser/parts/media/compositepart.css index fde7cdb706c8d..98f07d9cf18fb 100644 --- a/src/vs/workbench/browser/parts/media/compositepart.css +++ b/src/vs/workbench/browser/parts/media/compositepart.css @@ -7,6 +7,7 @@ height: 100%; } +.monaco-workbench .part > .composite.header-or-footer, .monaco-workbench .part > .composite.title { display: flex; } @@ -14,4 +15,4 @@ .monaco-workbench .part > .composite.title > .title-actions { flex: 1; padding-left: 5px; -} \ No newline at end of file +} diff --git a/src/vs/workbench/browser/parts/media/paneCompositePart.css b/src/vs/workbench/browser/parts/media/paneCompositePart.css index 9250cd44de141..52baa5324f725 100644 --- a/src/vs/workbench/browser/parts/media/paneCompositePart.css +++ b/src/vs/workbench/browser/parts/media/paneCompositePart.css @@ -20,11 +20,31 @@ display: none; } -.monaco-workbench .pane-composite-part > .title > .composite-bar-container { +.monaco-workbench .pane-composite-part > .header-or-footer { + padding-left: 4px; + padding-right: 4px; + background-color: var(--vscode-activityBarTop-background); +} + +.monaco-workbench .pane-composite-part > .header { + border-bottom: 1px solid var(--vscode-sideBarActivityBarTop-border); +} + +.monaco-workbench .pane-composite-part > .footer { + border-top: 1px solid var(--vscode-sideBarActivityBarTop-border); +} + +.monaco-workbench .pane-composite-part > .title > .composite-bar-container, +.monaco-workbench .pane-composite-part > .header-or-footer > .composite-bar-container { display: flex; } -.monaco-workbench .pane-composite-part > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-label.codicon-more { +.monaco-workbench .pane-composite-part > .header-or-footer .composite-bar-container { + flex: 1; +} + +.monaco-workbench .pane-composite-part > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-label.codicon-more, +.monaco-workbench .pane-composite-part > .header-or-footer > .composite-bar-container > .composite-bar > .monaco-action-bar .action-label.codicon-more { display: flex; align-items: center; justify-content: center; @@ -33,12 +53,14 @@ color: inherit !important; } -.monaco-workbench .pane-composite-part > .title > .composite-bar-container > .composite-bar > .monaco-action-bar { +.monaco-workbench .pane-composite-part > .title > .composite-bar-container > .composite-bar > .monaco-action-bar, +.monaco-workbench .pane-composite-part > .header-or-footer > .composite-bar-container > .composite-bar > .monaco-action-bar { line-height: 27px; /* matches panel titles in settings */ height: 35px; } -.monaco-workbench .pane-composite-part > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item { +.monaco-workbench .pane-composite-part > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item, +.monaco-workbench .pane-composite-part > .header-or-footer > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item { text-transform: uppercase; padding-left: 10px; padding-right: 10px; @@ -48,22 +70,27 @@ display: flex; } -.monaco-workbench .pane-composite-part > .title > .composite-bar-container >.composite-bar > .monaco-action-bar .action-item.icon { - height: 24px; +.monaco-workbench .pane-composite-part > .title > .composite-bar-container >.composite-bar > .monaco-action-bar .action-item.icon, +.monaco-workbench .pane-composite-part > .header-or-footer > .composite-bar-container >.composite-bar > .monaco-action-bar .action-item.icon { + height: 35px; /* matches height of composite container */ padding: 0 5px; } -.monaco-workbench .pane-composite-part > .title > .composite-bar-container >.composite-bar .monaco-action-bar .action-label.codicon { +.monaco-workbench .pane-composite-part > .title > .composite-bar-container >.composite-bar .monaco-action-bar .action-label.codicon, +.monaco-workbench .pane-composite-part > .header-or-footer > .composite-bar-container >.composite-bar .monaco-action-bar .action-label.codicon { font-size: 18px; } -.monaco-workbench .pane-composite-part > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item.icon .action-label:not(.codicon) { +.monaco-workbench .pane-composite-part > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item.icon .action-label:not(.codicon), +.monaco-workbench .pane-composite-part > .header-or-footer > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item.icon .action-label:not(.codicon) { width: 16px; height: 16px; } .monaco-workbench .pane-composite-part > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item::before, -.monaco-workbench .pane-composite-part > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item::after { +.monaco-workbench .pane-composite-part > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item::after, +.monaco-workbench .pane-composite-part > .header-or-footer > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item::before, +.monaco-workbench .pane-composite-part > .header-or-footer > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item::after { content: ''; width: 2px; height: 24px; @@ -77,26 +104,33 @@ } .monaco-workbench .pane-composite-part > .title > .composite-bar-container.dragged-over > .composite-bar > .monaco-action-bar .action-item::before, -.monaco-workbench .pane-composite-part > .title > .composite-bar-container.dragged-over > .composite-bar > .monaco-action-bar .action-item::after { +.monaco-workbench .pane-composite-part > .title > .composite-bar-container.dragged-over > .composite-bar > .monaco-action-bar .action-item::after, +.monaco-workbench .pane-composite-part > .header-or-footer > .composite-bar-container.dragged-over > .composite-bar > .monaco-action-bar .action-item::before, +.monaco-workbench .pane-composite-part > .header-or-footer > .composite-bar-container.dragged-over > .composite-bar > .monaco-action-bar .action-item::after { display: block; } -.monaco-workbench .pane-composite-part > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item::before { +.monaco-workbench .pane-composite-part > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item::before, +.monaco-workbench .pane-composite-part > .header-or-footer > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item::before { left: 1px; margin-left: -2px; } -.monaco-workbench .pane-composite-part > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item::after { +.monaco-workbench .pane-composite-part > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item::after, +.monaco-workbench .pane-composite-part > .header-or-footer > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item::after { right: 1px; margin-right: -2px; } -.monaco-workbench .pane-composite-part > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item:first-of-type::before { + +.monaco-workbench .pane-composite-part > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item:first-of-type::before, +.monaco-workbench .pane-composite-part > .header-or-footer > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item:first-of-type::before { left: 2px; margin-left: -2px; } -.monaco-workbench .pane-composite-part > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item:last-of-type::after { +.monaco-workbench .pane-composite-part > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item:last-of-type::after, +.monaco-workbench .pane-composite-part > .header-or-footer > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item:last-of-type::after { right: 2px; margin-right: -2px; } @@ -104,7 +138,11 @@ .monaco-workbench .pane-composite-part > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item.right::before, .monaco-workbench .pane-composite-part > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item.left::after, .monaco-workbench .pane-composite-part > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item.left::before, -.monaco-workbench .pane-composite-part > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item.right::after { +.monaco-workbench .pane-composite-part > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item.right::after, +.monaco-workbench .pane-composite-part > .header-or-footer > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item.right::before, +.monaco-workbench .pane-composite-part > .header-or-footer > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item.left::after, +.monaco-workbench .pane-composite-part > .header-or-footer > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item.left::before, +.monaco-workbench .pane-composite-part > .header-or-footer > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item.right::after { transition-delay: 0s; } @@ -112,39 +150,52 @@ .monaco-workbench .pane-composite-part > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item.left::before, .monaco-workbench .pane-composite-part > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item:last-of-type.right::after, .monaco-workbench .pane-composite-part > .title > .composite-bar-container.dragged-over-head > .composite-bar > .monaco-action-bar .action-item:first-of-type::before, -.monaco-workbench .pane-composite-part > .title > .composite-bar-container.dragged-over-tail > .composite-bar > .monaco-action-bar .action-item:last-of-type::after { +.monaco-workbench .pane-composite-part > .title > .composite-bar-container.dragged-over-tail > .composite-bar > .monaco-action-bar .action-item:last-of-type::after, +.monaco-workbench .pane-composite-part > .header-or-footer > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item.right + .action-item::before, +.monaco-workbench .pane-composite-part > .header-or-footer > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item.left::before, +.monaco-workbench .pane-composite-part > .header-or-footer > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item:last-of-type.right::after, +.monaco-workbench .pane-composite-part > .header-or-footer > .composite-bar-container.dragged-over-head > .composite-bar > .monaco-action-bar .action-item:first-of-type::before, +.monaco-workbench .pane-composite-part > .header-or-footer > .composite-bar-container.dragged-over-tail > .composite-bar > .monaco-action-bar .action-item:last-of-type::after { opacity: 1; } -.monaco-workbench .pane-composite-part > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item .action-label { +.monaco-workbench .pane-composite-part > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item .action-label, +.monaco-workbench .pane-composite-part > .header-or-footer > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item .action-label { margin-right: 0; padding: 2px; } -.monaco-workbench .pane-composite-part > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item .action-label { +.monaco-workbench .pane-composite-part > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item .action-label, +.monaco-workbench .pane-composite-part > .header-or-footer > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item .action-label { border-radius: 0; } .monaco-workbench .pane-composite-part > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item:not(.icon) .action-label, -.monaco-workbench .pane-composite-part > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item.icon .action-label.codicon { +.monaco-workbench .pane-composite-part > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item.icon .action-label.codicon, +.monaco-workbench .pane-composite-part > .header-or-footer > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item:not(.icon) .action-label, +.monaco-workbench .pane-composite-part > .header-or-footer > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item.icon .action-label.codicon { background: none !important; } -.monaco-workbench .pane-composite-part > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item.checked .action-label { +.monaco-workbench .pane-composite-part > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item.checked .action-label, +.monaco-workbench .pane-composite-part > .header-or-footer > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item.checked .action-label { margin-right: 0; } -.monaco-workbench .pane-composite-part > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .badge { +.monaco-workbench .pane-composite-part > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .badge, +.monaco-workbench .pane-composite-part > .header-or-footer > .composite-bar-container > .composite-bar > .monaco-action-bar .badge { margin-left: 8px; display: flex; align-items: center; } -.monaco-workbench .pane-composite-part > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item.icon .badge { +.monaco-workbench .pane-composite-part > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item.icon .badge, +.monaco-workbench .pane-composite-part > .header-or-footer > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item.icon .badge { margin-left: 0px; } -.monaco-workbench .pane-composite-part > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .badge .badge-content { +.monaco-workbench .pane-composite-part > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .badge .badge-content, +.monaco-workbench .pane-composite-part > .header-or-footer > .composite-bar-container > .composite-bar > .monaco-action-bar .badge .badge-content { padding: 3px 5px; border-radius: 11px; font-size: 11px; @@ -158,7 +209,8 @@ position: relative; } -.monaco-workbench .pane-composite-part > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item.icon .badge.compact { +.monaco-workbench .pane-composite-part > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item.icon .badge.compact, +.monaco-workbench .pane-composite-part > .header-or-footer > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item.icon .badge.compact { position: absolute; top: 0; bottom: 0; @@ -170,9 +222,10 @@ z-index: 2; } -.monaco-workbench .pane-composite-part > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item.icon .badge.compact .badge-content { +.monaco-workbench .pane-composite-part > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item.icon .badge.compact .badge-content, +.monaco-workbench .pane-composite-part > .header-or-footer > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item.icon .badge.compact .badge-content { position: absolute; - top: 11px; + top: 17px; right: 0px; font-size: 9px; font-weight: 600; @@ -184,7 +237,8 @@ text-align: center; } -.monaco-workbench .pane-composite-part > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item.icon .badge.compact.progress-badge .badge-content::before { +.monaco-workbench .pane-composite-part > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item.icon .badge.compact.progress-badge .badge-content::before, +.monaco-workbench .pane-composite-part > .header-or-footer > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item.icon .badge.compact.progress-badge .badge-content::before { mask-size: 11px; -webkit-mask-size: 11px; top: 3px; @@ -192,7 +246,8 @@ } /* active item indicator */ -.monaco-workbench .pane-composite-part > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item .active-item-indicator { +.monaco-workbench .pane-composite-part > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item .active-item-indicator, +.monaco-workbench .pane-composite-part > .header-or-footer > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item .active-item-indicator { position: absolute; z-index: 1; bottom: 0; @@ -201,44 +256,59 @@ height: 100%; } -.monaco-workbench .pane-composite-part > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item .active-item-indicator { +.monaco-workbench .pane-composite-part > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item .active-item-indicator, +.monaco-workbench .pane-composite-part > .header-or-footer > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item .active-item-indicator { top: -4px; left: 10px; width: calc(100% - 20px); } -.monaco-workbench .pane-composite-part > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item.icon .active-item-indicator { +.monaco-workbench .pane-composite-part > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item.icon .active-item-indicator, +.monaco-workbench .pane-composite-part > .header-or-footer > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item.icon .active-item-indicator { top: 1px; left: 2px; width: calc(100% - 4px); } +.monaco-workbench .pane-composite-part > .title > .composite-bar-container >.composite-bar > .monaco-action-bar .action-item.icon.checked, +.monaco-workbench .pane-composite-part > .header-or-footer > .composite-bar-container >.composite-bar > .monaco-action-bar .action-item.icon.checked { + background-color: var(--vscode-activityBarTop-activeBackground); +} + .monaco-workbench .pane-composite-part > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item.checked .active-item-indicator:before, -.monaco-workbench .pane-composite-part > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item:focus .active-item-indicator:before { +.monaco-workbench .pane-composite-part > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item:focus .active-item-indicator:before, +.monaco-workbench .pane-composite-part > .header-or-footer > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item.checked .active-item-indicator:before, +.monaco-workbench .pane-composite-part > .header-or-footer > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item:focus .active-item-indicator:before { content: ""; position: absolute; z-index: 1; - bottom: 0; + bottom: 2px; width: 100%; height: 0; border-top-width: 1px; border-top-style: solid; } -.monaco-workbench .pane-composite-part > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item.clicked:not(.checked):focus .active-item-indicator:before { +.monaco-workbench .pane-composite-part > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item.clicked:not(.checked):focus .active-item-indicator:before, +.monaco-workbench .pane-composite-part > .header-or-footer > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item.clicked:not(.checked):focus .active-item-indicator:before { border-top-color: transparent !important; /* hides border on clicked state */ } -.monaco-workbench .pane-composite-part > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item:focus .active-item-indicator:before { +.monaco-workbench .pane-composite-part > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item:focus .active-item-indicator:before, +.monaco-workbench .pane-composite-part > .header-or-footer > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item:focus .active-item-indicator:before { border-top-color: var(--vscode-focusBorder) !important; + border-top-width: 2px; } .monaco-workbench .pane-composite-part > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item.checked .action-label, -.monaco-workbench .pane-composite-part > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item:hover .action-label { +.monaco-workbench .pane-composite-part > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item:hover .action-label, +.monaco-workbench .pane-composite-part > .header-or-footer > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item.checked .action-label, +.monaco-workbench .pane-composite-part > .header-or-footer > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item:hover .action-label { outline: var(--vscode-contrastActiveBorder, unset) solid 1px !important; } -.monaco-workbench .pane-composite-part > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item:not(.checked):hover .action-label { +.monaco-workbench .pane-composite-part > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item:not(.checked):hover .action-label, +.monaco-workbench .pane-composite-part > .header-or-footer > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item:not(.checked):hover .action-label { outline: var(--vscode-contrastActiveBorder, unset) dashed 1px !important; } diff --git a/src/vs/workbench/browser/parts/notifications/notificationsCenter.ts b/src/vs/workbench/browser/parts/notifications/notificationsCenter.ts index 8a88a9e5f4a81..40bcde5fdb480 100644 --- a/src/vs/workbench/browser/parts/notifications/notificationsCenter.ts +++ b/src/vs/workbench/browser/parts/notifications/notificationsCenter.ts @@ -28,7 +28,7 @@ import { INotificationService, NotificationsFilter } from 'vs/platform/notificat import { mainWindow } from 'vs/base/browser/window'; import { IContextMenuService } from 'vs/platform/contextview/browser/contextView'; import { DropdownMenuActionViewItem } from 'vs/base/browser/ui/dropdown/dropdownActionViewItem'; -import { AudioCue, IAudioCueService } from 'vs/platform/audioCues/browser/audioCueService'; +import { AccessibilitySignal, IAccessibilitySignalService } from 'vs/platform/accessibilitySignal/browser/accessibilitySignalService'; export class NotificationsCenter extends Themable implements INotificationsCenterController { @@ -59,7 +59,7 @@ export class NotificationsCenter extends Themable implements INotificationsCente @IEditorGroupsService private readonly editorGroupService: IEditorGroupsService, @IKeybindingService private readonly keybindingService: IKeybindingService, @INotificationService private readonly notificationService: INotificationService, - @IAudioCueService private readonly audioCueService: IAudioCueService, + @IAccessibilitySignalService private readonly accessibilitySignalService: IAccessibilitySignalService, @IContextMenuService private readonly contextMenuService: IContextMenuService ) { super(themeService); @@ -173,7 +173,7 @@ export class NotificationsCenter extends Themable implements INotificationsCente const notificationsToolBar = this._register(new ActionBar(toolbarContainer, { ariaLabel: localize('notificationsToolbar', "Notification Center Actions"), actionRunner, - actionViewItemProvider: action => { + actionViewItemProvider: (action, options) => { if (action.id === ConfigureDoNotDisturbAction.ID) { return this._register(this.instantiationService.createInstance(DropdownMenuActionViewItem, action, { getActions() { @@ -208,6 +208,7 @@ export class NotificationsCenter extends Themable implements INotificationsCente return actions; }, }, this.contextMenuService, { + ...options, actionRunner, classNames: action.class, keybindingProvider: action => this.keybindingService.lookupKeybinding(action.id) @@ -383,7 +384,7 @@ export class NotificationsCenter extends Themable implements INotificationsCente if (!notification.hasProgress) { notification.close(); } - this.audioCueService.playAudioCue(AudioCue.clear); + this.accessibilitySignalService.playSignal(AccessibilitySignal.clear); } } } diff --git a/src/vs/workbench/browser/parts/notifications/notificationsCommands.ts b/src/vs/workbench/browser/parts/notifications/notificationsCommands.ts index 5e8135b4fcce5..6f205b9d81fdf 100644 --- a/src/vs/workbench/browser/parts/notifications/notificationsCommands.ts +++ b/src/vs/workbench/browser/parts/notifications/notificationsCommands.ts @@ -21,7 +21,7 @@ import { hash } from 'vs/base/common/hash'; import { firstOrDefault } from 'vs/base/common/arrays'; import { IQuickInputService, IQuickPickItem } from 'vs/platform/quickinput/common/quickInput'; import { DisposableStore } from 'vs/base/common/lifecycle'; -import { AudioCue, IAudioCueService } from 'vs/platform/audioCues/browser/audioCueService'; +import { AccessibilitySignal, IAccessibilitySignalService } from 'vs/platform/accessibilitySignal/browser/accessibilitySignalService'; // Center export const SHOW_NOTIFICATIONS_CENTER = 'notifications.showList'; @@ -142,11 +142,11 @@ export function registerNotificationCommands(center: INotificationsCenterControl primary: KeyMod.CtrlCmd | KeyCode.Backspace }, handler: (accessor, args?) => { - const audioCueService = accessor.get(IAudioCueService); + const accessibilitySignalService = accessor.get(IAccessibilitySignalService); const notification = getNotificationFromContext(accessor.get(IListService), args); if (notification && !notification.hasProgress) { notification.close(); - audioCueService.playAudioCue(AudioCue.clear); + accessibilitySignalService.playSignal(AccessibilitySignal.clear); } } }); diff --git a/src/vs/workbench/browser/parts/notifications/notificationsViewer.ts b/src/vs/workbench/browser/parts/notifications/notificationsViewer.ts index 484ee51adb4cb..a073b11dc8365 100644 --- a/src/vs/workbench/browser/parts/notifications/notificationsViewer.ts +++ b/src/vs/workbench/browser/parts/notifications/notificationsViewer.ts @@ -29,6 +29,9 @@ import { Event } from 'vs/base/common/event'; import { defaultButtonStyles, defaultProgressBarStyles } from 'vs/platform/theme/browser/defaultStyles'; import { KeyCode } from 'vs/base/common/keyCodes'; import { StandardKeyboardEvent } from 'vs/base/browser/keyboardEvent'; +import { getDefaultHoverDelegate } from 'vs/base/browser/ui/hover/hoverDelegateFactory'; +import type { IUpdatableHover } from 'vs/base/browser/ui/hover/hover'; +import { IHoverService } from 'vs/platform/hover/browser/hover'; export class NotificationsListDelegate implements IListVirtualDelegate { @@ -235,7 +238,7 @@ export class NotificationRenderer implements IListRenderer { + actionViewItemProvider: (action, options) => { if (action instanceof ConfigureNotificationAction) { return data.toDispose.add(new DropdownMenuActionViewItem(action, { getActions() { @@ -262,6 +265,7 @@ export class NotificationRenderer implements IListRenderer this.openerService.open(URI.parse(link), { allowCommands: true }), @@ -425,11 +432,8 @@ export class NotificationTemplateRenderer extends Disposable { })); const messageOverflows = notification.canCollapse && !notification.expanded && this.template.message.scrollWidth > this.template.message.clientWidth; - if (messageOverflows) { - this.template.message.title = this.template.message.textContent + ''; - } else { - this.template.message.removeAttribute('title'); - } + + customHover.update(messageOverflows ? this.template.message.textContent + '' : ''); return messageOverflows; } @@ -470,13 +474,13 @@ export class NotificationTemplateRenderer extends Disposable { actions.forEach(action => this.template.toolbar.push(action, { icon: true, label: false, keybinding: this.getKeybindingLabel(action) })); } - private renderSource(notification: INotificationViewItem): void { + private renderSource(notification: INotificationViewItem, sourceCustomHover: IUpdatableHover): void { if (notification.expanded && notification.source) { this.template.source.textContent = localize('notificationSource', "Source: {0}", notification.source); - this.template.source.title = notification.source; + sourceCustomHover.update(notification.source); } else { this.template.source.textContent = ''; - this.template.source.removeAttribute('title'); + sourceCustomHover.update(''); } } diff --git a/src/vs/workbench/browser/parts/paneCompositeBar.ts b/src/vs/workbench/browser/parts/paneCompositeBar.ts index 4387df1a1380c..94dce01b9582d 100644 --- a/src/vs/workbench/browser/parts/paneCompositeBar.ts +++ b/src/vs/workbench/browser/parts/paneCompositeBar.ts @@ -111,7 +111,7 @@ export class PaneCompositeBar extends Disposable { ? ViewContainerLocation.Panel : paneCompositePart.partId === Parts.AUXILIARYBAR_PART ? ViewContainerLocation.AuxiliaryBar : ViewContainerLocation.Sidebar; - this.dndHandler = new CompositeDragAndDrop(this.viewDescriptorService, this.location, + this.dndHandler = new CompositeDragAndDrop(this.viewDescriptorService, this.location, this.options.orientation, async (id: string, focus?: boolean) => { return await this.paneCompositePart.openPaneComposite(id, focus) ?? null; }, (from: string, to: string, before?: Before2D) => this.compositeBar.move(from, to, this.options.orientation === ActionsOrientation.VERTICAL ? before?.verticallyBefore : before?.horizontallyBefore), () => this.compositeBar.getCompositeBarItems(), @@ -207,12 +207,6 @@ export class PaneCompositeBar extends Disposable { if (to === this.location) { this.onDidRegisterViewContainers([container]); - - // Open view container if part is visible and there is no other view container opened - const visibleComposites = this.compositeBar.getVisibleComposites(); - if (!this.paneCompositePart.getActivePaneComposite() && this.layoutService.isVisible(this.paneCompositePart.partId) && visibleComposites.length) { - this.paneCompositePart.openPaneComposite(visibleComposites[0].id); - } } } diff --git a/src/vs/workbench/browser/parts/paneCompositePart.ts b/src/vs/workbench/browser/parts/paneCompositePart.ts index ebee8aa4b4216..efb1d75565c9d 100644 --- a/src/vs/workbench/browser/parts/paneCompositePart.ts +++ b/src/vs/workbench/browser/parts/paneCompositePart.ts @@ -39,6 +39,13 @@ import { IAction, SubmenuAction } from 'vs/base/common/actions'; import { Composite } from 'vs/workbench/browser/composite'; import { ViewsSubMenu } from 'vs/workbench/browser/parts/views/viewPaneContainer'; import { createAndFillInActionBarActions } from 'vs/platform/actions/browser/menuEntryActionViewItem'; +import { IHoverService } from 'vs/platform/hover/browser/hover'; + +export enum CompositeBarPosition { + TOP, + TITLE, + BOTTOM +} export interface IPaneCompositePart extends IView { @@ -108,8 +115,11 @@ export abstract class AbstractPaneCompositePart extends CompositePart()); + private readonly paneCompositeBar = this._register(new MutableDisposable()); + private compositeBarPosition: CompositeBarPosition | undefined = undefined; private emptyPaneMessageElement: HTMLElement | undefined; private globalToolBar: ToolBar | undefined; @@ -132,6 +142,7 @@ export abstract class AbstractPaneCompositePart extends CompositePart(registryId), @@ -180,7 +192,7 @@ export abstract class AbstractPaneCompositePart extends CompositePart this.updateGlobalToolbarActions())); - this._register(this.registry.onDidDeregister(async (viewletDescriptor: PaneCompositeDescriptor) => { + this._register(this.registry.onDidDeregister((viewletDescriptor: PaneCompositeDescriptor) => { const activeContainers = this.viewDescriptorService.getViewContainersByLocation(this.location) .filter(container => this.viewDescriptorService.getViewContainerModel(container).activeViewDescriptors.length > 0); @@ -189,7 +201,7 @@ export abstract class AbstractPaneCompositePart extends CompositePart c.id === defaultViewletId)[0] || activeContainers[0]; - await this.openPaneComposite(containerToOpen.id); + this.doOpenPaneComposite(containerToOpen.id); } } else { this.layoutService.setPartHidden(true, this.partId); @@ -238,6 +250,8 @@ export abstract class AbstractPaneCompositePart extends CompositePart this.paneFocusContextKey.set(true))); this._register(focusTracker.onDidBlur(() => this.paneFocusContextKey.set(false))); @@ -302,11 +316,12 @@ export abstract class AbstractPaneCompositePart extends CompositePart this.actionViewItemProvider(action), + actionViewItemProvider: (action, options) => this.actionViewItemProvider(action, options), orientation: ActionsOrientation.HORIZONTAL, getKeyBinding: action => this.keybindingService.lookupKeybinding(action.id), anchorAlignmentProvider: () => this.getTitleAreaDropDownAnchorAlignment(), - toggleMenuTitle: localize('moreActions', "More Actions...") + toggleMenuTitle: localize('moreActions', "More Actions..."), + hoverDelegate: this.toolbarHoverDelegate })); this.updateGlobalToolbarActions(); @@ -325,30 +340,107 @@ export abstract class AbstractPaneCompositePart extends CompositePart { + this.onCompositeBarAreaContextMenu(new StandardMouseEvent(getWindow(area), e)); + })); + this.headerFooterCompositeBarDispoables.add(Gesture.addTarget(area)); + this.headerFooterCompositeBarDispoables.add(addDisposableListener(area, GestureEventType.Contextmenu, e => { + this.onCompositeBarAreaContextMenu(new StandardMouseEvent(getWindow(area), e)); + })); + + return area; + } + + private removeFooterHeaderArea(header: boolean): void { + this.headerFooterCompositeBarContainer = undefined; + this.headerFooterCompositeBarDispoables.clear(); + if (header) { + this.removeHeaderArea(); + } else { + this.removeFooterArea(); } } - protected createCompisteBar(): PaneCompositeBar { + protected createCompositeBar(): PaneCompositeBar { return this.instantiationService.createInstance(PaneCompositeBar, this.getCompositeBarOptions(), this.partId, this); } @@ -456,16 +548,20 @@ export abstract class AbstractPaneCompositePart extends CompositePart event, - getActions: () => actions, - skipTelemetry: true - }); - } + if (this.shouldShowCompositeBar() && this.getCompositeBarPosition() === CompositeBarPosition.TITLE) { + return this.onCompositeBarContextMenu(event); } else { const activePaneComposite = this.getActivePaneComposite() as PaneComposite; const activePaneCompositeActions = activePaneComposite ? activePaneComposite.getContextMenuActions() : []; @@ -503,7 +596,7 @@ export abstract class AbstractPaneCompositePart extends CompositePart event, getActions: () => activePaneCompositeActions, - getActionViewItem: action => this.actionViewItemProvider(action), + getActionViewItem: (action, options) => this.actionViewItemProvider(action, options), actionRunner: activePaneComposite.getActionRunner(), skipTelemetry: true }); @@ -511,6 +604,23 @@ export abstract class AbstractPaneCompositePart extends CompositePart event, + getActions: () => actions, + skipTelemetry: true + }); + } + } + } + protected getViewsSubmenuAction(): SubmenuAction | undefined { const viewPaneContainer = (this.getActivePaneComposite() as PaneComposite)?.getViewPaneContainer(); if (viewPaneContainer) { @@ -528,5 +638,6 @@ export abstract class AbstractPaneCompositePart extends CompositePart .content .monaco-editor, .monaco-workbench .part.panel > .content .monaco-editor .margin, .monaco-workbench .part.panel > .content .monaco-editor .monaco-editor-background { + /* THIS DOESN'T WORK ANYMORE */ background-color: var(--vscode-panel-background); } diff --git a/src/vs/workbench/browser/parts/panel/panelActions.ts b/src/vs/workbench/browser/parts/panel/panelActions.ts index 23f5ce98a1b70..534db0283a72f 100644 --- a/src/vs/workbench/browser/parts/panel/panelActions.ts +++ b/src/vs/workbench/browser/parts/panel/panelActions.ts @@ -8,7 +8,7 @@ import { localize, localize2 } from 'vs/nls'; import { KeyMod, KeyCode } from 'vs/base/common/keyCodes'; import { MenuId, MenuRegistry, registerAction2, Action2, IAction2Options } from 'vs/platform/actions/common/actions'; import { Categories } from 'vs/platform/action/common/actionCommonCategories'; -import { IWorkbenchLayoutService, PanelAlignment, Parts, Position, positionToString } from 'vs/workbench/services/layout/browser/layoutService'; +import { ActivityBarPosition, IWorkbenchLayoutService, LayoutSettings, PanelAlignment, Parts, Position, positionToString } from 'vs/workbench/services/layout/browser/layoutService'; import { AuxiliaryBarVisibleContext, PanelAlignmentContext, PanelMaximizedContext, PanelPositionContext, PanelVisibleContext } from 'vs/workbench/common/contextkeys'; import { ContextKeyExpr, ContextKeyExpression } from 'vs/platform/contextkey/common/contextkey'; import { Codicon } from 'vs/base/common/codicons'; @@ -199,7 +199,7 @@ AlignPanelActionConfigs.forEach(alignPanelAction => { constructor() { super({ id, - title: title, + title, category: Categories.View, toggled: when.negate(), f1: true @@ -349,7 +349,12 @@ registerAction2(class extends Action2 { }, { id: MenuId.AuxiliaryBarTitle, group: 'navigation', - order: 2 + order: 2, + when: ContextKeyExpr.notEquals(`config.${LayoutSettings.ACTIVITY_BAR_LOCATION}`, ActivityBarPosition.TOP) + }, { + id: MenuId.AuxiliaryBarHeader, + group: 'navigation', + when: ContextKeyExpr.equals(`config.${LayoutSettings.ACTIVITY_BAR_LOCATION}`, ActivityBarPosition.TOP) }] }); } diff --git a/src/vs/workbench/browser/parts/panel/panelPart.ts b/src/vs/workbench/browser/parts/panel/panelPart.ts index 00b10081e8a56..e8e8b49bce536 100644 --- a/src/vs/workbench/browser/parts/panel/panelPart.ts +++ b/src/vs/workbench/browser/parts/panel/panelPart.ts @@ -25,10 +25,11 @@ import { IExtensionService } from 'vs/workbench/services/extensions/common/exten import { IViewDescriptorService } from 'vs/workbench/common/views'; import { HoverPosition } from 'vs/base/browser/ui/hover/hoverWidget'; import { IMenuService, MenuId } from 'vs/platform/actions/common/actions'; -import { AbstractPaneCompositePart } from 'vs/workbench/browser/parts/paneCompositePart'; +import { AbstractPaneCompositePart, CompositeBarPosition } from 'vs/workbench/browser/parts/paneCompositePart'; import { ICommandService } from 'vs/platform/commands/common/commands'; import { createAndFillInContextMenuActions } from 'vs/platform/actions/browser/menuEntryActionViewItem'; import { IPaneCompositeBarOptions } from 'vs/workbench/browser/parts/paneCompositeBar'; +import { IHoverService } from 'vs/platform/hover/browser/hover'; export class PanelPart extends AbstractPaneCompositePart { @@ -70,6 +71,7 @@ export class PanelPart extends AbstractPaneCompositePart { @IContextMenuService contextMenuService: IContextMenuService, @IWorkbenchLayoutService layoutService: IWorkbenchLayoutService, @IKeybindingService keybindingService: IKeybindingService, + @IHoverService hoverService: IHoverService, @IInstantiationService instantiationService: IInstantiationService, @IThemeService themeService: IThemeService, @IViewDescriptorService viewDescriptorService: IViewDescriptorService, @@ -92,6 +94,7 @@ export class PanelPart extends AbstractPaneCompositePart { contextMenuService, layoutService, keybindingService, + hoverService, instantiationService, themeService, viewDescriptorService, @@ -175,10 +178,14 @@ export class PanelPart extends AbstractPaneCompositePart { super.layout(dimensions.width, dimensions.height, top, left); } - protected shouldShowCompositeBar(): boolean { + protected override shouldShowCompositeBar(): boolean { return true; } + protected getCompositeBarPosition(): CompositeBarPosition { + return CompositeBarPosition.TITLE; + } + toJSON(): object { return { type: Parts.PANEL_PART diff --git a/src/vs/workbench/browser/parts/sidebar/media/sidebarpart.css b/src/vs/workbench/browser/parts/sidebar/media/sidebarpart.css index 65f962e824199..d4be01b7f2f4c 100644 --- a/src/vs/workbench/browser/parts/sidebar/media/sidebarpart.css +++ b/src/vs/workbench/browser/parts/sidebar/media/sidebarpart.css @@ -16,6 +16,10 @@ margin-right: 4px; } +.monaco-workbench .part.sidebar > .title { + background-color: var(--vscode-sideBarTitle-background); +} + .monaco-workbench .part.sidebar > .title > .title-label h2 { text-transform: uppercase; } @@ -60,11 +64,32 @@ height: 16px; } +.monaco-workbench .part.sidebar > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item:focus, +.monaco-workbench .part.sidebar > .header-or-footer > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item:focus { + outline: 0 !important; /* activity bar indicates focus custom */ +} + +.monaco-workbench .part.sidebar > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item .action-label, +.monaco-workbench .part.sidebar > .header-or-footer > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item .action-label { + border-radius: 0px; + outline-offset: 2px; +} + +.monaco-workbench .part.sidebar > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item .action-label::before, +.monaco-workbench .part.sidebar > .header-or-footer > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item .action-label::before { + position: absolute; + left: 6px; /* place icon in center */ +} + +.monaco-workbench .part.sidebar > .header-or-footer > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item.checked:not(:focus) .active-item-indicator:before, +.monaco-workbench .part.sidebar > .header-or-footer > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item.checked.clicked:focus .active-item-indicator:before, .monaco-workbench .part.sidebar > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item.checked:not(:focus) .active-item-indicator:before, .monaco-workbench .part.sidebar > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item.checked.clicked:focus .active-item-indicator:before { border-top-color: var(--vscode-activityBarTop-activeBorder) !important; } +.monaco-workbench .part.sidebar > .header-or-footer > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item:hover .action-label, +.monaco-workbench .part.sidebar > .header-or-footer > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item:focus .action-label, .monaco-workbench .part.sidebar > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item:hover .action-label, .monaco-workbench .part.sidebar > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item:focus .action-label { color: var(--vscode-activityBarTop-foreground) !important; diff --git a/src/vs/workbench/browser/parts/sidebar/sidebarPart.ts b/src/vs/workbench/browser/parts/sidebar/sidebarPart.ts index 783a16cfbf65a..46faebc843a84 100644 --- a/src/vs/workbench/browser/parts/sidebar/sidebarPart.ts +++ b/src/vs/workbench/browser/parts/sidebar/sidebarPart.ts @@ -21,7 +21,7 @@ import { IExtensionService } from 'vs/workbench/services/extensions/common/exten import { LayoutPriority } from 'vs/base/browser/ui/grid/grid'; import { assertIsDefined } from 'vs/base/common/types'; import { IViewDescriptorService } from 'vs/workbench/common/views'; -import { AbstractPaneCompositePart } from 'vs/workbench/browser/parts/paneCompositePart'; +import { AbstractPaneCompositePart, CompositeBarPosition } from 'vs/workbench/browser/parts/paneCompositePart'; import { ActivityBarCompositeBar, ActivitybarPart } from 'vs/workbench/browser/parts/activitybar/activitybarPart'; import { ActionsOrientation } from 'vs/base/browser/ui/actionbar/actionbar'; import { HoverPosition } from 'vs/base/browser/ui/hover/hoverWidget'; @@ -31,6 +31,7 @@ import { Action2, IMenuService, registerAction2 } from 'vs/platform/actions/comm import { Separator } from 'vs/base/common/actions'; import { ToggleActivityBarVisibilityActionId } from 'vs/workbench/browser/actions/layoutActions'; import { localize2 } from 'vs/nls'; +import { IHoverService } from 'vs/platform/hover/browser/hover'; export class SidebarPart extends AbstractPaneCompositePart { @@ -70,6 +71,7 @@ export class SidebarPart extends AbstractPaneCompositePart { @IContextMenuService contextMenuService: IContextMenuService, @IWorkbenchLayoutService layoutService: IWorkbenchLayoutService, @IKeybindingService keybindingService: IKeybindingService, + @IHoverService hoverService: IHoverService, @IInstantiationService instantiationService: IInstantiationService, @IThemeService themeService: IThemeService, @IViewDescriptorService viewDescriptorService: IViewDescriptorService, @@ -92,6 +94,7 @@ export class SidebarPart extends AbstractPaneCompositePart { contextMenuService, layoutService, keybindingService, + hoverService, instantiationService, themeService, viewDescriptorService, @@ -112,12 +115,19 @@ export class SidebarPart extends AbstractPaneCompositePart { } private onDidChangeActivityBarLocation(): void { - this.updateTitleArea(); + this.acitivityBarPart.hide(); + + this.updateCompositeBar(); + const id = this.getActiveComposite()?.getId(); if (id) { this.onTitleAreaUpdate(id); } - this.updateActivityBarVisiblity(); + + if (this.shouldShowActivityBar()) { + this.acitivityBarPart.show(); + } + this.rememberActivityBarVisiblePosition(); } @@ -153,7 +163,7 @@ export class SidebarPart extends AbstractPaneCompositePart { return this.layoutService.getSideBarPosition() === SideBarPosition.LEFT ? AnchorAlignment.LEFT : AnchorAlignment.RIGHT; } - protected override createCompisteBar(): ActivityBarCompositeBar { + protected override createCompositeBar(): ActivityBarCompositeBar { return this.instantiationService.createInstance(ActivityBarCompositeBar, this.getCompositeBarOptions(), this.partId, this, false); } @@ -167,7 +177,7 @@ export class SidebarPart extends AbstractPaneCompositePart { orientation: ActionsOrientation.HORIZONTAL, recomputeSizes: true, activityHoverOptions: { - position: () => HoverPosition.BELOW, + position: () => this.getCompositeBarPosition() === CompositeBarPosition.BOTTOM ? HoverPosition.ABOVE : HoverPosition.BELOW, }, fillExtraContextMenuActions: actions => { const viewsSubmenuAction = this.getViewsSubmenuAction(); @@ -178,7 +188,7 @@ export class SidebarPart extends AbstractPaneCompositePart { }, compositeSize: 0, iconSize: 16, - overflowActionSize: 44, + overflowActionSize: 30, colors: theme => ({ activeBackgroundColor: theme.getColor(SIDE_BAR_BACKGROUND), inactiveBackgroundColor: theme.getColor(SIDE_BAR_BACKGROUND), @@ -194,7 +204,8 @@ export class SidebarPart extends AbstractPaneCompositePart { } protected shouldShowCompositeBar(): boolean { - return this.configurationService.getValue(LayoutSettings.ACTIVITY_BAR_LOCATION) === ActivityBarPosition.TOP; + const activityBarPosition = this.configurationService.getValue(LayoutSettings.ACTIVITY_BAR_LOCATION); + return activityBarPosition === ActivityBarPosition.TOP || activityBarPosition === ActivityBarPosition.BOTTOM; } private shouldShowActivityBar(): boolean { @@ -204,6 +215,17 @@ export class SidebarPart extends AbstractPaneCompositePart { return this.configurationService.getValue(LayoutSettings.ACTIVITY_BAR_LOCATION) !== ActivityBarPosition.HIDDEN; } + protected getCompositeBarPosition(): CompositeBarPosition { + const activityBarPosition = this.configurationService.getValue(LayoutSettings.ACTIVITY_BAR_LOCATION); + switch (activityBarPosition) { + case ActivityBarPosition.TOP: return CompositeBarPosition.TOP; + case ActivityBarPosition.BOTTOM: return CompositeBarPosition.BOTTOM; + case ActivityBarPosition.HIDDEN: + case ActivityBarPosition.DEFAULT: // noop + default: return CompositeBarPosition.TITLE; + } + } + private rememberActivityBarVisiblePosition(): void { const activityBarPosition = this.configurationService.getValue(LayoutSettings.ACTIVITY_BAR_LOCATION); if (activityBarPosition !== ActivityBarPosition.HIDDEN) { @@ -214,16 +236,9 @@ export class SidebarPart extends AbstractPaneCompositePart { private getRememberedActivityBarVisiblePosition(): ActivityBarPosition { const activityBarPosition = this.storageService.get(LayoutSettings.ACTIVITY_BAR_LOCATION, StorageScope.PROFILE); switch (activityBarPosition) { - case ActivityBarPosition.SIDE: return ActivityBarPosition.SIDE; - default: return ActivityBarPosition.TOP; - } - } - - private updateActivityBarVisiblity(): void { - if (this.shouldShowActivityBar()) { - this.acitivityBarPart.show(); - } else { - this.acitivityBarPart.hide(); + case ActivityBarPosition.TOP: return ActivityBarPosition.TOP; + case ActivityBarPosition.BOTTOM: return ActivityBarPosition.BOTTOM; + default: return ActivityBarPosition.DEFAULT; } } diff --git a/src/vs/workbench/browser/parts/statusbar/statusbarItem.ts b/src/vs/workbench/browser/parts/statusbar/statusbarItem.ts index e18622523c587..07dd5640c0c0a 100644 --- a/src/vs/workbench/browser/parts/statusbar/statusbarItem.ts +++ b/src/vs/workbench/browser/parts/statusbar/statusbarItem.ts @@ -21,10 +21,11 @@ import { StandardKeyboardEvent } from 'vs/base/browser/keyboardEvent'; import { KeyCode } from 'vs/base/common/keyCodes'; import { renderIcon, renderLabelWithIcons } from 'vs/base/browser/ui/iconLabel/iconLabels'; import { spinningLoading, syncing } from 'vs/platform/theme/common/iconRegistry'; -import { ICustomHover, setupCustomHover } from 'vs/base/browser/ui/iconLabel/iconLabelHover'; import { isMarkdownString, markdownStringEqual } from 'vs/base/common/htmlContent'; -import { IHoverDelegate } from 'vs/base/browser/ui/iconLabel/iconHoverDelegate'; +import { IHoverDelegate } from 'vs/base/browser/ui/hover/hoverDelegate'; import { Gesture, EventType as TouchEventType } from 'vs/base/browser/touch'; +import type { IUpdatableHover } from 'vs/base/browser/ui/hover/hover'; +import { IHoverService } from 'vs/platform/hover/browser/hover'; export class StatusbarEntryItem extends Disposable { @@ -41,7 +42,7 @@ export class StatusbarEntryItem extends Disposable { private readonly focusListener = this._register(new MutableDisposable()); private readonly focusOutListener = this._register(new MutableDisposable()); - private hover: ICustomHover | undefined = undefined; + private hover: IUpdatableHover | undefined = undefined; readonly labelContainer: HTMLElement; readonly beakContainer: HTMLElement; @@ -59,6 +60,7 @@ export class StatusbarEntryItem extends Disposable { entry: IStatusbarEntry, private readonly hoverDelegate: IHoverDelegate, @ICommandService private readonly commandService: ICommandService, + @IHoverService private readonly hoverService: IHoverService, @INotificationService private readonly notificationService: INotificationService, @ITelemetryService private readonly telemetryService: ITelemetryService, @IThemeService private readonly themeService: IThemeService @@ -73,7 +75,7 @@ export class StatusbarEntryItem extends Disposable { this._register(Gesture.addTarget(this.labelContainer)); // enable touch // Label (with support for progress) - this.label = new StatusBarCodiconLabel(this.labelContainer); + this.label = this._register(new StatusBarCodiconLabel(this.labelContainer)); this.container.appendChild(this.labelContainer); // Beak Container @@ -120,7 +122,7 @@ export class StatusbarEntryItem extends Disposable { if (this.hover) { this.hover.update(hoverContents); } else { - this.hover = this._register(setupCustomHover(this.hoverDelegate, this.container, hoverContents)); + this.hover = this._register(this.hoverService.setupUpdatableHover(this.hoverDelegate, this.container, hoverContents)); } if (entry.command !== ShowTooltipCommand /* prevents flicker on click */) { this.focusListener.value = addDisposableListener(this.labelContainer, EventType.FOCUS, e => { diff --git a/src/vs/workbench/browser/parts/statusbar/statusbarModel.ts b/src/vs/workbench/browser/parts/statusbar/statusbarModel.ts index ae4ef6a97e4fe..8fd7e353217b3 100644 --- a/src/vs/workbench/browser/parts/statusbar/statusbarModel.ts +++ b/src/vs/workbench/browser/parts/statusbar/statusbarModel.ts @@ -47,8 +47,7 @@ export class StatusbarViewModel extends Disposable { const hiddenRaw = this.storageService.get(StatusbarViewModel.HIDDEN_ENTRIES_KEY, StorageScope.PROFILE); if (hiddenRaw) { try { - const hiddenArray: string[] = JSON.parse(hiddenRaw); - this.hidden = new Set(hiddenArray.filter(entry => !entry.startsWith('status.workspaceTrust.'))); // TODO@bpasero remove this migration eventually + this.hidden = new Set(JSON.parse(hiddenRaw)); } catch (error) { // ignore parsing errors } diff --git a/src/vs/workbench/browser/parts/statusbar/statusbarPart.ts b/src/vs/workbench/browser/parts/statusbar/statusbarPart.ts index 940b074561a5e..f938ea7b3535e 100644 --- a/src/vs/workbench/browser/parts/statusbar/statusbarPart.ts +++ b/src/vs/workbench/browser/parts/statusbar/statusbarPart.ts @@ -27,9 +27,7 @@ import { assertIsDefined } from 'vs/base/common/types'; import { IContextKeyService } from 'vs/platform/contextkey/common/contextkey'; import { isHighContrast } from 'vs/platform/theme/common/theme'; import { hash } from 'vs/base/common/hash'; -import { IHoverService } from 'vs/platform/hover/browser/hover'; -import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; -import { IHoverDelegate, IHoverDelegateOptions, IHoverWidget } from 'vs/base/browser/ui/iconLabel/iconHoverDelegate'; +import { WorkbenchHoverDelegate } from 'vs/platform/hover/browser/hover'; import { HideStatusbarEntryAction, ToggleStatusbarEntryVisibilityAction } from 'vs/workbench/browser/parts/statusbar/statusbarActions'; import { IStatusbarViewModelEntry, StatusbarViewModel } from 'vs/workbench/browser/parts/statusbar/statusbarModel'; import { StatusbarEntryItem } from 'vs/workbench/browser/parts/statusbar/statusbarItem'; @@ -138,39 +136,14 @@ class StatusbarPart extends Part implements IStatusbarEntryContainer { private leftItemsContainer: HTMLElement | undefined; private rightItemsContainer: HTMLElement | undefined; - private readonly hoverDelegate = new class implements IHoverDelegate { - - private lastHoverHideTime = 0; - - readonly placement = 'element'; - - get delay() { - if (Date.now() - this.lastHoverHideTime < 200) { - return 0; // show instantly when a hover was recently shown + private readonly hoverDelegate = this._register(this.instantiationService.createInstance(WorkbenchHoverDelegate, 'element', true, (_, focus?: boolean) => ( + { + persistence: { + hideOnKeyDown: true, + sticky: focus } - - return this.configurationService.getValue('workbench.hover.delay'); - } - - constructor( - private readonly configurationService: IConfigurationService, - private readonly hoverService: IHoverService - ) { } - - showHover(options: IHoverDelegateOptions, focus?: boolean): IHoverWidget | undefined { - return this.hoverService.showHover({ - ...options, - persistence: { - hideOnKeyDown: true, - sticky: focus - } - }, focus); - } - - onDidHideHover(): void { - this.lastHoverHideTime = Date.now(); } - }(this.configurationService, this.hoverService); + ))); private readonly compactEntriesDisposable = this._register(new MutableDisposable()); private readonly styleOverrides = new Set(); @@ -182,10 +155,8 @@ class StatusbarPart extends Part implements IStatusbarEntryContainer { @IWorkspaceContextService private readonly contextService: IWorkspaceContextService, @IStorageService private readonly storageService: IStorageService, @IWorkbenchLayoutService layoutService: IWorkbenchLayoutService, - @IContextMenuService private contextMenuService: IContextMenuService, + @IContextMenuService private readonly contextMenuService: IContextMenuService, @IContextKeyService private readonly contextKeyService: IContextKeyService, - @IHoverService private readonly hoverService: IHoverService, - @IConfigurationService private readonly configurationService: IConfigurationService ) { super(id, { hasTitle: false }, themeService, storageService, layoutService); @@ -367,7 +338,7 @@ class StatusbarPart extends Part implements IStatusbarEntryContainer { this.element = parent; // Track focus within container - const scopedContextKeyService = this.contextKeyService.createScoped(this.element); + const scopedContextKeyService = this._register(this.contextKeyService.createScoped(this.element)); StatusBarFocused.bindTo(scopedContextKeyService).set(true); // Left items container @@ -617,7 +588,7 @@ class StatusbarPart extends Part implements IStatusbarEntryContainer { } /* Status bar item focus outline */ - .monaco-workbench .part.statusbar > .items-container > .statusbar-item a:focus-visible:not(.disabled) { + .monaco-workbench .part.statusbar > .items-container > .statusbar-item a:focus-visible { outline: 1px solid ${this.getColor(activeContrastBorder) ?? itemBorderColor}; outline-offset: ${borderColor ? '-2px' : '-1px'}; } @@ -667,10 +638,8 @@ export class MainStatusbarPart extends StatusbarPart { @IWorkbenchLayoutService layoutService: IWorkbenchLayoutService, @IContextMenuService contextMenuService: IContextMenuService, @IContextKeyService contextKeyService: IContextKeyService, - @IHoverService hoverService: IHoverService, - @IConfigurationService configurationService: IConfigurationService ) { - super(Parts.STATUSBAR_PART, instantiationService, themeService, contextService, storageService, layoutService, contextMenuService, contextKeyService, hoverService, configurationService); + super(Parts.STATUSBAR_PART, instantiationService, themeService, contextService, storageService, layoutService, contextMenuService, contextKeyService); } } @@ -694,11 +663,9 @@ export class AuxiliaryStatusbarPart extends StatusbarPart implements IAuxiliaryS @IWorkbenchLayoutService layoutService: IWorkbenchLayoutService, @IContextMenuService contextMenuService: IContextMenuService, @IContextKeyService contextKeyService: IContextKeyService, - @IHoverService hoverService: IHoverService, - @IConfigurationService configurationService: IConfigurationService ) { const id = AuxiliaryStatusbarPart.COUNTER++; - super(`workbench.parts.auxiliaryStatus.${id}`, instantiationService, themeService, contextService, storageService, layoutService, contextMenuService, contextKeyService, hoverService, configurationService); + super(`workbench.parts.auxiliaryStatus.${id}`, instantiationService, themeService, contextService, storageService, layoutService, contextMenuService, contextKeyService); } } @@ -708,6 +675,9 @@ export class StatusbarService extends MultiWindowParts implements readonly mainPart = this._register(this.instantiationService.createInstance(MainStatusbarPart)); + private readonly _onDidCreateAuxiliaryStatusbarPart = this._register(new Emitter()); + private readonly onDidCreateAuxiliaryStatusbarPart = this._onDidCreateAuxiliaryStatusbarPart.event; + constructor( @IInstantiationService private readonly instantiationService: IInstantiationService, @IStorageService storageService: IStorageService, @@ -721,6 +691,8 @@ export class StatusbarService extends MultiWindowParts implements //#region Auxiliary Statusbar Parts createAuxiliaryStatusbarPart(container: HTMLElement): IAuxiliaryStatusbarPart { + + // Container const statusbarPartContainer = document.createElement('footer'); statusbarPartContainer.classList.add('part', 'statusbar'); statusbarPartContainer.setAttribute('role', 'status'); @@ -729,6 +701,7 @@ export class StatusbarService extends MultiWindowParts implements statusbarPartContainer.setAttribute('tabindex', '0'); container.appendChild(statusbarPartContainer); + // Statusbar Part const statusbarPart = this.instantiationService.createInstance(AuxiliaryStatusbarPart, statusbarPartContainer); const disposable = this.registerPart(statusbarPart); @@ -736,6 +709,9 @@ export class StatusbarService extends MultiWindowParts implements Event.once(statusbarPart.onWillDispose)(() => disposable.dispose()); + // Emit internal event + this._onDidCreateAuxiliaryStatusbarPart.fire(statusbarPart); + return statusbarPart; } @@ -750,9 +726,46 @@ export class StatusbarService extends MultiWindowParts implements readonly onDidChangeEntryVisibility = this.mainPart.onDidChangeEntryVisibility; addEntry(entry: IStatusbarEntry, id: string, alignment: StatusbarAlignment, priorityOrLocation: number | IStatusbarEntryLocation | IStatusbarEntryPriority = 0): IStatusbarEntryAccessor { + if (entry.showInAllWindows) { + return this.doAddEntryToAllWindows(entry, id, alignment, priorityOrLocation); + } + return this.mainPart.addEntry(entry, id, alignment, priorityOrLocation); } + private doAddEntryToAllWindows(entry: IStatusbarEntry, id: string, alignment: StatusbarAlignment, priorityOrLocation: number | IStatusbarEntryLocation | IStatusbarEntryPriority = 0): IStatusbarEntryAccessor { + const entryDisposables = new DisposableStore(); + + const accessors = new Set(); + + function addEntry(part: StatusbarPart | AuxiliaryStatusbarPart): void { + const partDisposables = new DisposableStore(); + partDisposables.add(part.onWillDispose(() => partDisposables.dispose())); + + const accessor = partDisposables.add(part.addEntry(entry, id, alignment, priorityOrLocation)); + accessors.add(accessor); + partDisposables.add(toDisposable(() => accessors.delete(accessor))); + + entryDisposables.add(partDisposables); + partDisposables.add(toDisposable(() => entryDisposables.delete(partDisposables))); + } + + for (const part of this.parts) { + addEntry(part); + } + + entryDisposables.add(this.onDidCreateAuxiliaryStatusbarPart(part => addEntry(part))); + + return { + update: (entry: IStatusbarEntry) => { + for (const update of accessors) { + update.update(entry); + } + }, + dispose: () => entryDisposables.dispose() + }; + } + isEntryVisible(id: string): boolean { return this.mainPart.isEntryVisible(id); } diff --git a/src/vs/workbench/browser/parts/titlebar/commandCenterControl.ts b/src/vs/workbench/browser/parts/titlebar/commandCenterControl.ts index 3843068e941a3..59b379f497d60 100644 --- a/src/vs/workbench/browser/parts/titlebar/commandCenterControl.ts +++ b/src/vs/workbench/browser/parts/titlebar/commandCenterControl.ts @@ -5,8 +5,8 @@ import { isActiveDocument, reset } from 'vs/base/browser/dom'; import { BaseActionViewItem, IBaseActionViewItemOptions } from 'vs/base/browser/ui/actionbar/actionViewItems'; -import { IHoverDelegate } from 'vs/base/browser/ui/iconLabel/iconHoverDelegate'; -import { setupCustomHover } from 'vs/base/browser/ui/iconLabel/iconLabelHover'; +import { getDefaultHoverDelegate } from 'vs/base/browser/ui/hover/hoverDelegateFactory'; +import { IHoverDelegate } from 'vs/base/browser/ui/hover/hoverDelegate'; import { renderIcon } from 'vs/base/browser/ui/iconLabel/iconLabels'; import { IAction, SubmenuAction } from 'vs/base/common/actions'; import { Codicon } from 'vs/base/common/codicons'; @@ -21,6 +21,7 @@ import { IKeybindingService } from 'vs/platform/keybinding/common/keybinding'; import { IQuickInputService } from 'vs/platform/quickinput/common/quickInput'; import { WindowTitle } from 'vs/workbench/browser/parts/titlebar/windowTitle'; import { IEditorGroupsService } from 'vs/workbench/services/editor/common/editorGroupsService'; +import { IHoverService } from 'vs/platform/hover/browser/hover'; export class CommandCenterControl { @@ -46,11 +47,11 @@ export class CommandCenterControl { primaryGroup: () => true, }, telemetrySource: 'commandCenter', - actionViewItemProvider: (action) => { + actionViewItemProvider: (action, options) => { if (action instanceof SubmenuItemAction && action.item.submenu === MenuId.CommandCenterCenter) { - return instantiationService.createInstance(CommandCenterCenterViewItem, action, windowTitle, hoverDelegate, {}); + return instantiationService.createInstance(CommandCenterCenterViewItem, action, windowTitle, { ...options, hoverDelegate }); } else { - return createActionViewItem(instantiationService, action, { hoverDelegate }); + return createActionViewItem(instantiationService, action, { ...options, hoverDelegate }); } } }); @@ -75,16 +76,19 @@ class CommandCenterCenterViewItem extends BaseActionViewItem { private static readonly _quickOpenCommandId = 'workbench.action.quickOpenWithModes'; + private readonly _hoverDelegate: IHoverDelegate; + constructor( private readonly _submenu: SubmenuItemAction, private readonly _windowTitle: WindowTitle, - private readonly _hoverDelegate: IHoverDelegate, options: IBaseActionViewItemOptions, + @IHoverService private readonly _hoverService: IHoverService, @IKeybindingService private _keybindingService: IKeybindingService, @IInstantiationService private _instaService: IInstantiationService, @IEditorGroupsService private _editorGroupService: IEditorGroupsService, ) { super(undefined, _submenu.actions.find(action => action.id === 'workbench.action.quickOpenWithModes') ?? _submenu.actions[0], options); + this._hoverDelegate = options.hoverDelegate ?? getDefaultHoverDelegate('mouse'); } override render(container: HTMLElement): void { @@ -92,7 +96,7 @@ class CommandCenterCenterViewItem extends BaseActionViewItem { container.classList.add('command-center-center'); container.classList.toggle('multiple', (this._submenu.actions.length > 1)); - const hover = this._store.add(setupCustomHover(this._hoverDelegate, container, this.getTooltip())); + const hover = this._store.add(this._hoverService.setupUpdatableHover(this._hoverDelegate, container, this.getTooltip())); // update label & tooltip when window title changes this._store.add(this._windowTitle.onDidChange(() => { @@ -153,7 +157,7 @@ class CommandCenterCenterViewItem extends BaseActionViewItem { labelElement.innerText = label; reset(container, searchIcon, labelElement); - const hover = this._store.add(setupCustomHover(that._hoverDelegate, container, this.getTooltip())); + const hover = this._store.add(that._hoverService.setupUpdatableHover(that._hoverDelegate, container, this.getTooltip())); // update label & tooltip when window title changes this._store.add(that._windowTitle.onDidChange(() => { diff --git a/src/vs/workbench/browser/parts/titlebar/media/titlebarpart.css b/src/vs/workbench/browser/parts/titlebar/media/titlebarpart.css index 9b713be146e1d..15afa9df6bf53 100644 --- a/src/vs/workbench/browser/parts/titlebar/media/titlebarpart.css +++ b/src/vs/workbench/browser/parts/titlebar/media/titlebarpart.css @@ -165,6 +165,10 @@ .monaco-workbench .part.titlebar > .titlebar-container > .titlebar-center > .window-title > .command-center .action-item.command-center-center .action-item.command-center-quick-pick { display: flex; + justify-content: start; + overflow: hidden; + margin: auto; + max-width: 600px; } .monaco-workbench .part.titlebar > .titlebar-container > .titlebar-center > .window-title > .command-center .action-item.command-center-center .action-item.command-center-quick-pick .search-icon { diff --git a/src/vs/workbench/browser/parts/titlebar/menubarControl.ts b/src/vs/workbench/browser/parts/titlebar/menubarControl.ts index eeec3dbcc5e62..23fe9a5b1b42d 100644 --- a/src/vs/workbench/browser/parts/titlebar/menubarControl.ts +++ b/src/vs/workbench/browser/parts/titlebar/menubarControl.ts @@ -25,7 +25,7 @@ import { INotificationService, Severity } from 'vs/platform/notification/common/ import { IPreferencesService } from 'vs/workbench/services/preferences/common/preferences'; import { IWorkbenchEnvironmentService } from 'vs/workbench/services/environment/common/environmentService'; import { MenuBar, IMenuBarOptions } from 'vs/base/browser/ui/menu/menubar'; -import { Direction } from 'vs/base/browser/ui/menu/menu'; +import { HorizontalDirection, IMenuDirection, VerticalDirection } from 'vs/base/browser/ui/menu/menu'; import { mnemonicMenuLabel, unmnemonicLabel } from 'vs/base/common/labels'; import { IAccessibilityService } from 'vs/platform/accessibility/common/accessibility'; import { isFullscreen, onDidChangeFullscreen } from 'vs/base/browser/browser'; @@ -41,6 +41,7 @@ import { isICommandActionToggleInfo } from 'vs/platform/action/common/action'; import { createAndFillInContextMenuActions } from 'vs/platform/actions/browser/menuEntryActionViewItem'; import { defaultMenuStyles } from 'vs/platform/theme/browser/defaultStyles'; import { mainWindow } from 'vs/base/browser/window'; +import { ActivityBarPosition } from 'vs/workbench/services/layout/browser/layoutService'; export type IOpenRecentAction = IAction & { uri: URI; remoteAuthority?: string }; @@ -142,7 +143,7 @@ export abstract class MenubarControl extends Disposable { protected topLevelTitles: { [menu: string]: string } = {}; - protected mainMenuDisposables: DisposableStore; + protected readonly mainMenuDisposables: DisposableStore; protected recentlyOpened: IRecentlyOpened = { files: [], workspaces: [] }; @@ -189,7 +190,7 @@ export abstract class MenubarControl extends Disposable { this._register(this.configurationService.onDidChangeConfiguration(e => this.onConfigurationUpdated(e))); // Listen to update service - this.updateService.onStateChange(() => this.onUpdateStateChange()); + this._register(this.updateService.onStateChange(() => this.onUpdateStateChange())); // Listen for changes in recently opened menu this._register(this.workspacesService.onDidChangeRecentlyOpened(() => { this.onDidChangeRecentlyOpened(); })); @@ -474,7 +475,7 @@ export class CustomMenubarControl extends MenubarControl { return new Action('update.downloading', localize('DownloadingUpdate', "Downloading Update..."), undefined, false); case StateType.Downloaded: - return new Action('update.install', localize({ key: 'installUpdate...', comment: ['&& denotes a mnemonic'] }, "Install &&Update..."), undefined, true, () => + return isMacintosh ? null : new Action('update.install', localize({ key: 'installUpdate...', comment: ['&& denotes a mnemonic'] }, "Install &&Update..."), undefined, true, () => this.updateService.applyUpdate()); case StateType.Updating: @@ -536,14 +537,19 @@ export class CustomMenubarControl extends MenubarControl { return enableMenuBarMnemonics && (!isWeb || isFullscreen(mainWindow)); } - private get currentCompactMenuMode(): Direction | undefined { + private get currentCompactMenuMode(): IMenuDirection | undefined { if (this.currentMenubarVisibility !== 'compact') { return undefined; } // Menu bar lives in activity bar and should flow based on its location const currentSidebarLocation = this.configurationService.getValue('workbench.sideBar.location'); - return currentSidebarLocation === 'right' ? Direction.Left : Direction.Right; + const horizontalDirection = currentSidebarLocation === 'right' ? HorizontalDirection.Left : HorizontalDirection.Right; + + const activityBarLocation = this.configurationService.getValue('workbench.activityBar.location'); + const verticalDirection = activityBarLocation === ActivityBarPosition.BOTTOM ? VerticalDirection.Above : VerticalDirection.Below; + + return { horizontal: horizontalDirection, vertical: verticalDirection }; } private onDidVisibilityChange(visible: boolean): void { @@ -558,7 +564,7 @@ export class CustomMenubarControl extends MenubarControl { return result; } - private reinstallDisposables = this._register(new DisposableStore()); + private readonly reinstallDisposables = this._register(new DisposableStore()); private setupCustomMenubar(firstTime: boolean): void { // If there is no container, we cannot setup the menubar if (!this.container) { @@ -665,7 +671,7 @@ export class CustomMenubarControl extends MenubarControl { if (!this.focusInsideMenubar) { const actions: IAction[] = []; updateActions(this.toActionsArray(menu), actions, title); - this.menubar?.updateMenu({ actions: actions, label: mnemonicMenuLabel(this.topLevelTitles[title]) }); + this.menubar?.updateMenu({ actions, label: mnemonicMenuLabel(this.topLevelTitles[title]) }); } })); @@ -675,7 +681,7 @@ export class CustomMenubarControl extends MenubarControl { if (!this.focusInsideMenubar) { const actions: IAction[] = []; updateActions(this.toActionsArray(menu), actions, title); - this.menubar?.updateMenu({ actions: actions, label: mnemonicMenuLabel(this.topLevelTitles[title]) }); + this.menubar?.updateMenu({ actions, label: mnemonicMenuLabel(this.topLevelTitles[title]) }); } })); } @@ -688,9 +694,9 @@ export class CustomMenubarControl extends MenubarControl { if (this.menubar) { if (!firstTime) { - this.menubar.updateMenu({ actions: actions, label: mnemonicMenuLabel(this.topLevelTitles[title]) }); + this.menubar.updateMenu({ actions, label: mnemonicMenuLabel(this.topLevelTitles[title]) }); } else { - this.menubar.push({ actions: actions, label: mnemonicMenuLabel(this.topLevelTitles[title]) }); + this.menubar.push({ actions, label: mnemonicMenuLabel(this.topLevelTitles[title]) }); } } } diff --git a/src/vs/workbench/browser/parts/titlebar/titlebarActions.ts b/src/vs/workbench/browser/parts/titlebar/titlebarActions.ts index 8b9ec83840d95..16832936d7cb1 100644 --- a/src/vs/workbench/browser/parts/titlebar/titlebarActions.ts +++ b/src/vs/workbench/browser/parts/titlebar/titlebarActions.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { localize, localize2 } from 'vs/nls'; +import { ILocalizedString, localize, localize2 } from 'vs/nls'; import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; import { ServicesAccessor } from 'vs/platform/instantiation/common/instantiation'; import { IStorageService, StorageScope, StorageTarget } from 'vs/platform/storage/common/storage'; @@ -19,11 +19,12 @@ import { CustomTitleBarVisibility, TitleBarSetting, TitlebarStyle } from 'vs/pla class ToggleConfigAction extends Action2 { - constructor(private readonly section: string, title: string, order: number, mainWindowOnly: boolean) { + constructor(private readonly section: string, title: string, description: string | ILocalizedString | undefined, order: number, mainWindowOnly: boolean) { const when = mainWindowOnly ? IsAuxiliaryWindowFocusedContext.toNegated() : ContextKeyExpr.true(); super({ id: `toggle.${section}`, title, + metadata: description ? { description } : undefined, toggled: ContextKeyExpr.equals(`config.${section}`, true), menu: [ { @@ -51,13 +52,13 @@ class ToggleConfigAction extends Action2 { registerAction2(class ToggleCommandCenter extends ToggleConfigAction { constructor() { - super(LayoutSettings.COMMAND_CENTER, localize('toggle.commandCenter', 'Command Center'), 1, false); + super(LayoutSettings.COMMAND_CENTER, localize('toggle.commandCenter', 'Command Center'), localize('toggle.commandCenterDescription', "Toggle visibility of the Command Center in title bar"), 1, false); } }); registerAction2(class ToggleLayoutControl extends ToggleConfigAction { constructor() { - super('workbench.layoutControl.enabled', localize('toggle.layout', 'Layout Controls'), 2, true); + super('workbench.layoutControl.enabled', localize('toggle.layout', 'Layout Controls'), localize('toggle.layoutDescription', "Toggle visibility of the Layout Controls in title bar"), 2, true); } }); @@ -116,7 +117,8 @@ class ToggleCustomTitleBar extends Action2 { ContextKeyExpr.equals('config.workbench.layoutControl.enabled', false), ContextKeyExpr.equals('config.window.commandCenter', false), ContextKeyExpr.notEquals('config.workbench.editor.editorActionsLocation', 'titleBar'), - ContextKeyExpr.notEquals('config.workbench.activityBar.location', 'top') + ContextKeyExpr.notEquals('config.workbench.activityBar.location', 'top'), + ContextKeyExpr.notEquals('config.workbench.activityBar.location', 'bottom') )?.negate() ), IsMainWindowFullscreenContext diff --git a/src/vs/workbench/browser/parts/titlebar/titlebarPart.ts b/src/vs/workbench/browser/parts/titlebar/titlebarPart.ts index 7e481cf928d85..17c912101b3e4 100644 --- a/src/vs/workbench/browser/parts/titlebar/titlebarPart.ts +++ b/src/vs/workbench/browser/parts/titlebar/titlebarPart.ts @@ -33,8 +33,6 @@ import { Codicon } from 'vs/base/common/codicons'; import { getIconRegistry } from 'vs/platform/theme/common/iconRegistry'; import { WindowTitle } from 'vs/workbench/browser/parts/titlebar/windowTitle'; import { CommandCenterControl } from 'vs/workbench/browser/parts/titlebar/commandCenterControl'; -import { IHoverDelegate } from 'vs/base/browser/ui/iconLabel/iconHoverDelegate'; -import { IHoverService } from 'vs/platform/hover/browser/hover'; import { Categories } from 'vs/platform/action/common/actionCommonCategories'; import { WorkbenchToolBar } from 'vs/platform/actions/browser/toolbar'; import { ACCOUNTS_ACTIVITY_ID, GLOBAL_ACTIVITY_ID } from 'vs/workbench/common/activity'; @@ -51,9 +49,17 @@ import { IKeybindingService } from 'vs/platform/keybinding/common/keybinding'; import { ResolvedKeybinding } from 'vs/base/common/keybindings'; import { EditorCommandsContextActionRunner } from 'vs/workbench/browser/parts/editor/editorTabsControl'; import { IEditorCommandsContext, IEditorPartOptionsChangeEvent, IToolbarActions } from 'vs/workbench/common/editor'; -import { mainWindow } from 'vs/base/browser/window'; +import { CodeWindow, mainWindow } from 'vs/base/browser/window'; import { ACCOUNTS_ACTIVITY_TILE_ACTION, GLOBAL_ACTIVITY_TITLE_ACTION } from 'vs/workbench/browser/parts/titlebar/titlebarActions'; import { IView } from 'vs/base/browser/ui/grid/grid'; +import { createInstantHoverDelegate } from 'vs/base/browser/ui/hover/hoverDelegateFactory'; +import { IBaseActionViewItemOptions } from 'vs/base/browser/ui/actionbar/actionViewItems'; +import { IHoverDelegate } from 'vs/base/browser/ui/hover/hoverDelegate'; + +export interface ITitleVariable { + readonly name: string; + readonly contextKey: string; +} export interface ITitleProperties { isPure?: boolean; @@ -72,6 +78,11 @@ export interface ITitlebarPart extends IDisposable { * Update some environmental title properties. */ updateProperties(properties: ITitleProperties): void; + + /** + * Adds variables to be supported in the window title. + */ + registerVariables(variables: ITitleVariable[]): void; } export class BrowserTitleService extends MultiWindowParts implements ITitleService { @@ -100,7 +111,7 @@ export class BrowserTitleService extends MultiWindowParts i // Focus action const that = this; - registerAction2(class FocusTitleBar extends Action2 { + this._register(registerAction2(class FocusTitleBar extends Action2 { constructor() { super({ @@ -114,7 +125,7 @@ export class BrowserTitleService extends MultiWindowParts i run(): void { that.getPartByDocument(getActiveDocument()).focus(); } - }); + })); } //#region Auxiliary Titlebar Parts @@ -134,6 +145,14 @@ export class BrowserTitleService extends MultiWindowParts i disposables.add(Event.runAndSubscribe(titlebarPart.onDidChange, () => titlebarPartContainer.style.height = `${titlebarPart.height}px`)); titlebarPart.create(titlebarPartContainer); + if (this.properties) { + titlebarPart.updateProperties(this.properties); + } + + if (this.variables.length) { + titlebarPart.registerVariables(this.variables); + } + Event.once(titlebarPart.onWillDispose)(() => disposables.dispose()); return titlebarPart; @@ -150,35 +169,27 @@ export class BrowserTitleService extends MultiWindowParts i readonly onMenubarVisibilityChange = this.mainPart.onMenubarVisibilityChange; + private properties: ITitleProperties | undefined = undefined; + updateProperties(properties: ITitleProperties): void { + this.properties = properties; + for (const part of this.parts) { part.updateProperties(properties); } } - //#endregion -} - -class TitlebarPartHoverDelegate implements IHoverDelegate { + private variables: ITitleVariable[] = []; - readonly showHover = this.hoverService.showHover.bind(this.hoverService); - readonly placement = 'element'; + registerVariables(variables: ITitleVariable[]): void { + this.variables.push(...variables); - private lastHoverHideTime: number = 0; - get delay(): number { - return Date.now() - this.lastHoverHideTime < 200 - ? 0 // show instantly when a hover was recently shown - : this.configurationService.getValue('workbench.hover.delay'); + for (const part of this.parts) { + part.registerVariables(variables); + } } - constructor( - @IHoverService private readonly hoverService: IHoverService, - @IConfigurationService private readonly configurationService: IConfigurationService - ) { } - - onDidHideHover() { - this.lastHoverHideTime = Date.now(); - } + //#endregion } export class BrowserTitlebarPart extends Part implements ITitlebarPart { @@ -224,15 +235,15 @@ export class BrowserTitlebarPart extends Part implements ITitlebarPart { private lastLayoutDimensions: Dimension | undefined; private actionToolBar!: WorkbenchToolBar; - private actionToolBarDisposable = this._register(new DisposableStore()); - private editorActionsChangeDisposable = this._register(new DisposableStore()); + private readonly actionToolBarDisposable = this._register(new DisposableStore()); + private readonly editorActionsChangeDisposable = this._register(new DisposableStore()); private actionToolBarElement!: HTMLElement; private layoutToolbarMenu: IMenu | undefined; private readonly editorToolbarMenuDisposables = this._register(new DisposableStore()); private readonly layoutToolbarMenuDisposables = this._register(new DisposableStore()); - private readonly hoverDelegate = new TitlebarPartHoverDelegate(this.hoverService, this.configurationService); + private readonly hoverDelegate: IHoverDelegate; private readonly titleDisposables = this._register(new DisposableStore()); private titleBarStyle: TitlebarStyle = getTitleBarStyle(this.configurationService); @@ -247,7 +258,7 @@ export class BrowserTitlebarPart extends Part implements ITitlebarPart { constructor( id: string, - targetWindow: Window, + targetWindow: CodeWindow, editorGroupsContainer: IEditorGroupsContainer | 'main', @IContextMenuService private readonly contextMenuService: IContextMenuService, @IConfigurationService protected readonly configurationService: IConfigurationService, @@ -258,7 +269,6 @@ export class BrowserTitlebarPart extends Part implements ITitlebarPart { @IWorkbenchLayoutService layoutService: IWorkbenchLayoutService, @IContextKeyService private readonly contextKeyService: IContextKeyService, @IHostService private readonly hostService: IHostService, - @IHoverService private readonly hoverService: IHoverService, @IEditorGroupsService private readonly editorGroupService: IEditorGroupsService, @IEditorService editorService: IEditorService, @IMenuService private readonly menuService: IMenuService, @@ -272,6 +282,8 @@ export class BrowserTitlebarPart extends Part implements ITitlebarPart { this.windowTitle = this._register(instantiationService.createInstance(WindowTitle, targetWindow, editorGroupsContainer)); + this.hoverDelegate = this._register(createInstantHoverDelegate()); + this.registerListeners(getWindowId(targetWindow)); } @@ -379,6 +391,10 @@ export class BrowserTitlebarPart extends Part implements ITitlebarPart { this.windowTitle.updateProperties(properties); } + registerVariables(variables: ITitleVariable[]): void { + this.windowTitle.registerVariables(variables); + } + protected override createContentArea(parent: HTMLElement): HTMLElement { this.element = parent; this.rootContainer = append(parent, $('.titlebar-container')); @@ -501,22 +517,22 @@ export class BrowserTitlebarPart extends Part implements ITitlebarPart { } } - private actionViewItemProvider(action: IAction): IActionViewItem | undefined { + private actionViewItemProvider(action: IAction, options: IBaseActionViewItemOptions): IActionViewItem | undefined { // --- Activity Actions if (!this.isAuxiliary) { if (action.id === GLOBAL_ACTIVITY_ID) { - return this.instantiationService.createInstance(SimpleGlobalActivityActionViewItem, { position: () => HoverPosition.BELOW }); + return this.instantiationService.createInstance(SimpleGlobalActivityActionViewItem, { position: () => HoverPosition.BELOW }, options); } if (action.id === ACCOUNTS_ACTIVITY_ID) { - return this.instantiationService.createInstance(SimpleAccountActivityActionViewItem, { position: () => HoverPosition.BELOW }); + return this.instantiationService.createInstance(SimpleAccountActivityActionViewItem, { position: () => HoverPosition.BELOW }, options); } } // --- Editor Actions const activeEditorPane = this.editorGroupsContainer.activeGroup?.activeEditorPane; if (activeEditorPane && activeEditorPane instanceof EditorPane) { - const result = activeEditorPane.getActionViewItem(action); + const result = activeEditorPane.getActionViewItem(action, options); if (result) { return result; @@ -524,7 +540,7 @@ export class BrowserTitlebarPart extends Part implements ITitlebarPart { } // Check extensions - return createActionViewItem(this.instantiationService, action, { hoverDelegate: this.hoverDelegate, menuAsChild: false }); + return createActionViewItem(this.instantiationService, action, { ...options, menuAsChild: false }); } private getKeybinding(action: IAction): ResolvedKeybinding | undefined { @@ -549,7 +565,8 @@ export class BrowserTitlebarPart extends Part implements ITitlebarPart { anchorAlignmentProvider: () => AnchorAlignment.RIGHT, telemetrySource: 'titlePart', highlightToggledItems: this.editorActionsEnabled, // Only show toggled state for editor actions (Layout actions are not shown as toggled) - actionViewItemProvider: action => this.actionViewItemProvider(action) + actionViewItemProvider: (action, options) => this.actionViewItemProvider(action, options), + hoverDelegate: this.hoverDelegate })); if (this.editorActionsEnabled) { @@ -711,7 +728,8 @@ export class BrowserTitlebarPart extends Part implements ITitlebarPart { } private get activityActionsEnabled(): boolean { - return !this.isAuxiliary && this.configurationService.getValue(LayoutSettings.ACTIVITY_BAR_LOCATION) === ActivityBarPosition.TOP; + const activityBarPosition = this.configurationService.getValue(LayoutSettings.ACTIVITY_BAR_LOCATION); + return !this.isAuxiliary && (activityBarPosition === ActivityBarPosition.TOP || activityBarPosition === ActivityBarPosition.BOTTOM); } get hasZoomableElements(): boolean { @@ -784,13 +802,12 @@ export class MainBrowserTitlebarPart extends BrowserTitlebarPart { @IWorkbenchLayoutService layoutService: IWorkbenchLayoutService, @IContextKeyService contextKeyService: IContextKeyService, @IHostService hostService: IHostService, - @IHoverService hoverService: IHoverService, @IEditorGroupsService editorGroupService: IEditorGroupsService, @IEditorService editorService: IEditorService, @IMenuService menuService: IMenuService, @IKeybindingService keybindingService: IKeybindingService, ) { - super(Parts.TITLEBAR_PART, mainWindow, 'main', contextMenuService, configurationService, environmentService, instantiationService, themeService, storageService, layoutService, contextKeyService, hostService, hoverService, editorGroupService, editorService, menuService, keybindingService); + super(Parts.TITLEBAR_PART, mainWindow, 'main', contextMenuService, configurationService, environmentService, instantiationService, themeService, storageService, layoutService, contextKeyService, hostService, editorGroupService, editorService, menuService, keybindingService); } } @@ -818,14 +835,13 @@ export class AuxiliaryBrowserTitlebarPart extends BrowserTitlebarPart implements @IWorkbenchLayoutService layoutService: IWorkbenchLayoutService, @IContextKeyService contextKeyService: IContextKeyService, @IHostService hostService: IHostService, - @IHoverService hoverService: IHoverService, @IEditorGroupsService editorGroupService: IEditorGroupsService, @IEditorService editorService: IEditorService, @IMenuService menuService: IMenuService, @IKeybindingService keybindingService: IKeybindingService, ) { const id = AuxiliaryBrowserTitlebarPart.COUNTER++; - super(`workbench.parts.auxiliaryTitle.${id}`, getWindow(container), editorGroupsContainer, contextMenuService, configurationService, environmentService, instantiationService, themeService, storageService, layoutService, contextKeyService, hostService, hoverService, editorGroupService, editorService, menuService, keybindingService); + super(`workbench.parts.auxiliaryTitle.${id}`, getWindow(container), editorGroupsContainer, contextMenuService, configurationService, environmentService, instantiationService, themeService, storageService, layoutService, contextKeyService, hostService, editorGroupService, editorService, menuService, keybindingService); } override get preventZoom(): boolean { diff --git a/src/vs/workbench/browser/parts/titlebar/windowTitle.ts b/src/vs/workbench/browser/parts/titlebar/windowTitle.ts index fffceb67bafcb..e025f5c4d6c57 100644 --- a/src/vs/workbench/browser/parts/titlebar/windowTitle.ts +++ b/src/vs/workbench/browser/parts/titlebar/windowTitle.ts @@ -5,14 +5,14 @@ import { localize } from 'vs/nls'; import { dirname, basename } from 'vs/base/common/resources'; -import { ITitleProperties } from 'vs/workbench/browser/parts/titlebar/titlebarPart'; +import { ITitleProperties, ITitleVariable } from 'vs/workbench/browser/parts/titlebar/titlebarPart'; import { IConfigurationService, IConfigurationChangeEvent } from 'vs/platform/configuration/common/configuration'; import { IEditorService } from 'vs/workbench/services/editor/common/editorService'; import { Disposable, DisposableStore } from 'vs/base/common/lifecycle'; import { EditorResourceAccessor, Verbosity, SideBySideEditor } from 'vs/workbench/common/editor'; import { IBrowserWorkbenchEnvironmentService } from 'vs/workbench/services/environment/browser/environmentService'; import { IWorkspaceContextService, WorkbenchState, IWorkspaceFolder } from 'vs/platform/workspace/common/workspace'; -import { isWindows, isWeb, isMacintosh } from 'vs/base/common/platform'; +import { isWindows, isWeb, isMacintosh, isNative } from 'vs/base/common/platform'; import { URI } from 'vs/base/common/uri'; import { trim } from 'vs/base/common/strings'; import { IEditorGroupsContainer } from 'vs/workbench/services/editor/common/editorGroupsService'; @@ -26,12 +26,29 @@ import { getVirtualWorkspaceLocation } from 'vs/platform/workspace/common/virtua import { IUserDataProfileService } from 'vs/workbench/services/userDataProfile/common/userDataProfile'; import { IViewsService } from 'vs/workbench/services/views/common/viewsService'; import { ICodeEditor, isCodeEditor, isDiffEditor } from 'vs/editor/browser/editorBrowser'; +import { IContextKeyService } from 'vs/platform/contextkey/common/contextkey'; +import { getWindowById } from 'vs/base/browser/dom'; +import { CodeWindow } from 'vs/base/browser/window'; const enum WindowSettingNames { titleSeparator = 'window.titleSeparator', - title = 'window.title', + title = 'window.title' } +export const defaultWindowTitle = (() => { + if (isMacintosh && isNative) { + return '${activeEditorShort}${separator}${rootName}${separator}${profileName}'; // macOS has native dirty indicator + } + + const base = '${dirty}${activeEditorShort}${separator}${rootName}${separator}${profileName}${separator}${appName}'; + if (isWeb) { + return base + '${separator}${remoteName}'; // Web: always show remote name + } + + return base; +})(); +export const defaultWindowTitleSeparator = isMacintosh ? ' \u2014 ' : ' - '; + export class WindowTitle extends Disposable { private static readonly NLS_USER_IS_ADMIN = isWindows ? localize('userIsAdmin', "[Administrator]") : localize('userIsSudo', "[Superuser]"); @@ -39,6 +56,8 @@ export class WindowTitle extends Disposable { private static readonly TITLE_DIRTY = '\u25cf '; private readonly properties: ITitleProperties = { isPure: true, isAdmin: false, prefix: undefined }; + private readonly variables = new Map(); + private readonly activeEditorListeners = this._register(new DisposableStore()); private readonly titleUpdater = this._register(new RunOnceScheduler(() => this.doUpdateTitle(), 0)); @@ -62,10 +81,13 @@ export class WindowTitle extends Disposable { private readonly editorService: IEditorService; + private readonly windowId: number; + constructor( - private readonly targetWindow: Window, + targetWindow: CodeWindow, editorGroupsContainer: IEditorGroupsContainer | 'main', @IConfigurationService protected readonly configurationService: IConfigurationService, + @IContextKeyService private readonly contextKeyService: IContextKeyService, @IEditorService editorService: IEditorService, @IBrowserWorkbenchEnvironmentService protected readonly environmentService: IBrowserWorkbenchEnvironmentService, @IWorkspaceContextService private readonly contextService: IWorkspaceContextService, @@ -77,6 +99,7 @@ export class WindowTitle extends Disposable { super(); this.editorService = editorService.createScoped(editorGroupsContainer, this._store); + this.windowId = targetWindow.vscodeWindowId; this.updateTitleIncludesFocusedView(); this.registerListeners(); @@ -95,6 +118,11 @@ export class WindowTitle extends Disposable { this.titleUpdater.schedule(); } })); + this._register(this.contextKeyService.onDidChangeContext(e => { + if (e.affectsSome(this.variables)) { + this.titleUpdater.schedule(); + } + })); } private onConfigurationChanged(event: IConfigurationChangeEvent): void { @@ -154,7 +182,8 @@ export class WindowTitle extends Disposable { nativeTitle = this.productService.nameLong; } - if (!this.targetWindow.document.title && isMacintosh && nativeTitle === this.productService.nameLong) { + const window = getWindowById(this.windowId, true).window; + if (!window.document.title && isMacintosh && nativeTitle === this.productService.nameLong) { // TODO@electron macOS: if we set a window title for // the first time and it matches the one we set in // `windowImpl.ts` somehow the window does not appear @@ -162,10 +191,10 @@ export class WindowTitle extends Disposable { // briefly to something different to ensure macOS // recognizes we have a window. // See: https://github.com/microsoft/vscode/issues/191288 - this.targetWindow.document.title = `${this.productService.nameLong} ${WindowTitle.TITLE_DIRTY}`; + window.document.title = `${this.productService.nameLong} ${WindowTitle.TITLE_DIRTY}`; } - this.targetWindow.document.title = nativeTitle; + window.document.title = nativeTitle; this.title = title; this.onDidChangeEmitter.fire(); @@ -223,6 +252,22 @@ export class WindowTitle extends Disposable { } } + registerVariables(variables: ITitleVariable[]): void { + let changed = false; + + for (const { name, contextKey } of variables) { + if (!this.variables.has(contextKey)) { + this.variables.set(contextKey, name); + + changed = true; + } + } + + if (changed) { + this.titleUpdater.schedule(); + } + } + /** * Possible template values: * @@ -299,11 +344,24 @@ export class WindowTitle extends Disposable { const dirty = editor?.isDirty() && !editor.isSaving() ? WindowTitle.TITLE_DIRTY : ''; const appName = this.productService.nameLong; const profileName = this.userDataProfileService.currentProfile.isDefault ? '' : this.userDataProfileService.currentProfile.name; - const separator = this.configurationService.getValue(WindowSettingNames.titleSeparator); - const titleTemplate = this.configurationService.getValue(WindowSettingNames.title); const focusedView: string = this.viewsService.getFocusedViewName(); + const variables: Record = {}; + for (const [contextKey, name] of this.variables) { + variables[name] = this.contextKeyService.getContextKeyValue(contextKey) ?? ''; + } + + let titleTemplate = this.configurationService.getValue(WindowSettingNames.title); + if (typeof titleTemplate !== 'string') { + titleTemplate = defaultWindowTitle; + } + + let separator = this.configurationService.getValue(WindowSettingNames.titleSeparator); + if (typeof separator !== 'string') { + separator = defaultWindowTitleSeparator; + } return template(titleTemplate, { + ...variables, activeEditorShort, activeEditorLong, activeEditorMedium, diff --git a/src/vs/workbench/browser/parts/views/checkbox.ts b/src/vs/workbench/browser/parts/views/checkbox.ts index 849966bbe33a4..6d6125e4f5c17 100644 --- a/src/vs/workbench/browser/parts/views/checkbox.ts +++ b/src/vs/workbench/browser/parts/views/checkbox.ts @@ -4,13 +4,14 @@ *--------------------------------------------------------------------------------------------*/ import * as DOM from 'vs/base/browser/dom'; -import { IHoverDelegate } from 'vs/base/browser/ui/iconLabel/iconHoverDelegate'; -import { ICustomHover, setupCustomHover } from 'vs/base/browser/ui/iconLabel/iconLabelHover'; +import type { IUpdatableHover } from 'vs/base/browser/ui/hover/hover'; +import { IHoverDelegate } from 'vs/base/browser/ui/hover/hoverDelegate'; import { Toggle } from 'vs/base/browser/ui/toggle/toggle'; import { Codicon } from 'vs/base/common/codicons'; import { Emitter, Event } from 'vs/base/common/event'; import { Disposable } from 'vs/base/common/lifecycle'; import { localize } from 'vs/nls'; +import type { IHoverService } from 'vs/platform/hover/browser/hover'; import { defaultToggleStyles } from 'vs/platform/theme/browser/defaultStyles'; import { ITreeItem, ITreeItemCheckboxState } from 'vs/workbench/common/views'; @@ -27,14 +28,19 @@ export class TreeItemCheckbox extends Disposable { public toggle: Toggle | undefined; private checkboxContainer: HTMLDivElement; public isDisposed = false; - private hover: ICustomHover | undefined; + private hover: IUpdatableHover | undefined; public static readonly checkboxClass = 'custom-view-tree-node-item-checkbox'; private readonly _onDidChangeState = new Emitter(); readonly onDidChangeState: Event = this._onDidChangeState.event; - constructor(container: HTMLElement, private checkboxStateHandler: CheckboxStateHandler, private readonly hoverDelegate: IHoverDelegate) { + constructor( + container: HTMLElement, + private checkboxStateHandler: CheckboxStateHandler, + private readonly hoverDelegate: IHoverDelegate, + private readonly hoverService: IHoverService + ) { super(); this.checkboxContainer = container; } @@ -81,8 +87,7 @@ export class TreeItemCheckbox extends Disposable { private setHover(checkbox: ITreeItemCheckboxState) { if (this.toggle) { if (!this.hover) { - this.hover = setupCustomHover(this.hoverDelegate, this.toggle.domNode, this.checkboxHoverContent(checkbox)); - this._register(this.hover); + this.hover = this._register(this.hoverService.setupUpdatableHover(this.hoverDelegate, this.toggle.domNode, this.checkboxHoverContent(checkbox))); } else { this.hover.update(checkbox.tooltip); } diff --git a/src/vs/workbench/browser/parts/views/media/views.css b/src/vs/workbench/browser/parts/views/media/views.css index 15c4a1aae00c5..9e34366929136 100644 --- a/src/vs/workbench/browser/parts/views/media/views.css +++ b/src/vs/workbench/browser/parts/views/media/views.css @@ -18,7 +18,7 @@ /* Misc */ .file-icon-themable-tree .monaco-list-row .content .monaco-highlighted-label .highlight, -.monaco-tl-contents .monaco-highlighted-label .highlight { +.pane-body .monaco-tl-contents .monaco-highlighted-label .highlight { color: unset !important; background-color: var(--vscode-list-filterMatchBackground); outline: 1px dotted var(--vscode-list-filterMatchBorder); @@ -268,7 +268,7 @@ } .viewpane-filter-container > .viewpane-filter > .viewpane-filter-controls > .viewpane-filter-badge { - margin: 4px 0px; + margin: 4px 2px 4px 0px; padding: 0px 8px; border-radius: 2px; } @@ -278,10 +278,6 @@ display: none; } -.viewpane-filter > .viewpane-filter-controls > .monaco-action-bar .action-item .action-label.codicon.filter { - padding: 2px; -} - .panel > .title .monaco-action-bar .action-item.viewpane-filter-container { max-width: 400px; min-width: 150px; diff --git a/src/vs/workbench/browser/parts/views/treeView.ts b/src/vs/workbench/browser/parts/views/treeView.ts index 19bfe50a6e2d3..2dbd4581cf735 100644 --- a/src/vs/workbench/browser/parts/views/treeView.ts +++ b/src/vs/workbench/browser/parts/views/treeView.ts @@ -8,8 +8,7 @@ import * as DOM from 'vs/base/browser/dom'; import { renderMarkdownAsPlaintext } from 'vs/base/browser/markdownRenderer'; import { ActionBar, IActionViewItemProvider } from 'vs/base/browser/ui/actionbar/actionbar'; import { ActionViewItem } from 'vs/base/browser/ui/actionbar/actionViewItems'; -import { IHoverDelegate, IHoverDelegateOptions } from 'vs/base/browser/ui/iconLabel/iconHoverDelegate'; -import { ITooltipMarkdownString } from 'vs/base/browser/ui/iconLabel/iconLabelHover'; +import { IHoverDelegate } from 'vs/base/browser/ui/hover/hoverDelegate'; import { IIdentityProvider, IListVirtualDelegate } from 'vs/base/browser/ui/list/list'; import { ElementsDragAndDropData, ListViewTargetSector } from 'vs/base/browser/ui/list/listView'; import { IAsyncDataSource, ITreeContextMenuEvent, ITreeDragAndDrop, ITreeDragOverReaction, ITreeNode, ITreeRenderer, TreeDragOverBubble } from 'vs/base/browser/ui/tree/tree'; @@ -22,7 +21,7 @@ import { isCancellationError } from 'vs/base/common/errors'; import { Emitter, Event } from 'vs/base/common/event'; import { createMatches, FuzzyScore } from 'vs/base/common/filters'; import { IMarkdownString, isMarkdownString } from 'vs/base/common/htmlContent'; -import { Disposable, DisposableStore, IDisposable, toDisposable } from 'vs/base/common/lifecycle'; +import { Disposable, DisposableStore, IDisposable, MutableDisposable, toDisposable } from 'vs/base/common/lifecycle'; import { Mimes } from 'vs/base/common/mime'; import { Schemas } from 'vs/base/common/network'; import { basename, dirname } from 'vs/base/common/resources'; @@ -56,13 +55,12 @@ import { ThemeIcon } from 'vs/base/common/themables'; import { fillEditorsDragData } from 'vs/workbench/browser/dnd'; import { IResourceLabel, ResourceLabels } from 'vs/workbench/browser/labels'; import { API_OPEN_DIFF_EDITOR_COMMAND_ID, API_OPEN_EDITOR_COMMAND_ID } from 'vs/workbench/browser/parts/editor/editorCommands'; -import { IViewPaneOptions, ViewPane } from 'vs/workbench/browser/parts/views/viewPane'; +import { getLocationBasedViewColors, IViewPaneOptions, ViewPane } from 'vs/workbench/browser/parts/views/viewPane'; import { IViewletViewOptions } from 'vs/workbench/browser/parts/views/viewsViewlet'; -import { PANEL_BACKGROUND, SIDE_BAR_BACKGROUND } from 'vs/workbench/common/theme'; import { Extensions, ITreeItem, ITreeItemLabel, ITreeView, ITreeViewDataProvider, ITreeViewDescriptor, ITreeViewDragAndDropController, IViewBadge, IViewDescriptorService, IViewsRegistry, ResolvableTreeItem, TreeCommand, TreeItemCollapsibleState, TreeViewItemHandleArg, TreeViewPaneHandleArg, ViewContainer, ViewContainerLocation } from 'vs/workbench/common/views'; import { IActivityService, NumberBadge } from 'vs/workbench/services/activity/common/activity'; import { IExtensionService } from 'vs/workbench/services/extensions/common/extensions'; -import { IHoverService } from 'vs/platform/hover/browser/hover'; +import { IHoverService, WorkbenchHoverDelegate } from 'vs/platform/hover/browser/hover'; import { ITreeViewsService } from 'vs/workbench/services/views/browser/treeViewsService'; import { CodeDataTransfers, LocalSelectionTransfer } from 'vs/platform/dnd/browser/dnd'; import { toExternalVSDataTransfer } from 'vs/editor/browser/dnd'; @@ -73,6 +71,7 @@ import { TelemetryTrustedValue } from 'vs/platform/telemetry/common/telemetryUti import { ITreeViewsDnDService } from 'vs/editor/common/services/treeViewsDndService'; import { DraggedTreeItemsIdentifier } from 'vs/editor/common/services/treeViewsDnd'; import { IMarkdownRenderResult, MarkdownRenderer } from 'vs/editor/browser/widget/markdownRenderer/browser/markdownRenderer'; +import type { IUpdatableHoverTooltipMarkdownString } from 'vs/base/browser/ui/hover/hover'; export class TreeViewPane extends ViewPane { @@ -91,9 +90,10 @@ export class TreeViewPane extends ViewPane { @IOpenerService openerService: IOpenerService, @IThemeService themeService: IThemeService, @ITelemetryService telemetryService: ITelemetryService, - @INotificationService notificationService: INotificationService + @INotificationService notificationService: INotificationService, + @IHoverService hoverService: IHoverService ) { - super({ ...(options as IViewPaneOptions), titleMenuId: MenuId.ViewTitle, donotForwardArgs: false }, keybindingService, contextMenuService, configurationService, contextKeyService, viewDescriptorService, instantiationService, openerService, themeService, telemetryService); + super({ ...(options as IViewPaneOptions), titleMenuId: MenuId.ViewTitle, donotForwardArgs: false }, keybindingService, contextMenuService, configurationService, contextKeyService, viewDescriptorService, instantiationService, openerService, themeService, telemetryService, hoverService); const { treeView } = (Registry.as(Extensions.ViewsRegistry).getView(options.id)); this.treeView = treeView; this._register(this.treeView.onDidChangeActions(() => this.updateActions(), this)); @@ -305,7 +305,7 @@ abstract class AbstractTreeView extends Disposable implements ITreeView { })); this._register(this.viewDescriptorService.onDidChangeLocation(({ views, from, to }) => { if (views.some(v => v.id === this.id)) { - this.tree?.updateOptions({ overrideStyles: { listBackground: this.viewLocation === ViewContainerLocation.Panel ? PANEL_BACKGROUND : SIDE_BAR_BACKGROUND } }); + this.tree?.updateOptions({ overrideStyles: getLocationBasedViewColors(this.viewLocation).listOverrideStyles }); } })); this.registerActions(); @@ -425,7 +425,8 @@ abstract class AbstractTreeView extends Disposable implements ITreeView { } private _badge: IViewBadge | undefined; - private _badgeActivity: IDisposable | undefined; + private readonly _activity = this._register(new MutableDisposable()); + get badge(): IViewBadge | undefined { return this._badge; } @@ -437,19 +438,13 @@ abstract class AbstractTreeView extends Disposable implements ITreeView { return; } - if (this._badgeActivity) { - this._badgeActivity.dispose(); - this._badgeActivity = undefined; - } - this._badge = badge; - if (badge) { const activity = { badge: new NumberBadge(badge.value, () => badge.tooltip), priority: 50 }; - this._badgeActivity = this.activityService.showViewActivity(this.id, activity); + this._activity.value = this.activityService.showViewActivity(this.id, activity); } } @@ -695,9 +690,7 @@ abstract class AbstractTreeView extends Disposable implements ITreeView { }, multipleSelectionSupport: this.canSelectMany, dnd: this.treeViewDnd, - overrideStyles: { - listBackground: this.viewLocation === ViewContainerLocation.Panel ? PANEL_BACKGROUND : SIDE_BAR_BACKGROUND - } + overrideStyles: getLocationBasedViewColors(this.viewLocation).listOverrideStyles }) as WorkbenchAsyncDataTree); treeMenus.setContextKeyService(this.tree.contextKeyService); aligner.tree = this.tree; @@ -772,7 +765,7 @@ abstract class AbstractTreeView extends Disposable implements ITreeView { let command = element?.command; if (element && !command) { if ((element instanceof ResolvableTreeItem) && element.hasResolve) { - await element.resolve(new CancellationTokenSource().token); + await element.resolve(CancellationToken.None); command = element.command; } } @@ -1106,15 +1099,13 @@ class TreeRenderer extends Disposable implements ITreeRenderer this.hoverService.showHover(options), - delay: this.configurationService.getValue('workbench.hover.delay') - }; + this._hoverDelegate = this._register(instantiationService.createInstance(WorkbenchHoverDelegate, 'mouse', false, {})); this._register(this.themeService.onDidFileIconThemeChange(() => this.rerender())); this._register(this.themeService.onDidColorThemeChange(() => this.rerender())); this._register(checkboxStateHandler.onDidChangeCheckboxState(items => { @@ -1144,7 +1135,7 @@ class TreeRenderer extends Disposable implements ITreeRenderer { + actionViewItemProvider: (action: IAction, options: IActionViewItemOptions) => { if (action instanceof SubmenuItemAction && action.item.submenu.id === viewFilterSubmenu.id) { - this.moreFiltersActionViewItem = this.instantiationService.createInstance(MoreFiltersActionViewItem, action, undefined); + this.moreFiltersActionViewItem = this.instantiationService.createInstance(MoreFiltersActionViewItem, action, options); this.moreFiltersActionViewItem.checked = this.isMoreFiltersChecked; return this.moreFiltersActionViewItem; } diff --git a/src/vs/workbench/browser/parts/views/viewPane.ts b/src/vs/workbench/browser/parts/views/viewPane.ts index aa66323770b2d..de4558ab75b44 100644 --- a/src/vs/workbench/browser/parts/views/viewPane.ts +++ b/src/vs/workbench/browser/parts/views/viewPane.ts @@ -7,8 +7,7 @@ import 'vs/css!./media/paneviewlet'; import * as nls from 'vs/nls'; import { Event, Emitter } from 'vs/base/common/event'; import { asCssVariable, foreground } from 'vs/platform/theme/common/colorRegistry'; -import { PANEL_BACKGROUND, SIDE_BAR_BACKGROUND } from 'vs/workbench/common/theme'; -import { after, append, $, trackFocus, EventType, addDisposableListener, createCSSRule, asCSSUrl, Dimension, reset, asCssValueWithDefault, focusWindow } from 'vs/base/browser/dom'; +import { after, append, $, trackFocus, EventType, addDisposableListener, createCSSRule, asCSSUrl, Dimension, reset, asCssValueWithDefault } from 'vs/base/browser/dom'; import { DisposableStore, toDisposable } from 'vs/base/common/lifecycle'; import { Action, IAction, IActionRunner } from 'vs/base/common/actions'; import { ActionsOrientation, IActionViewItem, prepareActions } from 'vs/base/browser/ui/actionbar/actionbar'; @@ -23,7 +22,7 @@ import { IConfigurationService } from 'vs/platform/configuration/common/configur import { Extensions as ViewContainerExtensions, IView, IViewDescriptorService, ViewContainerLocation, IViewsRegistry, IViewContentDescriptor, defaultViewIcon, ViewContainerLocationToString } from 'vs/workbench/common/views'; import { IViewsService } from 'vs/workbench/services/views/common/viewsService'; import { IContextKeyService } from 'vs/platform/contextkey/common/contextkey'; -import { assertIsDefined } from 'vs/base/common/types'; +import { assertIsDefined, PartialExcept } from 'vs/base/common/types'; import { IInstantiationService, ServicesAccessor } from 'vs/platform/instantiation/common/instantiation'; import { MenuId, Action2, IAction2Options, SubmenuItemAction } from 'vs/platform/actions/common/actions'; import { createActionViewItem } from 'vs/platform/actions/browser/menuEntryActionViewItem'; @@ -47,6 +46,12 @@ import { FilterWidget, IFilterWidgetOptions } from 'vs/workbench/browser/parts/v import { BaseActionViewItem } from 'vs/base/browser/ui/actionbar/actionViewItems'; import { ServiceCollection } from 'vs/platform/instantiation/common/serviceCollection'; import { defaultButtonStyles, defaultProgressBarStyles } from 'vs/platform/theme/browser/defaultStyles'; +import { getDefaultHoverDelegate } from 'vs/base/browser/ui/hover/hoverDelegateFactory'; +import { ILifecycleService } from 'vs/workbench/services/lifecycle/common/lifecycle'; +import type { IUpdatableHover } from 'vs/base/browser/ui/hover/hover'; +import { IHoverService } from 'vs/platform/hover/browser/hover'; +import { IListStyles } from 'vs/base/browser/ui/list/listWidget'; +import { PANEL_BACKGROUND, PANEL_STICKY_SCROLL_BACKGROUND, PANEL_STICKY_SCROLL_BORDER, PANEL_STICKY_SCROLL_SHADOW, SIDE_BAR_BACKGROUND, SIDE_BAR_STICKY_SCROLL_BACKGROUND, SIDE_BAR_STICKY_SCROLL_BORDER, SIDE_BAR_STICKY_SCROLL_SHADOW } from 'vs/workbench/common/theme'; export enum ViewPaneShowActions { /** Show the actions when the view is hovered. This is the default behavior. */ @@ -64,6 +69,8 @@ export interface IViewPaneOptions extends IPaneOptions { readonly showActions?: ViewPaneShowActions; readonly titleMenuId?: MenuId; readonly donotForwardArgs?: boolean; + // The title of the container pane when it is merged with the view container + readonly singleViewPaneContainerTitle?: string; } export interface IFilterViewPaneOptions extends IViewPaneOptions { @@ -116,9 +123,10 @@ class ViewWelcomeController { @IOpenerService protected openerService: IOpenerService, @ITelemetryService protected telemetryService: ITelemetryService, @IContextKeyService private contextKeyService: IContextKeyService, + @ILifecycleService lifecycleService: ILifecycleService ) { - this.delegate.onDidChangeViewWelcomeState(this.onDidChangeViewWelcomeState, this, this.disposables); - this.onDidChangeViewWelcomeState(); + this.disposables.add(Event.runAndSubscribe(this.delegate.onDidChangeViewWelcomeState, () => this.onDidChangeViewWelcomeState())); + this.disposables.add(lifecycleService.onWillShutdown(() => this.dispose())); // Fixes https://github.com/microsoft/vscode/issues/208878 } layout(height: number, width: number) { @@ -331,6 +339,11 @@ export abstract class ViewPane extends Pane implements IView { return this._titleDescription; } + private _singleViewPaneContainerTitle: string | undefined; + public get singleViewPaneContainerTitle(): string | undefined { + return this._singleViewPaneContainerTitle; + } + readonly menuActions: CompositeMenuActions; private progressBar!: ProgressBar; @@ -340,8 +353,11 @@ export abstract class ViewPane extends Pane implements IView { private readonly showActions: ViewPaneShowActions; private headerContainer?: HTMLElement; private titleContainer?: HTMLElement; + private titleContainerHover?: IUpdatableHover; private titleDescriptionContainer?: HTMLElement; + private titleDescriptionContainerHover?: IUpdatableHover; private iconContainer?: HTMLElement; + private iconContainerHover?: IUpdatableHover; protected twistiesContainer?: HTMLElement; private viewWelcomeController!: ViewWelcomeController; @@ -358,12 +374,14 @@ export abstract class ViewPane extends Pane implements IView { @IOpenerService protected openerService: IOpenerService, @IThemeService protected themeService: IThemeService, @ITelemetryService protected telemetryService: ITelemetryService, + @IHoverService protected readonly hoverService: IHoverService ) { super({ ...options, ...{ orientation: viewDescriptorService.getViewLocationById(options.id) === ViewContainerLocation.Panel ? Orientation.HORIZONTAL : Orientation.VERTICAL } }); this.id = options.id; this._title = options.title; this._titleDescription = options.titleDescription; + this._singleViewPaneContainerTitle = options.singleViewPaneContainerTitle; this.showActions = options.showActions ?? ViewPaneShowActions.Default; this.scopedContextKeyService = this._register(contextKeyService.createScoped(this.element)); @@ -432,7 +450,7 @@ export abstract class ViewPane extends Pane implements IView { actions.classList.toggle('show-expanded', this.showActions === ViewPaneShowActions.WhenExpanded); this.toolbar = this.instantiationService.createInstance(WorkbenchToolBar, actions, { orientation: ActionsOrientation.HORIZONTAL, - actionViewItemProvider: action => this.getActionViewItem(action), + actionViewItemProvider: (action, options) => this.getActionViewItem(action, options), ariaLabel: nls.localize('viewToolbarAriaLabel', "{0} actions", this.title), getKeyBinding: action => this.keybindingService.lookupKeybinding(action.id), renderDropdownAsChildElement: true, @@ -519,13 +537,14 @@ export abstract class ViewPane extends Pane implements IView { } const calculatedTitle = this.calculateTitle(title); - this.titleContainer = append(container, $('h3.title', { title: calculatedTitle }, calculatedTitle)); + this.titleContainer = append(container, $('h3.title', {}, calculatedTitle)); + this.titleContainerHover = this._register(this.hoverService.setupUpdatableHover(getDefaultHoverDelegate('mouse'), this.titleContainer, calculatedTitle)); if (this._titleDescription) { this.setTitleDescription(this._titleDescription); } - this.iconContainer.title = calculatedTitle; + this.iconContainerHover = this._register(this.hoverService.setupUpdatableHover(getDefaultHoverDelegate('mouse'), this.iconContainer, calculatedTitle)); this.iconContainer.setAttribute('aria-label', calculatedTitle); } @@ -533,11 +552,11 @@ export abstract class ViewPane extends Pane implements IView { const calculatedTitle = this.calculateTitle(title); if (this.titleContainer) { this.titleContainer.textContent = calculatedTitle; - this.titleContainer.setAttribute('title', calculatedTitle); + this.titleContainerHover?.update(calculatedTitle); } if (this.iconContainer) { - this.iconContainer.title = calculatedTitle; + this.iconContainerHover?.update(calculatedTitle); this.iconContainer.setAttribute('aria-label', calculatedTitle); } @@ -548,10 +567,11 @@ export abstract class ViewPane extends Pane implements IView { private setTitleDescription(description: string | undefined) { if (this.titleDescriptionContainer) { this.titleDescriptionContainer.textContent = description ?? ''; - this.titleDescriptionContainer.setAttribute('title', description ?? ''); + this.titleDescriptionContainerHover?.update(description ?? ''); } else if (description && this.titleContainer) { - this.titleDescriptionContainer = after(this.titleContainer, $('span.description', { title: description }, description)); + this.titleDescriptionContainer = after(this.titleContainer, $('span.description', {}, description)); + this.titleDescriptionContainerHover = this._register(this.hoverService.setupUpdatableHover(getDefaultHoverDelegate('mouse'), this.titleDescriptionContainer, description)); } } @@ -576,7 +596,7 @@ export abstract class ViewPane extends Pane implements IView { } protected renderBody(container: HTMLElement): void { - this.viewWelcomeController = this._register(new ViewWelcomeController(container, this, this.instantiationService, this.openerService, this.telemetryService, this.contextKeyService)); + this.viewWelcomeController = this._register(this.instantiationService.createInstance(ViewWelcomeController, container, this)); } protected layoutBody(height: number, width: number): void { @@ -596,12 +616,12 @@ export abstract class ViewPane extends Pane implements IView { if (this.progressIndicator === undefined) { const that = this; - this.progressIndicator = new ScopedProgressIndicator(assertIsDefined(this.progressBar), new class extends AbstractProgressScope { + this.progressIndicator = this._register(new ScopedProgressIndicator(assertIsDefined(this.progressBar), new class extends AbstractProgressScope { constructor() { super(that.id, that.isBodyVisible()); this._register(that.onDidChangeBodyVisibility(isVisible => isVisible ? this.onScopeOpened(that.id) : this.onScopeClosed(that.id))); } - }()); + }())); } return this.progressIndicator; } @@ -610,21 +630,11 @@ export abstract class ViewPane extends Pane implements IView { return this.viewDescriptorService.getViewContainerByViewId(this.id)!.id; } - protected getBackgroundColor(): string { - switch (this.viewDescriptorService.getViewLocationById(this.id)) { - case ViewContainerLocation.Panel: - return PANEL_BACKGROUND; - case ViewContainerLocation.Sidebar: - case ViewContainerLocation.AuxiliaryBar: - return SIDE_BAR_BACKGROUND; - } - - return SIDE_BAR_BACKGROUND; + protected getLocationBasedColors(): IViewPaneLocationColors { + return getLocationBasedViewColors(this.viewDescriptorService.getViewLocationById(this.id)); } focus(): void { - focusWindow(this.element); - if (this.viewWelcomeController.enabled) { this.viewWelcomeController.focus(); } else if (this.element) { @@ -719,8 +729,9 @@ export abstract class FilterViewPane extends ViewPane { @IOpenerService openerService: IOpenerService, @IThemeService themeService: IThemeService, @ITelemetryService telemetryService: ITelemetryService, + @IHoverService hoverService: IHoverService, ) { - super(options, keybindingService, contextMenuService, configurationService, contextKeyService, viewDescriptorService, instantiationService, openerService, themeService, telemetryService); + super(options, keybindingService, contextMenuService, configurationService, contextKeyService, viewDescriptorService, instantiationService, openerService, themeService, telemetryService, hoverService); this.filterWidget = this._register(instantiationService.createChild(new ServiceCollection([IContextKeyService, this.scopedContextKeyService])).createInstance(FilterWidget, options.filterOptions)); } @@ -763,6 +774,42 @@ export abstract class FilterViewPane extends ViewPane { } +export interface IViewPaneLocationColors { + background: string; + listOverrideStyles: PartialExcept; +} + +export function getLocationBasedViewColors(location: ViewContainerLocation | null): IViewPaneLocationColors { + let background, stickyScrollBackground, stickyScrollBorder, stickyScrollShadow; + + switch (location) { + case ViewContainerLocation.Panel: + background = PANEL_BACKGROUND; + stickyScrollBackground = PANEL_STICKY_SCROLL_BACKGROUND; + stickyScrollBorder = PANEL_STICKY_SCROLL_BORDER; + stickyScrollShadow = PANEL_STICKY_SCROLL_SHADOW; + break; + + case ViewContainerLocation.Sidebar: + case ViewContainerLocation.AuxiliaryBar: + default: + background = SIDE_BAR_BACKGROUND; + stickyScrollBackground = SIDE_BAR_STICKY_SCROLL_BACKGROUND; + stickyScrollBorder = SIDE_BAR_STICKY_SCROLL_BORDER; + stickyScrollShadow = SIDE_BAR_STICKY_SCROLL_SHADOW; + } + + return { + background, + listOverrideStyles: { + listBackground: background, + treeStickyScrollBackground: stickyScrollBackground, + treeStickyScrollBorder: stickyScrollBorder, + treeStickyScrollShadow: stickyScrollShadow + } + }; +} + export abstract class ViewAction extends Action2 { override readonly desc: Readonly & { viewId: string }; constructor(desc: Readonly & { viewId: string }) { diff --git a/src/vs/workbench/browser/parts/views/viewPaneContainer.ts b/src/vs/workbench/browser/parts/views/viewPaneContainer.ts index 3021fe51eee7a..67b7f28268f53 100644 --- a/src/vs/workbench/browser/parts/views/viewPaneContainer.ts +++ b/src/vs/workbench/browser/parts/views/viewPaneContainer.ts @@ -20,7 +20,7 @@ import * as nls from 'vs/nls'; import { createActionViewItem } from 'vs/platform/actions/browser/menuEntryActionViewItem'; import { Action2, IAction2Options, IMenuService, ISubmenuItem, MenuId, MenuRegistry, registerAction2 } from 'vs/platform/actions/common/actions'; import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; -import { ContextKeyExpr, IContextKeyService } from 'vs/platform/contextkey/common/contextkey'; +import { IContextKeyService } from 'vs/platform/contextkey/common/contextkey'; import { IContextMenuService } from 'vs/platform/contextview/browser/contextView'; import { IInstantiationService, ServicesAccessor } from 'vs/platform/instantiation/common/instantiation'; import { KeybindingWeight } from 'vs/platform/keybinding/common/keybindingsRegistry'; @@ -39,14 +39,14 @@ import { IAddedViewDescriptorRef, ICustomViewDescriptor, IView, IViewContainerMo import { IViewsService } from 'vs/workbench/services/views/common/viewsService'; import { FocusedViewContext } from 'vs/workbench/common/contextkeys'; import { IExtensionService } from 'vs/workbench/services/extensions/common/extensions'; -import { ActivityBarPosition, IWorkbenchLayoutService, LayoutSettings, Parts, Position } from 'vs/workbench/services/layout/browser/layoutService'; +import { IWorkbenchLayoutService, LayoutSettings, Position } from 'vs/workbench/services/layout/browser/layoutService'; +import { IBaseActionViewItemOptions } from 'vs/base/browser/ui/actionbar/actionViewItems'; export const ViewsSubMenu = new MenuId('Views'); MenuRegistry.appendMenuItem(MenuId.ViewContainerTitle, { submenu: ViewsSubMenu, title: nls.localize('views', "Views"), order: 1, - when: ContextKeyExpr.and(ContextKeyExpr.equals('viewContainerLocation', ViewContainerLocationToString(ViewContainerLocation.Sidebar)), ContextKeyExpr.notEquals(`config.${LayoutSettings.ACTIVITY_BAR_LOCATION}`, ActivityBarPosition.TOP)), }); export interface IViewPaneContainerOptions extends IPaneViewOptions { @@ -559,10 +559,16 @@ export class ViewPaneContainer extends Component implements IViewPaneContainer { const containerTitle = this.viewContainerModel.title; if (this.isViewMergedWithContainer()) { + const singleViewPaneContainerTitle = this.paneItems[0].pane.singleViewPaneContainerTitle; + if (singleViewPaneContainerTitle) { + return singleViewPaneContainerTitle; + } + const paneItemTitle = this.paneItems[0].pane.title; if (containerTitle === paneItemTitle) { - return this.paneItems[0].pane.title; + return paneItemTitle; } + return paneItemTitle ? `${containerTitle}: ${paneItemTitle}` : containerTitle; } @@ -590,11 +596,11 @@ export class ViewPaneContainer extends Component implements IViewPaneContainer { return undefined; } - getActionViewItem(action: IAction): IActionViewItem | undefined { + getActionViewItem(action: IAction, options: IBaseActionViewItemOptions): IActionViewItem | undefined { if (this.isViewMergedWithContainer()) { - return this.paneItems[0].pane.getActionViewItem(action); + return this.paneItems[0].pane.getActionViewItem(action, options); } - return createActionViewItem(this.instantiationService, action); + return createActionViewItem(this.instantiationService, action, options); } focus(): void { @@ -658,7 +664,7 @@ export class ViewPaneContainer extends Component implements IViewPaneContainer { addPanes(panes: { pane: ViewPane; size: number; index?: number; disposable: IDisposable }[]): void { const wasMerged = this.isViewMergedWithContainer(); - for (const { pane: pane, size, index, disposable } of panes) { + for (const { pane, size, index, disposable } of panes) { this.addPane(pane, size, disposable, index); } @@ -779,7 +785,8 @@ export class ViewPaneContainer extends Component implements IViewPaneContainer { id: viewDescriptor.id, title: viewDescriptor.name.value, fromExtensionId: (viewDescriptor as Partial).extensionId, - expanded: !collapsed + expanded: !collapsed, + singleViewPaneContainerTitle: viewDescriptor.singleViewPaneContainerTitle, }); pane.render(); @@ -1090,11 +1097,6 @@ export class ViewPaneContainer extends Component implements IViewPaneContainer { } isViewMergedWithContainer(): boolean { - const location = this.viewDescriptorService.getViewContainerLocation(this.viewContainer); - // Do not merge views in side bar when activity bar is on top because the view title is not shown - if (location === ViewContainerLocation.Sidebar && !this.layoutService.isVisible(Parts.ACTIVITYBAR_PART) && this.configurationService.getValue(LayoutSettings.ACTIVITY_BAR_LOCATION) === ActivityBarPosition.TOP) { - return false; - } if (!(this.options.mergeViewWithContainerWhenSingleView && this.paneItems.length === 1)) { return false; } diff --git a/src/vs/workbench/browser/quickaccess.ts b/src/vs/workbench/browser/quickaccess.ts index a73c3d7072c5c..aec2065963d64 100644 --- a/src/vs/workbench/browser/quickaccess.ts +++ b/src/vs/workbench/browser/quickaccess.ts @@ -8,12 +8,14 @@ import { ContextKeyExpr, RawContextKey } from 'vs/platform/contextkey/common/con import { ICommandHandler } from 'vs/platform/commands/common/commands'; import { IKeybindingService } from 'vs/platform/keybinding/common/keybinding'; import { IQuickInputService } from 'vs/platform/quickinput/common/quickInput'; +import { Disposable } from 'vs/base/common/lifecycle'; import { getIEditor } from 'vs/editor/browser/editorBrowser'; import { ICodeEditorViewState, IDiffEditorViewState } from 'vs/editor/common/editorCommon'; +import { IResourceEditorInput, ITextResourceEditorInput } from 'vs/platform/editor/common/editor'; import { EditorInput } from 'vs/workbench/common/editor/editorInput'; -import { IEditorGroup } from 'vs/workbench/services/editor/common/editorGroupsService'; -import { IEditorService } from 'vs/workbench/services/editor/common/editorService'; -import { IEditorOptions } from 'vs/platform/editor/common/editor'; +import { IEditorGroup, IEditorGroupsService } from 'vs/workbench/services/editor/common/editorGroupsService'; +import { ACTIVE_GROUP_TYPE, AUX_WINDOW_GROUP_TYPE, IEditorService, SIDE_GROUP_TYPE } from 'vs/workbench/services/editor/common/editorService'; +import { IUntitledTextResourceEditorInput, IUntypedEditorInput, GroupIdentifier, IEditorPane } from 'vs/workbench/common/editor'; export const inQuickPickContextKeyValue = 'inQuickOpen'; export const InQuickPickContextKey = new RawContextKey(inQuickPickContextKeyValue, false, localize('inQuickOpen', "Whether keyboard focus is inside the quick open control")); @@ -51,14 +53,21 @@ export function getQuickNavigateHandler(id: string, next?: boolean): ICommandHan quickInputService.navigate(!!next, quickNavigate); }; } -export class EditorViewState { +export class PickerEditorState extends Disposable { private _editorViewState: { editor: EditorInput; group: IEditorGroup; state: ICodeEditorViewState | IDiffEditorViewState | undefined; } | undefined = undefined; - constructor(private readonly editorService: IEditorService) { } + private readonly openedTransientEditors = new Set(); // editors that were opened between set and restore + + constructor( + @IEditorService private readonly editorService: IEditorService, + @IEditorGroupsService private readonly editorGroupsService: IEditorGroupsService + ) { + super(); + } set(): void { if (this._editorViewState) { @@ -73,20 +82,55 @@ export class EditorViewState { state: getIEditor(activeEditorPane.getControl())?.saveViewState() ?? undefined, }; } + + } + + /** + * Open a transient editor such that it may be closed when the state is restored. + * Note that, when the state is restored, if the editor is no longer transient, it will not be closed. + */ + async openTransientEditor(editor: IResourceEditorInput | ITextResourceEditorInput | IUntitledTextResourceEditorInput | IUntypedEditorInput, group?: IEditorGroup | GroupIdentifier | SIDE_GROUP_TYPE | ACTIVE_GROUP_TYPE | AUX_WINDOW_GROUP_TYPE): Promise { + editor.options = { ...editor.options, transient: true }; + + const editorPane = await this.editorService.openEditor(editor, group); + if (editorPane?.input && editorPane.input !== this._editorViewState?.editor && editorPane.group.isTransient(editorPane.input)) { + this.openedTransientEditors.add(editorPane.input); + } + + return editorPane; } async restore(): Promise { if (this._editorViewState) { - const options: IEditorOptions = { + for (const editor of this.openedTransientEditors) { + if (editor.isDirty()) { + continue; + } + + for (const group of this.editorGroupsService.groups) { + if (group.isTransient(editor)) { + await group.closeEditor(editor, { preserveFocus: true }); + } + } + } + + await this._editorViewState.group.openEditor(this._editorViewState.editor, { viewState: this._editorViewState.state, - preserveFocus: true /* import to not close the picker as a result */ - }; + preserveFocus: true // important to not close the picker as a result + }); - await this._editorViewState.group.openEditor(this._editorViewState.editor, options); + this.reset(); } } reset() { this._editorViewState = undefined; + this.openedTransientEditors.clear(); + } + + override dispose(): void { + super.dispose(); + + this.reset(); } } diff --git a/src/vs/workbench/browser/style.ts b/src/vs/workbench/browser/style.ts index 23f2e2bac99dc..8fab9bc5b71cb 100644 --- a/src/vs/workbench/browser/style.ts +++ b/src/vs/workbench/browser/style.ts @@ -6,7 +6,7 @@ import 'vs/css!./media/style'; import { registerThemingParticipant } from 'vs/platform/theme/common/themeService'; import { WORKBENCH_BACKGROUND, TITLE_BAR_ACTIVE_BACKGROUND } from 'vs/workbench/common/theme'; -import { isWeb, isIOS, isMacintosh, isWindows } from 'vs/base/common/platform'; +import { isWeb, isIOS } from 'vs/base/common/platform'; import { createMetaElement } from 'vs/base/browser/dom'; import { isSafari, isStandalone } from 'vs/base/browser/browser'; import { selectionBackground } from 'vs/platform/theme/common/colorRegistry'; @@ -60,13 +60,3 @@ registerThemingParticipant((theme, collector) => { collector.addRule(`body { background-color: ${workbenchBackground}; }`); } }); - -/** - * The best font-family to be used in CSS based on the platform: - * - Windows: Segoe preferred, fallback to sans-serif - * - macOS: standard system font, fallback to sans-serif - * - Linux: standard system font preferred, fallback to Ubuntu fonts - * - * Note: this currently does not adjust for different locales. - */ -export const DEFAULT_FONT_FAMILY = isWindows ? '"Segoe WPC", "Segoe UI", sans-serif' : isMacintosh ? '-apple-system, BlinkMacSystemFont, sans-serif' : 'system-ui, "Ubuntu", "Droid Sans", sans-serif'; diff --git a/src/vs/workbench/browser/web.api.ts b/src/vs/workbench/browser/web.api.ts index 50d76a2213c46..94acb8f57b280 100644 --- a/src/vs/workbench/browser/web.api.ts +++ b/src/vs/workbench/browser/web.api.ts @@ -106,6 +106,10 @@ export interface IWorkbench { }; workspace: { + /** + * Resolves once the remote authority has been resolved. + */ + didResolveRemoteAuthority(): Promise; /** * Forwards a port. If the current embedder implements a tunnelFactory then that will be used to make the tunnel. @@ -142,6 +146,13 @@ export interface IWorkbenchConstructionOptions { */ readonly remoteAuthority?: string; + /** + * The server base path is the path where the workbench is served from. + * The path must be absolute (start with a slash). + * Corresponds to option `server-base-path` on the server side. + */ + readonly serverBasePath?: string; + /** * The connection token to send to the server. */ diff --git a/src/vs/workbench/browser/web.factory.ts b/src/vs/workbench/browser/web.factory.ts index 5fab020f5de3b..79e01d879a01c 100644 --- a/src/vs/workbench/browser/web.factory.ts +++ b/src/vs/workbench/browser/web.factory.ts @@ -154,6 +154,14 @@ export namespace window { export namespace workspace { + /** + * {@linkcode IWorkbench.workspace IWorkbench.workspace.didResolveRemoteAuthority} + */ + export async function didResolveRemoteAuthority() { + const workbench = await workbenchPromise.p; + await workbench.workspace.didResolveRemoteAuthority(); + } + /** * {@linkcode IWorkbench.workspace IWorkbench.workspace.openTunnel} */ diff --git a/src/vs/workbench/browser/web.main.ts b/src/vs/workbench/browser/web.main.ts index 9061af27859af..71e9f556fc46e 100644 --- a/src/vs/workbench/browser/web.main.ts +++ b/src/vs/workbench/browser/web.main.ts @@ -156,6 +156,7 @@ export class BrowserMain extends Disposable { const remoteExplorerService = accessor.get(IRemoteExplorerService); const labelService = accessor.get(ILabelService); const embedderTerminalService = accessor.get(IEmbedderTerminalService); + const remoteAuthorityResolverService = accessor.get(IRemoteAuthorityResolverService); let logger: DelayedLogChannel | undefined = undefined; @@ -190,6 +191,13 @@ export class BrowserMain extends Disposable { createTerminal: async (options) => embedderTerminalService.createTerminal(options), }, workspace: { + didResolveRemoteAuthority: async () => { + if (!this.configuration.remoteAuthority) { + return; + } + + await remoteAuthorityResolverService.resolveAuthority(this.configuration.remoteAuthority); + }, openTunnel: async tunnelOptions => { const tunnel = assertIsDefined(await remoteExplorerService.forward({ remote: tunnelOptions.remoteAddress, @@ -284,11 +292,11 @@ export class BrowserMain extends Disposable { // Register them early because they are needed for the profiles initialization await this.registerIndexedDBFileSystemProviders(environmentService, fileService, logService, loggerService, logsPath); - // Remote + const connectionToken = environmentService.options.connectionToken || getCookieValue(connectionTokenCookieName); const remoteResourceLoader = this.configuration.remoteResourceProvider ? new BrowserRemoteResourceLoader(fileService, this.configuration.remoteResourceProvider) : undefined; const resourceUriProvider = this.configuration.resourceUriProvider ?? remoteResourceLoader?.getResourceUriProvider(); - const remoteAuthorityResolverService = new RemoteAuthorityResolverService(!environmentService.expectsResolverExtension, connectionToken, resourceUriProvider, productService, logService); + const remoteAuthorityResolverService = new RemoteAuthorityResolverService(!environmentService.expectsResolverExtension, connectionToken, resourceUriProvider, this.configuration.serverBasePath, productService, logService); serviceCollection.set(IRemoteAuthorityResolverService, remoteAuthorityResolverService); // Signing @@ -476,7 +484,7 @@ export class BrowserMain extends Disposable { } private registerDeveloperActions(provider: IndexedDBFileSystemProvider): void { - registerAction2(class ResetUserDataAction extends Action2 { + this._register(registerAction2(class ResetUserDataAction extends Action2 { constructor() { super({ id: 'workbench.action.resetUserData', @@ -511,7 +519,7 @@ export class BrowserMain extends Disposable { hostService.reload(); } - }); + })); } private async createStorageService(workspace: IAnyWorkspaceIdentifier, logService: ILogService, userDataProfileService: IUserDataProfileService): Promise { diff --git a/src/vs/workbench/browser/window.ts b/src/vs/workbench/browser/window.ts index 17354e5f403a0..e964af6543ed3 100644 --- a/src/vs/workbench/browser/window.ts +++ b/src/vs/workbench/browser/window.ts @@ -30,6 +30,7 @@ import { registerWindowDriver } from 'vs/workbench/services/driver/browser/drive import { CodeWindow, isAuxiliaryWindow, mainWindow } from 'vs/base/browser/window'; import { createSingleCallFunction } from 'vs/base/common/functional'; import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; +import { IWorkbenchEnvironmentService } from 'vs/workbench/services/environment/common/environmentService'; export abstract class BaseWindow extends Disposable { @@ -39,7 +40,8 @@ export abstract class BaseWindow extends Disposable { constructor( targetWindow: CodeWindow, dom = { getWindowsCount, getWindows }, /* for testing */ - @IHostService protected readonly hostService: IHostService + @IHostService protected readonly hostService: IHostService, + @IWorkbenchEnvironmentService protected readonly environmentService: IWorkbenchEnvironmentService ) { super(); @@ -52,31 +54,54 @@ export abstract class BaseWindow extends Disposable { //#region focus handling in multi-window applications protected enableWindowFocusOnElementFocus(targetWindow: CodeWindow): void { - const originalFocus = HTMLElement.prototype.focus; + const originalFocus = targetWindow.HTMLElement.prototype.focus; + const that = this; targetWindow.HTMLElement.prototype.focus = function (this: HTMLElement, options?: FocusOptions | undefined): void { - // If the active focused window is not the same as the - // window of the element to focus, make sure to focus - // that window first before focusing the element. - const activeWindow = getActiveWindow(); - if (activeWindow.document.hasFocus()) { - const elementWindow = getWindow(this); - if (activeWindow !== elementWindow) { - elementWindow.focus(); - } - } + // Ensure the window the element belongs to is focused + // in scenarios where auxiliary windows are present + that.onElementFocus(getWindow(this)); // Pass to original focus() method originalFocus.apply(this, [options]); }; } + private onElementFocus(targetWindow: CodeWindow): void { + const activeWindow = getActiveWindow(); + if (activeWindow !== targetWindow && activeWindow.document.hasFocus()) { + + // Call original focus() + targetWindow.focus(); + + // In Electron, `window.focus()` fails to bring the window + // to the front if multiple windows exist in the same process + // group (floating windows). As such, we ask the host service + // to focus the window which can take care of bringin the + // window to the front. + // + // To minimise disruption by bringing windows to the front + // by accident, we only do this if the window is not already + // focused and the active window is not the target window + // but has focus. This is an indication that multiple windows + // are opened in the same process group while the target window + // is not focused. + + if ( + !this.environmentService.extensionTestsLocationURI && + !targetWindow.document.hasFocus() + ) { + this.hostService.focus(targetWindow); + } + } + } + //#endregion //#region timeout handling in multi-window applications - private enableMultiWindowAwareTimeout(targetWindow: Window, dom = { getWindowsCount, getWindows }): void { + protected enableMultiWindowAwareTimeout(targetWindow: Window, dom = { getWindowsCount, getWindows }): void { // Override `setTimeout` and `clearTimeout` on the provided window to make // sure timeouts are dispatched to all opened windows. Some browsers may decide @@ -186,12 +211,12 @@ export class BrowserWindow extends BaseWindow { @IDialogService private readonly dialogService: IDialogService, @ILabelService private readonly labelService: ILabelService, @IProductService private readonly productService: IProductService, - @IBrowserWorkbenchEnvironmentService private readonly environmentService: IBrowserWorkbenchEnvironmentService, + @IBrowserWorkbenchEnvironmentService private readonly browserEnvironmentService: IBrowserWorkbenchEnvironmentService, @IWorkbenchLayoutService private readonly layoutService: IWorkbenchLayoutService, @IInstantiationService private readonly instantiationService: IInstantiationService, @IHostService hostService: IHostService ) { - super(mainWindow, undefined, hostService); + super(mainWindow, undefined, hostService, browserEnvironmentService); this.registerListeners(); this.create(); @@ -288,8 +313,8 @@ export class BrowserWindow extends BaseWindow { this.openerService.setDefaultExternalOpener({ openExternal: async (href: string) => { let isAllowedOpener = false; - if (this.environmentService.options?.openerAllowedExternalUrlPrefixes) { - for (const trustedPopupPrefix of this.environmentService.options.openerAllowedExternalUrlPrefixes) { + if (this.browserEnvironmentService.options?.openerAllowedExternalUrlPrefixes) { + for (const trustedPopupPrefix of this.browserEnvironmentService.options.openerAllowedExternalUrlPrefixes) { if (href.startsWith(trustedPopupPrefix)) { isAllowedOpener = true; break; diff --git a/src/vs/workbench/browser/workbench.contribution.ts b/src/vs/workbench/browser/workbench.contribution.ts index d9cfbe2465444..8f88583cf4faf 100644 --- a/src/vs/workbench/browser/workbench.contribution.ts +++ b/src/vs/workbench/browser/workbench.contribution.ts @@ -6,11 +6,13 @@ import { Registry } from 'vs/platform/registry/common/platform'; import { localize } from 'vs/nls'; import { IConfigurationRegistry, Extensions as ConfigurationExtensions, ConfigurationScope } from 'vs/platform/configuration/common/configurationRegistry'; -import { isMacintosh, isWindows, isLinux, isWeb, isNative } from 'vs/base/common/platform'; +import { isMacintosh, isWindows, isLinux, isWeb } from 'vs/base/common/platform'; import { ConfigurationMigrationWorkbenchContribution, DynamicWorkbenchSecurityConfiguration, IConfigurationMigrationRegistry, workbenchConfigurationNodeBase, Extensions, ConfigurationKeyValuePairs, problemsConfigurationNodeBase } from 'vs/workbench/common/configuration'; import { isStandalone } from 'vs/base/browser/browser'; import { WorkbenchPhase, registerWorkbenchContribution2 } from 'vs/workbench/common/contributions'; import { ActivityBarPosition, EditorActionsLocation, EditorTabsMode, LayoutSettings } from 'vs/workbench/services/layout/browser/layoutService'; +import { defaultWindowTitle, defaultWindowTitleSeparator } from 'vs/workbench/browser/parts/titlebar/windowTitle'; +import { CustomEditorLabelService } from 'vs/workbench/services/editor/common/customEditorLabelService'; const registry = Registry.as(ConfigurationExtensions.Configuration); @@ -84,6 +86,34 @@ const registry = Registry.as(ConfigurationExtensions.Con 'markdownDescription': localize('decorations.colors', "Controls whether editor file decorations should use colors."), 'default': true }, + [CustomEditorLabelService.SETTING_ID_ENABLED]: { + 'type': 'boolean', + 'markdownDescription': localize('workbench.editor.label.enabled', "Controls whether the custom workbench editor labels should be applied."), + 'default': true, + }, + [CustomEditorLabelService.SETTING_ID_PATTERNS]: { + 'type': 'object', + 'markdownDescription': (() => { + let customEditorLabelDescription = localize('workbench.editor.label.patterns', "Controls the rendering of the editor label. Each __Item__ is a pattern that matches a file path. Both relative and absolute file paths are supported. In case multiple patterns match, the longest matching path will be picked. Each __Value__ is the template for the rendered editor when the __Item__ matches. Variables are substituted based on the context:"); + customEditorLabelDescription += '\n- ' + [ + localize('workbench.editor.label.dirname', "`${dirname}`: name of the folder in which the file is located (e.g. `root/folder/file.txt -> folder`)."), + localize('workbench.editor.label.nthdirname', "`${dirname(N)}`: name of the nth parent folder in which the file is located (e.g. `N=1: root/folder/file.txt -> root`)."), + localize('workbench.editor.label.filename', "`${filename}`: name of the file without the file extension (e.g. `root/folder/file.txt -> file`)."), + localize('workbench.editor.label.extname', "`${extname}`: the file extension (e.g. `root/folder/file.txt -> txt`)."), + ].join('\n- '); // intentionally concatenated to not produce a string that is too long for translations + customEditorLabelDescription += '\n\n' + localize('customEditorLabelDescriptionExample', "Example: `\"**/static/**/*.html\": \"${filename} - ${dirname} (${extname})\"` will render a file `root/static/folder/file.html` as `file - folder (html)`."); + + return customEditorLabelDescription; + })(), + additionalProperties: + { + type: 'string', + markdownDescription: localize('workbench.editor.label.template', "The template which should be rendered when the pattern mtches. May include the variables ${dirname}, ${filename} and ${extname}."), + minLength: 1, + pattern: '.*[a-zA-Z0-9].*' + }, + 'default': {} + }, 'workbench.editor.labelFormat': { 'type': 'string', 'enum': ['default', 'short', 'medium', 'long'], @@ -258,12 +288,12 @@ const registry = Registry.as(ConfigurationExtensions.Con }, 'workbench.editor.enablePreviewFromQuickOpen': { 'type': 'boolean', - 'markdownDescription': localize({ comment: ['{0}, {1} will be a setting name rendered as a link'], key: 'enablePreviewFromQuickOpen' }, "Controls whether editors opened from Quick Open show as preview editors. Preview editors do not stay open, and are reused until explicitly set to be kept open (via double-click or editing). When enabled, hold Ctrl before selection to open an editor as a non-preview. This value is ignored when {0} is not set to {1}.", '`#workbench.editor.enablePreview#`', '`multiple`'), + 'markdownDescription': localize({ comment: ['{0}, {1} will be a setting name rendered as a link'], key: 'enablePreviewFromQuickOpen' }, "Controls whether editors opened from Quick Open show as preview editors. Preview editors do not stay open, and are reused until explicitly set to be kept open (via double-click or editing). When enabled, hold Ctrl before selection to open an editor as a non-preview. This value is ignored when {0} is not set to {1}.", '`#workbench.editor.showTabs#`', '`multiple`'), 'default': false }, 'workbench.editor.enablePreviewFromCodeNavigation': { 'type': 'boolean', - 'markdownDescription': localize({ comment: ['{0}, {1} will be a setting name rendered as a link'], key: 'enablePreviewFromCodeNavigation' }, "Controls whether editors remain in preview when a code navigation is started from them. Preview editors do not stay open, and are reused until explicitly set to be kept open (via double-click or editing). This value is ignored when {0} is not set to {1}.", '`#workbench.editor.enablePreview#`', '`multiple`'), + 'markdownDescription': localize({ comment: ['{0}, {1} will be a setting name rendered as a link'], key: 'enablePreviewFromCodeNavigation' }, "Controls whether editors remain in preview when a code navigation is started from them. Preview editors do not stay open, and are reused until explicitly set to be kept open (via double-click or editing). This value is ignored when {0} is not set to {1}.", '`#workbench.editor.showTabs#`', '`multiple`'), 'default': false }, 'workbench.editor.closeOnFileDelete': { @@ -496,13 +526,14 @@ const registry = Registry.as(ConfigurationExtensions.Con }, [LayoutSettings.ACTIVITY_BAR_LOCATION]: { 'type': 'string', - 'enum': ['side', 'top', 'hidden'], - 'default': 'side', - 'markdownDescription': localize({ comment: ['This is the description for a setting'], key: 'activityBarLocation' }, "Controls the location of the Activity Bar. It can either show to the `side` or `top` of the Primary Side Bar or `hidden`."), + 'enum': ['default', 'top', 'bottom', 'hidden'], + 'default': 'default', + 'markdownDescription': localize({ comment: ['This is the description for a setting'], key: 'activityBarLocation' }, "Controls the location of the Activity Bar. It can either show to the `default` or `top` / `bottom` of the Primary and Secondary Side Bar or `hidden`."), 'enumDescriptions': [ - localize('workbench.activityBar.location.side', "Show the Activity Bar to the side of the Primary Side Bar."), - localize('workbench.activityBar.location.top', "Show the Activity Bar on top of the Primary Side Bar."), - localize('workbench.activityBar.location.hide', "Hide the Activity Bar.") + localize('workbench.activityBar.location.default', "Show the Activity Bar of the Primary Side Bar on the side."), + localize('workbench.activityBar.location.top', "Show the Activity Bar on top of the Primary and Secondary Side Bar."), + localize('workbench.activityBar.location.bottom', "Show the Activity Bar at the bottom of the Primary and Secondary Side Bar."), + localize('workbench.activityBar.location.hide', "Hide the Activity Bar in the Primary and Secondary Side Bar.") ], }, 'workbench.activityBar.iconClickBehavior': { @@ -611,6 +642,8 @@ const registry = Registry.as(ConfigurationExtensions.Con localize('remoteName', "`${remoteName}`: e.g. SSH"), localize('dirty', "`${dirty}`: an indicator for when the active editor has unsaved changes."), localize('focusedView', "`${focusedView}`: the name of the view that is currently focused."), + localize('activeRepositoryName', "`${activeRepositoryName}`: the name of the active repository (e.g. vscode)."), + localize('activeRepositoryBranchName', "`${activeRepositoryBranchName}`: the name of the active branch in the active repository (e.g. main)."), localize('separator', "`${separator}`: a conditional separator (\" - \") that only shows when surrounded by variables with values or static text.") ].join('\n- '); // intentionally concatenated to not produce a string that is too long for translations @@ -622,23 +655,12 @@ const registry = Registry.as(ConfigurationExtensions.Con 'properties': { 'window.title': { 'type': 'string', - 'default': (() => { - if (isMacintosh && isNative) { - return '${activeEditorShort}${separator}${rootName}${separator}${profileName}'; // macOS has native dirty indicator - } - - const base = '${dirty}${activeEditorShort}${separator}${rootName}${separator}${profileName}${separator}${appName}'; - if (isWeb) { - return base + '${separator}${remoteName}'; // Web: always show remote name - } - - return base; - })(), + 'default': defaultWindowTitle, 'markdownDescription': windowTitleDescription }, 'window.titleSeparator': { 'type': 'string', - 'default': isMacintosh ? ' \u2014 ' : ' - ', + 'default': defaultWindowTitleSeparator, 'markdownDescription': localize("window.titleSeparator", "Separator used by {0}.", '`#window.title#`') }, [LayoutSettings.COMMAND_CENTER]: { @@ -818,6 +840,17 @@ Registry.as(Extensions.ConfigurationMigration) } }]); +Registry.as(Extensions.ConfigurationMigration) + .registerConfigurationMigrations([{ + key: LayoutSettings.ACTIVITY_BAR_LOCATION, migrateFn: (value: any) => { + const results: ConfigurationKeyValuePairs = []; + if (value === 'side') { + results.push([LayoutSettings.ACTIVITY_BAR_LOCATION, { value: ActivityBarPosition.DEFAULT }]); + } + return results; + } + }]); + Registry.as(Extensions.ConfigurationMigration) .registerConfigurationMigrations([{ key: 'workbench.editor.doubleClickTabToToggleEditorGroupSizes', migrateFn: (value: any) => { diff --git a/src/vs/workbench/browser/workbench.ts b/src/vs/workbench/browser/workbench.ts index 9ac40779c6e05..910c41347886b 100644 --- a/src/vs/workbench/browser/workbench.ts +++ b/src/vs/workbench/browser/workbench.ts @@ -43,6 +43,9 @@ import { IHostService } from 'vs/workbench/services/host/browser/host'; import { IDialogService } from 'vs/platform/dialogs/common/dialogs'; import { mainWindow } from 'vs/base/browser/window'; import { PixelRatio } from 'vs/base/browser/pixelRatio'; +import { IHoverService, WorkbenchHoverDelegate } from 'vs/platform/hover/browser/hover'; +import { setHoverDelegateFactory } from 'vs/base/browser/ui/hover/hoverDelegateFactory'; +import { setBaseLayerHoverDelegate } from 'vs/base/browser/ui/hover/hoverDelegate2'; export interface IWorkbenchOptions { @@ -139,7 +142,7 @@ export class Workbench extends Layout { try { // Configure emitter leak warning threshold - setGlobalLeakWarningThreshold(175); + this._register(setGlobalLeakWarningThreshold(175)); // Services const instantiationService = this.initServices(this.serviceCollection); @@ -149,9 +152,15 @@ export class Workbench extends Layout { const storageService = accessor.get(IStorageService); const configurationService = accessor.get(IConfigurationService); const hostService = accessor.get(IHostService); + const hoverService = accessor.get(IHoverService); const dialogService = accessor.get(IDialogService); const notificationService = accessor.get(INotificationService) as NotificationService; + // Default Hover Delegate must be registered before creating any workbench/layout components + // as these possibly will use the default hover delegate + setHoverDelegateFactory((placement, enableInstantHover) => instantiationService.createInstance(WorkbenchHoverDelegate, placement, enableInstantHover, {})); + setBaseLayerHoverDelegate(hoverService); + // Layout this.initLayout(accessor); diff --git a/src/vs/workbench/common/comments.ts b/src/vs/workbench/common/comments.ts new file mode 100644 index 0000000000000..038819d8f99e7 --- /dev/null +++ b/src/vs/workbench/common/comments.ts @@ -0,0 +1,17 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { MarshalledId } from 'vs/base/common/marshallingIds'; +import { CommentThread } from 'vs/editor/common/languages'; + +export interface MarshalledCommentThread { + $mid: MarshalledId.CommentThread; + commentControlHandle: number; + commentThreadHandle: number; +} + +export interface MarshalledCommentThreadInternal extends MarshalledCommentThread { + thread: CommentThread; +} diff --git a/src/vs/workbench/common/component.ts b/src/vs/workbench/common/component.ts index 6c25dc9d977e6..f8dd011541270 100644 --- a/src/vs/workbench/common/component.ts +++ b/src/vs/workbench/common/component.ts @@ -20,7 +20,6 @@ export class Component extends Themable { ) { super(themeService); - this.id = id; this.memento = new Memento(this.id, storageService); this._register(storageService.onWillSaveState(() => { diff --git a/src/vs/workbench/common/contextkeys.ts b/src/vs/workbench/common/contextkeys.ts index a6464aeacd9ce..612f006a88c3e 100644 --- a/src/vs/workbench/common/contextkeys.ts +++ b/src/vs/workbench/common/contextkeys.ts @@ -52,7 +52,7 @@ export const ActiveEditorFirstInGroupContext = new RawContextKey('activ export const ActiveEditorLastInGroupContext = new RawContextKey('activeEditorIsLastInGroup', false, localize('activeEditorIsLastInGroup', "Whether the active editor is the last one in its group")); export const ActiveEditorStickyContext = new RawContextKey('activeEditorIsPinned', false, localize('activeEditorIsPinned', "Whether the active editor is pinned")); export const ActiveEditorReadonlyContext = new RawContextKey('activeEditorIsReadonly', false, localize('activeEditorIsReadonly', "Whether the active editor is read-only")); -export const ActiveCompareEditorOriginalWriteableContext = new RawContextKey('activeCompareEditorOriginalWritable', false, localize('activeCompareEditorOriginalWritable', "Whether the active compare editor has a writable original side")); +export const ActiveCompareEditorCanSwapContext = new RawContextKey('activeCompareEditorCanSwap', false, localize('activeCompareEditorCanSwap', "Whether the active compare editor can swap sides")); export const ActiveEditorCanToggleReadonlyContext = new RawContextKey('activeEditorCanToggleReadonly', true, localize('activeEditorCanToggleReadonly', "Whether the active editor can toggle between being read-only or writeable")); export const ActiveEditorCanRevertContext = new RawContextKey('activeEditorCanRevert', false, localize('activeEditorCanRevert', "Whether the active editor can revert")); export const ActiveEditorCanSplitInGroupContext = new RawContextKey('activeEditorCanSplitInGroup', true); diff --git a/src/vs/workbench/common/contributions.ts b/src/vs/workbench/common/contributions.ts index b96df67819647..aaf1452c25a4a 100644 --- a/src/vs/workbench/common/contributions.ts +++ b/src/vs/workbench/common/contributions.ts @@ -385,7 +385,7 @@ export class WorkbenchContributionsRegistry extends Disposable implements IWorkb } } - if (typeof contribution.id === 'string' || !environmentService.isBuilt /* only log out of sources where we have good ctor names (TODO@bpasero remove when adopted IDs) */) { + if (typeof contribution.id === 'string' || !environmentService.isBuilt /* only log out of sources where we have good ctor names */) { const time = Date.now() - now; if (time > (phase < LifecyclePhase.Restored ? WorkbenchContributionsRegistry.BLOCK_BEFORE_RESTORE_WARN_THRESHOLD : WorkbenchContributionsRegistry.BLOCK_AFTER_RESTORE_WARN_THRESHOLD)) { logService.warn(`Creation of workbench contribution '${contribution.id ?? contribution.ctor.name}' took ${time}ms.`); diff --git a/src/vs/workbench/common/editor.ts b/src/vs/workbench/common/editor.ts index bf6785d967516..bd2c42d85109f 100644 --- a/src/vs/workbench/common/editor.ts +++ b/src/vs/workbench/common/editor.ts @@ -74,7 +74,7 @@ export interface IEditorDescriptor { /** * Instantiates the editor pane using the provided services. */ - instantiate(instantiationService: IInstantiationService): T; + instantiate(instantiationService: IInstantiationService, group: IEditorGroup): T; /** * Whether the descriptor is for the provided editor pane. @@ -106,6 +106,11 @@ export interface IEditorPane extends IComposite { */ readonly onDidChangeSelection?: Event; + /** + * An optional event to notify when the editor inside the pane scrolled + */ + readonly onDidChangeScroll?: Event; + /** * The assigned input of this editor. */ @@ -119,7 +124,7 @@ export interface IEditorPane extends IComposite { /** * The assigned group this editor is showing in. */ - readonly group: IEditorGroup | undefined; + readonly group: IEditorGroup; /** * The minimum width of this editor. @@ -182,6 +187,22 @@ export interface IEditorPane extends IComposite { */ getSelection?(): IEditorPaneSelection | undefined; + /** + * An optional method to return the current scroll position + * of an editor inside the pane. + * + * Clients of this method will typically react to the + * `onDidChangeScroll` event to receive the current + * scroll position as needed. + */ + getScrollPosition?(): IEditorPaneScrollPosition; + + /** + * An optional method to set the current scroll position + * of an editor inside the pane. + */ + setScrollPosition?(scrollPosition: IEditorPaneScrollPosition): void; + /** * Finds out if this editor is visible or not. */ @@ -305,6 +326,29 @@ export function isEditorPaneWithSelection(editorPane: IEditorPane | undefined): return !!candidate && typeof candidate.getSelection === 'function' && !!candidate.onDidChangeSelection; } +export interface IEditorPaneWithScrolling extends IEditorPane { + + readonly onDidChangeScroll: Event; + + getScrollPosition(): IEditorPaneScrollPosition; + + setScrollPosition(position: IEditorPaneScrollPosition): void; +} + +export function isEditorPaneWithScrolling(editorPane: IEditorPane | undefined): editorPane is IEditorPaneWithScrolling { + const candidate = editorPane as IEditorPaneWithScrolling | undefined; + + return !!candidate && typeof candidate.getScrollPosition === 'function' && typeof candidate.setScrollPosition === 'function' && !!candidate.onDidChangeScroll; +} + +/** + * Scroll position of a pane + */ +export interface IEditorPaneScrollPosition { + readonly scrollTop: number; + readonly scrollLeft?: number; +} + /** * Try to retrieve the view state for the editor pane that * has the provided editor input opened, if at all. @@ -327,7 +371,6 @@ export function findViewStateForEditor(input: EditorInput, group: GroupIdentifie */ export interface IVisibleEditorPane extends IEditorPane { readonly input: EditorInput; - readonly group: IEditorGroup; } /** @@ -505,6 +548,11 @@ export interface IResourceMultiDiffEditorInput extends IBaseUntypedEditorInput { * If not set, the resources are dynamically derived from the {@link multiDiffSource}. */ readonly resources?: IResourceDiffEditorInput[]; + + /** + * Whether the editor should be serialized and stored for subsequent sessions. + */ + readonly isTransient?: boolean; } export type IResourceMergeEditorInputSide = (IResourceEditorInput | ITextResourceEditorInput) & { detail?: string }; @@ -559,7 +607,7 @@ export function isResourceDiffEditorInput(editor: unknown): editor is IResourceD return candidate?.original !== undefined && candidate.modified !== undefined; } -export function isResourceDiffListEditorInput(editor: unknown): editor is IResourceMultiDiffEditorInput { +export function isResourceMultiDiffEditorInput(editor: unknown): editor is IResourceMultiDiffEditorInput { if (isEditorInput(editor)) { return false; // make sure to not accidentally match on typed editor inputs } @@ -785,13 +833,7 @@ export const enum EditorInputCapabilities { * Signals that the editor cannot be in a dirty state * and may still have unsaved changes */ - Scratchpad = 1 << 9, - - /** - * Signals that the editor does not support opening in - * auxiliary windows yet. - */ - AuxWindowUnsupported = 1 << 10 + Scratchpad = 1 << 9 } export type IUntypedEditorInput = IResourceEditorInput | ITextResourceEditorInput | IUntitledTextResourceEditorInput | IResourceDiffEditorInput | IResourceMultiDiffEditorInput | IResourceSideBySideEditorInput | IResourceMergeEditorInput; @@ -1128,6 +1170,7 @@ export const enum GroupModelChangeKind { EDITOR_LABEL, EDITOR_CAPABILITIES, EDITOR_PIN, + EDITOR_TRANSIENT, EDITOR_STICKY, EDITOR_DIRTY, EDITOR_WILL_DISPOSE @@ -1305,7 +1348,7 @@ class EditorResourceAccessorImpl { } } - if (isResourceDiffEditorInput(editor) || isResourceDiffListEditorInput(editor) || isResourceSideBySideEditorInput(editor) || isResourceMergeEditorInput(editor)) { + if (isResourceDiffEditorInput(editor) || isResourceMultiDiffEditorInput(editor) || isResourceSideBySideEditorInput(editor) || isResourceMergeEditorInput(editor)) { return undefined; } @@ -1374,7 +1417,7 @@ class EditorResourceAccessorImpl { } } - if (isResourceDiffEditorInput(editor) || isResourceDiffListEditorInput(editor) || isResourceSideBySideEditorInput(editor) || isResourceMergeEditorInput(editor)) { + if (isResourceDiffEditorInput(editor) || isResourceMultiDiffEditorInput(editor) || isResourceSideBySideEditorInput(editor) || isResourceMergeEditorInput(editor)) { return undefined; } diff --git a/src/vs/workbench/common/editor/diffEditorInput.ts b/src/vs/workbench/common/editor/diffEditorInput.ts index 1d6e611875149..f9b9aeb58b287 100644 --- a/src/vs/workbench/common/editor/diffEditorInput.ts +++ b/src/vs/workbench/common/editor/diffEditorInput.ts @@ -14,7 +14,7 @@ import { TextDiffEditorModel } from 'vs/workbench/common/editor/textDiffEditorMo import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; import { IEditorService } from 'vs/workbench/services/editor/common/editorService'; import { shorten } from 'vs/base/common/labels'; -import { IEditorOptions, isResolvedEditorModel } from 'vs/platform/editor/common/editor'; +import { isResolvedEditorModel } from 'vs/platform/editor/common/editor'; interface IDiffEditorInputLabels { name: string; @@ -171,13 +171,13 @@ export class DiffEditorInput extends SideBySideEditorInput implements IDiffEdito } } - override async resolve(options?: IEditorOptions): Promise { + override async resolve(): Promise { // Create Model - we never reuse our cached model if refresh is true because we cannot // decide for the inputs within if the cached model can be reused or not. There may be // inputs that need to be loaded again and thus we always recreate the model and dispose // the previous one - if any. - const resolvedModel = await this.createModel(options); + const resolvedModel = await this.createModel(); this.cachedModel?.dispose(); this.cachedModel = resolvedModel; @@ -193,12 +193,12 @@ export class DiffEditorInput extends SideBySideEditorInput implements IDiffEdito return editorPanes.find(editorPane => editorPane.typeId === TEXT_DIFF_EDITOR_ID); } - private async createModel(options?: IEditorOptions): Promise { + private async createModel(): Promise { // Join resolve call over two inputs and build diff editor model const [originalEditorModel, modifiedEditorModel] = await Promise.all([ - this.original.resolve(options), - this.modified.resolve(options) + this.original.resolve(), + this.modified.resolve() ]); // If both are text models, return textdiffeditor model diff --git a/src/vs/workbench/common/editor/editorGroupModel.ts b/src/vs/workbench/common/editor/editorGroupModel.ts index 497ef8daf46e9..60047f630e320 100644 --- a/src/vs/workbench/common/editor/editorGroupModel.ts +++ b/src/vs/workbench/common/editor/editorGroupModel.ts @@ -22,7 +22,8 @@ const EditorOpenPositioning = { export interface IEditorOpenOptions { readonly pinned?: boolean; - sticky?: boolean; + readonly sticky?: boolean; + readonly transient?: boolean; active?: boolean; readonly index?: number; readonly supportSideBySide?: SideBySideEditor.ANY | SideBySideEditor.BOTH; @@ -180,6 +181,7 @@ export interface IReadonlyEditorGroupModel { isActive(editor: EditorInput | IUntypedEditorInput): boolean; isPinned(editorOrIndex: EditorInput | number): boolean; isSticky(editorOrIndex: EditorInput | number): boolean; + isTransient(editorOrIndex: EditorInput | number): boolean; isFirst(editor: EditorInput, editors?: EditorInput[]): boolean; isLast(editor: EditorInput, editors?: EditorInput[]): boolean; findEditor(editor: EditorInput | null, options?: IMatchEditorOptions): [EditorInput, number /* index */] | undefined; @@ -217,6 +219,7 @@ export class EditorGroupModel extends Disposable implements IEditorGroupModel { private preview: EditorInput | null = null; // editor in preview state private active: EditorInput | null = null; // editor in active state private sticky = -1; // index of first editor in sticky state + private transient = new Set(); // editors in transient state private editorOpenPositioning: ('left' | 'right' | 'first' | 'last') | undefined; private focusRecentEditorAfterClose: boolean | undefined; @@ -295,6 +298,7 @@ export class EditorGroupModel extends Disposable implements IEditorGroupModel { openEditor(candidate: EditorInput, options?: IEditorOpenOptions): IEditorOpenResult { const makeSticky = options?.sticky || (typeof options?.index === 'number' && this.isSticky(options.index)); const makePinned = options?.pinned || options?.sticky; + const makeTransient = !!options?.transient; const makeActive = options?.active || !this.activeEditor || (!makePinned && this.matches(this.preview, this.activeEditor)); const existingEditorAndIndex = this.findEditor(candidate, options); @@ -365,6 +369,11 @@ export class EditorGroupModel extends Disposable implements IEditorGroupModel { this.splice(targetIndex, false, newEditor); } + // Handle transient + if (makeTransient) { + this.doSetTransient(newEditor, targetIndex, true); + } + // Handle preview if (!makePinned) { @@ -407,6 +416,9 @@ export class EditorGroupModel extends Disposable implements IEditorGroupModel { else { const [existingEditor, existingEditorIndex] = existingEditorAndIndex; + // Update transient (existing editors do not turn transient if they were not before) + this.doSetTransient(existingEditor, existingEditorIndex, makeTransient === false ? false : this.isTransient(existingEditor)); + // Pin it if (makePinned) { this.doPin(existingEditor, existingEditorIndex); @@ -563,6 +575,9 @@ export class EditorGroupModel extends Disposable implements IEditorGroupModel { this.preview = null; } + // Remove from transient + this.transient.delete(editor); + // Remove from arrays this.splice(index, true); @@ -711,6 +726,9 @@ export class EditorGroupModel extends Disposable implements IEditorGroupModel { return; // can only pin a preview editor } + // Clear Transient + this.setTransient(editor, false); + // Convert the preview editor to be a pinned editor this.preview = null; @@ -860,6 +878,62 @@ export class EditorGroupModel extends Disposable implements IEditorGroupModel { return index <= this.sticky; } + setTransient(candidate: EditorInput, transient: boolean): EditorInput | undefined { + if (!transient && this.transient.size === 0) { + return; // no transient editor + } + + const res = this.findEditor(candidate); + if (!res) { + return; // not found + } + + const [editor, editorIndex] = res; + + this.doSetTransient(editor, editorIndex, transient); + + return editor; + } + + private doSetTransient(editor: EditorInput, editorIndex: number, transient: boolean): void { + if (transient) { + if (this.transient.has(editor)) { + return; + } + + this.transient.add(editor); + } else { + if (!this.transient.has(editor)) { + return; + } + + this.transient.delete(editor); + } + + // Event + const event: IGroupEditorChangeEvent = { + kind: GroupModelChangeKind.EDITOR_TRANSIENT, + editor, + editorIndex + }; + this._onDidModelChange.fire(event); + } + + isTransient(editorOrIndex: EditorInput | number): boolean { + if (this.transient.size === 0) { + return false; // no transient editor + } + + let editor: EditorInput | undefined; + if (typeof editorOrIndex === 'number') { + editor = this.editors[editorOrIndex]; + } else { + editor = this.findEditor(editorOrIndex)?.[0]; + } + + return !!editor && this.transient.has(editor); + } + private splice(index: number, del: boolean, editor?: EditorInput): void { const editorToDeleteOrReplace = this.editors[index]; @@ -1033,7 +1107,7 @@ export class EditorGroupModel extends Disposable implements IEditorGroupModel { const editorSerializer = registry.getEditorSerializer(editor); if (editorSerializer) { - const value = editorSerializer.serialize(editor); + const value = editorSerializer.canSerialize(editor) ? editorSerializer.serialize(editor) : undefined; // Editor can be serialized if (typeof value === 'string') { @@ -1124,6 +1198,8 @@ export class EditorGroupModel extends Disposable implements IEditorGroupModel { dispose(Array.from(this.editorListeners)); this.editorListeners.clear(); + this.transient.clear(); + super.dispose(); } } diff --git a/src/vs/workbench/common/editor/editorInput.ts b/src/vs/workbench/common/editor/editorInput.ts index 9bce607e38701..d6a10e8c35eb5 100644 --- a/src/vs/workbench/common/editor/editorInput.ts +++ b/src/vs/workbench/common/editor/editorInput.ts @@ -5,7 +5,6 @@ import { Emitter } from 'vs/base/common/event'; import { URI } from 'vs/base/common/uri'; -import { IEditorOptions } from 'vs/platform/editor/common/editor'; import { firstOrDefault } from 'vs/base/common/arrays'; import { EditorInputCapabilities, Verbosity, GroupIdentifier, ISaveOptions, IRevertOptions, IMoveResult, IEditorDescriptor, IEditorPane, IUntypedEditorInput, EditorResourceAccessor, AbstractEditorInput, isEditorInput, IEditorIdentifier } from 'vs/workbench/common/editor'; import { isEqual } from 'vs/base/common/resources'; @@ -235,7 +234,7 @@ export abstract class EditorInput extends AbstractEditorInput { * The `options` parameter are passed down from the editor when the * input is resolved as part of it. */ - async resolve(options?: IEditorOptions): Promise { + async resolve(): Promise { return null; } @@ -289,6 +288,19 @@ export abstract class EditorInput extends AbstractEditorInput { return this; } + /** + * Indicates if this editor can be moved to another group. By default + * editors can freely be moved around groups. If an editor cannot be + * moved, a message should be returned to show to the user. + * + * @returns `true` if the editor can be moved to the target group, or + * a string with a message to show to the user if the editor cannot be + * moved. + */ + canMove(sourceGroup: GroupIdentifier, targetGroup: GroupIdentifier): true | string { + return true; + } + /** * Returns if the other object matches this input. */ diff --git a/src/vs/workbench/common/editor/filteredEditorGroupModel.ts b/src/vs/workbench/common/editor/filteredEditorGroupModel.ts index 7b427fe5dedbb..390b19874c8d1 100644 --- a/src/vs/workbench/common/editor/filteredEditorGroupModel.ts +++ b/src/vs/workbench/common/editor/filteredEditorGroupModel.ts @@ -38,6 +38,7 @@ abstract class FilteredEditorGroupModel extends Disposable implements IReadonlyE get previewEditor(): EditorInput | null { return this.model.previewEditor && this.filter(this.model.previewEditor) ? this.model.previewEditor : null; } isPinned(editorOrIndex: EditorInput | number): boolean { return this.model.isPinned(editorOrIndex); } + isTransient(editorOrIndex: EditorInput | number): boolean { return this.model.isTransient(editorOrIndex); } isSticky(editorOrIndex: EditorInput | number): boolean { return this.model.isSticky(editorOrIndex); } isActive(editor: EditorInput | IUntypedEditorInput): boolean { return this.model.isActive(editor); } diff --git a/src/vs/workbench/common/editor/resourceEditorInput.ts b/src/vs/workbench/common/editor/resourceEditorInput.ts index 6f5af0deee3a1..7bc68f5e3661d 100644 --- a/src/vs/workbench/common/editor/resourceEditorInput.ts +++ b/src/vs/workbench/common/editor/resourceEditorInput.ts @@ -13,6 +13,7 @@ import { IFilesConfigurationService } from 'vs/workbench/services/filesConfigura import { IMarkdownString } from 'vs/base/common/htmlContent'; import { isConfigured } from 'vs/platform/configuration/common/configuration'; import { ITextResourceConfigurationService } from 'vs/editor/common/services/textResourceConfiguration'; +import { ICustomEditorLabelService } from 'vs/workbench/services/editor/common/customEditorLabelService'; /** * The base class for all editor inputs that open resources. @@ -46,7 +47,8 @@ export abstract class AbstractResourceEditorInput extends EditorInput implements @ILabelService protected readonly labelService: ILabelService, @IFileService protected readonly fileService: IFileService, @IFilesConfigurationService protected readonly filesConfigurationService: IFilesConfigurationService, - @ITextResourceConfigurationService protected readonly textResourceConfigurationService: ITextResourceConfigurationService + @ITextResourceConfigurationService protected readonly textResourceConfigurationService: ITextResourceConfigurationService, + @ICustomEditorLabelService protected readonly customEditorLabelService: ICustomEditorLabelService ) { super(); @@ -61,6 +63,7 @@ export abstract class AbstractResourceEditorInput extends EditorInput implements this._register(this.labelService.onDidChangeFormatters(e => this.onLabelEvent(e.scheme))); this._register(this.fileService.onDidChangeFileSystemProviderRegistrations(e => this.onLabelEvent(e.scheme))); this._register(this.fileService.onDidChangeFileSystemProviderCapabilities(e => this.onLabelEvent(e.scheme))); + this._register(this.customEditorLabelService.onDidChange(() => this.updateLabel())); } private onLabelEvent(scheme: string): void { @@ -95,7 +98,7 @@ export abstract class AbstractResourceEditorInput extends EditorInput implements private _name: string | undefined = undefined; override getName(): string { if (typeof this._name !== 'string') { - this._name = this.labelService.getUriBasenameLabel(this._preferredResource); + this._name = this.customEditorLabelService.getName(this._preferredResource) ?? this.labelService.getUriBasenameLabel(this._preferredResource); } return this._name; diff --git a/src/vs/workbench/common/editor/sideBySideEditorInput.ts b/src/vs/workbench/common/editor/sideBySideEditorInput.ts index 88585cb807ca1..0c6e63d43ce82 100644 --- a/src/vs/workbench/common/editor/sideBySideEditorInput.ts +++ b/src/vs/workbench/common/editor/sideBySideEditorInput.ts @@ -9,7 +9,7 @@ import { URI } from 'vs/base/common/uri'; import { localize } from 'vs/nls'; import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; import { Registry } from 'vs/platform/registry/common/platform'; -import { EditorInputCapabilities, GroupIdentifier, ISaveOptions, IRevertOptions, EditorExtensions, IEditorFactoryRegistry, IEditorSerializer, ISideBySideEditorInput, IUntypedEditorInput, isResourceSideBySideEditorInput, isDiffEditorInput, isResourceDiffEditorInput, IResourceSideBySideEditorInput, findViewStateForEditor, IMoveResult, isEditorInput, isResourceEditorInput, Verbosity, isResourceMergeEditorInput, isResourceDiffListEditorInput } from 'vs/workbench/common/editor'; +import { EditorInputCapabilities, GroupIdentifier, ISaveOptions, IRevertOptions, EditorExtensions, IEditorFactoryRegistry, IEditorSerializer, ISideBySideEditorInput, IUntypedEditorInput, isResourceSideBySideEditorInput, isDiffEditorInput, isResourceDiffEditorInput, IResourceSideBySideEditorInput, findViewStateForEditor, IMoveResult, isEditorInput, isResourceEditorInput, Verbosity, isResourceMergeEditorInput, isResourceMultiDiffEditorInput } from 'vs/workbench/common/editor'; import { EditorInput, IUntypedEditorOptions } from 'vs/workbench/common/editor/editorInput'; import { IEditorService } from 'vs/workbench/services/editor/common/editorService'; @@ -210,7 +210,7 @@ export class SideBySideEditorInput extends EditorInput implements ISideBySideEdi return new SideBySideEditorInput(this.preferredName, this.preferredDescription, primarySaveResult, primarySaveResult, this.editorService); } - if (!isResourceDiffEditorInput(primarySaveResult) && !isResourceDiffListEditorInput(primarySaveResult) && !isResourceSideBySideEditorInput(primarySaveResult) && !isResourceMergeEditorInput(primarySaveResult)) { + if (!isResourceDiffEditorInput(primarySaveResult) && !isResourceMultiDiffEditorInput(primarySaveResult) && !isResourceSideBySideEditorInput(primarySaveResult) && !isResourceMergeEditorInput(primarySaveResult)) { return { primary: primarySaveResult, secondary: primarySaveResult, @@ -279,7 +279,7 @@ export class SideBySideEditorInput extends EditorInput implements ISideBySideEdi if ( primaryResourceEditorInput && secondaryResourceEditorInput && !isResourceDiffEditorInput(primaryResourceEditorInput) && !isResourceDiffEditorInput(secondaryResourceEditorInput) && - !isResourceDiffListEditorInput(primaryResourceEditorInput) && !isResourceDiffListEditorInput(secondaryResourceEditorInput) && + !isResourceMultiDiffEditorInput(primaryResourceEditorInput) && !isResourceMultiDiffEditorInput(secondaryResourceEditorInput) && !isResourceSideBySideEditorInput(primaryResourceEditorInput) && !isResourceSideBySideEditorInput(secondaryResourceEditorInput) && !isResourceMergeEditorInput(primaryResourceEditorInput) && !isResourceMergeEditorInput(secondaryResourceEditorInput) ) { @@ -362,8 +362,8 @@ export abstract class AbstractSideBySideEditorInputSerializer implements IEditor const serializedEditorInput: ISerializedSideBySideEditorInput = { name: input.getPreferredName(), description: input.getPreferredDescription(), - primarySerialized: primarySerialized, - secondarySerialized: secondarySerialized, + primarySerialized, + secondarySerialized, primaryTypeId: input.primary.typeId, secondaryTypeId: input.secondary.typeId }; diff --git a/src/vs/workbench/common/editor/textResourceEditorInput.ts b/src/vs/workbench/common/editor/textResourceEditorInput.ts index 8416d2cc2493c..9635fa3f67046 100644 --- a/src/vs/workbench/common/editor/textResourceEditorInput.ts +++ b/src/vs/workbench/common/editor/textResourceEditorInput.ts @@ -19,6 +19,7 @@ import { IReference } from 'vs/base/common/lifecycle'; import { createTextBufferFactory } from 'vs/editor/common/model/textModel'; import { IFilesConfigurationService } from 'vs/workbench/services/filesConfiguration/common/filesConfigurationService'; import { ITextResourceConfigurationService } from 'vs/editor/common/services/textResourceConfiguration'; +import { ICustomEditorLabelService } from 'vs/workbench/services/editor/common/customEditorLabelService'; /** * The base class for all editor inputs that open in text editors. @@ -33,9 +34,10 @@ export abstract class AbstractTextResourceEditorInput extends AbstractResourceEd @ILabelService labelService: ILabelService, @IFileService fileService: IFileService, @IFilesConfigurationService filesConfigurationService: IFilesConfigurationService, - @ITextResourceConfigurationService textResourceConfigurationService: ITextResourceConfigurationService + @ITextResourceConfigurationService textResourceConfigurationService: ITextResourceConfigurationService, + @ICustomEditorLabelService customEditorLabelService: ICustomEditorLabelService ) { - super(resource, preferredResource, labelService, fileService, filesConfigurationService, textResourceConfigurationService); + super(resource, preferredResource, labelService, fileService, filesConfigurationService, textResourceConfigurationService, customEditorLabelService); } override save(group: GroupIdentifier, options?: ITextFileSaveOptions): Promise { @@ -107,9 +109,10 @@ export class TextResourceEditorInput extends AbstractTextResourceEditorInput imp @IFileService fileService: IFileService, @ILabelService labelService: ILabelService, @IFilesConfigurationService filesConfigurationService: IFilesConfigurationService, - @ITextResourceConfigurationService textResourceConfigurationService: ITextResourceConfigurationService + @ITextResourceConfigurationService textResourceConfigurationService: ITextResourceConfigurationService, + @ICustomEditorLabelService customEditorLabelService: ICustomEditorLabelService ) { - super(resource, undefined, editorService, textFileService, labelService, fileService, filesConfigurationService, textResourceConfigurationService); + super(resource, undefined, editorService, textFileService, labelService, fileService, filesConfigurationService, textResourceConfigurationService, customEditorLabelService); } override getName(): string { diff --git a/src/vs/workbench/common/theme.ts b/src/vs/workbench/common/theme.ts index febf755414e4d..9a0f4dda2c576 100644 --- a/src/vs/workbench/common/theme.ts +++ b/src/vs/workbench/common/theme.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { localize } from 'vs/nls'; -import { registerColor, editorBackground, contrastBorder, transparent, editorWidgetBackground, textLinkForeground, lighten, darken, focusBorder, activeContrastBorder, editorWidgetForeground, editorErrorForeground, editorWarningForeground, editorInfoForeground, treeIndentGuidesStroke, errorForeground, listActiveSelectionBackground, listActiveSelectionForeground, editorForeground, toolbarHoverBackground, inputBorder, widgetBorder } from 'vs/platform/theme/common/colorRegistry'; +import { registerColor, editorBackground, contrastBorder, transparent, editorWidgetBackground, textLinkForeground, lighten, darken, focusBorder, activeContrastBorder, editorWidgetForeground, editorErrorForeground, editorWarningForeground, editorInfoForeground, treeIndentGuidesStroke, errorForeground, listActiveSelectionBackground, listActiveSelectionForeground, editorForeground, toolbarHoverBackground, inputBorder, widgetBorder, scrollbarShadow } from 'vs/platform/theme/common/colorRegistry'; import { IColorTheme } from 'vs/platform/theme/common/themeService'; import { Color } from 'vs/base/common/color'; import { ColorScheme } from 'vs/platform/theme/common/theme'; @@ -411,6 +411,27 @@ export const PANEL_SECTION_BORDER = registerColor('panelSection.border', { hcLight: PANEL_BORDER }, localize('panelSectionBorder', "Panel section border color used when multiple views are stacked horizontally in the panel. Panels are shown below the editor area and contain views like output and integrated terminal. Panel sections are views nested within the panels.")); +export const PANEL_STICKY_SCROLL_BACKGROUND = registerColor('panelStickyScroll.background', { + dark: PANEL_BACKGROUND, + light: PANEL_BACKGROUND, + hcDark: PANEL_BACKGROUND, + hcLight: PANEL_BACKGROUND +}, localize('panelStickyScrollBackground', "Background color of sticky scroll in the panel.")); + +export const PANEL_STICKY_SCROLL_BORDER = registerColor('panelStickyScroll.border', { + dark: null, + light: null, + hcDark: null, + hcLight: null +}, localize('panelStickyScrollBorder', "Border color of sticky scroll in the panel.")); + +export const PANEL_STICKY_SCROLL_SHADOW = registerColor('panelStickyScroll.shadow', { + dark: scrollbarShadow, + light: scrollbarShadow, + hcDark: scrollbarShadow, + hcLight: scrollbarShadow +}, localize('panelStickyScrollShadow', "Shadow color of sticky scroll in the panel.")); + // < --- Output Editor --> const OUTPUT_VIEW_BACKGROUND = registerColor('outputView.background', { @@ -700,28 +721,42 @@ export const ACTIVITY_BAR_TOP_FOREGROUND = registerColor('activityBarTop.foregro light: '#424242', hcDark: Color.white, hcLight: editorForeground -}, localize('activityBarTop', "Active foreground color of the item in the Activity bar when it is on top. The activity allows to switch between views of the side bar.")); +}, localize('activityBarTop', "Active foreground color of the item in the Activity bar when it is on top / bottom. The activity allows to switch between views of the side bar.")); export const ACTIVITY_BAR_TOP_ACTIVE_BORDER = registerColor('activityBarTop.activeBorder', { dark: ACTIVITY_BAR_TOP_FOREGROUND, light: ACTIVITY_BAR_TOP_FOREGROUND, hcDark: contrastBorder, hcLight: '#B5200D' -}, localize('activityBarTopActiveFocusBorder', "Focus border color for the active item in the Activity bar when it is on top. The activity allows to switch between views of the side bar.")); +}, localize('activityBarTopActiveFocusBorder', "Focus border color for the active item in the Activity bar when it is on top / bottom. The activity allows to switch between views of the side bar.")); + +export const ACTIVITY_BAR_TOP_ACTIVE_BACKGROUND = registerColor('activityBarTop.activeBackground', { + dark: null, + light: null, + hcDark: null, + hcLight: null +}, localize('activityBarTopActiveBackground', "Background color for the active item in the Activity bar when it is on top / bottom. The activity allows to switch between views of the side bar.")); export const ACTIVITY_BAR_TOP_INACTIVE_FOREGROUND = registerColor('activityBarTop.inactiveForeground', { dark: transparent(ACTIVITY_BAR_TOP_FOREGROUND, 0.6), light: transparent(ACTIVITY_BAR_TOP_FOREGROUND, 0.75), hcDark: Color.white, hcLight: editorForeground -}, localize('activityBarTopInActiveForeground', "Inactive foreground color of the item in the Activity bar when it is on top. The activity allows to switch between views of the side bar.")); +}, localize('activityBarTopInActiveForeground', "Inactive foreground color of the item in the Activity bar when it is on top / bottom. The activity allows to switch between views of the side bar.")); export const ACTIVITY_BAR_TOP_DRAG_AND_DROP_BORDER = registerColor('activityBarTop.dropBorder', { dark: ACTIVITY_BAR_TOP_FOREGROUND, light: ACTIVITY_BAR_TOP_FOREGROUND, hcDark: ACTIVITY_BAR_TOP_FOREGROUND, hcLight: ACTIVITY_BAR_TOP_FOREGROUND -}, localize('activityBarTopDragAndDropBorder', "Drag and drop feedback color for the items in the Activity bar when it is on top. The activity allows to switch between views of the side bar.")); +}, localize('activityBarTopDragAndDropBorder', "Drag and drop feedback color for the items in the Activity bar when it is on top / bottom. The activity allows to switch between views of the side bar.")); + +export const ACTIVITY_BAR_TOP_BACKGROUND = registerColor('activityBarTop.background', { + dark: null, + light: null, + hcDark: null, + hcLight: null, +}, localize('activityBarTopBackground', "Background color of the activity bar when set to top / bottom.")); // < --- Profiles --- > @@ -836,6 +871,13 @@ export const SIDE_BAR_BORDER = registerColor('sideBar.border', { hcLight: contrastBorder }, localize('sideBarBorder', "Side bar border color on the side separating to the editor. The side bar is the container for views like explorer and search.")); +export const SIDE_BAR_TITLE_BACKGROUND = registerColor('sideBarTitle.background', { + dark: SIDE_BAR_BACKGROUND, + light: SIDE_BAR_BACKGROUND, + hcDark: SIDE_BAR_BACKGROUND, + hcLight: SIDE_BAR_BACKGROUND +}, localize('sideBarTitleBackground', "Side bar title background color. The side bar is the container for views like explorer and search.")); + export const SIDE_BAR_TITLE_FOREGROUND = registerColor('sideBarTitle.foreground', { dark: SIDE_BAR_FOREGROUND, light: SIDE_BAR_FOREGROUND, @@ -871,6 +913,33 @@ export const SIDE_BAR_SECTION_HEADER_BORDER = registerColor('sideBarSectionHeade hcLight: contrastBorder }, localize('sideBarSectionHeaderBorder', "Side bar section header border color. The side bar is the container for views like explorer and search. Side bar sections are views nested within the side bar.")); +export const ACTIVITY_BAR_TOP_BORDER = registerColor('sideBarActivityBarTop.border', { + dark: SIDE_BAR_SECTION_HEADER_BORDER, + light: SIDE_BAR_SECTION_HEADER_BORDER, + hcDark: SIDE_BAR_SECTION_HEADER_BORDER, + hcLight: SIDE_BAR_SECTION_HEADER_BORDER +}, localize('sideBarActivityBarTopBorder', "Border color between the activity bar at the top/bottom and the views.")); + +export const SIDE_BAR_STICKY_SCROLL_BACKGROUND = registerColor('sideBarStickyScroll.background', { + dark: SIDE_BAR_BACKGROUND, + light: SIDE_BAR_BACKGROUND, + hcDark: SIDE_BAR_BACKGROUND, + hcLight: SIDE_BAR_BACKGROUND +}, localize('sideBarStickyScrollBackground', "Background color of sticky scroll in the side bar.")); + +export const SIDE_BAR_STICKY_SCROLL_BORDER = registerColor('sideBarStickyScroll.border', { + dark: null, + light: null, + hcDark: null, + hcLight: null +}, localize('sideBarStickyScrollBorder', "Border color of sticky scroll in the side bar.")); + +export const SIDE_BAR_STICKY_SCROLL_SHADOW = registerColor('sideBarStickyScroll.shadow', { + dark: scrollbarShadow, + light: scrollbarShadow, + hcDark: scrollbarShadow, + hcLight: scrollbarShadow +}, localize('sideBarStickyScrollShadow', "Shadow color of sticky scroll in the side bar.")); // < --- Title Bar --- > diff --git a/src/vs/workbench/common/views.ts b/src/vs/workbench/common/views.ts index 7288219657eea..8a2dca5944c87 100644 --- a/src/vs/workbench/common/views.ts +++ b/src/vs/workbench/common/views.ts @@ -284,6 +284,8 @@ export interface IViewDescriptor { readonly containerTitle?: string; + readonly singleViewPaneContainerTitle?: string; + // Applies only to newly created views readonly hideByDefault?: boolean; diff --git a/src/vs/workbench/contrib/accessibility/browser/accessibility.contribution.ts b/src/vs/workbench/contrib/accessibility/browser/accessibility.contribution.ts index 4ec8d2e9b16d2..c55340d0f75b6 100644 --- a/src/vs/workbench/contrib/accessibility/browser/accessibility.contribution.ts +++ b/src/vs/workbench/contrib/accessibility/browser/accessibility.contribution.ts @@ -10,13 +10,17 @@ import { LifecyclePhase } from 'vs/workbench/services/lifecycle/common/lifecycle import { Registry } from 'vs/platform/registry/common/platform'; import { IAccessibleViewService, AccessibleViewService } from 'vs/workbench/contrib/accessibility/browser/accessibleView'; import { UnfocusedViewDimmingContribution } from 'vs/workbench/contrib/accessibility/browser/unfocusedViewDimmingContribution'; -import { HoverAccessibleViewContribution, InlineCompletionsAccessibleViewContribution, NotificationAccessibleViewContribution } from 'vs/workbench/contrib/accessibility/browser/accessibilityContributions'; +import { HoverAccessibleViewContribution, InlineCompletionsAccessibleViewContribution, NotificationAccessibleViewContribution } from 'vs/workbench/contrib/accessibility/browser/accessibleViewContributions'; import { AccessibilityStatus } from 'vs/workbench/contrib/accessibility/browser/accessibilityStatus'; import { EditorAccessibilityHelpContribution } from 'vs/workbench/contrib/accessibility/browser/editorAccessibilityHelp'; -import { SaveAudioCueContribution } from 'vs/workbench/contrib/accessibility/browser/saveAudioCue'; +import { SaveAccessibilitySignalContribution } from 'vs/workbench/contrib/accessibilitySignals/browser/saveAccessibilitySignal'; import { CommentsAccessibilityHelpContribution } from 'vs/workbench/contrib/comments/browser/commentsAccessibility'; +import { DiffEditorActiveAnnouncementContribution } from 'vs/workbench/contrib/accessibilitySignals/browser/openDiffEditorAnnouncement'; +import { SpeechAccessibilitySignalContribution } from 'vs/workbench/contrib/speech/browser/speechAccessibilitySignal'; +import { registerAudioCueConfiguration } from 'vs/workbench/contrib/accessibility/browser/audioCueConfiguration'; registerAccessibilityConfiguration(); +registerAudioCueConfiguration(); registerSingleton(IAccessibleViewService, AccessibleViewService, InstantiationType.Delayed); const workbenchRegistry = Registry.as(WorkbenchExtensions.Workbench); @@ -29,5 +33,7 @@ workbenchRegistry.registerWorkbenchContribution(NotificationAccessibleViewContri workbenchRegistry.registerWorkbenchContribution(InlineCompletionsAccessibleViewContribution, LifecyclePhase.Eventually); registerWorkbenchContribution2(AccessibilityStatus.ID, AccessibilityStatus, WorkbenchPhase.BlockRestore); -registerWorkbenchContribution2(SaveAudioCueContribution.ID, SaveAudioCueContribution, WorkbenchPhase.BlockRestore); +registerWorkbenchContribution2(SaveAccessibilitySignalContribution.ID, SaveAccessibilitySignalContribution, WorkbenchPhase.AfterRestored); +registerWorkbenchContribution2(SpeechAccessibilitySignalContribution.ID, SpeechAccessibilitySignalContribution, WorkbenchPhase.AfterRestored); +registerWorkbenchContribution2(DiffEditorActiveAnnouncementContribution.ID, DiffEditorActiveAnnouncementContribution, WorkbenchPhase.AfterRestored); registerWorkbenchContribution2(DynamicSpeechAccessibilityConfiguration.ID, DynamicSpeechAccessibilityConfiguration, WorkbenchPhase.AfterRestored); diff --git a/src/vs/workbench/contrib/accessibility/browser/accessibilityConfiguration.ts b/src/vs/workbench/contrib/accessibility/browser/accessibilityConfiguration.ts index 4e5b465ff71c8..29495275ab227 100644 --- a/src/vs/workbench/contrib/accessibility/browser/accessibilityConfiguration.ts +++ b/src/vs/workbench/contrib/accessibility/browser/accessibilityConfiguration.ts @@ -4,12 +4,12 @@ *--------------------------------------------------------------------------------------------*/ import { localize } from 'vs/nls'; -import { ConfigurationScope, Extensions, IConfigurationNode, IConfigurationRegistry } from 'vs/platform/configuration/common/configurationRegistry'; +import { ConfigurationScope, Extensions, IConfigurationNode, IConfigurationPropertySchema, IConfigurationRegistry } from 'vs/platform/configuration/common/configurationRegistry'; import { Registry } from 'vs/platform/registry/common/platform'; import { RawContextKey } from 'vs/platform/contextkey/common/contextkey'; -import { workbenchConfigurationNodeBase } from 'vs/workbench/common/configuration'; -import { AccessibilityAlertSettingId } from 'vs/platform/audioCues/browser/audioCueService'; -import { ISpeechService } from 'vs/workbench/contrib/speech/common/speechService'; +import { workbenchConfigurationNodeBase, Extensions as WorkbenchExtensions, IConfigurationMigrationRegistry, ConfigurationKeyValuePairs } from 'vs/workbench/common/configuration'; +import { AccessibilityAlertSettingId, AccessibilitySignal } from 'vs/platform/accessibilitySignal/browser/accessibilitySignalService'; +import { ISpeechService, SPEECH_LANGUAGES, SPEECH_LANGUAGE_CONFIG } from 'vs/workbench/contrib/speech/common/speechService'; import { Disposable } from 'vs/base/common/lifecycle'; import { IWorkbenchContribution } from 'vs/workbench/common/contributions'; import { Event } from 'vs/base/common/event'; @@ -21,6 +21,8 @@ export const accessibleViewVerbosityEnabled = new RawContextKey('access export const accessibleViewGoToSymbolSupported = new RawContextKey('accessibleViewGoToSymbolSupported', false, true); export const accessibleViewOnLastLine = new RawContextKey('accessibleViewOnLastLine', false, true); export const accessibleViewCurrentProviderId = new RawContextKey('accessibleViewCurrentProviderId', undefined, undefined); +export const accessibleViewInCodeBlock = new RawContextKey('accessibleViewInCodeBlock', undefined, undefined); +export const accessibleViewContainsCodeBlocks = new RawContextKey('accessibleViewContainsCodeBlocks', undefined, undefined); /** * Miscellaneous settings tagged with accessibility and implemented in the accessibility contrib but @@ -44,6 +46,7 @@ export const enum AccessibilityVerbositySettingId { DiffEditor = 'accessibility.verbosity.diffEditor', Chat = 'accessibility.verbosity.panelChat', InlineChat = 'accessibility.verbosity.inlineChat', + TerminalChat = 'accessibility.verbosity.terminalChat', InlineCompletions = 'accessibility.verbosity.inlineCompletions', KeybindingsEditor = 'accessibility.verbosity.keybindingsEditor', Notebook = 'accessibility.verbosity.notebook', @@ -51,11 +54,13 @@ export const enum AccessibilityVerbositySettingId { Hover = 'accessibility.verbosity.hover', Notification = 'accessibility.verbosity.notification', EmptyEditorHint = 'accessibility.verbosity.emptyEditorHint', - Comments = 'accessibility.verbosity.comments' + Comments = 'accessibility.verbosity.comments', + DiffEditorActive = 'accessibility.verbosity.diffEditorActive' } export const enum AccessibleViewProviderId { Terminal = 'terminal', + TerminalChat = 'terminal-chat', TerminalHelp = 'terminal-help', DiffEditor = 'diffEditor', Chat = 'panelChat', @@ -70,11 +75,18 @@ export const enum AccessibleViewProviderId { Comments = 'comments' } -const baseProperty: object = { +const baseVerbosityProperty: IConfigurationPropertySchema = { type: 'boolean', default: true, tags: ['accessibility'] }; +const markdownDeprecationMessage = localize('accessibility.announcement.deprecationMessage', "This setting is deprecated. Use the `signals` settings instead."); +const baseAlertProperty: IConfigurationPropertySchema = { + type: 'boolean', + default: true, + tags: ['accessibility'], + markdownDeprecationMessage +}; export const accessibilityConfigurationNodeBase = Object.freeze({ id: 'accessibility', @@ -82,183 +94,582 @@ export const accessibilityConfigurationNodeBase = Object.freeze this.updateConfiguration())); + this._register(Event.runAndSubscribe(speechService.onDidChangeHasSpeechProvider, () => this.updateConfiguration())); } private updateConfiguration(): void { @@ -317,6 +729,11 @@ export class DynamicSpeechAccessibilityConfiguration extends Disposable implemen return; // these settings require a speech provider } + const languages = this.getLanguages(); + const languagesSorted = Object.keys(languages).sort((langA, langB) => { + return languages[langA].name.localeCompare(languages[langB].name); + }); + const registry = Registry.as(Extensions.Configuration); registry.registerConfiguration({ ...accessibilityConfigurationNodeBase, @@ -327,8 +744,83 @@ export class DynamicSpeechAccessibilityConfiguration extends Disposable implemen 'default': SpeechTimeoutDefault, 'minimum': 0, 'tags': ['accessibility'] + }, + [AccessibilityVoiceSettingId.SpeechLanguage]: { + 'markdownDescription': localize('voice.speechLanguage', "The language that voice speech recognition should recognize. Select `auto` to use the configured display language if possible. Note that not all display languages maybe supported by speech recognition"), + 'type': 'string', + 'enum': languagesSorted, + 'default': 'auto', + 'tags': ['accessibility'], + 'enumDescriptions': languagesSorted.map(key => languages[key].name), + 'enumItemLabels': languagesSorted.map(key => languages[key].name) } } }); } + + private getLanguages(): { [locale: string]: { name: string } } { + return { + ['auto']: { + name: localize('speechLanguage.auto', "Auto (Use Display Language)") + }, + ...SPEECH_LANGUAGES + }; + } } + +Registry.as(WorkbenchExtensions.ConfigurationMigration) + .registerConfigurationMigrations([{ + key: 'audioCues.volume', + migrateFn: (value, accessor) => { + return [ + ['accessibility.signals.sounds.volume', { value }], + ['audioCues.volume', { value: undefined }] + ]; + } + }]); + +Registry.as(WorkbenchExtensions.ConfigurationMigration) + .registerConfigurationMigrations([{ + key: 'audioCues.debouncePositionChanges', + migrateFn: (value, accessor) => { + return [ + ['accessibility.signals.debouncePositionChanges', { value }], + ['audioCues.debouncePositionChanges', { value: undefined }] + ]; + } + }]); + +Registry.as(WorkbenchExtensions.ConfigurationMigration) + .registerConfigurationMigrations(AccessibilitySignal.allAccessibilitySignals.map(item => ({ + key: item.legacySoundSettingsKey, + migrateFn: (sound, accessor) => { + const configurationKeyValuePairs: ConfigurationKeyValuePairs = []; + const legacyAnnouncementSettingsKey = item.legacyAnnouncementSettingsKey; + let announcement: string | undefined; + if (legacyAnnouncementSettingsKey) { + announcement = accessor(legacyAnnouncementSettingsKey) ?? undefined; + if (announcement !== undefined && typeof announcement !== 'string') { + announcement = announcement ? 'auto' : 'off'; + } + } + configurationKeyValuePairs.push([`${item.legacySoundSettingsKey}`, { value: undefined }]); + configurationKeyValuePairs.push([`${item.settingsKey}`, { value: announcement !== undefined ? { announcement, sound } : { sound } }]); + return configurationKeyValuePairs; + } + }))); + +Registry.as(WorkbenchExtensions.ConfigurationMigration) + .registerConfigurationMigrations(AccessibilitySignal.allAccessibilitySignals.filter(i => !!i.legacyAnnouncementSettingsKey).map(item => ({ + key: item.legacyAnnouncementSettingsKey!, + migrateFn: (announcement, accessor) => { + const configurationKeyValuePairs: ConfigurationKeyValuePairs = []; + const sound = accessor(item.settingsKey)?.sound || accessor(item.legacySoundSettingsKey); + if (announcement !== undefined && typeof announcement !== 'string') { + announcement = announcement ? 'auto' : 'off'; + } + configurationKeyValuePairs.push([`${item.settingsKey}`, { value: announcement !== undefined ? { announcement, sound } : { sound } }]); + configurationKeyValuePairs.push([`${item.legacyAnnouncementSettingsKey}`, { value: undefined }]); + configurationKeyValuePairs.push([`${item.legacySoundSettingsKey}`, { value: undefined }]); + return configurationKeyValuePairs; + } + }))); diff --git a/src/vs/workbench/contrib/accessibility/browser/accessibilityStatus.ts b/src/vs/workbench/contrib/accessibility/browser/accessibilityStatus.ts index ce585d71d3b38..5d3949ad1b2ab 100644 --- a/src/vs/workbench/contrib/accessibility/browser/accessibilityStatus.ts +++ b/src/vs/workbench/contrib/accessibility/browser/accessibilityStatus.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { Disposable, DisposableStore, MutableDisposable, toDisposable } from 'vs/base/common/lifecycle'; +import { Disposable, MutableDisposable } from 'vs/base/common/lifecycle'; import { Event } from 'vs/base/common/event'; import Severity from 'vs/base/common/severity'; import { localize } from 'vs/nls'; @@ -13,34 +13,6 @@ import { ConfigurationTarget, IConfigurationService } from 'vs/platform/configur import { INotificationHandle, INotificationService, NotificationPriority } from 'vs/platform/notification/common/notification'; import { IWorkbenchContribution } from 'vs/workbench/common/contributions'; import { IStatusbarEntryAccessor, IStatusbarService, StatusbarAlignment } from 'vs/workbench/services/statusbar/browser/statusbar'; -import { IEditorGroupsService } from 'vs/workbench/services/editor/common/editorGroupsService'; -import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; - -class ScreenReaderModeStatusEntry extends Disposable { - - private readonly screenReaderModeElement = this._register(new MutableDisposable()); - - constructor(@IStatusbarService private readonly statusbarService: IStatusbarService) { - super(); - } - - updateScreenReaderModeElement(visible: boolean): void { - if (visible) { - if (!this.screenReaderModeElement.value) { - const text = localize('screenReaderDetected', "Screen Reader Optimized"); - this.screenReaderModeElement.value = this.statusbarService.addEntry({ - name: localize('status.editor.screenReaderMode', "Screen Reader Mode"), - text, - ariaLabel: text, - command: 'showEditorScreenReaderNotification', - kind: 'prominent' - }, 'status.editor.screenReaderMode', StatusbarAlignment.RIGHT, 100.6); - } - } else { - this.screenReaderModeElement.clear(); - } - } -} export class AccessibilityStatus extends Disposable implements IWorkbenchContribution { @@ -48,40 +20,23 @@ export class AccessibilityStatus extends Disposable implements IWorkbenchContrib private screenReaderNotification: INotificationHandle | null = null; private promptedScreenReader: boolean = false; - private readonly screenReaderModeElements = new Set(); + private readonly screenReaderModeElement = this._register(new MutableDisposable()); constructor( @IConfigurationService private readonly configurationService: IConfigurationService, @INotificationService private readonly notificationService: INotificationService, @IAccessibilityService private readonly accessibilityService: IAccessibilityService, - @IInstantiationService instantiationService: IInstantiationService, - @IEditorGroupsService private readonly editorGroupService: IEditorGroupsService + @IStatusbarService private readonly statusbarService: IStatusbarService ) { super(); - this.createScreenReaderModeElement(instantiationService, this._store); - this.updateScreenReaderModeElements(accessibilityService.isScreenReaderOptimized()); + this._register(CommandsRegistry.registerCommand({ id: 'showEditorScreenReaderNotification', handler: () => this.showScreenReaderNotification() })); - CommandsRegistry.registerCommand({ id: 'showEditorScreenReaderNotification', handler: () => this.showScreenReaderNotification() }); + this.updateScreenReaderModeElement(this.accessibilityService.isScreenReaderOptimized()); this.registerListeners(); } - private createScreenReaderModeElement(instantiationService: IInstantiationService, disposables: DisposableStore): ScreenReaderModeStatusEntry { - const entry = disposables.add(instantiationService.createInstance(ScreenReaderModeStatusEntry)); - - this.screenReaderModeElements.add(entry); - disposables.add(toDisposable(() => this.screenReaderModeElements.delete(entry))); - - return entry; - } - - private updateScreenReaderModeElements(visible: boolean): void { - for (const entry of this.screenReaderModeElements) { - entry.updateScreenReaderModeElement(visible); - } - } - private registerListeners(): void { this._register(this.accessibilityService.onDidChangeScreenReaderOptimized(() => this.onScreenReaderModeChange())); @@ -90,11 +45,6 @@ export class AccessibilityStatus extends Disposable implements IWorkbenchContrib this.onScreenReaderModeChange(); } })); - - this._register(this.editorGroupService.onDidCreateAuxiliaryEditorPart(({ instantiationService, disposables }) => { - const entry = this.createScreenReaderModeElement(instantiationService, disposables); - entry.updateScreenReaderModeElement(this.accessibilityService.isScreenReaderOptimized()); - })); } private showScreenReaderNotification(): void { @@ -120,6 +70,23 @@ export class AccessibilityStatus extends Disposable implements IWorkbenchContrib Event.once(this.screenReaderNotification.onDidClose)(() => this.screenReaderNotification = null); } + private updateScreenReaderModeElement(visible: boolean): void { + if (visible) { + if (!this.screenReaderModeElement.value) { + const text = localize('screenReaderDetected', "Screen Reader Optimized"); + this.screenReaderModeElement.value = this.statusbarService.addEntry({ + name: localize('status.editor.screenReaderMode', "Screen Reader Mode"), + text, + ariaLabel: text, + command: 'showEditorScreenReaderNotification', + kind: 'prominent', + showInAllWindows: true + }, 'status.editor.screenReaderMode', StatusbarAlignment.RIGHT, 100.6); + } + } else { + this.screenReaderModeElement.clear(); + } + } private onScreenReaderModeChange(): void { @@ -138,14 +105,6 @@ export class AccessibilityStatus extends Disposable implements IWorkbenchContrib if (this.screenReaderNotification) { this.screenReaderNotification.close(); } - this.updateScreenReaderModeElements(this.accessibilityService.isScreenReaderOptimized()); - } - - override dispose(): void { - super.dispose(); - - for (const entry of this.screenReaderModeElements) { - entry.dispose(); - } + this.updateScreenReaderModeElement(this.accessibilityService.isScreenReaderOptimized()); } } diff --git a/src/vs/workbench/contrib/accessibility/browser/accessibleView.ts b/src/vs/workbench/contrib/accessibility/browser/accessibleView.ts index 5f3a2df9d06a5..3ddb70b920803 100644 --- a/src/vs/workbench/contrib/accessibility/browser/accessibleView.ts +++ b/src/vs/workbench/contrib/accessibility/browser/accessibleView.ts @@ -13,12 +13,12 @@ import { Event } from 'vs/base/common/event'; import { KeyCode } from 'vs/base/common/keyCodes'; import { Disposable, DisposableStore, IDisposable } from 'vs/base/common/lifecycle'; import { marked } from 'vs/base/common/marked/marked'; -import { isMacintosh } from 'vs/base/common/platform'; +import { isMacintosh, isWindows } from 'vs/base/common/platform'; import { ThemeIcon } from 'vs/base/common/themables'; import { URI } from 'vs/base/common/uri'; import { IEditorConstructionOptions } from 'vs/editor/browser/config/editorConfiguration'; import { EditorExtensionsRegistry } from 'vs/editor/browser/editorExtensions'; -import { CodeEditorWidget, ICodeEditorWidgetOptions } from 'vs/editor/browser/widget/codeEditorWidget'; +import { CodeEditorWidget, ICodeEditorWidgetOptions } from 'vs/editor/browser/widget/codeEditor/codeEditorWidget'; import { Position } from 'vs/editor/common/core/position'; import { ITextModel } from 'vs/editor/common/model'; import { IModelService } from 'vs/editor/common/services/model'; @@ -29,6 +29,7 @@ import { IAccessibilityService } from 'vs/platform/accessibility/common/accessib import { createAndFillInActionBarActions } from 'vs/platform/actions/browser/menuEntryActionViewItem'; import { WorkbenchToolBar } from 'vs/platform/actions/browser/toolbar'; import { IMenuService, MenuId } from 'vs/platform/actions/common/actions'; +import { ICommandService } from 'vs/platform/commands/common/commands'; import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; import { IContextKey, IContextKeyService } from 'vs/platform/contextkey/common/contextkey'; import { IContextViewDelegate, IContextViewService } from 'vs/platform/contextview/browser/contextView'; @@ -39,8 +40,10 @@ import { ILayoutService } from 'vs/platform/layout/browser/layoutService'; import { IOpenerService } from 'vs/platform/opener/common/opener'; import { IPickerQuickAccessItem } from 'vs/platform/quickinput/browser/pickerQuickAccess'; import { IQuickInputService } from 'vs/platform/quickinput/common/quickInput'; -import { AccessibilityVerbositySettingId, AccessibilityWorkbenchSettingId, AccessibleViewProviderId, accessibilityHelpIsShown, accessibleViewCurrentProviderId, accessibleViewGoToSymbolSupported, accessibleViewIsShown, accessibleViewOnLastLine, accessibleViewSupportsNavigation, accessibleViewVerbosityEnabled } from 'vs/workbench/contrib/accessibility/browser/accessibilityConfiguration'; +import { AccessibilityVerbositySettingId, AccessibilityWorkbenchSettingId, AccessibleViewProviderId, accessibilityHelpIsShown, accessibleViewContainsCodeBlocks, accessibleViewCurrentProviderId, accessibleViewGoToSymbolSupported, accessibleViewInCodeBlock, accessibleViewIsShown, accessibleViewOnLastLine, accessibleViewSupportsNavigation, accessibleViewVerbosityEnabled } from 'vs/workbench/contrib/accessibility/browser/accessibilityConfiguration'; import { AccessibilityCommandId } from 'vs/workbench/contrib/accessibility/common/accessibilityCommands'; +import { IChatCodeBlockContextProviderService } from 'vs/workbench/contrib/chat/browser/chat'; +import { ICodeBlockActionContext } from 'vs/workbench/contrib/chat/browser/codeBlockPart'; import { getSimpleEditorOptions } from 'vs/workbench/contrib/codeEditor/browser/simpleEditorOptions'; const enum DIMENSIONS { @@ -90,6 +93,7 @@ export interface IAccessibleViewService { * @param verbositySettingKey The setting key for the verbosity of the feature */ getOpenAriaHint(verbositySettingKey: AccessibilityVerbositySettingId): string | null; + getCodeBlockContext(): ICodeBlockActionContext | undefined; } export const enum AccessibleViewType { @@ -126,6 +130,13 @@ export interface IAccessibleViewOptions { id?: AccessibleViewProviderId; } +interface ICodeBlock { + startLine: number; + endLine: number; + code: string; + languageId?: string; +} + export class AccessibleView extends Disposable { private _editorWidget: CodeEditorWidget; @@ -136,6 +147,9 @@ export class AccessibleView extends Disposable { private _accessibleViewVerbosityEnabled: IContextKey; private _accessibleViewGoToSymbolSupported: IContextKey; private _accessibleViewCurrentProviderId: IContextKey; + private _accessibleViewInCodeBlock: IContextKey; + private _accessibleViewContainsCodeBlocks: IContextKey; + private _codeBlocks?: ICodeBlock[]; get editorWidget() { return this._editorWidget; } private _container: HTMLElement; @@ -157,7 +171,9 @@ export class AccessibleView extends Disposable { @IAccessibilityService private readonly _accessibilityService: IAccessibilityService, @IKeybindingService private readonly _keybindingService: IKeybindingService, @ILayoutService private readonly _layoutService: ILayoutService, - @IMenuService private readonly _menuService: IMenuService + @IMenuService private readonly _menuService: IMenuService, + @ICommandService private readonly _commandService: ICommandService, + @IChatCodeBlockContextProviderService private readonly _codeBlockContextProviderService: IChatCodeBlockContextProviderService ) { super(); @@ -167,6 +183,8 @@ export class AccessibleView extends Disposable { this._accessibleViewVerbosityEnabled = accessibleViewVerbosityEnabled.bindTo(this._contextKeyService); this._accessibleViewGoToSymbolSupported = accessibleViewGoToSymbolSupported.bindTo(this._contextKeyService); this._accessibleViewCurrentProviderId = accessibleViewCurrentProviderId.bindTo(this._contextKeyService); + this._accessibleViewInCodeBlock = accessibleViewInCodeBlock.bindTo(this._contextKeyService); + this._accessibleViewContainsCodeBlocks = accessibleViewContainsCodeBlocks.bindTo(this._contextKeyService); this._onLastLine = accessibleViewOnLastLine.bindTo(this._contextKeyService); this._container = document.createElement('div'); @@ -227,6 +245,13 @@ export class AccessibleView extends Disposable { this._register(this._editorWidget.onDidChangeCursorPosition(() => { this._onLastLine.set(this._editorWidget.getPosition()?.lineNumber === this._editorWidget.getModel()?.getLineCount()); })); + this._register(this._editorWidget.onDidChangeCursorPosition(() => { + const cursorPosition = this._editorWidget.getPosition()?.lineNumber; + if (this._codeBlocks && cursorPosition !== undefined) { + const inCodeBlock = this._codeBlocks.find(c => c.startLine <= cursorPosition && c.endLine >= cursorPosition) !== undefined; + this._accessibleViewInCodeBlock.set(inCodeBlock); + } + })); } private _resetContextKeys(): void { @@ -252,6 +277,19 @@ export class AccessibleView extends Disposable { } } + getCodeBlockContext(): ICodeBlockActionContext | undefined { + const position = this._editorWidget.getPosition(); + if (!this._codeBlocks?.length || !position) { + return; + } + const codeBlockIndex = this._codeBlocks?.findIndex(c => c.startLine <= position?.lineNumber && c.endLine >= position?.lineNumber); + const codeBlock = codeBlockIndex !== undefined && codeBlockIndex > -1 ? this._codeBlocks[codeBlockIndex] : undefined; + if (!codeBlock || codeBlockIndex === undefined) { + return; + } + return { code: codeBlock.code, languageId: codeBlock.languageId, codeBlockIndex, element: undefined }; + } + showLastProvider(id: AccessibleViewProviderId): void { if (!this._lastProvider || this._lastProvider.options.id !== id) { return; @@ -282,10 +320,10 @@ export class AccessibleView extends Disposable { if (position) { // Context view takes time to show up, so we need to wait for it to show up before we can set the position - setTimeout(() => { + queueMicrotask(() => { this._editorWidget.revealLine(position.lineNumber); this._editorWidget.setSelection({ startLineNumber: position.lineNumber, startColumn: position.column, endLineNumber: position.lineNumber, endColumn: position.column }); - }, 10); + }); } if (symbol && this._currentProvider) { @@ -302,6 +340,9 @@ export class AccessibleView extends Disposable { // only cache a provider with an ID so that it will eventually be cleared. this._lastProvider = provider; } + if (provider.id === AccessibleViewProviderId.Chat) { + this._register(this._codeBlockContextProviderService.registerProvider({ getCodeBlockContext: () => this.getCodeBlockContext() }, 'accessibleView')); + } } previous(): void { @@ -325,6 +366,35 @@ export class AccessibleView extends Disposable { this._instantiationService.createInstance(AccessibleViewSymbolQuickPick, this).show(this._currentProvider); } + calculateCodeBlocks(markdown: string): void { + if (this._currentProvider?.id !== AccessibleViewProviderId.Chat) { + return; + } + if (this._currentProvider.options.language && this._currentProvider.options.language !== 'markdown') { + // Symbols haven't been provided and we cannot parse this language + return; + } + const lines = markdown.split('\n'); + this._codeBlocks = []; + let inBlock = false; + let startLine = 0; + + let languageId: string | undefined; + lines.forEach((line, i) => { + if (!inBlock && line.startsWith('```')) { + inBlock = true; + startLine = i + 1; + languageId = line.substring(3).trim(); + } else if (inBlock && line.startsWith('```')) { + inBlock = false; + const endLine = i; + const code = lines.slice(startLine, endLine).join('\n'); + this._codeBlocks?.push({ startLine, endLine, code, languageId }); + } + }); + this._accessibleViewContainsCodeBlocks.set(this._codeBlocks.length > 0); + } + getSymbols(): IAccessibleViewSymbol[] | undefined { if (!this._currentProvider || !this._currentContent) { return; @@ -427,11 +497,8 @@ export class AccessibleView extends Disposable { } private _render(provider: IAccessibleContentProvider, container: HTMLElement, showAccessibleViewHelp?: boolean): IDisposable { - if (!showAccessibleViewHelp) { - // don't overwrite the current provider - this._currentProvider = provider; - this._accessibleViewCurrentProviderId.set(provider.id); - } + this._currentProvider = provider; + this._accessibleViewCurrentProviderId.set(provider.id); const value = this._configurationService.getValue(provider.verbositySettingKey); const readMoreLink = provider.options.readMoreUrl ? localize("openDoc", "\n\nOpen a browser window with more information related to accessibility (H).") : ''; let disableHelpHint = ''; @@ -456,9 +523,11 @@ export class AccessibleView extends Disposable { } const verbose = this._configurationService.getValue(provider.verbositySettingKey); const exitThisDialogHint = verbose && !provider.options.position ? localize('exit', '\n\nExit this dialog (Escape).') : ''; - this._currentContent = message + provider.provideContent() + readMoreLink + disableHelpHint + exitThisDialogHint; + const newContent = message + provider.provideContent() + readMoreLink + disableHelpHint + exitThisDialogHint; + this.calculateCodeBlocks(newContent); + this._currentContent = newContent; this._updateContextKeys(provider, true); - + const widgetIsFocused = this._editorWidget.hasTextFocus() || this._editorWidget.hasWidgetFocus(); this._getTextModel(URI.from({ path: `accessible-view-${provider.verbositySettingKey}`, scheme: 'accessible-view', fragment: this._currentContent })).then((model) => { if (!model) { return; @@ -483,6 +552,11 @@ export class AccessibleView extends Disposable { } else if (actionsHint) { ariaLabel = localize('accessibility-help-hint', "Accessibility Help, {0}", actionsHint); } + if (isWindows && widgetIsFocused) { + // prevent the screen reader on windows from reading + // the aria label again when it's refocused + ariaLabel = ''; + } this._editorWidget.updateOptions({ ariaLabel }); this._editorWidget.focus(); if (this._currentProvider?.options.position) { @@ -506,10 +580,13 @@ export class AccessibleView extends Disposable { this._contextViewService.hideContextView(); this._updateContextKeys(provider, false); this._lastProvider = undefined; + this._currentContent = undefined; }; const disposableStore = new DisposableStore(); disposableStore.add(this._editorWidget.onKeyDown((e) => { - if (e.keyCode === KeyCode.Escape || shouldHide(e.browserEvent, this._keybindingService, this._configurationService)) { + if (e.keyCode === KeyCode.Enter) { + this._commandService.executeCommand('editor.action.openLink'); + } else if (e.keyCode === KeyCode.Escape || shouldHide(e.browserEvent, this._keybindingService, this._configurationService)) { hide(e); } else if (e.keyCode === KeyCode.KeyH && provider.options.readMoreUrl) { const url: string = provider.options.readMoreUrl; @@ -595,19 +672,24 @@ export class AccessibleView extends Disposable { const accessibleViewHelpProvider: IAccessibleContentProvider = { id: lastProvider.id, provideContent: () => lastProvider.options.customHelp ? lastProvider?.options.customHelp() : this._getAccessibleViewHelpDialogContent(this._goToSymbolsSupported()), - onClose: () => this.show(lastProvider), + onClose: () => { + this._contextViewService.hideContextView(); + // HACK: Delay to allow the context view to hide #207638 + queueMicrotask(() => this.show(lastProvider)); + }, options: { type: AccessibleViewType.Help }, verbositySettingKey: lastProvider.verbositySettingKey }; this._contextViewService.hideContextView(); // HACK: Delay to allow the context view to hide #186514 - setTimeout(() => this.show(accessibleViewHelpProvider, undefined, true), 100); + queueMicrotask(() => this.show(accessibleViewHelpProvider, undefined, true)); } private _getAccessibleViewHelpDialogContent(providerHasSymbols?: boolean): string { const navigationHint = this._getNavigationHint(); const goToSymbolHint = this._getGoToSymbolHint(providerHasSymbols); - const toolbarHint = localize('toolbar', "Navigate to the toolbar (Shift+Tab))."); + const toolbarHint = localize('toolbar', "Navigate to the toolbar (Shift+Tab)."); + const chatHints = this._getChatHints(); let hint = localize('intro', "In the accessible view, you can:\n"); if (navigationHint) { @@ -619,6 +701,37 @@ export class AccessibleView extends Disposable { if (toolbarHint) { hint += ' - ' + toolbarHint + '\n'; } + if (chatHints) { + hint += chatHints; + } + return hint; + } + + private _getChatHints(): string | undefined { + if (this._currentProvider?.id !== AccessibleViewProviderId.Chat) { + return; + } + let hint = ''; + const insertAtCursorKb = this._keybindingService.lookupKeybinding('workbench.action.chat.insertCodeBlock')?.getAriaLabel(); + const insertIntoNewFileKb = this._keybindingService.lookupKeybinding('workbench.action.chat.insertIntoNewFile')?.getAriaLabel(); + const runInTerminalKb = this._keybindingService.lookupKeybinding('workbench.action.chat.runInTerminal')?.getAriaLabel(); + + if (insertAtCursorKb) { + hint += localize('insertAtCursor', " - Insert the code block at the cursor ({0}).\n", insertAtCursorKb); + } else { + hint += localize('insertAtCursorNoKb', " - Insert the code block at the cursor by configuring a keybinding for the Chat: Insert Code Block command.\n"); + } + if (insertIntoNewFileKb) { + hint += localize('insertIntoNewFile', " - Insert the code block into a new file ({0}).\n", insertIntoNewFileKb); + } else { + hint += localize('insertIntoNewFileNoKb', " - Insert the code block into a new file by configuring a keybinding for the Chat: Insert into New File command.\n"); + } + if (runInTerminalKb) { + hint += localize('runInTerminal', " - Run the code block in the terminal ({0}).\n", runInTerminalKb); + } else { + hint += localize('runInTerminalNoKb', " - Run the coe block in the terminal by configuring a keybinding for the Chat: Insert into Terminal command.\n"); + } + return hint; } @@ -652,7 +765,7 @@ export class AccessibleView extends Disposable { let goToSymbolHint = ''; if (providerHasSymbols) { if (goToSymbolKb) { - goToSymbolHint = localize('goToSymbolHint', 'Go to a symbol ({0})', goToSymbolKb); + goToSymbolHint = localize('goToSymbolHint', 'Go to a symbol ({0}).', goToSymbolKb); } else { goToSymbolHint = localize('goToSymbolHintNoKb', 'To go to a symbol, configure a keybinding for the command Go To Symbol in Accessible View'); } @@ -724,6 +837,9 @@ export class AccessibleViewService extends Disposable implements IAccessibleView editorWidget?.revealLine(position.lineNumber); } } + getCodeBlockContext(): ICodeBlockActionContext | undefined { + return this._accessibleView?.getCodeBlockContext(); + } } class AccessibleViewSymbolQuickPick { @@ -765,6 +881,7 @@ export interface IAccessibleViewSymbol extends IPickerQuickAccessItem { markdownToParse?: string; firstListItem?: string; lineNumber?: number; + endLineNumber?: number; } function shouldHide(event: KeyboardEvent, keybindingService: IKeybindingService, configurationService: IConfigurationService): boolean { diff --git a/src/vs/workbench/contrib/accessibility/browser/accessibilityContributions.ts b/src/vs/workbench/contrib/accessibility/browser/accessibleViewContributions.ts similarity index 94% rename from src/vs/workbench/contrib/accessibility/browser/accessibilityContributions.ts rename to src/vs/workbench/contrib/accessibility/browser/accessibleViewContributions.ts index 815ac45fe1d0a..30756d7398405 100644 --- a/src/vs/workbench/contrib/accessibility/browser/accessibilityContributions.ts +++ b/src/vs/workbench/contrib/accessibility/browser/accessibleViewContributions.ts @@ -32,7 +32,7 @@ import { Codicon } from 'vs/base/common/codicons'; import { InlineCompletionsController } from 'vs/editor/contrib/inlineCompletions/browser/inlineCompletionsController'; import { InlineCompletionContextKeys } from 'vs/editor/contrib/inlineCompletions/browser/inlineCompletionContextKeys'; import { ContextKeyExpr } from 'vs/platform/contextkey/common/contextkey'; -import { AudioCue, IAudioCueService } from 'vs/platform/audioCues/browser/audioCueService'; +import { AccessibilitySignal, IAccessibilitySignalService } from 'vs/platform/accessibilitySignal/browser/accessibilitySignalService'; export function descriptionForCommand(commandId: string, msg: string, noKbMsg: string, keybindingService: IKeybindingService): string { const kb = keybindingService.lookupKeybinding(commandId); @@ -104,7 +104,7 @@ export class NotificationAccessibleViewContribution extends Disposable { const accessibleViewService = accessor.get(IAccessibleViewService); const listService = accessor.get(IListService); const commandService = accessor.get(ICommandService); - const audioCueService = accessor.get(IAudioCueService); + const accessibilitySignalService = accessor.get(IAccessibilitySignalService); function renderAccessibleView(): boolean { const notification = getNotificationFromContext(listService); @@ -165,7 +165,7 @@ export class NotificationAccessibleViewContribution extends Disposable { }, verbositySettingKey: AccessibilityVerbositySettingId.Notification, options: { type: AccessibleViewType.View }, - actions: getActionsFromNotification(notification, audioCueService) + actions: getActionsFromNotification(notification, accessibilitySignalService) }); return true; } @@ -174,7 +174,7 @@ export class NotificationAccessibleViewContribution extends Disposable { } } -function getActionsFromNotification(notification: INotificationViewItem, audioCueService: IAudioCueService): IAction[] | undefined { +function getActionsFromNotification(notification: INotificationViewItem, accessibilitySignalService: IAccessibilitySignalService): IAction[] | undefined { let actions = undefined; if (notification.actions) { actions = []; @@ -203,7 +203,7 @@ function getActionsFromNotification(notification: INotificationViewItem, audioCu actions.push({ id: 'clearNotification', label: localize('clearNotification', "Clear Notification"), tooltip: localize('clearNotification', "Clear Notification"), run: () => { notification.close(); - audioCueService.playAudioCue(AudioCue.clear); + accessibilitySignalService.playSignal(AccessibilitySignal.clear); }, enabled: true, class: ThemeIcon.asClassName(Codicon.clearAll) }); } @@ -242,8 +242,8 @@ export class InlineCompletionsAccessibleViewContribution extends Disposable { if (!model || !state) { return false; } - const lineText = model.textModel.getLineContent(state.ghostText.lineNumber); - const ghostText = state.ghostText.renderForScreenReader(lineText); + const lineText = model.textModel.getLineContent(state.primaryGhostText.lineNumber); + const ghostText = state.primaryGhostText.renderForScreenReader(lineText); if (!ghostText) { return false; } diff --git a/src/vs/workbench/contrib/accessibility/browser/audioCueConfiguration.ts b/src/vs/workbench/contrib/accessibility/browser/audioCueConfiguration.ts new file mode 100644 index 0000000000000..a1114ff859cf1 --- /dev/null +++ b/src/vs/workbench/contrib/accessibility/browser/audioCueConfiguration.ts @@ -0,0 +1,163 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { localize } from 'vs/nls'; +import { Extensions as ConfigurationExtensions, ConfigurationScope, IConfigurationPropertySchema, IConfigurationRegistry } from 'vs/platform/configuration/common/configurationRegistry'; +import { Registry } from 'vs/platform/registry/common/platform'; + +export const audioCueFeatureBase: IConfigurationPropertySchema = { + 'type': 'string', + 'enum': ['auto', 'on', 'off'], + 'default': 'auto', + 'enumDescriptions': [ + localize('audioCues.enabled.auto', "Enable audio cue when a screen reader is attached."), + localize('audioCues.enabled.on', "Enable audio cue."), + localize('audioCues.enabled.off', "Disable audio cue.") + ], + tags: ['accessibility'], +}; +const markdownDeprecationMessage = localize('audioCues.enabled.deprecated', "This setting is deprecated. Use `signals` settings instead."); +const soundDeprecatedFeatureBase: IConfigurationPropertySchema = { + ...audioCueFeatureBase, + markdownDeprecationMessage +}; +export function registerAudioCueConfiguration() { + Registry.as(ConfigurationExtensions.Configuration).registerConfiguration({ + scope: ConfigurationScope.RESOURCE, + 'properties': { + 'audioCues.enabled': { + markdownDeprecationMessage: 'Deprecated. Use the specific setting for each audio cue instead (`audioCues.*`).', + tags: ['accessibility'] + }, + 'audioCues.volume': { + markdownDeprecationMessage: 'Deprecated. Use `accessibility.signals.sounds.volume` instead.', + tags: ['accessibility'] + }, + 'audioCues.debouncePositionChanges': { + 'description': localize('audioCues.debouncePositionChanges', "Whether or not position changes should be debounced"), + 'type': 'boolean', + 'default': false, + tags: ['accessibility'], + 'markdownDeprecationMessage': localize('audioCues.debouncePositionChangesDeprecated', 'This setting is deprecated, instead use the `signals.debouncePositionChanges` setting.') + }, + 'audioCues.lineHasBreakpoint': { + 'description': localize('audioCues.lineHasBreakpoint', "Plays a sound when the active line has a breakpoint."), + ...soundDeprecatedFeatureBase + }, + 'audioCues.lineHasInlineSuggestion': { + 'description': localize('audioCues.lineHasInlineSuggestion', "Plays a sound when the active line has an inline suggestion."), + ...soundDeprecatedFeatureBase + }, + 'audioCues.lineHasError': { + 'description': localize('audioCues.lineHasError', "Plays a sound when the active line has an error."), + ...soundDeprecatedFeatureBase, + }, + 'audioCues.lineHasFoldedArea': { + 'description': localize('audioCues.lineHasFoldedArea', "Plays a sound when the active line has a folded area that can be unfolded."), + ...soundDeprecatedFeatureBase, + }, + 'audioCues.lineHasWarning': { + 'description': localize('audioCues.lineHasWarning', "Plays a sound when the active line has a warning."), + ...soundDeprecatedFeatureBase, + default: 'off', + }, + 'audioCues.onDebugBreak': { + 'description': localize('audioCues.onDebugBreak', "Plays a sound when the debugger stopped on a breakpoint."), + ...soundDeprecatedFeatureBase, + }, + 'audioCues.noInlayHints': { + 'description': localize('audioCues.noInlayHints', "Plays a sound when trying to read a line with inlay hints that has no inlay hints."), + ...soundDeprecatedFeatureBase, + }, + 'audioCues.taskCompleted': { + 'description': localize('audioCues.taskCompleted', "Plays a sound when a task is completed."), + ...soundDeprecatedFeatureBase, + }, + 'audioCues.taskFailed': { + 'description': localize('audioCues.taskFailed', "Plays a sound when a task fails (non-zero exit code)."), + ...soundDeprecatedFeatureBase, + }, + 'audioCues.terminalCommandFailed': { + 'description': localize('audioCues.terminalCommandFailed', "Plays a sound when a terminal command fails (non-zero exit code)."), + ...soundDeprecatedFeatureBase, + }, + 'audioCues.terminalQuickFix': { + 'description': localize('audioCues.terminalQuickFix', "Plays a sound when terminal Quick Fixes are available."), + ...soundDeprecatedFeatureBase, + }, + 'audioCues.terminalBell': { + 'description': localize('audioCues.terminalBell', "Plays a sound when the terminal bell is ringing."), + ...soundDeprecatedFeatureBase, + default: 'on' + }, + 'audioCues.diffLineInserted': { + 'description': localize('audioCues.diffLineInserted', "Plays a sound when the focus moves to an inserted line in Accessible Diff Viewer mode or to the next/previous change."), + ...soundDeprecatedFeatureBase, + }, + 'audioCues.diffLineDeleted': { + 'description': localize('audioCues.diffLineDeleted', "Plays a sound when the focus moves to a deleted line in Accessible Diff Viewer mode or to the next/previous change."), + ...soundDeprecatedFeatureBase, + }, + 'audioCues.diffLineModified': { + 'description': localize('audioCues.diffLineModified', "Plays a sound when the focus moves to a modified line in Accessible Diff Viewer mode or to the next/previous change."), + ...soundDeprecatedFeatureBase, + }, + 'audioCues.notebookCellCompleted': { + 'description': localize('audioCues.notebookCellCompleted', "Plays a sound when a notebook cell execution is successfully completed."), + ...soundDeprecatedFeatureBase, + }, + 'audioCues.notebookCellFailed': { + 'description': localize('audioCues.notebookCellFailed', "Plays a sound when a notebook cell execution fails."), + ...soundDeprecatedFeatureBase, + }, + 'audioCues.chatRequestSent': { + 'description': localize('audioCues.chatRequestSent', "Plays a sound when a chat request is made."), + ...soundDeprecatedFeatureBase, + default: 'off' + }, + 'audioCues.chatResponsePending': { + 'description': localize('audioCues.chatResponsePending', "Plays a sound on loop while the response is pending."), + ...soundDeprecatedFeatureBase, + default: 'auto' + }, + 'audioCues.chatResponseReceived': { + 'description': localize('audioCues.chatResponseReceived', "Plays a sound on loop while the response has been received."), + ...soundDeprecatedFeatureBase, + default: 'off' + }, + 'audioCues.clear': { + 'description': localize('audioCues.clear', "Plays a sound when a feature is cleared (for example, the terminal, Debug Console, or Output channel). When this is disabled, an ARIA alert will announce 'Cleared'."), + ...soundDeprecatedFeatureBase, + default: 'off' + }, + 'audioCues.save': { + 'markdownDescription': localize('audioCues.save', "Plays a sound when a file is saved. Also see {0}", '`#accessibility.alert.save#`'), + 'type': 'string', + 'enum': ['userGesture', 'always', 'never'], + 'default': 'never', + 'enumDescriptions': [ + localize('audioCues.save.userGesture', "Plays the audio cue when a user explicitly saves a file."), + localize('audioCues.save.always', "Plays the audio cue whenever a file is saved, including auto save."), + localize('audioCues.save.never', "Never plays the audio cue.") + ], + tags: ['accessibility'], + markdownDeprecationMessage + }, + 'audioCues.format': { + 'markdownDescription': localize('audioCues.format', "Plays a sound when a file or notebook is formatted. Also see {0}", '`#accessibility.alert.format#`'), + 'type': 'string', + 'enum': ['userGesture', 'always', 'never'], + 'default': 'never', + 'enumDescriptions': [ + localize('audioCues.format.userGesture', "Plays the audio cue when a user explicitly formats a file."), + localize('audioCues.format.always', "Plays the audio cue whenever a file is formatted, including if it is set to format on save, type, or, paste, or run of a cell."), + localize('audioCues.format.never', "Never plays the audio cue.") + ], + tags: ['accessibility'], + markdownDeprecationMessage + }, + }, + }); +} diff --git a/src/vs/workbench/contrib/accessibility/browser/editorAccessibilityHelp.ts b/src/vs/workbench/contrib/accessibility/browser/editorAccessibilityHelp.ts index a084c6b378db6..10f0392f38278 100644 --- a/src/vs/workbench/contrib/accessibility/browser/editorAccessibilityHelp.ts +++ b/src/vs/workbench/contrib/accessibility/browser/editorAccessibilityHelp.ts @@ -15,7 +15,7 @@ import { IContextKeyService } from 'vs/platform/contextkey/common/contextkey'; import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; import { IKeybindingService } from 'vs/platform/keybinding/common/keybinding'; import { AccessibleViewProviderId, AccessibilityVerbositySettingId } from 'vs/workbench/contrib/accessibility/browser/accessibilityConfiguration'; -import { descriptionForCommand } from 'vs/workbench/contrib/accessibility/browser/accessibilityContributions'; +import { descriptionForCommand } from 'vs/workbench/contrib/accessibility/browser/accessibleViewContributions'; import { IAccessibleViewService, IAccessibleContentProvider, IAccessibleViewOptions, AccessibleViewType } from 'vs/workbench/contrib/accessibility/browser/accessibleView'; import { AccessibilityHelpAction } from 'vs/workbench/contrib/accessibility/browser/accessibleViewActions'; import { CONTEXT_PROVIDER_EXISTS } from 'vs/workbench/contrib/chat/common/chatContextKeys'; @@ -75,7 +75,7 @@ class EditorAccessibilityHelpProvider implements IAccessibleContentProvider { } } - content.push(AccessibilityHelpNLS.listAudioCues); + content.push(AccessibilityHelpNLS.listSignalSounds); content.push(AccessibilityHelpNLS.listAlerts); const chatCommandInfo = getChatCommandInfo(this._keybindingService, this._contextKeyService); diff --git a/src/vs/workbench/contrib/accessibilitySignals/browser/accessibilitySignal.contribution.ts b/src/vs/workbench/contrib/accessibilitySignals/browser/accessibilitySignal.contribution.ts new file mode 100644 index 0000000000000..f0ce38c970e7b --- /dev/null +++ b/src/vs/workbench/contrib/accessibilitySignals/browser/accessibilitySignal.contribution.ts @@ -0,0 +1,23 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { ShowAccessibilityAnnouncementHelp, ShowSignalSoundHelp } from 'vs/workbench/contrib/accessibilitySignals/browser/commands'; +import { registerAction2 } from 'vs/platform/actions/common/actions'; +import { InstantiationType, registerSingleton } from 'vs/platform/instantiation/common/extensions'; +import { Registry } from 'vs/platform/registry/common/platform'; +import { Extensions as WorkbenchExtensions, IWorkbenchContributionsRegistry } from 'vs/workbench/common/contributions'; +import { LifecyclePhase } from 'vs/workbench/services/lifecycle/common/lifecycle'; +import { IAccessibilitySignalService, AccessibilitySignalService } from 'vs/platform/accessibilitySignal/browser/accessibilitySignalService'; +import { AccessibilitySignalLineDebuggerContribution } from 'vs/workbench/contrib/accessibilitySignals/browser/accessibilitySignalDebuggerContribution'; +import { SignalLineFeatureContribution } from 'vs/workbench/contrib/accessibilitySignals/browser/accessibilitySignalLineFeatureContribution'; + +registerSingleton(IAccessibilitySignalService, AccessibilitySignalService, InstantiationType.Delayed); + +Registry.as(WorkbenchExtensions.Workbench).registerWorkbenchContribution(SignalLineFeatureContribution, LifecyclePhase.Restored); +Registry.as(WorkbenchExtensions.Workbench).registerWorkbenchContribution(AccessibilitySignalLineDebuggerContribution, LifecyclePhase.Restored); + +registerAction2(ShowSignalSoundHelp); +registerAction2(ShowAccessibilityAnnouncementHelp); + diff --git a/src/vs/workbench/contrib/audioCues/browser/audioCueDebuggerContribution.ts b/src/vs/workbench/contrib/accessibilitySignals/browser/accessibilitySignalDebuggerContribution.ts similarity index 77% rename from src/vs/workbench/contrib/audioCues/browser/audioCueDebuggerContribution.ts rename to src/vs/workbench/contrib/accessibilitySignals/browser/accessibilitySignalDebuggerContribution.ts index 6150308ccfa19..45d5f2b312120 100644 --- a/src/vs/workbench/contrib/audioCues/browser/audioCueDebuggerContribution.ts +++ b/src/vs/workbench/contrib/accessibilitySignals/browser/accessibilitySignalDebuggerContribution.ts @@ -5,23 +5,23 @@ import { Disposable, IDisposable, toDisposable } from 'vs/base/common/lifecycle'; import { autorunWithStore, observableFromEvent } from 'vs/base/common/observable'; -import { IAudioCueService, AudioCue, AudioCueService } from 'vs/platform/audioCues/browser/audioCueService'; +import { IAccessibilitySignalService, AccessibilitySignal, AccessibilitySignalService } from 'vs/platform/accessibilitySignal/browser/accessibilitySignalService'; import { IWorkbenchContribution } from 'vs/workbench/common/contributions'; import { IDebugService, IDebugSession } from 'vs/workbench/contrib/debug/common/debug'; -export class AudioCueLineDebuggerContribution +export class AccessibilitySignalLineDebuggerContribution extends Disposable implements IWorkbenchContribution { constructor( @IDebugService debugService: IDebugService, - @IAudioCueService private readonly audioCueService: AudioCueService, + @IAccessibilitySignalService private readonly accessibilitySignalService: AccessibilitySignalService, ) { super(); const isEnabled = observableFromEvent( - audioCueService.onEnabledChanged(AudioCue.onDebugBreak), - () => audioCueService.isCueEnabled(AudioCue.onDebugBreak) + accessibilitySignalService.onSoundEnabledChanged(AccessibilitySignal.onDebugBreak), + () => accessibilitySignalService.isSoundEnabled(AccessibilitySignal.onDebugBreak) ); this._register(autorunWithStore((reader, store) => { /** @description subscribe to debug sessions */ @@ -60,7 +60,7 @@ export class AudioCueLineDebuggerContribution const stoppedDetails = session.getStoppedDetails(); const BREAKPOINT_STOP_REASON = 'breakpoint'; if (stoppedDetails && stoppedDetails.reason === BREAKPOINT_STOP_REASON) { - this.audioCueService.playAudioCue(AudioCue.onDebugBreak); + this.accessibilitySignalService.playSignal(AccessibilitySignal.onDebugBreak); } }); diff --git a/src/vs/workbench/contrib/accessibilitySignals/browser/accessibilitySignalLineFeatureContribution.ts b/src/vs/workbench/contrib/accessibilitySignals/browser/accessibilitySignalLineFeatureContribution.ts new file mode 100644 index 0000000000000..7d46797c74984 --- /dev/null +++ b/src/vs/workbench/contrib/accessibilitySignals/browser/accessibilitySignalLineFeatureContribution.ts @@ -0,0 +1,273 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { CachedFunction } from 'vs/base/common/cache'; +import { Event } from 'vs/base/common/event'; +import { Disposable, DisposableStore } from 'vs/base/common/lifecycle'; +import { IObservable, IReader, autorun, autorunDelta, derived, derivedOpts, observableFromEvent, observableFromPromise, wasEventTriggeredRecently } from 'vs/base/common/observable'; +import { debouncedObservable2, observableSignalFromEvent } from 'vs/base/common/observableInternal/utils'; +import { ICodeEditor, isCodeEditor, isDiffEditor } from 'vs/editor/browser/editorBrowser'; +import { Position } from 'vs/editor/common/core/position'; +import { CursorChangeReason } from 'vs/editor/common/cursorEvents'; +import { ITextModel } from 'vs/editor/common/model'; +import { FoldingController } from 'vs/editor/contrib/folding/browser/folding'; +import { AccessibilitySignal, IAccessibilitySignalService } from 'vs/platform/accessibilitySignal/browser/accessibilitySignalService'; +import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; +import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; +import { IMarkerService, MarkerSeverity } from 'vs/platform/markers/common/markers'; +import { IWorkbenchContribution } from 'vs/workbench/common/contributions'; +import { IDebugService } from 'vs/workbench/contrib/debug/common/debug'; +import { IEditorService } from 'vs/workbench/services/editor/common/editorService'; + +export class SignalLineFeatureContribution + extends Disposable + implements IWorkbenchContribution { + private readonly store = this._register(new DisposableStore()); + private _previousLineNumber: number | undefined = undefined; + + private readonly features: LineFeature[] = [ + this.instantiationService.createInstance(MarkerLineFeature, AccessibilitySignal.error, MarkerSeverity.Error), + this.instantiationService.createInstance(MarkerLineFeature, AccessibilitySignal.warning, MarkerSeverity.Warning), + this.instantiationService.createInstance(FoldedAreaLineFeature), + this.instantiationService.createInstance(BreakpointLineFeature), + ]; + + private readonly isEnabledCache = new CachedFunction>((cue) => observableFromEvent( + Event.any( + this.accessibilitySignalService.onSoundEnabledChanged(cue), + this.accessibilitySignalService.onAnnouncementEnabledChanged(cue), + ), + () => this.accessibilitySignalService.isSoundEnabled(cue) || this.accessibilitySignalService.isAnnouncementEnabled(cue) + )); + + private readonly _someAccessibilitySignalIsEnabled = derived(this, + (reader) => this.features.some((feature) => + this.isEnabledCache.get(feature.signal).read(reader) + ) + ); + + private readonly _activeEditorObservable = observableFromEvent( + this.editorService.onDidActiveEditorChange, + (_) => { + const activeTextEditorControl = + this.editorService.activeTextEditorControl; + + const editor = isDiffEditor(activeTextEditorControl) + ? activeTextEditorControl.getOriginalEditor() + : isCodeEditor(activeTextEditorControl) + ? activeTextEditorControl + : undefined; + + return editor && editor.hasModel() ? { editor, model: editor.getModel() } : undefined; + } + ); + + constructor( + @IEditorService private readonly editorService: IEditorService, + @IInstantiationService private readonly instantiationService: IInstantiationService, + @IAccessibilitySignalService private readonly accessibilitySignalService: IAccessibilitySignalService, + @IConfigurationService private readonly _configurationService: IConfigurationService + ) { + super(); + + + this._register( + autorun(reader => { + /** @description updateSignalsEnabled */ + this.store.clear(); + + if (!this._someAccessibilitySignalIsEnabled.read(reader)) { + return; + } + const activeEditor = this._activeEditorObservable.read(reader); + if (activeEditor) { + this.registerAccessibilitySignalsForEditor(activeEditor.editor, activeEditor.model, this.store); + } + }) + ); + } + + private registerAccessibilitySignalsForEditor( + editor: ICodeEditor, + editorModel: ITextModel, + store: DisposableStore + ): void { + const curPosition = observableFromEvent( + editor.onDidChangeCursorPosition, + (args) => { + /** @description editor.onDidChangeCursorPosition (caused by user) */ + if ( + args && + args.reason !== CursorChangeReason.Explicit && + args.reason !== CursorChangeReason.NotSet + ) { + // Ignore cursor changes caused by navigation (e.g. which happens when execution is paused). + return undefined; + } + return editor.getPosition(); + } + ); + const debouncedPosition = debouncedObservable2(curPosition, this._configurationService.getValue('accessibility.signals.debouncePositionChanges') ? 300 : 0); + const isTyping = wasEventTriggeredRecently( + e => editorModel.onDidChangeContent(e), + 1000, + store + ); + + const featureStates = this.features.map((feature) => { + const lineFeatureState = feature.createSource(editor, editorModel); + const isFeaturePresent = derivedOpts( + { debugName: `isPresentInLine:${feature.signal.name}` }, + (reader) => { + if (!this.isEnabledCache.get(feature.signal).read(reader)) { + return false; + } + const position = debouncedPosition.read(reader); + if (!position) { + return false; + } + const lineChanged = this._previousLineNumber !== position.lineNumber; + const isPresent = lineFeatureState.isPresent?.(position, reader) || (lineChanged && lineFeatureState.isPresentOnLine(position.lineNumber, reader)); + this._previousLineNumber = position.lineNumber; + return isPresent; + } + ); + return derivedOpts( + { debugName: `typingDebouncedFeatureState:\n${feature.signal.name}` }, + (reader) => + feature.debounceWhileTyping && isTyping.read(reader) + ? (debouncedPosition.read(reader), isFeaturePresent.get()) + : isFeaturePresent.read(reader) + ); + }); + + const state = derived( + (reader) => /** @description states */({ + lineNumber: debouncedPosition.read(reader), + featureStates: new Map( + this.features.map((feature, idx) => [ + feature, + featureStates[idx].read(reader), + ]) + ), + }) + ); + + store.add( + autorunDelta(state, ({ lastValue, newValue }) => { + /** @description Play Accessibility Signal */ + const newFeatures = this.features.filter( + feature => + newValue?.featureStates.get(feature) && + (!lastValue?.featureStates?.get(feature) || newValue.lineNumber !== lastValue.lineNumber) + ); + + this.accessibilitySignalService.playSignals(newFeatures.map(f => f.signal)); + }) + ); + } +} + +interface LineFeature { + readonly signal: AccessibilitySignal; + readonly debounceWhileTyping?: boolean; + createSource( + editor: ICodeEditor, + model: ITextModel + ): LineFeatureSource; +} + + +interface LineFeatureSource { + isPresentOnLine(lineNumber: number, reader: IReader): boolean; + isPresent?(position: Position, reader: IReader): boolean; +} + +class MarkerLineFeature implements LineFeature { + public readonly debounceWhileTyping = true; + constructor( + public readonly signal: AccessibilitySignal, + private readonly severity: MarkerSeverity, + @IMarkerService private readonly markerService: IMarkerService, + + ) { } + + createSource(editor: ICodeEditor, model: ITextModel): LineFeatureSource { + const obs = observableSignalFromEvent('onMarkerChanged', this.markerService.onMarkerChanged); + return { + isPresent: (position, reader) => { + obs.read(reader); + const hasMarker = this.markerService + .read({ resource: model.uri }) + .some( + (m) => + m.severity === this.severity && + m.startLineNumber <= position.lineNumber && + position.lineNumber <= m.endLineNumber && + m.startColumn <= position.column && + position.column <= m.endColumn + ); + return hasMarker; + }, + isPresentOnLine: (lineNumber, reader) => { + obs.read(reader); + const hasMarker = this.markerService + .read({ resource: model.uri }) + .some( + (m) => + m.severity === this.severity && + m.startLineNumber <= lineNumber && + lineNumber <= m.endLineNumber + ); + return hasMarker; + } + }; + } +} + +class FoldedAreaLineFeature implements LineFeature { + public readonly signal = AccessibilitySignal.foldedArea; + + createSource(editor: ICodeEditor, _model: ITextModel): LineFeatureSource { + const foldingController = FoldingController.get(editor); + if (!foldingController) { + return { isPresentOnLine: () => false }; + } + + const foldingModel = observableFromPromise(foldingController.getFoldingModel() ?? Promise.resolve(undefined)); + return { + isPresentOnLine(lineNumber: number, reader: IReader): boolean { + const m = foldingModel.read(reader); + const regionAtLine = m.value?.getRegionAtLine(lineNumber); + const hasFolding = !regionAtLine + ? false + : regionAtLine.isCollapsed && + regionAtLine.startLineNumber === lineNumber; + return hasFolding; + } + }; + } +} + +class BreakpointLineFeature implements LineFeature { + public readonly signal = AccessibilitySignal.break; + + constructor(@IDebugService private readonly debugService: IDebugService) { } + + createSource(editor: ICodeEditor, model: ITextModel): LineFeatureSource { + const signal = observableSignalFromEvent('onDidChangeBreakpoints', this.debugService.getModel().onDidChangeBreakpoints); + const debugService = this.debugService; + return { + isPresentOnLine(lineNumber: number, reader: IReader): boolean { + signal.read(reader); + const breakpoints = debugService + .getModel() + .getBreakpoints({ uri: model.uri, lineNumber }); + const hasBreakpoints = breakpoints.length > 0; + return hasBreakpoints; + } + }; + } +} diff --git a/src/vs/workbench/contrib/accessibilitySignals/browser/commands.ts b/src/vs/workbench/contrib/accessibilitySignals/browser/commands.ts new file mode 100644 index 0000000000000..23dc8cf04498d --- /dev/null +++ b/src/vs/workbench/contrib/accessibilitySignals/browser/commands.ts @@ -0,0 +1,150 @@ +/*--------------------------------------------------------------------------------------------- + * 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 'vs/base/common/codicons'; +import { ThemeIcon } from 'vs/base/common/themables'; +import { localize, localize2 } from 'vs/nls'; +import { IAccessibilityService } from 'vs/platform/accessibility/common/accessibility'; +import { Action2 } from 'vs/platform/actions/common/actions'; +import { AccessibilitySignal, IAccessibilitySignalService } from 'vs/platform/accessibilitySignal/browser/accessibilitySignalService'; +import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; +import { ServicesAccessor } from 'vs/platform/instantiation/common/instantiation'; +import { IQuickInputService, IQuickPickItem } from 'vs/platform/quickinput/common/quickInput'; +import { IPreferencesService } from 'vs/workbench/services/preferences/common/preferences'; + +export class ShowSignalSoundHelp extends Action2 { + static readonly ID = 'signals.sounds.help'; + + constructor() { + super({ + id: ShowSignalSoundHelp.ID, + title: localize2('signals.sound.help', "Help: List Signal Sounds"), + f1: true, + metadata: { + description: localize('accessibility.sound.help.description', "List all accessibility sounds, noises, or audio cues and configure their settings") + } + }); + } + + override async run(accessor: ServicesAccessor): Promise { + const accessibilitySignalService = accessor.get(IAccessibilitySignalService); + const quickInputService = accessor.get(IQuickInputService); + const configurationService = accessor.get(IConfigurationService); + const accessibilityService = accessor.get(IAccessibilityService); + const preferencesService = accessor.get(IPreferencesService); + const userGestureSignals = [AccessibilitySignal.save, AccessibilitySignal.format]; + const items: (IQuickPickItem & { signal: AccessibilitySignal })[] = AccessibilitySignal.allAccessibilitySignals.map((signal, idx) => ({ + label: userGestureSignals.includes(signal) ? `${signal.name} (${configurationService.getValue(signal.settingsKey + '.sound')})` : signal.name, + signal, + buttons: userGestureSignals.includes(signal) ? [{ + iconClass: ThemeIcon.asClassName(Codicon.settingsGear), + tooltip: localize('sounds.help.settings', 'Configure Sound'), + alwaysVisible: true + }] : [] + })).sort((a, b) => a.label.localeCompare(b.label)); + const qp = quickInputService.createQuickPick(); + qp.items = items; + qp.selectedItems = items.filter(i => accessibilitySignalService.isSoundEnabled(i.signal) || userGestureSignals.includes(i.signal) && configurationService.getValue(i.signal.settingsKey + '.sound') !== 'never'); + qp.onDidAccept(() => { + const enabledSounds = qp.selectedItems.map(i => i.signal); + const disabledSounds = qp.items.map(i => (i as any).signal).filter(i => !enabledSounds.includes(i)); + for (const signal of enabledSounds) { + let { sound, announcement } = configurationService.getValue<{ sound: string; announcement?: string }>(signal.settingsKey); + sound = userGestureSignals.includes(signal) ? 'userGesture' : accessibilityService.isScreenReaderOptimized() ? 'auto' : 'on'; + if (announcement) { + configurationService.updateValue(signal.settingsKey, { sound, announcement }); + } else { + configurationService.updateValue(signal.settingsKey, { sound }); + } + } + + for (const signal of disabledSounds) { + const announcement = configurationService.getValue(signal.settingsKey + '.announcement'); + const sound = getDisabledSettingValue(userGestureSignals.includes(signal), accessibilityService.isScreenReaderOptimized()); + const value = announcement ? { sound, announcement } : { sound }; + configurationService.updateValue(signal.settingsKey, value); + } + qp.hide(); + }); + qp.onDidTriggerItemButton(e => { + preferencesService.openUserSettings({ jsonEditor: true, revealSetting: { key: e.item.signal.settingsKey, edit: true } }); + }); + qp.onDidChangeActive(() => { + accessibilitySignalService.playSound(qp.activeItems[0].signal.sound.getSound(true), true); + }); + qp.placeholder = localize('sounds.help.placeholder', 'Select a sound to play and configure'); + qp.canSelectMany = true; + await qp.show(); + } +} + +function getDisabledSettingValue(isUserGestureSignal: boolean, isScreenReaderOptimized: boolean): string { + return isScreenReaderOptimized ? (isUserGestureSignal ? 'never' : 'off') : (isUserGestureSignal ? 'never' : 'auto'); +} + +export class ShowAccessibilityAnnouncementHelp extends Action2 { + static readonly ID = 'accessibility.announcement.help'; + + constructor() { + super({ + id: ShowAccessibilityAnnouncementHelp.ID, + title: localize2('accessibility.announcement.help', "Help: List Signal Announcements"), + f1: true, + metadata: { + description: localize('accessibility.announcement.help.description', "List all accessibility announcements, alerts, braille messages, and configure their settings") + } + }); + } + + override async run(accessor: ServicesAccessor): Promise { + const accessibilitySignalService = accessor.get(IAccessibilitySignalService); + const quickInputService = accessor.get(IQuickInputService); + const configurationService = accessor.get(IConfigurationService); + const accessibilityService = accessor.get(IAccessibilityService); + const preferencesService = accessor.get(IPreferencesService); + const userGestureSignals = [AccessibilitySignal.save, AccessibilitySignal.format]; + const items: (IQuickPickItem & { signal: AccessibilitySignal })[] = AccessibilitySignal.allAccessibilitySignals.filter(c => !!c.legacyAnnouncementSettingsKey).map((signal, idx) => ({ + label: userGestureSignals.includes(signal) ? `${signal.name} (${configurationService.getValue(signal.settingsKey + '.announcement')})` : signal.name, + signal, + buttons: userGestureSignals.includes(signal) ? [{ + iconClass: ThemeIcon.asClassName(Codicon.settingsGear), + tooltip: localize('announcement.help.settings', 'Configure Announcement'), + alwaysVisible: true, + }] : [] + })).sort((a, b) => a.label.localeCompare(b.label)); + const qp = quickInputService.createQuickPick(); + qp.items = items; + qp.selectedItems = items.filter(i => accessibilitySignalService.isAnnouncementEnabled(i.signal) || userGestureSignals.includes(i.signal) && configurationService.getValue(i.signal.settingsKey + '.announcement') !== 'never'); + const screenReaderOptimized = accessibilityService.isScreenReaderOptimized(); + qp.onDidAccept(() => { + if (!screenReaderOptimized) { + // announcements are off by default when screen reader is not active + qp.hide(); + return; + } + const enabledAnnouncements = qp.selectedItems.map(i => i.signal); + const disabledAnnouncements = AccessibilitySignal.allAccessibilitySignals.filter(cue => !!cue.legacyAnnouncementSettingsKey && !enabledAnnouncements.includes(cue)); + for (const signal of enabledAnnouncements) { + let { sound, announcement } = configurationService.getValue<{ sound: string; announcement?: string }>(signal.settingsKey); + announcement = userGestureSignals.includes(signal) ? 'userGesture' : signal.announcementMessage && accessibilityService.isScreenReaderOptimized() ? 'auto' : undefined; + configurationService.updateValue(signal.settingsKey, { sound, announcement }); + } + + for (const signal of disabledAnnouncements) { + const announcement = getDisabledSettingValue(userGestureSignals.includes(signal), true); + const sound = configurationService.getValue(signal.settingsKey + '.sound'); + const value = announcement ? { sound, announcement } : { sound }; + configurationService.updateValue(signal.settingsKey, value); + } + qp.hide(); + }); + qp.onDidTriggerItemButton(e => { + preferencesService.openUserSettings({ jsonEditor: true, revealSetting: { key: e.item.signal.settingsKey, edit: true } }); + }); + qp.placeholder = screenReaderOptimized ? localize('announcement.help.placeholder', 'Select an announcement to configure') : localize('announcement.help.placeholder.disabled', 'Screen reader is not active, announcements are disabled by default.'); + qp.canSelectMany = true; + await qp.show(); + } +} diff --git a/src/vs/workbench/contrib/accessibilitySignals/browser/openDiffEditorAnnouncement.ts b/src/vs/workbench/contrib/accessibilitySignals/browser/openDiffEditorAnnouncement.ts new file mode 100644 index 0000000000000..ed3d0f75cb0c3 --- /dev/null +++ b/src/vs/workbench/contrib/accessibilitySignals/browser/openDiffEditorAnnouncement.ts @@ -0,0 +1,56 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable, IDisposable } from 'vs/base/common/lifecycle'; +import { isDiffEditor } from 'vs/editor/browser/editorBrowser'; +import { localize } from 'vs/nls'; +import { IAccessibilityService } from 'vs/platform/accessibility/common/accessibility'; +import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; +import { IWorkbenchContribution } from 'vs/workbench/common/contributions'; +import { IEditorService } from 'vs/workbench/services/editor/common/editorService'; +import { Event } from 'vs/base/common/event'; +import { AccessibilityVerbositySettingId } from 'vs/workbench/contrib/accessibility/browser/accessibilityConfiguration'; + +export class DiffEditorActiveAnnouncementContribution extends Disposable implements IWorkbenchContribution { + + static readonly ID = 'workbench.contrib.diffEditorActiveAnnouncement'; + + private _onDidActiveEditorChangeListener?: IDisposable; + + constructor( + @IEditorService private readonly _editorService: IEditorService, + @IAccessibilityService private readonly _accessibilityService: IAccessibilityService, + @IConfigurationService private readonly _configurationService: IConfigurationService + ) { + super(); + this._register(Event.runAndSubscribe(_accessibilityService.onDidChangeScreenReaderOptimized, () => this._updateListener())); + this._register(_configurationService.onDidChangeConfiguration(e => { + if (e.affectsConfiguration(AccessibilityVerbositySettingId.DiffEditorActive)) { + this._updateListener(); + } + })); + } + + private _updateListener(): void { + const announcementEnabled = this._configurationService.getValue(AccessibilityVerbositySettingId.DiffEditorActive); + const screenReaderOptimized = this._accessibilityService.isScreenReaderOptimized(); + + if (!announcementEnabled || !screenReaderOptimized) { + this._onDidActiveEditorChangeListener?.dispose(); + this._onDidActiveEditorChangeListener = undefined; + return; + } + + if (this._onDidActiveEditorChangeListener) { + return; + } + + this._onDidActiveEditorChangeListener = this._register(this._editorService.onDidActiveEditorChange(() => { + if (isDiffEditor(this._editorService.activeTextEditorControl)) { + this._accessibilityService.alert(localize('openDiffEditorAnnouncement', "Diff editor")); + } + })); + } +} diff --git a/src/vs/workbench/contrib/accessibility/browser/saveAudioCue.ts b/src/vs/workbench/contrib/accessibilitySignals/browser/saveAccessibilitySignal.ts similarity index 56% rename from src/vs/workbench/contrib/accessibility/browser/saveAudioCue.ts rename to src/vs/workbench/contrib/accessibilitySignals/browser/saveAccessibilitySignal.ts index 32ef2425e81c2..e4df2fcc74eeb 100644 --- a/src/vs/workbench/contrib/accessibility/browser/saveAudioCue.ts +++ b/src/vs/workbench/contrib/accessibilitySignals/browser/saveAccessibilitySignal.ts @@ -4,22 +4,20 @@ *--------------------------------------------------------------------------------------------*/ import { Disposable } from 'vs/base/common/lifecycle'; -import { AudioCue, IAudioCueService } from 'vs/platform/audioCues/browser/audioCueService'; +import { AccessibilitySignal, IAccessibilitySignalService } from 'vs/platform/accessibilitySignal/browser/accessibilitySignalService'; import { IWorkbenchContribution } from 'vs/workbench/common/contributions'; import { SaveReason } from 'vs/workbench/common/editor'; import { IWorkingCopyService } from 'vs/workbench/services/workingCopy/common/workingCopyService'; -export class SaveAudioCueContribution extends Disposable implements IWorkbenchContribution { +export class SaveAccessibilitySignalContribution extends Disposable implements IWorkbenchContribution { - static readonly ID = 'workbench.contrib.saveAudioCues'; + static readonly ID = 'workbench.contrib.saveAccessibilitySignal'; constructor( - @IAudioCueService private readonly _audioCueService: IAudioCueService, + @IAccessibilitySignalService private readonly _accessibilitySignalService: IAccessibilitySignalService, @IWorkingCopyService private readonly _workingCopyService: IWorkingCopyService, ) { super(); - this._register(this._workingCopyService.onDidSave((e) => { - this._audioCueService.playAudioCue(AudioCue.save, { userGesture: e.reason === SaveReason.EXPLICIT }); - })); + this._register(this._workingCopyService.onDidSave(e => this._accessibilitySignalService.playSignal(AccessibilitySignal.save, { userGesture: e.reason === SaveReason.EXPLICIT }))); } } diff --git a/src/vs/workbench/contrib/accountEntitlements/browser/accountsEntitlements.contribution.ts b/src/vs/workbench/contrib/accountEntitlements/browser/accountsEntitlements.contribution.ts index ecc7d689e2778..348240f38abce 100644 --- a/src/vs/workbench/contrib/accountEntitlements/browser/accountsEntitlements.contribution.ts +++ b/src/vs/workbench/contrib/accountEntitlements/browser/accountsEntitlements.contribution.ts @@ -3,9 +3,8 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { LifecyclePhase } from 'vs/workbench/services/lifecycle/common/lifecycle'; import { Registry } from 'vs/platform/registry/common/platform'; -import { Extensions as WorkbenchExtensions, IWorkbenchContributionsRegistry, IWorkbenchContribution } from 'vs/workbench/common/contributions'; +import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from 'vs/workbench/common/contributions'; import { Disposable, MutableDisposable } from 'vs/base/common/lifecycle'; import { ContextKeyExpr, IContextKeyService, RawContextKey } from 'vs/platform/contextkey/common/contextkey'; import { ServicesAccessor } from 'vs/platform/instantiation/common/instantiation'; @@ -27,62 +26,51 @@ import { IRequestService, asText } from 'vs/platform/request/common/request'; import { CancellationToken } from 'vs/base/common/cancellation'; import { IDialogService } from 'vs/platform/dialogs/common/dialogs'; import { isWeb } from 'vs/base/common/platform'; +import { isInternalTelemetry } from 'vs/platform/telemetry/common/telemetryUtils'; -const configurationKey = 'workbench.accounts.experimental.showEntitlements'; +const accountsBadgeConfigKey = 'workbench.accounts.experimental.showEntitlements'; +const chatWelcomeViewConfigKey = 'workbench.chat.experimental.showWelcomeView'; type EntitlementEnablementClassification = { - enabled: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Flag indicating if the account entitlement is enabled' }; + enabled: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Flag indicating if the entitlement is enabled' }; owner: 'bhavyaus'; - comment: 'Reporting when the account entitlement is shown'; + comment: 'Reporting when the entitlement is shown'; }; type EntitlementActionClassification = { command: { classification: 'PublicNonPersonalData'; purpose: 'FeatureInsight'; comment: 'The command being executed by the entitlement action' }; owner: 'bhavyaus'; - comment: 'Reporting the account entitlement action'; + comment: 'Reporting the entitlement action'; }; -class AccountsEntitlement extends Disposable implements IWorkbenchContribution { +class EntitlementsContribution extends Disposable implements IWorkbenchContribution { + private isInitialized = false; - private contextKey = new RawContextKey(configurationKey, true).bindTo(this.contextService); + private showAccountsBadgeContextKey = new RawContextKey(accountsBadgeConfigKey, false).bindTo(this.contextService); + private showChatWelcomeViewContextKey = new RawContextKey(chatWelcomeViewConfigKey, false).bindTo(this.contextService); + private readonly accountsMenuBadgeDisposable = this._register(new MutableDisposable()); constructor( - @IContextKeyService readonly contextService: IContextKeyService, - @ICommandService readonly commandService: ICommandService, - @ITelemetryService readonly telemetryService: ITelemetryService, - @IAuthenticationService readonly authenticationService: IAuthenticationService, - @IProductService readonly productService: IProductService, - @IStorageService readonly storageService: IStorageService, - @IExtensionManagementService readonly extensionManagementService: IExtensionManagementService, - @IActivityService readonly activityService: IActivityService, - @IExtensionService readonly extensionService: IExtensionService, - @IConfigurationService readonly configurationService: IConfigurationService, - @IContextKeyService readonly contextKeyService: IContextKeyService, - @IRequestService readonly requestService: IRequestService, - ) { + @IContextKeyService private readonly contextService: IContextKeyService, + @ITelemetryService private readonly telemetryService: ITelemetryService, + @IAuthenticationService private readonly authenticationService: IAuthenticationService, + @IProductService private readonly productService: IProductService, + @IStorageService private readonly storageService: IStorageService, + @IExtensionManagementService private readonly extensionManagementService: IExtensionManagementService, + @IActivityService private readonly activityService: IActivityService, + @IExtensionService private readonly extensionService: IExtensionService, + @IConfigurationService private readonly configurationService: IConfigurationService, + @IRequestService private readonly requestService: IRequestService) { super(); if (!this.productService.gitHubEntitlement || isWeb) { return; } - // if previously shown, do not show again. - const showEntitlements = this.storageService.getBoolean(configurationKey, StorageScope.APPLICATION, true); - if (!showEntitlements) { - return; - } - - const setting = this.configurationService.inspect(configurationKey); - if (!setting.value) { - return; - } - - this.extensionManagementService.getInstalled().then(exts => { + this.extensionManagementService.getInstalled().then(async exts => { const installed = exts.find(value => ExtensionIdentifier.equals(value.identifier.id, this.productService.gitHubEntitlement!.extensionId)); if (installed) { - this.storageService.store(configurationKey, false, StorageScope.APPLICATION, StorageTarget.MACHINE); - this.contextKey.set(false); - return; + this.disableEntitlements(); } else { this.registerListeners(); } @@ -90,35 +78,38 @@ class AccountsEntitlement extends Disposable implements IWorkbenchContribution { } private registerListeners() { + this._register(this.extensionService.onDidChangeExtensions(async (result) => { for (const ext of result.added) { if (ExtensionIdentifier.equals(this.productService.gitHubEntitlement!.extensionId, ext.identifier)) { - this.storageService.store(configurationKey, false, StorageScope.APPLICATION, StorageTarget.MACHINE); - this.contextKey.set(false); + this.disableEntitlements(); return; } } })); this._register(this.authenticationService.onDidChangeSessions(async (e) => { - if (e.providerId === this.productService.gitHubEntitlement!.providerId && e.event.added?.length && !this.isInitialized) { - this.onSessionChange(e.event.added[0]); + if (e.providerId === this.productService.gitHubEntitlement!.providerId && e.event.added?.length) { + await this.enableEntitlements(e.event.added[0]); } else if (e.providerId === this.productService.gitHubEntitlement!.providerId && e.event.removed?.length) { - this.contextKey.set(false); + this.showAccountsBadgeContextKey.set(false); + this.showChatWelcomeViewContextKey.set(false); + this.accountsMenuBadgeDisposable.clear(); } })); this._register(this.authenticationService.onDidRegisterAuthenticationProvider(async e => { - if (e.id === this.productService.gitHubEntitlement!.providerId && !this.isInitialized) { - const session = await this.authenticationService.getSessions(e.id); - this.onSessionChange(session[0]); + if (e.id === this.productService.gitHubEntitlement!.providerId) { + await this.enableEntitlements((await this.authenticationService.getSessions(e.id))[0]); } })); } - private async onSessionChange(session: AuthenticationSession) { + private async getEntitlementsInfo(session: AuthenticationSession): Promise<[enabled: boolean, org: string | undefined]> { - this.isInitialized = true; + if (this.isInitialized) { + return [false, '']; + } const context = await this.requestService.request({ type: 'GET', @@ -129,11 +120,11 @@ class AccountsEntitlement extends Disposable implements IWorkbenchContribution { }, CancellationToken.None); if (context.res.statusCode && context.res.statusCode !== 200) { - return; + return [false, '']; } const result = await asText(context); if (!result) { - return; + return [false, '']; } let parsedResult: any; @@ -142,25 +133,54 @@ class AccountsEntitlement extends Disposable implements IWorkbenchContribution { } catch (err) { //ignore - return; + return [false, '']; } if (!(this.productService.gitHubEntitlement!.enablementKey in parsedResult) || !parsedResult[this.productService.gitHubEntitlement!.enablementKey]) { - return; + this.telemetryService.publicLog2<{ enabled: boolean }, EntitlementEnablementClassification>('entitlements.enabled', { enabled: false }); + return [false, '']; } + this.telemetryService.publicLog2<{ enabled: boolean }, EntitlementEnablementClassification>('entitlements.enabled', { enabled: true }); + this.isInitialized = true; + const orgs = parsedResult['organization_login_list'] as any[]; + return [true, orgs ? orgs[orgs.length - 1] : undefined]; + } - this.contextKey.set(true); - this.telemetryService.publicLog2<{ enabled: boolean }, EntitlementEnablementClassification>(configurationKey, { enabled: true }); + private async enableEntitlements(session: AuthenticationSession) { + const isInternal = isInternalTelemetry(this.productService, this.configurationService); + const showAccountsBadge = this.configurationService.inspect(accountsBadgeConfigKey).value ?? false; + const showWelcomeView = this.configurationService.inspect(chatWelcomeViewConfigKey).value ?? false; - const orgs = parsedResult['organization_login_list'] as any[]; - const menuTitle = orgs ? this.productService.gitHubEntitlement!.command.title.replace('{{org}}', orgs[orgs.length - 1]) : this.productService.gitHubEntitlement!.command.titleWithoutPlaceHolder; + const [enabled, org] = await this.getEntitlementsInfo(session); + if (enabled) { + if (isInternal && showWelcomeView) { + this.showChatWelcomeViewContextKey.set(true); + this.telemetryService.publicLog2<{ enabled: boolean }, EntitlementEnablementClassification>(chatWelcomeViewConfigKey, { enabled: true }); + } + if (showAccountsBadge) { + this.createAccountsBadge(org); + this.showAccountsBadgeContextKey.set(showAccountsBadge); + this.telemetryService.publicLog2<{ enabled: boolean }, EntitlementEnablementClassification>(accountsBadgeConfigKey, { enabled: true }); + } + } + } - const badge = new NumberBadge(1, () => menuTitle); - const accountsMenuBadgeDisposable = this._register(new MutableDisposable()); - accountsMenuBadgeDisposable.value = this.activityService.showAccountsActivity({ badge, }); + private disableEntitlements() { + this.storageService.store(accountsBadgeConfigKey, false, StorageScope.APPLICATION, StorageTarget.MACHINE); + this.storageService.store(chatWelcomeViewConfigKey, false, StorageScope.APPLICATION, StorageTarget.MACHINE); + this.showAccountsBadgeContextKey.set(false); + this.showChatWelcomeViewContextKey.set(false); + this.accountsMenuBadgeDisposable.clear(); + } + + private async createAccountsBadge(org: string | undefined) { + const menuTitle = org ? this.productService.gitHubEntitlement!.command.title.replace('{{org}}', org) : this.productService.gitHubEntitlement!.command.titleWithoutPlaceHolder; - registerAction2(class extends Action2 { + const badge = new NumberBadge(1, () => menuTitle); + this.accountsMenuBadgeDisposable.value = this.activityService.showAccountsActivity({ badge, }); + + this._register(registerAction2(class extends Action2 { constructor() { super({ id: 'workbench.action.entitlementAction', @@ -169,7 +189,7 @@ class AccountsEntitlement extends Disposable implements IWorkbenchContribution { menu: { id: MenuId.AccountsContext, group: '5_AccountsEntitlements', - when: ContextKeyExpr.equals(configurationKey, true), + when: ContextKeyExpr.equals(accountsBadgeConfigKey, true), } }); } @@ -201,19 +221,14 @@ class AccountsEntitlement extends Disposable implements IWorkbenchContribution { }); } - accountsMenuBadgeDisposable.clear(); - const contextKey = new RawContextKey(configurationKey, true).bindTo(contextKeyService); + const contextKey = new RawContextKey(accountsBadgeConfigKey, true).bindTo(contextKeyService); contextKey.set(false); - storageService.store(configurationKey, false, StorageScope.APPLICATION, StorageTarget.MACHINE); + storageService.store(accountsBadgeConfigKey, false, StorageScope.APPLICATION, StorageTarget.MACHINE); } - }); + })); } } -Registry.as(WorkbenchExtensions.Workbench) - .registerWorkbenchContribution(AccountsEntitlement, LifecyclePhase.Eventually); - - const configurationRegistry = Registry.as(ConfigurationExtensions.Configuration); configurationRegistry.registerConfiguration({ ...applicationConfigurationNodeBase, @@ -227,3 +242,18 @@ configurationRegistry.registerConfiguration({ } } }); + +configurationRegistry.registerConfiguration({ + ...applicationConfigurationNodeBase, + properties: { + 'workbench.chat.experimental.showWelcomeView': { + scope: ConfigurationScope.MACHINE, + type: 'boolean', + default: false, + tags: ['experimental'], + description: localize('workbench.chat.showWelcomeView', "When enabled, the chat panel welcome view will be shown.") + } + } +}); + +registerWorkbenchContribution2('workbench.contrib.entitlements', EntitlementsContribution, WorkbenchPhase.BlockRestore); diff --git a/src/vs/workbench/contrib/audioCues/browser/audioCueLineFeatureContribution.ts b/src/vs/workbench/contrib/audioCues/browser/audioCueLineFeatureContribution.ts deleted file mode 100644 index e7bba6b3f35b2..0000000000000 --- a/src/vs/workbench/contrib/audioCues/browser/audioCueLineFeatureContribution.ts +++ /dev/null @@ -1,262 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { CachedFunction } from 'vs/base/common/cache'; -import { Event } from 'vs/base/common/event'; -import { Disposable, DisposableStore } from 'vs/base/common/lifecycle'; -import { IObservable, autorun, autorunDelta, constObservable, debouncedObservable, derived, derivedOpts, observableFromEvent, observableFromPromise, wasEventTriggeredRecently } from 'vs/base/common/observable'; -import { ICodeEditor, isCodeEditor, isDiffEditor } from 'vs/editor/browser/editorBrowser'; -import { Position } from 'vs/editor/common/core/position'; -import { CursorChangeReason } from 'vs/editor/common/cursorEvents'; -import { ITextModel } from 'vs/editor/common/model'; -import { FoldingController } from 'vs/editor/contrib/folding/browser/folding'; -import { AudioCue, IAudioCueService } from 'vs/platform/audioCues/browser/audioCueService'; -import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; -import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; -import { IMarkerService, MarkerSeverity } from 'vs/platform/markers/common/markers'; -import { IWorkbenchContribution } from 'vs/workbench/common/contributions'; -import { IDebugService } from 'vs/workbench/contrib/debug/common/debug'; -import { IEditorService } from 'vs/workbench/services/editor/common/editorService'; - -export class AudioCueLineFeatureContribution - extends Disposable - implements IWorkbenchContribution { - private readonly store = this._register(new DisposableStore()); - - private readonly features: LineFeature[] = [ - this.instantiationService.createInstance(MarkerLineFeature, AudioCue.error, MarkerSeverity.Error), - this.instantiationService.createInstance(MarkerLineFeature, AudioCue.warning, MarkerSeverity.Warning), - this.instantiationService.createInstance(FoldedAreaLineFeature), - this.instantiationService.createInstance(BreakpointLineFeature), - ]; - - private readonly isEnabledCache = new CachedFunction>((cue) => observableFromEvent( - this.audioCueService.onEnabledChanged(cue), - () => this.audioCueService.isCueEnabled(cue) - )); - - private readonly isAlertEnabledCache = new CachedFunction>((cue) => observableFromEvent( - this.audioCueService.onAlertEnabledChanged(cue), - () => this.audioCueService.isAlertEnabled(cue) - )); - - constructor( - @IEditorService private readonly editorService: IEditorService, - @IInstantiationService private readonly instantiationService: IInstantiationService, - @IAudioCueService private readonly audioCueService: IAudioCueService, - @IConfigurationService private readonly _configurationService: IConfigurationService - ) { - super(); - - const someAudioCueFeatureIsEnabled = derived( - (reader) => /** @description someAudioCueFeatureIsEnabled */ this.features.some((feature) => - this.isEnabledCache.get(feature.audioCue).read(reader) || this.isAlertEnabledCache.get(feature.audioCue).read(reader) - ) - ); - - const activeEditorObservable = observableFromEvent( - this.editorService.onDidActiveEditorChange, - (_) => { - const activeTextEditorControl = - this.editorService.activeTextEditorControl; - - const editor = isDiffEditor(activeTextEditorControl) - ? activeTextEditorControl.getOriginalEditor() - : isCodeEditor(activeTextEditorControl) - ? activeTextEditorControl - : undefined; - - return editor && editor.hasModel() ? { editor, model: editor.getModel() } : undefined; - } - ); - - this._register( - autorun(reader => { - /** @description updateAudioCuesEnabled */ - this.store.clear(); - - if (!someAudioCueFeatureIsEnabled.read(reader)) { - return; - } - - const activeEditor = activeEditorObservable.read(reader); - if (activeEditor) { - this.registerAudioCuesForEditor(activeEditor.editor, activeEditor.model, this.store); - } - }) - ); - } - - private registerAudioCuesForEditor( - editor: ICodeEditor, - editorModel: ITextModel, - store: DisposableStore - ): void { - const curPosition = observableFromEvent( - editor.onDidChangeCursorPosition, - (args) => { - /** @description editor.onDidChangeCursorPosition (caused by user) */ - if ( - args && - args.reason !== CursorChangeReason.Explicit && - args.reason !== CursorChangeReason.NotSet - ) { - // Ignore cursor changes caused by navigation (e.g. which happens when execution is paused). - return undefined; - } - return editor.getPosition(); - } - ); - const debouncedPosition = debouncedObservable(curPosition, this._configurationService.getValue('audioCues.debouncePositionChanges') ? 300 : 0, store); - const isTyping = wasEventTriggeredRecently( - editorModel.onDidChangeContent.bind(editorModel), - 1000, - store - ); - - const featureStates = this.features.map((feature) => { - const lineFeatureState = feature.getObservableState(editor, editorModel); - const isFeaturePresent = derivedOpts( - { debugName: `isPresentInLine:${feature.audioCue.name}` }, - (reader) => { - if (!this.isEnabledCache.get(feature.audioCue).read(reader) && !this.isAlertEnabledCache.get(feature.audioCue).read(reader)) { - return false; - } - const position = debouncedPosition.read(reader); - if (!position) { - return false; - } - return lineFeatureState.read(reader).isPresent(position); - } - ); - return derivedOpts( - { debugName: `typingDebouncedFeatureState:\n${feature.audioCue.name}` }, - (reader) => - feature.debounceWhileTyping && isTyping.read(reader) - ? (debouncedPosition.read(reader), isFeaturePresent.get()) - : isFeaturePresent.read(reader) - ); - }); - - const state = derived( - (reader) => /** @description states */({ - lineNumber: debouncedPosition.read(reader), - featureStates: new Map( - this.features.map((feature, idx) => [ - feature, - featureStates[idx].read(reader), - ]) - ), - }) - ); - - store.add( - autorunDelta(state, ({ lastValue, newValue }) => { - /** @description Play Audio Cue */ - const newFeatures = this.features.filter( - feature => - newValue?.featureStates.get(feature) && - (!lastValue?.featureStates?.get(feature) || newValue.lineNumber !== lastValue.lineNumber) - ); - - this.audioCueService.playAudioCues(newFeatures.map(f => f.audioCue)); - }) - ); - } -} - -interface LineFeature { - audioCue: AudioCue; - debounceWhileTyping?: boolean; - getObservableState( - editor: ICodeEditor, - model: ITextModel - ): IObservable; -} - -interface LineFeatureState { - isPresent(position: Position): boolean; -} - -class MarkerLineFeature implements LineFeature { - public readonly debounceWhileTyping = true; - private _previousLine: number = 0; - constructor( - public readonly audioCue: AudioCue, - private readonly severity: MarkerSeverity, - @IMarkerService private readonly markerService: IMarkerService, - - ) { } - - getObservableState(editor: ICodeEditor, model: ITextModel): IObservable { - return observableFromEvent( - Event.filter(this.markerService.onMarkerChanged, (changedUris) => - changedUris.some((u) => u.toString() === model.uri.toString()) - ), - () => /** @description this.markerService.onMarkerChanged */({ - isPresent: (position) => { - const lineChanged = position.lineNumber !== this._previousLine; - this._previousLine = position.lineNumber; - const hasMarker = this.markerService - .read({ resource: model.uri }) - .some( - (m) => { - const onLine = m.severity === this.severity && m.startLineNumber <= position.lineNumber && position.lineNumber <= m.endLineNumber; - return lineChanged ? onLine : onLine && (position.lineNumber <= m.endLineNumber && m.startColumn <= position.column && m.endColumn >= position.column); - }); - return hasMarker; - }, - }) - ); - } -} - -class FoldedAreaLineFeature implements LineFeature { - public readonly audioCue = AudioCue.foldedArea; - - getObservableState(editor: ICodeEditor, model: ITextModel): IObservable { - const foldingController = FoldingController.get(editor); - if (!foldingController) { - return constObservable({ - isPresent: () => false, - }); - } - - const foldingModel = observableFromPromise( - foldingController.getFoldingModel() ?? Promise.resolve(undefined) - ); - return foldingModel.map((v) => ({ - isPresent: (position) => { - const regionAtLine = v.value?.getRegionAtLine(position.lineNumber); - const hasFolding = !regionAtLine - ? false - : regionAtLine.isCollapsed && - regionAtLine.startLineNumber === position.lineNumber; - return hasFolding; - }, - })); - } -} - -class BreakpointLineFeature implements LineFeature { - public readonly audioCue = AudioCue.break; - - constructor(@IDebugService private readonly debugService: IDebugService) { } - - getObservableState(editor: ICodeEditor, model: ITextModel): IObservable { - return observableFromEvent( - this.debugService.getModel().onDidChangeBreakpoints, - () => /** @description debugService.getModel().onDidChangeBreakpoints */({ - isPresent: (position) => { - const breakpoints = this.debugService - .getModel() - .getBreakpoints({ uri: model.uri, lineNumber: position.lineNumber }); - const hasBreakpoints = breakpoints.length > 0; - return hasBreakpoints; - }, - }) - ); - } -} diff --git a/src/vs/workbench/contrib/audioCues/browser/audioCues.contribution.ts b/src/vs/workbench/contrib/audioCues/browser/audioCues.contribution.ts deleted file mode 100644 index 1fced33582713..0000000000000 --- a/src/vs/workbench/contrib/audioCues/browser/audioCues.contribution.ts +++ /dev/null @@ -1,173 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { ShowAccessibilityAlertHelp, ShowAudioCueHelp } from 'vs/workbench/contrib/audioCues/browser/commands'; -import { localize } from 'vs/nls'; -import { registerAction2 } from 'vs/platform/actions/common/actions'; -import { Extensions as ConfigurationExtensions, IConfigurationPropertySchema, IConfigurationRegistry } from 'vs/platform/configuration/common/configurationRegistry'; -import { InstantiationType, registerSingleton } from 'vs/platform/instantiation/common/extensions'; -import { Registry } from 'vs/platform/registry/common/platform'; -import { Extensions as WorkbenchExtensions, IWorkbenchContributionsRegistry } from 'vs/workbench/common/contributions'; -import { LifecyclePhase } from 'vs/workbench/services/lifecycle/common/lifecycle'; -import { IAudioCueService, AudioCueService } from 'vs/platform/audioCues/browser/audioCueService'; -import { AudioCueLineDebuggerContribution } from 'vs/workbench/contrib/audioCues/browser/audioCueDebuggerContribution'; -import { AudioCueLineFeatureContribution } from 'vs/workbench/contrib/audioCues/browser/audioCueLineFeatureContribution'; - -registerSingleton(IAudioCueService, AudioCueService, InstantiationType.Delayed); - -Registry.as(WorkbenchExtensions.Workbench).registerWorkbenchContribution(AudioCueLineFeatureContribution, LifecyclePhase.Restored); -Registry.as(WorkbenchExtensions.Workbench).registerWorkbenchContribution(AudioCueLineDebuggerContribution, LifecyclePhase.Restored); - -const audioCueFeatureBase: IConfigurationPropertySchema = { - 'type': 'string', - 'enum': ['auto', 'on', 'off'], - 'default': 'auto', - 'enumDescriptions': [ - localize('audioCues.enabled.auto', "Enable audio cue when a screen reader is attached."), - localize('audioCues.enabled.on', "Enable audio cue."), - localize('audioCues.enabled.off', "Disable audio cue.") - ], - tags: ['accessibility'] -}; - -Registry.as(ConfigurationExtensions.Configuration).registerConfiguration({ - 'properties': { - 'audioCues.enabled': { - markdownDeprecationMessage: 'Deprecated. Use the specific setting for each audio cue instead (`audioCues.*`).', - tags: ['accessibility'] - }, - 'audioCues.volume': { - 'description': localize('audioCues.volume', "The volume of the audio cues in percent (0-100)."), - 'type': 'number', - 'minimum': 0, - 'maximum': 100, - 'default': 70, - tags: ['accessibility'] - }, - 'audioCues.debouncePositionChanges': { - 'description': localize('audioCues.debouncePositionChanges', "Whether or not position changes should be debounced"), - 'type': 'boolean', - 'default': false, - tags: ['accessibility'] - }, - 'audioCues.lineHasBreakpoint': { - 'description': localize('audioCues.lineHasBreakpoint', "Plays a sound when the active line has a breakpoint."), - ...audioCueFeatureBase - }, - 'audioCues.lineHasInlineSuggestion': { - 'description': localize('audioCues.lineHasInlineSuggestion', "Plays a sound when the active line has an inline suggestion."), - ...audioCueFeatureBase - }, - 'audioCues.lineHasError': { - 'description': localize('audioCues.lineHasError', "Plays a sound when the active line has an error."), - ...audioCueFeatureBase, - }, - 'audioCues.lineHasFoldedArea': { - 'description': localize('audioCues.lineHasFoldedArea', "Plays a sound when the active line has a folded area that can be unfolded."), - ...audioCueFeatureBase, - }, - 'audioCues.lineHasWarning': { - 'description': localize('audioCues.lineHasWarning', "Plays a sound when the active line has a warning."), - ...audioCueFeatureBase, - default: 'off', - }, - 'audioCues.onDebugBreak': { - 'description': localize('audioCues.onDebugBreak', "Plays a sound when the debugger stopped on a breakpoint."), - ...audioCueFeatureBase, - }, - 'audioCues.noInlayHints': { - 'description': localize('audioCues.noInlayHints', "Plays a sound when trying to read a line with inlay hints that has no inlay hints."), - ...audioCueFeatureBase, - }, - 'audioCues.taskCompleted': { - 'description': localize('audioCues.taskCompleted', "Plays a sound when a task is completed."), - ...audioCueFeatureBase, - }, - 'audioCues.taskFailed': { - 'description': localize('audioCues.taskFailed', "Plays a sound when a task fails (non-zero exit code)."), - ...audioCueFeatureBase, - }, - 'audioCues.terminalCommandFailed': { - 'description': localize('audioCues.terminalCommandFailed', "Plays a sound when a terminal command fails (non-zero exit code)."), - ...audioCueFeatureBase, - }, - 'audioCues.terminalQuickFix': { - 'description': localize('audioCues.terminalQuickFix', "Plays a sound when terminal Quick Fixes are available."), - ...audioCueFeatureBase, - }, - 'audioCues.terminalBell': { - 'description': localize('audioCues.terminalBell', "Plays a sound when the terminal bell is ringing."), - ...audioCueFeatureBase, - default: 'on' - }, - 'audioCues.diffLineInserted': { - 'description': localize('audioCues.diffLineInserted', "Plays a sound when the focus moves to an inserted line in Accessible Diff Viewer mode or to the next/previous change."), - ...audioCueFeatureBase, - }, - 'audioCues.diffLineDeleted': { - 'description': localize('audioCues.diffLineDeleted', "Plays a sound when the focus moves to a deleted line in Accessible Diff Viewer mode or to the next/previous change."), - ...audioCueFeatureBase, - }, - 'audioCues.diffLineModified': { - 'description': localize('audioCues.diffLineModified', "Plays a sound when the focus moves to a modified line in Accessible Diff Viewer mode or to the next/previous change."), - ...audioCueFeatureBase, - }, - 'audioCues.notebookCellCompleted': { - 'description': localize('audioCues.notebookCellCompleted', "Plays a sound when a notebook cell execution is successfully completed."), - ...audioCueFeatureBase, - }, - 'audioCues.notebookCellFailed': { - 'description': localize('audioCues.notebookCellFailed', "Plays a sound when a notebook cell execution fails."), - ...audioCueFeatureBase, - }, - 'audioCues.chatRequestSent': { - 'description': localize('audioCues.chatRequestSent', "Plays a sound when a chat request is made."), - ...audioCueFeatureBase, - default: 'off' - }, - 'audioCues.chatResponsePending': { - 'description': localize('audioCues.chatResponsePending', "Plays a sound on loop while the response is pending."), - ...audioCueFeatureBase, - default: 'auto' - }, - 'audioCues.chatResponseReceived': { - 'description': localize('audioCues.chatResponseReceived', "Plays a sound on loop while the response has been received."), - ...audioCueFeatureBase, - default: 'off' - }, - 'audioCues.clear': { - 'description': localize('audioCues.clear', "Plays a sound when a feature is cleared (for example, the terminal, Debug Console, or Output channel). When this is disabled, an ARIA alert will announce 'Cleared'."), - ...audioCueFeatureBase, - default: 'off' - }, - 'audioCues.save': { - 'markdownDescription': localize('audioCues.save', "Plays a sound when a file is saved. Also see {0}", '`#accessibility.alert.save#`'), - 'type': 'string', - 'enum': ['userGesture', 'always', 'never'], - 'default': 'never', - 'enumDescriptions': [ - localize('audioCues.save.userGesture', "Plays the audio cue when a user explicitly saves a file."), - localize('audioCues.save.always', "Plays the audio cue whenever a file is saved, including auto save."), - localize('audioCues.save.never', "Never plays the audio cue.") - ], - tags: ['accessibility'] - }, - 'audioCues.format': { - 'markdownDescription': localize('audioCues.format', "Plays a sound when a file or notebook is formatted. Also see {0}", '`#accessibility.alert.format#`'), - 'type': 'string', - 'enum': ['userGesture', 'always', 'never'], - 'default': 'never', - 'enumDescriptions': [ - localize('audioCues.format.userGesture', "Plays the audio cue when a user explicitly formats a file."), - localize('audioCues.format.always', "Plays the audio cue whenever a file is formatted, including if it is set to format on save, type, or, paste, or run of a cell."), - localize('audioCues.format.never', "Never plays the audio cue.") - ], - tags: ['accessibility'] - }, - }, -}); - -registerAction2(ShowAudioCueHelp); -registerAction2(ShowAccessibilityAlertHelp); diff --git a/src/vs/workbench/contrib/audioCues/browser/commands.ts b/src/vs/workbench/contrib/audioCues/browser/commands.ts deleted file mode 100644 index dbaac656df64f..0000000000000 --- a/src/vs/workbench/contrib/audioCues/browser/commands.ts +++ /dev/null @@ -1,120 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * 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 'vs/base/common/codicons'; -import { ThemeIcon } from 'vs/base/common/themables'; -import { localize, localize2 } from 'vs/nls'; -import { IAccessibilityService } from 'vs/platform/accessibility/common/accessibility'; -import { Action2 } from 'vs/platform/actions/common/actions'; -import { AudioCue, IAudioCueService } from 'vs/platform/audioCues/browser/audioCueService'; -import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; -import { ServicesAccessor } from 'vs/platform/instantiation/common/instantiation'; -import { IQuickInputService, IQuickPickItem } from 'vs/platform/quickinput/common/quickInput'; - -export class ShowAudioCueHelp extends Action2 { - static readonly ID = 'audioCues.help'; - - constructor() { - super({ - id: ShowAudioCueHelp.ID, - title: localize2('audioCues.help', "Help: List Audio Cues"), - f1: true, - }); - } - - override async run(accessor: ServicesAccessor): Promise { - const audioCueService = accessor.get(IAudioCueService); - const quickInputService = accessor.get(IQuickInputService); - const configurationService = accessor.get(IConfigurationService); - const accessibilityService = accessor.get(IAccessibilityService); - const userGestureCues = [AudioCue.save, AudioCue.format]; - const items: (IQuickPickItem & { audioCue: AudioCue })[] = AudioCue.allAudioCues.map((cue, idx) => ({ - label: userGestureCues.includes(cue) ? `${cue.name} (${configurationService.getValue(cue.settingsKey)})` : cue.name, - audioCue: cue, - buttons: userGestureCues.includes(cue) ? [{ - iconClass: ThemeIcon.asClassName(Codicon.settingsGear), - tooltip: localize('audioCues.help.settings', 'Enable/Disable Audio Cue'), - alwaysVisible: true - }] : [] - })); - const qp = quickInputService.createQuickPick(); - qp.items = items; - qp.selectedItems = items.filter(i => audioCueService.isCueEnabled(i.audioCue)); - qp.onDidAccept(() => { - const enabledCues = qp.selectedItems.map(i => i.audioCue); - const disabledCues = AudioCue.allAudioCues.filter(cue => !enabledCues.includes(cue)); - for (const cue of enabledCues) { - if (!userGestureCues.includes(cue)) { - configurationService.updateValue(cue.settingsKey, accessibilityService.isScreenReaderOptimized() ? 'auto' : 'on'); - } - } - for (const cue of disabledCues) { - if (userGestureCues.includes(cue)) { - configurationService.updateValue(cue.settingsKey, 'never'); - } else { - configurationService.updateValue(cue.settingsKey, 'off'); - } - } - qp.hide(); - }); - qp.onDidChangeActive(() => { - audioCueService.playSound(qp.activeItems[0].audioCue.sound.getSound(true), true); - }); - qp.placeholder = localize('audioCues.help.placeholder', 'Select an audio cue to play and configure'); - qp.canSelectMany = true; - await qp.show(); - } -} - -export class ShowAccessibilityAlertHelp extends Action2 { - static readonly ID = 'accessibility.alert.help'; - - constructor() { - super({ - id: ShowAccessibilityAlertHelp.ID, - title: localize2('accessibility.alert.help', "Help: List Alerts"), - f1: true, - }); - } - - override async run(accessor: ServicesAccessor): Promise { - const audioCueService = accessor.get(IAudioCueService); - const quickInputService = accessor.get(IQuickInputService); - const configurationService = accessor.get(IConfigurationService); - const userGestureAlerts = [AudioCue.save, AudioCue.format]; - const items: (IQuickPickItem & { audioCue: AudioCue })[] = AudioCue.allAudioCues.filter(c => c.alertSettingsKey).map((cue, idx) => ({ - label: userGestureAlerts.includes(cue) && cue.alertSettingsKey ? `${cue.name} (${configurationService.getValue(cue.alertSettingsKey)})` : cue.name, - audioCue: cue, - buttons: userGestureAlerts.includes(cue) ? [{ - iconClass: ThemeIcon.asClassName(Codicon.settingsGear), - tooltip: localize('alert.help.settings', 'Enable/Disable Alert'), - alwaysVisible: true - }] : [] - })); - const qp = quickInputService.createQuickPick(); - qp.items = items; - qp.selectedItems = items.filter(i => audioCueService.isAlertEnabled(i.audioCue)); - qp.onDidAccept(() => { - const enabledAlerts = qp.selectedItems.map(i => i.audioCue); - const disabledAlerts = AudioCue.allAudioCues.filter(cue => !enabledAlerts.includes(cue)); - for (const cue of enabledAlerts) { - if (!userGestureAlerts.includes(cue)) { - configurationService.updateValue(cue.alertSettingsKey!, true); - } - } - for (const cue of disabledAlerts) { - if (userGestureAlerts.includes(cue)) { - configurationService.updateValue(cue.alertSettingsKey!, 'never'); - } else { - configurationService.updateValue(cue.alertSettingsKey!, false); - } - } - qp.hide(); - }); - qp.placeholder = localize('alert.help.placeholder', 'Select an alert to configure'); - qp.canSelectMany = true; - await qp.show(); - } -} diff --git a/src/vs/workbench/contrib/authentication/browser/actions/manageTrustedExtensionsForAccountAction.ts b/src/vs/workbench/contrib/authentication/browser/actions/manageTrustedExtensionsForAccountAction.ts new file mode 100644 index 0000000000000..3c3d4b9aee1b8 --- /dev/null +++ b/src/vs/workbench/contrib/authentication/browser/actions/manageTrustedExtensionsForAccountAction.ts @@ -0,0 +1,189 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { fromNow } from 'vs/base/common/date'; +import { DisposableStore } from 'vs/base/common/lifecycle'; +import { localize, localize2 } from 'vs/nls'; +import { Action2 } from 'vs/platform/actions/common/actions'; +import { IDialogService } from 'vs/platform/dialogs/common/dialogs'; +import { ServicesAccessor } from 'vs/platform/instantiation/common/instantiation'; +import { IProductService } from 'vs/platform/product/common/productService'; +import { IQuickInputService, IQuickPickItem, IQuickPickSeparator } from 'vs/platform/quickinput/common/quickInput'; +import { IAuthenticationAccessService } from 'vs/workbench/services/authentication/browser/authenticationAccessService'; +import { IAuthenticationUsageService } from 'vs/workbench/services/authentication/browser/authenticationUsageService'; +import { AllowedExtension, IAuthenticationService } from 'vs/workbench/services/authentication/common/authentication'; +import { IExtensionService } from 'vs/workbench/services/extensions/common/extensions'; + +export class ManageTrustedExtensionsForAccountAction extends Action2 { + constructor() { + super({ + id: '_manageTrustedExtensionsForAccount', + title: localize2('manageTrustedExtensionsForAccount', "Manage Trusted Extensions For Account"), + category: localize2('accounts', "Accounts"), + f1: true + }); + } + + override async run(accessor: ServicesAccessor, options?: { providerId: string; accountLabel: string }): Promise { + const productService = accessor.get(IProductService); + const extensionService = accessor.get(IExtensionService); + const dialogService = accessor.get(IDialogService); + const quickInputService = accessor.get(IQuickInputService); + const authenticationService = accessor.get(IAuthenticationService); + const authenticationUsageService = accessor.get(IAuthenticationUsageService); + const authenticationAccessService = accessor.get(IAuthenticationAccessService); + + let providerId = options?.providerId; + let accountLabel = options?.accountLabel; + + if (!providerId || !accountLabel) { + const accounts = new Array<{ providerId: string; providerLabel: string; accountLabel: string }>(); + for (const id of authenticationService.getProviderIds()) { + const providerLabel = authenticationService.getProvider(id).label; + const sessions = await authenticationService.getSessions(id); + const uniqueAccountLabels = new Set(); + for (const session of sessions) { + if (!uniqueAccountLabels.has(session.account.label)) { + uniqueAccountLabels.add(session.account.label); + accounts.push({ providerId: id, providerLabel, accountLabel: session.account.label }); + } + } + } + + const pick = await quickInputService.pick( + accounts.map(account => ({ + providerId: account.providerId, + label: account.accountLabel, + description: account.providerLabel + })), + { + placeHolder: localize('pickAccount', "Pick an account to manage trusted extensions for"), + matchOnDescription: true, + } + ); + + if (pick) { + providerId = pick.providerId; + accountLabel = pick.label; + } else { + return; + } + } + + const allowedExtensions = authenticationAccessService.readAllowedExtensions(providerId, accountLabel); + const trustedExtensionAuthAccess = productService.trustedExtensionAuthAccess; + const trustedExtensionIds = + // Case 1: trustedExtensionAuthAccess is an array + Array.isArray(trustedExtensionAuthAccess) + ? trustedExtensionAuthAccess + // Case 2: trustedExtensionAuthAccess is an object + : typeof trustedExtensionAuthAccess === 'object' + ? trustedExtensionAuthAccess[providerId] ?? [] + : []; + for (const extensionId of trustedExtensionIds) { + const allowedExtension = allowedExtensions.find(ext => ext.id === extensionId); + if (!allowedExtension) { + // Add the extension to the allowedExtensions list + const extension = await extensionService.getExtension(extensionId); + if (extension) { + allowedExtensions.push({ + id: extensionId, + name: extension.displayName || extension.name, + allowed: true, + trusted: true + }); + } + } else { + // Update the extension to be allowed + allowedExtension.allowed = true; + allowedExtension.trusted = true; + } + } + + if (!allowedExtensions.length) { + dialogService.info(localize('noTrustedExtensions', "This account has not been used by any extensions.")); + return; + } + + interface TrustedExtensionsQuickPickItem extends IQuickPickItem { + extension: AllowedExtension; + lastUsed?: number; + } + + const disposableStore = new DisposableStore(); + const quickPick = disposableStore.add(quickInputService.createQuickPick()); + quickPick.canSelectMany = true; + quickPick.customButton = true; + quickPick.customLabel = localize('manageTrustedExtensions.cancel', 'Cancel'); + const usages = authenticationUsageService.readAccountUsages(providerId, accountLabel); + const trustedExtensions = []; + const otherExtensions = []; + for (const extension of allowedExtensions) { + const usage = usages.find(usage => extension.id === usage.extensionId); + extension.lastUsed = usage?.lastUsed; + if (extension.trusted) { + trustedExtensions.push(extension); + } else { + otherExtensions.push(extension); + } + } + + const sortByLastUsed = (a: AllowedExtension, b: AllowedExtension) => (b.lastUsed || 0) - (a.lastUsed || 0); + const toQuickPickItem = function (extension: AllowedExtension): IQuickPickItem & { extension: AllowedExtension } { + const lastUsed = extension.lastUsed; + const description = lastUsed + ? localize({ key: 'accountLastUsedDate', comment: ['The placeholder {0} is a string with time information, such as "3 days ago"'] }, "Last used this account {0}", fromNow(lastUsed, true)) + : localize('notUsed', "Has not used this account"); + let tooltip: string | undefined; + let disabled: boolean | undefined; + if (extension.trusted) { + tooltip = localize('trustedExtensionTooltip', "This extension is trusted by Microsoft and\nalways has access to this account"); + disabled = true; + } + return { + label: extension.name, + extension, + description, + tooltip, + disabled, + picked: extension.allowed === undefined || extension.allowed + }; + }; + const items: Array = [ + ...otherExtensions.sort(sortByLastUsed).map(toQuickPickItem), + { type: 'separator', label: localize('trustedExtensions', "Trusted by Microsoft") }, + ...trustedExtensions.sort(sortByLastUsed).map(toQuickPickItem) + ]; + + quickPick.items = items; + quickPick.selectedItems = items.filter((item): item is TrustedExtensionsQuickPickItem => item.type !== 'separator' && (item.extension.allowed === undefined || item.extension.allowed)); + quickPick.title = localize('manageTrustedExtensions', "Manage Trusted Extensions"); + quickPick.placeholder = localize('manageExtensions', "Choose which extensions can access this account"); + + disposableStore.add(quickPick.onDidAccept(() => { + const updatedAllowedList = quickPick.items + .filter((item): item is TrustedExtensionsQuickPickItem => item.type !== 'separator') + .map(i => i.extension); + + const allowedExtensionsSet = new Set(quickPick.selectedItems.map(i => i.extension)); + updatedAllowedList.forEach(extension => { + extension.allowed = allowedExtensionsSet.has(extension); + }); + authenticationAccessService.updateAllowedExtensions(providerId, accountLabel, updatedAllowedList); + quickPick.hide(); + })); + + disposableStore.add(quickPick.onDidHide(() => { + disposableStore.dispose(); + })); + + disposableStore.add(quickPick.onDidCustom(() => { + quickPick.hide(); + })); + + quickPick.show(); + } + +} diff --git a/src/vs/workbench/contrib/authentication/browser/actions/signOutOfAccountAction.ts b/src/vs/workbench/contrib/authentication/browser/actions/signOutOfAccountAction.ts new file mode 100644 index 0000000000000..87afd379e24e6 --- /dev/null +++ b/src/vs/workbench/contrib/authentication/browser/actions/signOutOfAccountAction.ts @@ -0,0 +1,54 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import Severity from 'vs/base/common/severity'; +import { localize } from 'vs/nls'; +import { Action2 } from 'vs/platform/actions/common/actions'; +import { IDialogService } from 'vs/platform/dialogs/common/dialogs'; +import { ServicesAccessor } from 'vs/platform/instantiation/common/instantiation'; +import { IAuthenticationAccessService } from 'vs/workbench/services/authentication/browser/authenticationAccessService'; +import { IAuthenticationUsageService } from 'vs/workbench/services/authentication/browser/authenticationUsageService'; +import { IAuthenticationService } from 'vs/workbench/services/authentication/common/authentication'; + +export class SignOutOfAccountAction extends Action2 { + constructor() { + super({ + id: '_signOutOfAccount', + title: localize('signOutOfAccount', "Sign out of account"), + f1: false + }); + } + + override async run(accessor: ServicesAccessor, { providerId, accountLabel }: { providerId: string; accountLabel: string }): Promise { + const authenticationService = accessor.get(IAuthenticationService); + const authenticationUsageService = accessor.get(IAuthenticationUsageService); + const authenticationAccessService = accessor.get(IAuthenticationAccessService); + const dialogService = accessor.get(IDialogService); + + if (!providerId || !accountLabel) { + throw new Error('Invalid arguments. Expected: { providerId: string; accountLabel: string }'); + } + + const allSessions = await authenticationService.getSessions(providerId); + const sessions = allSessions.filter(s => s.account.label === accountLabel); + + const accountUsages = authenticationUsageService.readAccountUsages(providerId, accountLabel); + + const { confirmed } = await dialogService.confirm({ + type: Severity.Info, + message: accountUsages.length + ? localize('signOutMessage', "The account '{0}' has been used by: \n\n{1}\n\n Sign out from these extensions?", accountLabel, accountUsages.map(usage => usage.extensionName).join('\n')) + : localize('signOutMessageSimple', "Sign out of '{0}'?", accountLabel), + primaryButton: localize({ key: 'signOut', comment: ['&& denotes a mnemonic'] }, "&&Sign Out") + }); + + if (confirmed) { + const removeSessionPromises = sessions.map(session => authenticationService.removeSession(providerId, session.id)); + await Promise.all(removeSessionPromises); + authenticationUsageService.removeAccountUsage(providerId, accountLabel); + authenticationAccessService.removeAllowedExtensions(providerId, accountLabel); + } + } +} diff --git a/src/vs/workbench/contrib/authentication/browser/authentication.contribution.ts b/src/vs/workbench/contrib/authentication/browser/authentication.contribution.ts new file mode 100644 index 0000000000000..36d4e4e1352cb --- /dev/null +++ b/src/vs/workbench/contrib/authentication/browser/authentication.contribution.ts @@ -0,0 +1,197 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IJSONSchema } from 'vs/base/common/jsonSchema'; +import { Disposable, IDisposable } from 'vs/base/common/lifecycle'; +import { isFalsyOrWhitespace } from 'vs/base/common/strings'; +import { localize } from 'vs/nls'; +import { MenuId, MenuRegistry, registerAction2 } from 'vs/platform/actions/common/actions'; +import { CommandsRegistry } from 'vs/platform/commands/common/commands'; +import { ContextKeyExpr } from 'vs/platform/contextkey/common/contextkey'; +import { IExtensionManifest } from 'vs/platform/extensions/common/extensions'; +import { SyncDescriptor } from 'vs/platform/instantiation/common/descriptors'; +import { Registry } from 'vs/platform/registry/common/platform'; +import { IWorkbenchContribution, WorkbenchPhase, registerWorkbenchContribution2 } from 'vs/workbench/common/contributions'; +import { SignOutOfAccountAction } from 'vs/workbench/contrib/authentication/browser/actions/signOutOfAccountAction'; +import { AuthenticationProviderInformation, IAuthenticationService } from 'vs/workbench/services/authentication/common/authentication'; +import { IBrowserWorkbenchEnvironmentService } from 'vs/workbench/services/environment/browser/environmentService'; +import { Extensions, IExtensionFeatureTableRenderer, IExtensionFeaturesRegistry, IRenderedData, IRowData, ITableData } from 'vs/workbench/services/extensionManagement/common/extensionFeatures'; +import { ExtensionsRegistry } from 'vs/workbench/services/extensions/common/extensionsRegistry'; +import { ManageTrustedExtensionsForAccountAction } from './actions/manageTrustedExtensionsForAccountAction'; + +const codeExchangeProxyCommand = CommandsRegistry.registerCommand('workbench.getCodeExchangeProxyEndpoints', function (accessor, _) { + const environmentService = accessor.get(IBrowserWorkbenchEnvironmentService); + return environmentService.options?.codeExchangeProxyEndpoints; +}); + +const authenticationDefinitionSchema: IJSONSchema = { + type: 'object', + additionalProperties: false, + properties: { + id: { + type: 'string', + description: localize('authentication.id', 'The id of the authentication provider.') + }, + label: { + type: 'string', + description: localize('authentication.label', 'The human readable name of the authentication provider.'), + } + } +}; + +const authenticationExtPoint = ExtensionsRegistry.registerExtensionPoint({ + extensionPoint: 'authentication', + jsonSchema: { + description: localize({ key: 'authenticationExtensionPoint', comment: [`'Contributes' means adds here`] }, 'Contributes authentication'), + type: 'array', + items: authenticationDefinitionSchema + }, + activationEventsGenerator: (authenticationProviders, result) => { + for (const authenticationProvider of authenticationProviders) { + if (authenticationProvider.id) { + result.push(`onAuthenticationRequest:${authenticationProvider.id}`); + } + } + } +}); + +class AuthenticationDataRenderer extends Disposable implements IExtensionFeatureTableRenderer { + + readonly type = 'table'; + + shouldRender(manifest: IExtensionManifest): boolean { + return !!manifest.contributes?.authentication; + } + + render(manifest: IExtensionManifest): IRenderedData { + const authentication = manifest.contributes?.authentication || []; + if (!authentication.length) { + return { data: { headers: [], rows: [] }, dispose: () => { } }; + } + + const headers = [ + localize('authenticationlabel', "Label"), + localize('authenticationid', "ID"), + ]; + + const rows: IRowData[][] = authentication + .sort((a, b) => a.label.localeCompare(b.label)) + .map(auth => { + return [ + auth.label, + auth.id, + ]; + }); + + return { + data: { + headers, + rows + }, + dispose: () => { } + }; + } +} + +const extensionFeature = Registry.as(Extensions.ExtensionFeaturesRegistry).registerExtensionFeature({ + id: 'authentication', + label: localize('authentication', "Authentication"), + access: { + canToggle: false + }, + renderer: new SyncDescriptor(AuthenticationDataRenderer), +}); + +export class AuthenticationContribution extends Disposable implements IWorkbenchContribution { + static ID = 'workbench.contrib.authentication'; + + private _placeholderMenuItem: IDisposable | undefined = MenuRegistry.appendMenuItem(MenuId.AccountsContext, { + command: { + id: 'noAuthenticationProviders', + title: localize('authentication.Placeholder', "No accounts requested yet..."), + precondition: ContextKeyExpr.false() + }, + }); + + constructor( + @IAuthenticationService private readonly _authenticationService: IAuthenticationService, + @IBrowserWorkbenchEnvironmentService private readonly _environmentService: IBrowserWorkbenchEnvironmentService + ) { + super(); + this._register(codeExchangeProxyCommand); + this._register(extensionFeature); + + this._registerHandlers(); + this._registerAuthenticationExtentionPointHandler(); + this._registerEnvContributedAuthenticationProviders(); + this._registerActions(); + } + + private _registerAuthenticationExtentionPointHandler(): void { + authenticationExtPoint.setHandler((extensions, { added, removed }) => { + added.forEach(point => { + for (const provider of point.value) { + if (isFalsyOrWhitespace(provider.id)) { + point.collector.error(localize('authentication.missingId', 'An authentication contribution must specify an id.')); + continue; + } + + if (isFalsyOrWhitespace(provider.label)) { + point.collector.error(localize('authentication.missingLabel', 'An authentication contribution must specify a label.')); + continue; + } + + if (!this._authenticationService.declaredProviders.some(p => p.id === provider.id)) { + this._authenticationService.registerDeclaredAuthenticationProvider(provider); + } else { + point.collector.error(localize('authentication.idConflict', "This authentication id '{0}' has already been registered", provider.id)); + } + } + }); + + const removedExtPoints = removed.flatMap(r => r.value); + removedExtPoints.forEach(point => { + const provider = this._authenticationService.declaredProviders.find(provider => provider.id === point.id); + if (provider) { + this._authenticationService.unregisterDeclaredAuthenticationProvider(provider.id); + } + }); + }); + } + + private _registerEnvContributedAuthenticationProviders(): void { + if (!this._environmentService.options?.authenticationProviders?.length) { + return; + } + for (const provider of this._environmentService.options.authenticationProviders) { + this._authenticationService.registerAuthenticationProvider(provider.id, provider); + } + } + + private _registerHandlers(): void { + this._register(this._authenticationService.onDidRegisterAuthenticationProvider(_e => { + this._placeholderMenuItem?.dispose(); + this._placeholderMenuItem = undefined; + })); + this._register(this._authenticationService.onDidUnregisterAuthenticationProvider(_e => { + if (!this._authenticationService.getProviderIds().length) { + this._placeholderMenuItem = MenuRegistry.appendMenuItem(MenuId.AccountsContext, { + command: { + id: 'noAuthenticationProviders', + title: localize('loading', "Loading..."), + precondition: ContextKeyExpr.false() + } + }); + } + })); + } + + private _registerActions(): void { + this._register(registerAction2(SignOutOfAccountAction)); + this._register(registerAction2(ManageTrustedExtensionsForAccountAction)); + } +} + +registerWorkbenchContribution2(AuthenticationContribution.ID, AuthenticationContribution, WorkbenchPhase.AfterRestored); diff --git a/src/vs/workbench/contrib/bulkEdit/browser/bulkFileEdits.ts b/src/vs/workbench/contrib/bulkEdit/browser/bulkFileEdits.ts index f094897579084..75d29fe71e291 100644 --- a/src/vs/workbench/contrib/bulkEdit/browser/bulkFileEdits.ts +++ b/src/vs/workbench/contrib/bulkEdit/browser/bulkFileEdits.ts @@ -16,7 +16,6 @@ import { ILogService } from 'vs/platform/log/common/log'; import { VSBuffer } from 'vs/base/common/buffer'; import { ResourceFileEdit } from 'vs/editor/browser/services/bulkEditService'; import { CancellationToken } from 'vs/base/common/cancellation'; -import { tail } from 'vs/base/common/arrays'; import { ITextFileService } from 'vs/workbench/services/textfile/common/textfiles'; import { Schemas } from 'vs/base/common/network'; @@ -362,8 +361,8 @@ export class BulkFileEdits { for (let i = 1; i < edits.length; i++) { const edit = edits[i]; - const lastGroup = tail(groups); - if (lastGroup[0].type === edit.type) { + const lastGroup = groups.at(-1); + if (lastGroup?.[0].type === edit.type) { lastGroup.push(edit); } else { groups.push([edit]); diff --git a/src/vs/workbench/contrib/bulkEdit/browser/preview/bulkEdit.contribution.ts b/src/vs/workbench/contrib/bulkEdit/browser/preview/bulkEdit.contribution.ts index 1d7f842bebcba..7e97fd01a696b 100644 --- a/src/vs/workbench/contrib/bulkEdit/browser/preview/bulkEdit.contribution.ts +++ b/src/vs/workbench/contrib/bulkEdit/browser/preview/bulkEdit.contribution.ts @@ -14,7 +14,6 @@ import { localize, localize2 } from 'vs/nls'; import { ViewPaneContainer } from 'vs/workbench/browser/parts/views/viewPaneContainer'; import { RawContextKey, IContextKeyService, IContextKey, ContextKeyExpr } from 'vs/platform/contextkey/common/contextkey'; import { IEditorGroupsService } from 'vs/workbench/services/editor/common/editorGroupsService'; -import { BulkEditPreviewProvider } from 'vs/workbench/contrib/bulkEdit/browser/preview/bulkEditPreview'; import { KeybindingWeight } from 'vs/platform/keybinding/common/keybindingsRegistry'; import { KeyMod, KeyCode } from 'vs/base/common/keyCodes'; import { WorkbenchListFocusContextKey } from 'vs/platform/list/browser/listService'; @@ -67,7 +66,7 @@ class UXState { for (const input of group.editors) { const resource = EditorResourceAccessor.getCanonicalUri(input, { supportSideBySide: SideBySideEditor.PRIMARY }); - if (resource?.scheme === BulkEditPreviewProvider.Schema) { + if (resource?.scheme === BulkEditPane.Schema) { previewEditors.push(input); } } @@ -179,7 +178,7 @@ registerAction2(class ApplyAction extends Action2 { keybinding: { weight: KeybindingWeight.EditorContrib - 10, when: ContextKeyExpr.and(BulkEditPreviewContribution.ctxEnabled, FocusedViewContext.isEqualTo(BulkEditPane.ID)), - primary: KeyMod.Shift + KeyCode.Enter, + primary: KeyMod.CtrlCmd + KeyCode.Enter, } }); } diff --git a/src/vs/workbench/contrib/bulkEdit/browser/preview/bulkEditPane.ts b/src/vs/workbench/contrib/bulkEdit/browser/preview/bulkEditPane.ts index 4a18e62f25636..3298ebe0097ab 100644 --- a/src/vs/workbench/contrib/bulkEdit/browser/preview/bulkEditPane.ts +++ b/src/vs/workbench/contrib/bulkEdit/browser/preview/bulkEditPane.ts @@ -5,14 +5,14 @@ import 'vs/css!./bulkEdit'; import { WorkbenchAsyncDataTree, IOpenEvent } from 'vs/platform/list/browser/listService'; -import { BulkEditElement, BulkEditDelegate, TextEditElementRenderer, FileElementRenderer, BulkEditDataSource, BulkEditIdentityProvider, FileElement, TextEditElement, BulkEditAccessibilityProvider, CategoryElementRenderer, BulkEditNaviLabelProvider, CategoryElement, BulkEditSorter } from 'vs/workbench/contrib/bulkEdit/browser/preview/bulkEditTree'; +import { BulkEditElement, BulkEditDelegate, TextEditElementRenderer, FileElementRenderer, BulkEditDataSource, BulkEditIdentityProvider, FileElement, TextEditElement, BulkEditAccessibilityProvider, CategoryElementRenderer, BulkEditNaviLabelProvider, CategoryElement, BulkEditSorter, compareBulkFileOperations } from 'vs/workbench/contrib/bulkEdit/browser/preview/bulkEditTree'; import { FuzzyScore } from 'vs/base/common/filters'; import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; import { IThemeService } from 'vs/platform/theme/common/themeService'; import { localize } from 'vs/nls'; import { DisposableStore } from 'vs/base/common/lifecycle'; import { ACTIVE_GROUP, IEditorService, SIDE_GROUP } from 'vs/workbench/services/editor/common/editorService'; -import { BulkEditPreviewProvider, BulkFileOperations, BulkFileOperationType } from 'vs/workbench/contrib/bulkEdit/browser/preview/bulkEditPreview'; +import { BulkEditPreviewProvider, BulkFileOperation, BulkFileOperations, BulkFileOperationType } from 'vs/workbench/contrib/bulkEdit/browser/preview/bulkEditPreview'; import { ILabelService } from 'vs/platform/label/common/label'; import { ITextModelService } from 'vs/editor/common/services/resolverService'; import { URI } from 'vs/base/common/uri'; @@ -24,11 +24,9 @@ import { IContextKeyService, RawContextKey, IContextKey } from 'vs/platform/cont import { IViewletViewOptions } from 'vs/workbench/browser/parts/views/viewsViewlet'; import { ResourceLabels, IResourceLabelsContainer } from 'vs/workbench/browser/labels'; import { IDialogService } from 'vs/platform/dialogs/common/dialogs'; -import { basename, dirname } from 'vs/base/common/resources'; import { MenuId } from 'vs/platform/actions/common/actions'; import { ITreeContextMenuEvent } from 'vs/base/browser/ui/tree/tree'; import { CancellationToken } from 'vs/base/common/cancellation'; -import { ITextEditorOptions } from 'vs/platform/editor/common/editor'; import type { IAsyncDataTreeViewState } from 'vs/base/browser/ui/tree/asyncDataTree'; import { IStorageService, StorageScope, StorageTarget } from 'vs/platform/storage/common/storage'; import { IViewDescriptorService } from 'vs/workbench/common/views'; @@ -38,6 +36,11 @@ import { ResourceEdit } from 'vs/editor/browser/services/bulkEditService'; import { ButtonBar } from 'vs/base/browser/ui/button/button'; import { defaultButtonStyles } from 'vs/platform/theme/browser/defaultStyles'; import { Mutable } from 'vs/base/common/types'; +import { IResourceDiffEditorInput } from 'vs/workbench/common/editor'; +import { IMultiDiffEditorOptions, IMultiDiffResourceId } from 'vs/editor/browser/widget/multiDiffEditor/multiDiffEditorWidgetImpl'; +import { IRange } from 'vs/editor/common/core/range'; +import { CachedFunction, LRUCachedFunction } from 'vs/base/common/cache'; +import { IHoverService } from 'vs/platform/hover/browser/hover'; const enum State { Data = 'data', @@ -47,6 +50,7 @@ const enum State { export class BulkEditPane extends ViewPane { static readonly ID = 'refactorPreview'; + static readonly Schema = 'vscode-bulkeditpreview-multieditor'; static readonly ctxHasCategories = new RawContextKey('refactorPreview.hasCategories', false); static readonly ctxGroupByFile = new RawContextKey('refactorPreview.groupByFile', true); @@ -69,7 +73,6 @@ export class BulkEditPane extends ViewPane { private _currentInput?: BulkFileOperations; private _currentProvider?: BulkEditPreviewProvider; - constructor( options: IViewletViewOptions, @IInstantiationService private readonly _instaService: IInstantiationService, @@ -87,16 +90,23 @@ export class BulkEditPane extends ViewPane { @IOpenerService openerService: IOpenerService, @IThemeService themeService: IThemeService, @ITelemetryService telemetryService: ITelemetryService, + @IHoverService hoverService: IHoverService, ) { super( { ...options, titleMenuId: MenuId.BulkEditTitle }, - keybindingService, contextMenuService, configurationService, contextKeyService, viewDescriptorService, _instaService, openerService, themeService, telemetryService + keybindingService, contextMenuService, configurationService, contextKeyService, viewDescriptorService, _instaService, openerService, themeService, telemetryService, hoverService ); this.element.classList.add('bulk-edit-panel', 'show-file-icons'); this._ctxHasCategories = BulkEditPane.ctxHasCategories.bindTo(contextKeyService); this._ctxGroupByFile = BulkEditPane.ctxGroupByFile.bindTo(contextKeyService); this._ctxHasCheckedChanges = BulkEditPane.ctxHasCheckedChanges.bindTo(contextKeyService); + // telemetry + type BulkEditPaneOpened = { + owner: 'aiday-mar'; + comment: 'Report when the bulk edit pane has been opened'; + }; + this.telemetryService.publicLog2<{}, BulkEditPaneOpened>('views.bulkEditPane'); } override dispose(): void { @@ -143,7 +153,7 @@ export class BulkEditPane extends ViewPane { ); this._disposables.add(this._tree.onContextMenu(this._onContextMenu, this)); - this._disposables.add(this._tree.onDidOpen(e => this._openElementAsEditor(e))); + this._disposables.add(this._tree.onDidOpen(e => this._openElementInMultiDiffEditor(e))); // buttons const buttonsContainer = document.createElement('div'); @@ -316,66 +326,91 @@ export class BulkEditPane extends ViewPane { } } - private async _openElementAsEditor(e: IOpenEvent): Promise { + private async _openElementInMultiDiffEditor(e: IOpenEvent): Promise { + + const fileOperations = this._currentInput?.fileOperations; + if (!fileOperations) { + return; + } - const options: Mutable = { ...e.editorOptions }; + let selection: IRange | undefined = undefined; let fileElement: FileElement; if (e.element instanceof TextEditElement) { fileElement = e.element.parent; - options.selection = e.element.edit.textEdit.textEdit.range; - + selection = e.element.edit.textEdit.textEdit.range; } else if (e.element instanceof FileElement) { fileElement = e.element; - options.selection = e.element.edit.textEdits[0]?.textEdit.textEdit.range; - + selection = e.element.edit.textEdits[0]?.textEdit.textEdit.range; } else { // invalid event return; } - const previewUri = this._currentProvider!.asPreviewUri(fileElement.edit.uri); - - if (fileElement.edit.type & BulkFileOperationType.Delete) { - // delete -> show single editor - this._editorService.openEditor({ - label: localize('edt.title.del', "{0} (delete, refactor preview)", basename(fileElement.edit.uri)), - resource: previewUri, - options - }); - - } else { - // rename, create, edits -> show diff editr - let leftResource: URI | undefined; - try { - (await this._textModelService.createModelReference(fileElement.edit.uri)).dispose(); - leftResource = fileElement.edit.uri; - } catch { - leftResource = BulkEditPreviewProvider.emptyPreview; + const result = await this._computeResourceDiffEditorInputs.get(fileOperations); + const resourceId = await result.getResourceDiffEditorInputIdOfOperation(fileElement.edit); + const options: Mutable = { + ...e.editorOptions, + viewState: { + revealData: { + resource: resourceId, + range: selection, + } } + }; + const multiDiffSource = URI.from({ scheme: BulkEditPane.Schema }); + const label = 'Refactor Preview'; + this._editorService.openEditor({ + multiDiffSource, + label, + options, + isTransient: true, + description: label, + resources: result.resources + }, e.sideBySide ? SIDE_GROUP : ACTIVE_GROUP); + } - let typeLabel: string | undefined; - if (fileElement.edit.type & BulkFileOperationType.Rename) { - typeLabel = localize('rename', "rename"); - } else if (fileElement.edit.type & BulkFileOperationType.Create) { - typeLabel = localize('create', "create"); - } + private readonly _computeResourceDiffEditorInputs = new LRUCachedFunction(async (fileOperations: BulkFileOperation[]) => { + const computeDiffEditorInput = new CachedFunction>(async (fileOperation) => { + const fileOperationUri = fileOperation.uri; + const previewUri = this._currentProvider!.asPreviewUri(fileOperationUri); + // delete + if (fileOperation.type & BulkFileOperationType.Delete) { + return { + original: { resource: URI.revive(previewUri) }, + modified: { resource: undefined } + }; - let label: string; - if (typeLabel) { - label = localize('edt.title.2', "{0} ({1}, refactor preview)", basename(fileElement.edit.uri), typeLabel); - } else { - label = localize('edt.title.1', "{0} (refactor preview)", basename(fileElement.edit.uri)); } + // rename, create, edits + else { + let leftResource: URI | undefined; + try { + (await this._textModelService.createModelReference(fileOperationUri)).dispose(); + leftResource = fileOperationUri; + } catch { + leftResource = BulkEditPreviewProvider.emptyPreview; + } + return { + original: { resource: URI.revive(leftResource) }, + modified: { resource: URI.revive(previewUri) } + }; + } + }); - this._editorService.openEditor({ - original: { resource: leftResource }, - modified: { resource: previewUri }, - label, - description: this._labelService.getUriLabel(dirname(leftResource), { relative: true }), - options - }, e.sideBySide ? SIDE_GROUP : ACTIVE_GROUP); + const sortedFileOperations = fileOperations.slice().sort(compareBulkFileOperations); + const resources: IResourceDiffEditorInput[] = []; + for (const operation of sortedFileOperations) { + resources.push(await computeDiffEditorInput.get(operation)); } - } + const getResourceDiffEditorInputIdOfOperation = async (operation: BulkFileOperation): Promise => { + const resource = await computeDiffEditorInput.get(operation); + return { original: resource.original.resource, modified: resource.modified.resource }; + }; + return { + resources, + getResourceDiffEditorInputIdOfOperation + }; + }, key => key); private _onContextMenu(e: ITreeContextMenuEvent): void { diff --git a/src/vs/workbench/contrib/bulkEdit/browser/preview/bulkEditPreview.ts b/src/vs/workbench/contrib/bulkEdit/browser/preview/bulkEditPreview.ts index d9e8a1112a0d2..40fbb606f8b96 100644 --- a/src/vs/workbench/contrib/bulkEdit/browser/preview/bulkEditPreview.ts +++ b/src/vs/workbench/contrib/bulkEdit/browser/preview/bulkEditPreview.ts @@ -351,7 +351,7 @@ export class BulkFileOperations { export class BulkEditPreviewProvider implements ITextModelContentProvider { - static readonly Schema = 'vscode-bulkeditpreview'; + private static readonly Schema = 'vscode-bulkeditpreview-editor'; static emptyPreview = URI.from({ scheme: BulkEditPreviewProvider.Schema, fragment: 'empty' }); diff --git a/src/vs/workbench/contrib/bulkEdit/browser/preview/bulkEditTree.ts b/src/vs/workbench/contrib/bulkEdit/browser/preview/bulkEditTree.ts index 5dda15aee3e8c..e45a95008c3ff 100644 --- a/src/vs/workbench/contrib/bulkEdit/browser/preview/bulkEditTree.ts +++ b/src/vs/workbench/contrib/bulkEdit/browser/preview/bulkEditTree.ts @@ -286,7 +286,7 @@ export class BulkEditSorter implements ITreeSorter { compare(a: BulkEditElement, b: BulkEditElement): number { if (a instanceof FileElement && b instanceof FileElement) { - return compare(a.edit.uri.toString(), b.edit.uri.toString()); + return compareBulkFileOperations(a.edit, b.edit); } if (a instanceof TextEditElement && b instanceof TextEditElement) { @@ -297,6 +297,10 @@ export class BulkEditSorter implements ITreeSorter { } } +export function compareBulkFileOperations(a: BulkFileOperation, b: BulkFileOperation): number { + return compare(a.uri.toString(), b.uri.toString()); +} + // --- ACCESSI export class BulkEditAccessibilityProvider implements IListAccessibilityProvider { @@ -576,7 +580,7 @@ class TextEditElementTemplate { this._icon = document.createElement('div'); container.appendChild(this._icon); - this._label = new HighlightedLabel(container); + this._label = this._disposables.add(new HighlightedLabel(container)); } dispose(): void { diff --git a/src/vs/workbench/contrib/bulkEdit/test/browser/bulkCellEdits.test.ts b/src/vs/workbench/contrib/bulkEdit/test/browser/bulkCellEdits.test.ts index 76497826d7466..22c507ca0b336 100644 --- a/src/vs/workbench/contrib/bulkEdit/test/browser/bulkCellEdits.test.ts +++ b/src/vs/workbench/contrib/bulkEdit/test/browser/bulkCellEdits.test.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import * as assert from 'assert'; -import { CancellationTokenSource } from 'vs/base/common/cancellation'; +import { CancellationToken } from 'vs/base/common/cancellation'; import { URI } from 'vs/base/common/uri'; import { mockObject } from 'vs/base/test/common/mock'; import { ensureNoDisposablesAreLeakedInTestSuite } from 'vs/base/test/common/utils'; @@ -35,7 +35,7 @@ suite('BulkCellEdits', function () { const edits = [ new ResourceNotebookCellEdit(inputUri, { index: 0, count: 1, editType: CellEditType.Replace, cells: [] }) ]; - const bce = new BulkCellEdits(new UndoRedoGroup(), new UndoRedoSource(), progress, new CancellationTokenSource().token, edits, editorService, notebookService as any); + const bce = new BulkCellEdits(new UndoRedoGroup(), new UndoRedoSource(), progress, CancellationToken.None, edits, editorService, notebookService as any); await bce.apply(); const resolveArgs = notebookService.resolve.args[0]; diff --git a/src/vs/workbench/contrib/callHierarchy/browser/callHierarchy.contribution.ts b/src/vs/workbench/contrib/callHierarchy/browser/callHierarchy.contribution.ts index 8dcb48da8a28f..a68eaeed3c3ca 100644 --- a/src/vs/workbench/contrib/callHierarchy/browser/callHierarchy.contribution.ts +++ b/src/vs/workbench/contrib/callHierarchy/browser/callHierarchy.contribution.ts @@ -186,7 +186,8 @@ registerAction2(class PeekCallHierarchyAction extends EditorAction2 { order: 1000, when: ContextKeyExpr.and( _ctxHasCallHierarchyProvider, - PeekContext.notInPeekEditor + PeekContext.notInPeekEditor, + EditorContextKeys.isInEmbeddedEditor.toNegated(), ), }, keybinding: { diff --git a/src/vs/workbench/contrib/callHierarchy/browser/callHierarchyPeek.ts b/src/vs/workbench/contrib/callHierarchy/browser/callHierarchyPeek.ts index 6eb9f63cba9bf..3dada0c5698bf 100644 --- a/src/vs/workbench/contrib/callHierarchy/browser/callHierarchyPeek.ts +++ b/src/vs/workbench/contrib/callHierarchy/browser/callHierarchyPeek.ts @@ -19,7 +19,7 @@ import { SplitView, Orientation, Sizing } from 'vs/base/browser/ui/splitview/spl import { Dimension, isKeyboardEvent } from 'vs/base/browser/dom'; import { Event } from 'vs/base/common/event'; import { IEditorService } from 'vs/workbench/services/editor/common/editorService'; -import { EmbeddedCodeEditorWidget } from 'vs/editor/browser/widget/embeddedCodeEditorWidget'; +import { EmbeddedCodeEditorWidget } from 'vs/editor/browser/widget/codeEditor/embeddedCodeEditorWidget'; import { IEditorOptions } from 'vs/editor/common/config/editorOptions'; import { ITextModelService } from 'vs/editor/common/services/resolverService'; import { toDisposable, DisposableStore } from 'vs/base/common/lifecycle'; diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatAccessibilityHelp.ts b/src/vs/workbench/contrib/chat/browser/actions/chatAccessibilityHelp.ts index 6be43b0fcff20..e225605c01702 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatAccessibilityHelp.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatAccessibilityHelp.ts @@ -9,10 +9,10 @@ import { IKeybindingService } from 'vs/platform/keybinding/common/keybinding'; import { ICodeEditor } from 'vs/editor/browser/editorBrowser'; import { ServicesAccessor } from 'vs/editor/browser/editorExtensions'; import { IChatWidgetService } from 'vs/workbench/contrib/chat/browser/chat'; -import { InlineChatController } from 'vs/workbench/contrib/inlineChat/browser/inlineChatController'; import { AccessibleViewType, IAccessibleViewService } from 'vs/workbench/contrib/accessibility/browser/accessibleView'; import { AccessibilityVerbositySettingId, AccessibleViewProviderId } from 'vs/workbench/contrib/accessibility/browser/accessibilityConfiguration'; -import { AccessibleDiffViewerNext } from 'vs/editor/browser/widget/diffEditor/diffEditor.contribution'; +import { AccessibleDiffViewerNext } from 'vs/editor/browser/widget/diffEditor/commands'; +import { INLINE_CHAT_ID } from 'vs/workbench/contrib/inlineChat/common/inlineChat'; export function getAccessibilityHelpText(accessor: ServicesAccessor, type: 'panelChat' | 'inlineChat'): string { const keybindingService = accessor.get(IKeybindingService); @@ -45,7 +45,7 @@ export function getAccessibilityHelpText(accessor: ServicesAccessor, type: 'pane content.push(diffReviewKeybinding ? localize('inlineChat.diff', "Once in the diff editor, enter review mode with ({0}). Use up and down arrows to navigate lines with the proposed changes.", diffReviewKeybinding) : localize('inlineChat.diffNoKb', "Tab again to enter the Diff editor with the changes and enter review mode with the Go to Next Difference Command. Use Up/DownArrow to navigate lines with the proposed changes.")); content.push(localize('inlineChat.toolbar', "Use tab to reach conditional parts like commands, status, message responses and more.")); } - content.push(localize('chat.audioCues', "Audio cues can be changed via settings with a prefix of audioCues.chat. By default, if a request takes more than 4 seconds, you will hear an audio cue indicating that progress is still occurring.")); + content.push(localize('chat.signals', "Accessibility Signals can be changed via settings with a prefix of signals.chat. By default, if a request takes more than 4 seconds, you will hear a sound indicating that progress is still occurring.")); return content.join('\n\n'); } @@ -81,10 +81,12 @@ export async function runAccessibilityHelpAction(accessor: ServicesAccessor, edi if (type === 'panelChat' && cachedPosition) { inputEditor.setPosition(cachedPosition); inputEditor.focus(); + } else if (type === 'inlineChat') { - if (editor) { - InlineChatController.get(editor)?.focus(); - } + // TODO@jrieken find a better way for this + const ctrl = <{ focus(): void } | undefined>editor?.getContribution(INLINE_CHAT_ID); + ctrl?.focus(); + } }, options: { type: AccessibleViewType.Help } diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts index 929fb17898cac..3c5fb0a252f83 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts @@ -10,31 +10,29 @@ import { ThemeIcon } from 'vs/base/common/themables'; import { ICodeEditor } from 'vs/editor/browser/editorBrowser'; import { EditorAction2, ServicesAccessor } from 'vs/editor/browser/editorExtensions'; import { ICodeEditorService } from 'vs/editor/browser/services/codeEditorService'; -import { EditorContextKeys } from 'vs/editor/common/editorContextKeys'; import { localize, localize2 } from 'vs/nls'; import { Action2, IAction2Options, MenuId, registerAction2 } from 'vs/platform/actions/common/actions'; import { ContextKeyExpr } from 'vs/platform/contextkey/common/contextkey'; +import { IsLinuxContext, IsWindowsContext } from 'vs/platform/contextkey/common/contextkeys'; import { KeybindingWeight } from 'vs/platform/keybinding/common/keybindingsRegistry'; import { IQuickInputService, IQuickPickItem } from 'vs/platform/quickinput/common/quickInput'; import { Registry } from 'vs/platform/registry/common/platform'; import { ViewAction } from 'vs/workbench/browser/parts/views/viewPane'; import { IWorkbenchContributionsRegistry, Extensions as WorkbenchExtensions } from 'vs/workbench/common/contributions'; -import { IViewsService } from 'vs/workbench/services/views/common/viewsService'; import { AccessibilityHelpAction } from 'vs/workbench/contrib/accessibility/browser/accessibleViewActions'; import { runAccessibilityHelpAction } from 'vs/workbench/contrib/chat/browser/actions/chatAccessibilityHelp'; import { IChatWidgetService } from 'vs/workbench/contrib/chat/browser/chat'; import { IChatEditorOptions } from 'vs/workbench/contrib/chat/browser/chatEditor'; import { ChatEditorInput } from 'vs/workbench/contrib/chat/browser/chatEditorInput'; import { ChatViewPane } from 'vs/workbench/contrib/chat/browser/chatViewPane'; -import { IChatAgentService } from 'vs/workbench/contrib/chat/common/chatAgents'; -import { CONTEXT_CHAT_INPUT_CURSOR_AT_TOP, CONTEXT_IN_CHAT_INPUT, CONTEXT_IN_CHAT_SESSION, CONTEXT_PROVIDER_EXISTS, CONTEXT_REQUEST, CONTEXT_RESPONSE } from 'vs/workbench/contrib/chat/common/chatContextKeys'; +import { ChatAgentLocation } from 'vs/workbench/contrib/chat/common/chatAgents'; +import { CONTEXT_CHAT_INPUT_CURSOR_AT_TOP, CONTEXT_CHAT_LOCATION, CONTEXT_IN_CHAT_INPUT, CONTEXT_IN_CHAT_SESSION, CONTEXT_PROVIDER_EXISTS, CONTEXT_REQUEST, CONTEXT_RESPONSE } from 'vs/workbench/contrib/chat/common/chatContextKeys'; import { IChatContributionService } from 'vs/workbench/contrib/chat/common/chatContributionService'; -import { chatAgentLeader } from 'vs/workbench/contrib/chat/common/chatParserTypes'; import { IChatDetail, IChatService } from 'vs/workbench/contrib/chat/common/chatService'; import { IChatWidgetHistoryService } from 'vs/workbench/contrib/chat/common/chatWidgetHistoryService'; import { IEditorService } from 'vs/workbench/services/editor/common/editorService'; import { LifecyclePhase } from 'vs/workbench/services/lifecycle/common/lifecycle'; -import { IsLinuxContext, IsWindowsContext } from 'vs/platform/contextkey/common/contextkeys'; +import { IViewsService } from 'vs/workbench/services/views/common/viewsService'; export const CHAT_CATEGORY = localize2('chat.category', 'Chat'); export const CHAT_OPEN_ACTION_ID = 'workbench.action.chat.open'; @@ -94,76 +92,8 @@ class OpenChatGlobalAction extends Action2 { } } -export class ChatSubmitSecondaryAgentEditorAction extends EditorAction2 { - static readonly ID = 'workbench.action.chat.submitSecondaryAgent'; - - constructor() { - super({ - id: ChatSubmitSecondaryAgentEditorAction.ID, - title: localize2({ key: 'actions.chat.submitSecondaryAgent', comment: ['Send input from the chat input box to the secondary agent'] }, "Submit to Secondary Agent"), - precondition: CONTEXT_IN_CHAT_INPUT, - keybinding: { - when: EditorContextKeys.textInputFocus, - primary: KeyMod.CtrlCmd | KeyCode.Enter, - weight: KeybindingWeight.EditorContrib - } - }); - } - - runEditorCommand(accessor: ServicesAccessor, editor: ICodeEditor): void | Promise { - const editorUri = editor.getModel()?.uri; - if (editorUri) { - const agentService = accessor.get(IChatAgentService); - const secondaryAgent = agentService.getSecondaryAgent(); - if (!secondaryAgent) { - return; - } - - const widgetService = accessor.get(IChatWidgetService); - const widget = widgetService.getWidgetByInputUri(editorUri); - if (!widget) { - return; - } - - if (widget.getInput().match(/^\s*@/)) { - widget.acceptInput(); - } else { - widget.acceptInputWithPrefix(`${chatAgentLeader}${secondaryAgent.id}`); - } - } - } -} - -export class ChatSubmitEditorAction extends EditorAction2 { - static readonly ID = 'workbench.action.chat.acceptInput'; - - constructor() { - super({ - id: ChatSubmitEditorAction.ID, - title: localize2({ key: 'actions.chat.submit', comment: ['Apply input from the chat input box'] }, "Submit"), - precondition: CONTEXT_IN_CHAT_INPUT, - keybinding: { - when: EditorContextKeys.textInputFocus, - primary: KeyCode.Enter, - weight: KeybindingWeight.EditorContrib - } - }); - } - - runEditorCommand(accessor: ServicesAccessor, editor: ICodeEditor): void | Promise { - const editorUri = editor.getModel()?.uri; - if (editorUri) { - const widgetService = accessor.get(IChatWidgetService); - widgetService.getWidgetByInputUri(editorUri)?.acceptInput(); - } - } -} - export function registerChatActions() { registerAction2(OpenChatGlobalAction); - registerAction2(ChatSubmitEditorAction); - - registerAction2(ChatSubmitSecondaryAgentEditorAction); registerAction2(class ClearChatInputHistoryAction extends Action2 { constructor() { @@ -202,7 +132,7 @@ export function registerChatActions() { super({ id: 'chat.action.focus', title: localize2('actions.interactiveSession.focus', 'Focus Chat List'), - precondition: CONTEXT_IN_CHAT_INPUT, + precondition: ContextKeyExpr.and(CONTEXT_IN_CHAT_INPUT, CONTEXT_CHAT_LOCATION.isEqualTo(ChatAgentLocation.Panel)), category: CHAT_CATEGORY, keybinding: [ // On mac, require that the cursor is at the top of the input, to avoid stealing cmd+up to move the cursor to the top @@ -295,7 +225,7 @@ const getHistoryChatActionDescriptorForViewTitle = (viewId: string, providerId: }, category: CHAT_CATEGORY, icon: Codicon.history, - f1: false, + f1: true, precondition: CONTEXT_PROVIDER_EXISTS }); diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatClearActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatClearActions.ts index c3c1f31f1f1a4..2736da158c57f 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatClearActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatClearActions.ts @@ -8,7 +8,7 @@ import { KeyCode, KeyMod } from 'vs/base/common/keyCodes'; import { ServicesAccessor } from 'vs/editor/browser/editorExtensions'; import { localize2 } from 'vs/nls'; import { Action2, IAction2Options, MenuId, registerAction2 } from 'vs/platform/actions/common/actions'; -import { AudioCue, IAudioCueService } from 'vs/platform/audioCues/browser/audioCueService'; +import { AccessibilitySignal, IAccessibilitySignalService } from 'vs/platform/accessibilitySignal/browser/accessibilitySignalService'; import { ContextKeyExpr } from 'vs/platform/contextkey/common/contextkey'; import { KeybindingWeight } from 'vs/platform/keybinding/common/keybindingsRegistry'; import { ViewAction } from 'vs/workbench/browser/parts/views/viewPane'; @@ -117,5 +117,5 @@ export function getNewChatAction(viewId: string, providerId: string) { } function announceChatCleared(accessor: ServicesAccessor): void { - accessor.get(IAudioCueService).playAudioCue(AudioCue.clear); + accessor.get(IAccessibilitySignalService).playSignal(AccessibilitySignal.clear); } diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatCodeblockActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatCodeblockActions.ts index 2d616c7be6893..145a74b8f08c1 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatCodeblockActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatCodeblockActions.ts @@ -12,10 +12,11 @@ import { IBulkEditService, ResourceTextEdit } from 'vs/editor/browser/services/b import { ICodeEditorService } from 'vs/editor/browser/services/codeEditorService'; import { Range } from 'vs/editor/common/core/range'; import { EditorContextKeys } from 'vs/editor/common/editorContextKeys'; -import { DocumentContextItem, WorkspaceEdit } from 'vs/editor/common/languages'; +import { DocumentContextItem, TextEdit, WorkspaceEdit } from 'vs/editor/common/languages'; import { ILanguageService } from 'vs/editor/common/languages/language'; import { ITextModel } from 'vs/editor/common/model'; import { ILanguageFeaturesService } from 'vs/editor/common/services/languageFeatures'; +import { ITextModelService } from 'vs/editor/common/services/resolverService'; import { CopyAction } from 'vs/editor/contrib/clipboard/browser/clipboard'; import { localize2 } from 'vs/nls'; import { Action2, MenuId, registerAction2 } from 'vs/platform/actions/common/actions'; @@ -24,13 +25,13 @@ import { ContextKeyExpr } from 'vs/platform/contextkey/common/contextkey'; import { KeybindingWeight } from 'vs/platform/keybinding/common/keybindingsRegistry'; import { TerminalLocation } from 'vs/platform/terminal/common/terminal'; import { IUntitledTextResourceEditorInput } from 'vs/workbench/common/editor'; +import { accessibleViewInCodeBlock } from 'vs/workbench/contrib/accessibility/browser/accessibilityConfiguration'; import { CHAT_CATEGORY } from 'vs/workbench/contrib/chat/browser/actions/chatActions'; -import { IChatWidgetService } from 'vs/workbench/contrib/chat/browser/chat'; -import { ICodeBlockActionContext } from 'vs/workbench/contrib/chat/browser/codeBlockPart'; +import { IChatWidgetService, IChatCodeBlockContextProviderService } from 'vs/workbench/contrib/chat/browser/chat'; +import { ICodeBlockActionContext, ICodeCompareBlockActionContext } from 'vs/workbench/contrib/chat/browser/codeBlockPart'; import { CONTEXT_IN_CHAT_INPUT, CONTEXT_IN_CHAT_SESSION, CONTEXT_PROVIDER_EXISTS } from 'vs/workbench/contrib/chat/common/chatContextKeys'; -import { ChatAgentCopyKind, IChatService, IDocumentContext } from 'vs/workbench/contrib/chat/common/chatService'; +import { ChatCopyKind, IChatService, IDocumentContext } from 'vs/workbench/contrib/chat/common/chatService'; import { IChatResponseViewModel, isResponseVM } from 'vs/workbench/contrib/chat/common/chatViewModel'; -import { CTX_INLINE_CHAT_VISIBLE } from 'vs/workbench/contrib/inlineChat/common/inlineChat'; import { insertCell } from 'vs/workbench/contrib/notebook/browser/controller/cellOperations'; import { INotebookEditor } from 'vs/workbench/contrib/notebook/browser/notebookBrowser'; import { CellKind, NOTEBOOK_EDITOR_ID } from 'vs/workbench/contrib/notebook/common/notebookCommon'; @@ -46,6 +47,10 @@ export function isCodeBlockActionContext(thing: unknown): thing is ICodeBlockAct return typeof thing === 'object' && thing !== null && 'code' in thing && 'element' in thing; } +export function isCodeCompareBlockActionContext(thing: unknown): thing is ICodeCompareBlockActionContext { + return typeof thing === 'object' && thing !== null && 'element' in thing; +} + function isResponseFiltered(context: ICodeBlockActionContext) { return isResponseVM(context.element) && context.element.errorDetails?.responseIsFiltered; } @@ -87,7 +92,7 @@ export function registerChatCodeBlockActions() { icon: Codicon.copy, menu: { id: MenuId.ChatCodeBlock, - group: 'navigation', + group: 'navigation' } }); } @@ -108,10 +113,11 @@ export function registerChatCodeBlockActions() { agentId: context.element.agent?.id, sessionId: context.element.sessionId, requestId: context.element.requestId, + result: context.element.result, action: { kind: 'copy', codeBlockIndex: context.codeBlockIndex, - copyKind: ChatAgentCopyKind.Toolbar, + copyKind: ChatCopyKind.Toolbar, copiedCharacters: context.code.length, totalCharacters: context.code.length, copiedText: context.code, @@ -146,20 +152,24 @@ export function registerChatCodeBlockActions() { // Report copy to extensions const chatService = accessor.get(IChatService); - chatService.notifyUserAction({ - providerId: context.element.providerId, - agentId: context.element.agent?.id, - sessionId: context.element.sessionId, - requestId: context.element.requestId, - action: { - kind: 'copy', - codeBlockIndex: context.codeBlockIndex, - copyKind: ChatAgentCopyKind.Action, - copiedText, - copiedCharacters: copiedText.length, - totalCharacters, - } - }); + const element = context.element as IChatResponseViewModel | undefined; + if (element) { + chatService.notifyUserAction({ + providerId: element.providerId, + agentId: element.agent?.id, + sessionId: element.sessionId, + requestId: element.requestId, + result: element.result, + action: { + kind: 'copy', + codeBlockIndex: context.codeBlockIndex, + copyKind: ChatCopyKind.Action, + copiedText, + copiedCharacters: copiedText.length, + totalCharacters, + } + }); + } // Copy full cell if no selection, otherwise fall back on normal editor implementation if (noSelection) { @@ -182,13 +192,13 @@ export function registerChatCodeBlockActions() { menu: { id: MenuId.ChatCodeBlock, group: 'navigation', - when: CONTEXT_IN_CHAT_SESSION, + when: CONTEXT_IN_CHAT_SESSION }, keybinding: { - when: ContextKeyExpr.and(CONTEXT_IN_CHAT_SESSION, CONTEXT_IN_CHAT_INPUT.negate()), + when: ContextKeyExpr.or(ContextKeyExpr.and(CONTEXT_IN_CHAT_SESSION, CONTEXT_IN_CHAT_INPUT.negate()), accessibleViewInCodeBlock), primary: KeyMod.CtrlCmd | KeyCode.Enter, mac: { primary: KeyMod.WinCtrl | KeyCode.Enter }, - weight: KeybindingWeight.WorkbenchContrib + weight: KeybindingWeight.ExternalExtension + 1 }, }); } @@ -325,6 +335,7 @@ export function registerChatCodeBlockActions() { agentId: context.element.agent?.id, sessionId: context.element.sessionId, requestId: context.element.requestId, + result: context.element.result, action: { kind: 'insert', codeBlockIndex: context.codeBlockIndex, @@ -348,7 +359,7 @@ export function registerChatCodeBlockActions() { menu: { id: MenuId.ChatCodeBlock, group: 'navigation', - isHiddenByDefault: true, + isHiddenByDefault: true } }); } @@ -370,6 +381,7 @@ export function registerChatCodeBlockActions() { agentId: context.element.agent?.id, sessionId: context.element.sessionId, requestId: context.element.requestId, + result: context.element.result, action: { kind: 'insert', codeBlockIndex: context.codeBlockIndex, @@ -382,8 +394,13 @@ export function registerChatCodeBlockActions() { }); const shellLangIds = [ + 'fish', + 'ps1', + 'pwsh', 'powershell', - 'shellscript' + 'sh', + 'shellscript', + 'zsh' ]; registerAction2(class RunInTerminalAction extends ChatCodeBlockAction { constructor() { @@ -410,11 +427,6 @@ export function registerChatCodeBlockActions() { CONTEXT_IN_CHAT_SESSION, ...shellLangIds.map(e => ContextKeyExpr.notEquals(EditorContextKeys.languageId.key, e)) ) - }, - { - id: MenuId.ChatCodeBlock, - group: 'navigation', - when: CTX_INLINE_CHAT_VISIBLE, }], keybinding: [{ primary: KeyMod.CtrlCmd | KeyMod.Alt | KeyCode.Enter, @@ -422,7 +434,7 @@ export function registerChatCodeBlockActions() { primary: KeyMod.WinCtrl | KeyMod.Alt | KeyCode.Enter }, weight: KeybindingWeight.EditorContrib, - when: CONTEXT_IN_CHAT_SESSION, + when: ContextKeyExpr.or(CONTEXT_IN_CHAT_SESSION, accessibleViewInCodeBlock), }] }); } @@ -462,6 +474,7 @@ export function registerChatCodeBlockActions() { agentId: context.element.agent?.id, sessionId: context.element.sessionId, requestId: context.element.requestId, + result: context.element.result, action: { kind: 'runInTerminal', codeBlockIndex: context.codeBlockIndex, @@ -547,20 +560,23 @@ export function registerChatCodeBlockActions() { }); } -function getContextFromEditor(editor: ICodeEditor, accessor: ServicesAccessor): IChatCodeBlockActionContext | undefined { +function getContextFromEditor(editor: ICodeEditor, accessor: ServicesAccessor): ICodeBlockActionContext | undefined { const chatWidgetService = accessor.get(IChatWidgetService); + const chatCodeBlockContextProviderService = accessor.get(IChatCodeBlockContextProviderService); const model = editor.getModel(); if (!model) { return; } const widget = chatWidgetService.lastFocusedWidget; - if (!widget) { - return; - } - - const codeBlockInfo = widget.getCodeBlockInfoForEditor(model.uri); + const codeBlockInfo = widget?.getCodeBlockInfoForEditor(model.uri); if (!codeBlockInfo) { + for (const provider of chatCodeBlockContextProviderService.providers) { + const context = provider.getCodeBlockContext(editor); + if (context) { + return context; + } + } return; } @@ -571,3 +587,53 @@ function getContextFromEditor(editor: ICodeEditor, accessor: ServicesAccessor): languageId: editor.getModel()!.getLanguageId(), }; } + +export function registerChatCodeCompareBlockActions() { + + abstract class ChatCompareCodeBlockAction extends Action2 { + run(accessor: ServicesAccessor, ...args: any[]) { + const context = args[0]; + if (!isCodeCompareBlockActionContext(context)) { + return; + // TODO@jrieken derive context + } + + return this.runWithContext(accessor, context); + } + + abstract runWithContext(accessor: ServicesAccessor, context: ICodeCompareBlockActionContext): any; + } + + registerAction2(class ApplyEditsCompareBlockAction extends ChatCompareCodeBlockAction { + constructor() { + super({ + id: 'workbench.action.chat.applyCompareEdits', + title: localize2('interactive.compare.apply', "Apply Edits"), + f1: false, + category: CHAT_CATEGORY, + icon: Codicon.check, + menu: { + id: MenuId.ChatCompareBlock, + group: 'navigation' + } + }); + } + + async runWithContext(accessor: ServicesAccessor, context: ICodeCompareBlockActionContext): Promise { + if (!isResponseVM(context.element)) { + return; + } + const modelService = accessor.get(ITextModelService); + const ref = await modelService.createModelReference(context.uri); + try { + const edits = context.edits.map(TextEdit.asEditOperation); + ref.object.textEditorModel.pushStackElement(); + ref.object.textEditorModel.pushEditOperations(null, edits, () => null); + ref.object.textEditorModel.pushStackElement(); + } finally { + ref.dispose(); + } + } + }); + +} diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts index 9ceec5ff1d1c5..65f81218005b6 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts @@ -4,12 +4,17 @@ *--------------------------------------------------------------------------------------------*/ import { Codicon } from 'vs/base/common/codicons'; +import { KeyCode, KeyMod } from 'vs/base/common/keyCodes'; import { ServicesAccessor } from 'vs/editor/browser/editorExtensions'; import { localize2 } from 'vs/nls'; import { Action2, MenuId, registerAction2 } from 'vs/platform/actions/common/actions'; +import { ContextKeyExpr } from 'vs/platform/contextkey/common/contextkey'; +import { KeybindingWeight } from 'vs/platform/keybinding/common/keybindingsRegistry'; import { CHAT_CATEGORY } from 'vs/workbench/contrib/chat/browser/actions/chatActions'; import { IChatWidget, IChatWidgetService } from 'vs/workbench/contrib/chat/browser/chat'; -import { CONTEXT_CHAT_INPUT_HAS_TEXT, CONTEXT_CHAT_REQUEST_IN_PROGRESS } from 'vs/workbench/contrib/chat/common/chatContextKeys'; +import { IChatAgentService } from 'vs/workbench/contrib/chat/common/chatAgents'; +import { CONTEXT_CHAT_INPUT_HAS_AGENT, CONTEXT_CHAT_INPUT_HAS_TEXT, CONTEXT_CHAT_REQUEST_IN_PROGRESS, CONTEXT_IN_CHAT_INPUT } from 'vs/workbench/contrib/chat/common/chatContextKeys'; +import { chatAgentLeader, extractAgentAndCommand } from 'vs/workbench/contrib/chat/common/chatParserTypes'; import { IChatService } from 'vs/workbench/contrib/chat/common/chatService'; export interface IVoiceChatExecuteActionContext { @@ -28,16 +33,27 @@ export class SubmitAction extends Action2 { constructor() { super({ id: SubmitAction.ID, - title: localize2('interactive.submit.label', "Submit"), + title: localize2('interactive.submit.label', "Send"), f1: false, category: CHAT_CATEGORY, icon: Codicon.send, - precondition: CONTEXT_CHAT_INPUT_HAS_TEXT, - menu: { - id: MenuId.ChatExecute, - when: CONTEXT_CHAT_REQUEST_IN_PROGRESS.negate(), - group: 'navigation', + precondition: ContextKeyExpr.and(CONTEXT_CHAT_INPUT_HAS_TEXT, CONTEXT_CHAT_REQUEST_IN_PROGRESS.negate()), + keybinding: { + when: CONTEXT_IN_CHAT_INPUT, + primary: KeyCode.Enter, + weight: KeybindingWeight.EditorContrib }, + menu: [ + { + id: MenuId.ChatExecuteSecondary, + group: 'group_1', + }, + { + id: MenuId.ChatExecute, + when: CONTEXT_CHAT_REQUEST_IN_PROGRESS.negate(), + group: 'navigation', + }, + ] }); } @@ -50,34 +66,117 @@ export class SubmitAction extends Action2 { } } -export function registerChatExecuteActions() { - registerAction2(SubmitAction); - registerAction2(class CancelAction extends Action2 { - constructor() { - super({ - id: 'workbench.action.chat.cancel', - title: localize2('interactive.cancel.label', "Cancel"), - f1: false, - category: CHAT_CATEGORY, - icon: Codicon.debugStop, - menu: { - id: MenuId.ChatExecute, - when: CONTEXT_CHAT_REQUEST_IN_PROGRESS, - group: 'navigation', - } - }); + +export class ChatSubmitSecondaryAgentAction extends Action2 { + static readonly ID = 'workbench.action.chat.submitSecondaryAgent'; + + constructor() { + super({ + id: ChatSubmitSecondaryAgentAction.ID, + title: localize2({ key: 'actions.chat.submitSecondaryAgent', comment: ['Send input from the chat input box to the secondary agent'] }, "Submit to Secondary Agent"), + precondition: ContextKeyExpr.and(CONTEXT_CHAT_INPUT_HAS_TEXT, CONTEXT_CHAT_INPUT_HAS_AGENT.negate(), CONTEXT_CHAT_REQUEST_IN_PROGRESS.negate()), + keybinding: { + when: CONTEXT_IN_CHAT_INPUT, + primary: KeyMod.CtrlCmd | KeyCode.Enter, + weight: KeybindingWeight.EditorContrib + }, + menu: { + id: MenuId.ChatExecuteSecondary, + group: 'group_1' + } + }); + } + + run(accessor: ServicesAccessor, ...args: any[]) { + const context: IChatExecuteActionContext | undefined = args[0]; + const agentService = accessor.get(IChatAgentService); + const secondaryAgent = agentService.getSecondaryAgent(); + if (!secondaryAgent) { + return; + } + + const widgetService = accessor.get(IChatWidgetService); + const widget = context?.widget ?? widgetService.lastFocusedWidget; + if (!widget) { + return; + } + + if (extractAgentAndCommand(widget.parsedInput).agentPart) { + widget.acceptInput(); + } else { + widget.lastSelectedAgent = secondaryAgent; + widget.acceptInputWithPrefix(`${chatAgentLeader}${secondaryAgent.name}`); } + } +} - run(accessor: ServicesAccessor, ...args: any[]) { - const context: IChatExecuteActionContext = args[0]; - if (!context.widget) { - return; +class SendToNewChatAction extends Action2 { + constructor() { + super({ + id: 'workbench.action.chat.sendToNewChat', + title: localize2('chat.newChat.label', "Send to New Chat"), + precondition: ContextKeyExpr.and(CONTEXT_CHAT_REQUEST_IN_PROGRESS.negate(), CONTEXT_CHAT_INPUT_HAS_TEXT), + category: CHAT_CATEGORY, + f1: false, + menu: { + id: MenuId.ChatExecuteSecondary, + group: 'group_2' + }, + keybinding: { + weight: KeybindingWeight.WorkbenchContrib, + primary: KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.Enter, + when: CONTEXT_IN_CHAT_INPUT, } + }); + } + + async run(accessor: ServicesAccessor, ...args: any[]) { + const context: IChatExecuteActionContext | undefined = args[0]; + + const widgetService = accessor.get(IChatWidgetService); + const widget = context?.widget ?? widgetService.lastFocusedWidget; + if (!widget) { + return; + } + + widget.clear(); + widget.acceptInput(context?.inputValue); + } +} - const chatService = accessor.get(IChatService); - if (context.widget.viewModel) { - chatService.cancelCurrentRequestForSession(context.widget.viewModel.sessionId); +export class CancelAction extends Action2 { + static readonly ID = 'workbench.action.chat.cancel'; + constructor() { + super({ + id: CancelAction.ID, + title: localize2('interactive.cancel.label', "Cancel"), + f1: false, + category: CHAT_CATEGORY, + icon: Codicon.debugStop, + menu: { + id: MenuId.ChatExecute, + when: CONTEXT_CHAT_REQUEST_IN_PROGRESS, + group: 'navigation', } + }); + } + + run(accessor: ServicesAccessor, ...args: any[]) { + const context: IChatExecuteActionContext = args[0]; + if (!context.widget) { + return; + } + + const chatService = accessor.get(IChatService); + if (context.widget.viewModel) { + chatService.cancelCurrentRequestForSession(context.widget.viewModel.sessionId); } - }); + } +} + +export function registerChatExecuteActions() { + registerAction2(SubmitAction); + registerAction2(CancelAction); + registerAction2(SendToNewChatAction); + registerAction2(ChatSubmitSecondaryAgentAction); } diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatMoveActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatMoveActions.ts index 20e2bc80bb8ae..e00f7fa998ed6 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatMoveActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatMoveActions.ts @@ -95,7 +95,7 @@ export function registerMoveActions() { constructor() { super({ id: `workbench.action.chat.openInNewWindow`, - title: localize2('interactiveSession.openInNewWindow.label', "Open Chat in New Window"), + title: localize2('interactiveSession.openInNewWindow.label', "Open/move the panel chat to an undocked window."), category: CHAT_CATEGORY, precondition: CONTEXT_PROVIDER_EXISTS, f1: true diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatQuickInputActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatQuickInputActions.ts index 4df9285764e3e..d1863ef7d6cb9 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatQuickInputActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatQuickInputActions.ts @@ -178,7 +178,10 @@ export function getQuickChatActionForProvider(id: string, label: string) { override run(accessor: ServicesAccessor, query?: string): void { const quickChatService = accessor.get(IQuickChatService); - quickChatService.toggle(id, query ? { query } : undefined); + quickChatService.toggle(id, query ? { + query, + selection: new Selection(1, query.length + 1, 1, query.length + 1) + } : undefined); } }; } diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatTitleActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatTitleActions.ts index f98c6fc65247b..8204a71dea17f 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatTitleActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatTitleActions.ts @@ -54,6 +54,7 @@ export function registerChatTitleActions() { agentId: item.agent?.id, sessionId: item.sessionId, requestId: item.requestId, + result: item.result, action: { kind: 'vote', direction: InteractiveSessionVoteDirection.Up, @@ -93,6 +94,7 @@ export function registerChatTitleActions() { agentId: item.agent?.id, sessionId: item.sessionId, requestId: item.requestId, + result: item.result, action: { kind: 'vote', direction: InteractiveSessionVoteDirection.Down, @@ -131,6 +133,7 @@ export function registerChatTitleActions() { agentId: item.agent?.id, sessionId: item.sessionId, requestId: item.requestId, + result: item.result, action: { kind: 'bug' } diff --git a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts index f334bfc59fa13..0aff29be67767 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts @@ -3,65 +3,62 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { Disposable, DisposableStore } from 'vs/base/common/lifecycle'; +import { IMarkdownString, MarkdownString, isMarkdownString } from 'vs/base/common/htmlContent'; +import { Disposable } from 'vs/base/common/lifecycle'; import { Schemas } from 'vs/base/common/network'; import { isMacintosh } from 'vs/base/common/platform'; -import { Emitter } from 'vs/base/common/event'; +import { ICodeEditorService } from 'vs/editor/browser/services/codeEditorService'; import * as nls from 'vs/nls'; +import { ICommandService } from 'vs/platform/commands/common/commands'; import { Extensions as ConfigurationExtensions, IConfigurationRegistry } from 'vs/platform/configuration/common/configurationRegistry'; import { SyncDescriptor } from 'vs/platform/instantiation/common/descriptors'; import { InstantiationType, registerSingleton } from 'vs/platform/instantiation/common/extensions'; import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; import { Registry } from 'vs/platform/registry/common/platform'; import { EditorPaneDescriptor, IEditorPaneRegistry } from 'vs/workbench/browser/editor'; -import { IWorkbenchContributionsRegistry, WorkbenchPhase, Extensions as WorkbenchExtensions, registerWorkbenchContribution2 } from 'vs/workbench/common/contributions'; +import { IWorkbenchContributionsRegistry, Extensions as WorkbenchExtensions, WorkbenchPhase, registerWorkbenchContribution2 } from 'vs/workbench/common/contributions'; import { EditorExtensions, IEditorFactoryRegistry } from 'vs/workbench/common/editor'; +import { AccessibilityVerbositySettingId, AccessibleViewProviderId } from 'vs/workbench/contrib/accessibility/browser/accessibilityConfiguration'; +import { alertFocusChange } from 'vs/workbench/contrib/accessibility/browser/accessibleViewContributions'; +import { AccessibleViewType, IAccessibleViewService } from 'vs/workbench/contrib/accessibility/browser/accessibleView'; +import { AccessibleViewAction } from 'vs/workbench/contrib/accessibility/browser/accessibleViewActions'; import { registerChatActions } from 'vs/workbench/contrib/chat/browser/actions/chatActions'; -import { registerChatCodeBlockActions } from 'vs/workbench/contrib/chat/browser/actions/chatCodeblockActions'; +import { ACTION_ID_NEW_CHAT, registerNewChatActions } from 'vs/workbench/contrib/chat/browser/actions/chatClearActions'; +import { registerChatCodeBlockActions, registerChatCodeCompareBlockActions } from 'vs/workbench/contrib/chat/browser/actions/chatCodeblockActions'; import { registerChatCopyActions } from 'vs/workbench/contrib/chat/browser/actions/chatCopyActions'; import { IChatExecuteActionContext, SubmitAction, registerChatExecuteActions } from 'vs/workbench/contrib/chat/browser/actions/chatExecuteActions'; +import { registerChatFileTreeActions } from 'vs/workbench/contrib/chat/browser/actions/chatFileTreeActions'; +import { registerChatExportActions } from 'vs/workbench/contrib/chat/browser/actions/chatImportExport'; +import { registerMoveActions } from 'vs/workbench/contrib/chat/browser/actions/chatMoveActions'; import { registerQuickChatActions } from 'vs/workbench/contrib/chat/browser/actions/chatQuickInputActions'; import { registerChatTitleActions } from 'vs/workbench/contrib/chat/browser/actions/chatTitleActions'; -import { registerChatExportActions } from 'vs/workbench/contrib/chat/browser/actions/chatImportExport'; -import { IChatAccessibilityService, IChatWidget, IChatWidgetService, IQuickChatService } from 'vs/workbench/contrib/chat/browser/chat'; +import { IChatAccessibilityService, IChatCodeBlockContextProviderService, IChatWidget, IChatWidgetService, IQuickChatService } from 'vs/workbench/contrib/chat/browser/chat'; +import { ChatAccessibilityService } from 'vs/workbench/contrib/chat/browser/chatAccessibilityService'; import { ChatContributionService } from 'vs/workbench/contrib/chat/browser/chatContributionServiceImpl'; import { ChatEditor, IChatEditorOptions } from 'vs/workbench/contrib/chat/browser/chatEditor'; import { ChatEditorInput, ChatEditorInputSerializer } from 'vs/workbench/contrib/chat/browser/chatEditorInput'; +import { QuickChatService } from 'vs/workbench/contrib/chat/browser/chatQuick'; +import { ChatVariablesService } from 'vs/workbench/contrib/chat/browser/chatVariables'; import { ChatWidgetService } from 'vs/workbench/contrib/chat/browser/chatWidget'; -import 'vs/workbench/contrib/chat/browser/contrib/chatInputEditorContrib'; import 'vs/workbench/contrib/chat/browser/contrib/chatHistoryVariables'; +import 'vs/workbench/contrib/chat/browser/contrib/chatInputEditorContrib'; +import { ChatAgentLocation, ChatAgentService, IChatAgentService } from 'vs/workbench/contrib/chat/common/chatAgents'; +import { CONTEXT_IN_CHAT_SESSION } from 'vs/workbench/contrib/chat/common/chatContextKeys'; import { IChatContributionService } from 'vs/workbench/contrib/chat/common/chatContributionService'; -import { CHAT_FEATURE_ID, IChatService } from 'vs/workbench/contrib/chat/common/chatService'; +import { ChatWelcomeMessageModel } from 'vs/workbench/contrib/chat/common/chatModel'; +import { chatAgentLeader, chatSubcommandLeader, chatVariableLeader } from 'vs/workbench/contrib/chat/common/chatParserTypes'; +import { IChatService } from 'vs/workbench/contrib/chat/common/chatService'; import { ChatService } from 'vs/workbench/contrib/chat/common/chatServiceImpl'; +import { ChatSlashCommandService, IChatSlashCommandService } from 'vs/workbench/contrib/chat/common/chatSlashCommands'; +import { IChatVariablesService } from 'vs/workbench/contrib/chat/common/chatVariables'; +import { isResponseVM } from 'vs/workbench/contrib/chat/common/chatViewModel'; import { ChatWidgetHistoryService, IChatWidgetHistoryService } from 'vs/workbench/contrib/chat/common/chatWidgetHistoryService'; +import { ILanguageModelsService, LanguageModelsService } from 'vs/workbench/contrib/chat/common/languageModels'; +import { IVoiceChatService, VoiceChatService } from 'vs/workbench/contrib/chat/common/voiceChat'; import { IEditorResolverService, RegisteredEditorPriority } from 'vs/workbench/services/editor/common/editorResolverService'; import { LifecyclePhase } from 'vs/workbench/services/lifecycle/common/lifecycle'; import '../common/chatColors'; -import { registerMoveActions } from 'vs/workbench/contrib/chat/browser/actions/chatMoveActions'; -import { ACTION_ID_NEW_CHAT, registerNewChatActions } from 'vs/workbench/contrib/chat/browser/actions/chatClearActions'; -import { AccessibleViewType, IAccessibleViewService } from 'vs/workbench/contrib/accessibility/browser/accessibleView'; -import { isResponseVM } from 'vs/workbench/contrib/chat/common/chatViewModel'; -import { CONTEXT_IN_CHAT_SESSION } from 'vs/workbench/contrib/chat/common/chatContextKeys'; -import { ChatAccessibilityService } from 'vs/workbench/contrib/chat/browser/chatAccessibilityService'; -import { ICodeEditorService } from 'vs/editor/browser/services/codeEditorService'; -import { AccessibilityVerbositySettingId, AccessibleViewProviderId } from 'vs/workbench/contrib/accessibility/browser/accessibilityConfiguration'; -import { ChatWelcomeMessageModel } from 'vs/workbench/contrib/chat/common/chatModel'; -import { IMarkdownString, MarkdownString, isMarkdownString } from 'vs/base/common/htmlContent'; -import { ChatProviderService, IChatProviderService } from 'vs/workbench/contrib/chat/common/chatProvider'; -import { ChatSlashCommandService, IChatSlashCommandService } from 'vs/workbench/contrib/chat/common/chatSlashCommands'; -import { alertFocusChange } from 'vs/workbench/contrib/accessibility/browser/accessibilityContributions'; -import { AccessibleViewAction } from 'vs/workbench/contrib/accessibility/browser/accessibleViewActions'; -import { ICommandService } from 'vs/platform/commands/common/commands'; -import { IChatVariablesService } from 'vs/workbench/contrib/chat/common/chatVariables'; -import { registerChatFileTreeActions } from 'vs/workbench/contrib/chat/browser/actions/chatFileTreeActions'; -import { QuickChatService } from 'vs/workbench/contrib/chat/browser/chatQuick'; -import { ChatAgentService, IChatAgentService } from 'vs/workbench/contrib/chat/common/chatAgents'; -import { ChatVariablesService } from 'vs/workbench/contrib/chat/browser/chatVariables'; -import { chatAgentLeader, chatSubcommandLeader } from 'vs/workbench/contrib/chat/common/chatParserTypes'; -import { CancellationToken } from 'vs/base/common/cancellation'; -import { IExtensionFeatureMarkdownRenderer, Extensions as ExtensionFeaturesExtensions, IRenderedData, IExtensionFeaturesRegistry, IExtensionFeaturesManagementService } from 'vs/workbench/services/extensionManagement/common/extensionFeatures'; -import { ExtensionIdentifier, IExtensionManifest } from 'vs/platform/extensions/common/extensions'; -import { getExtensionId } from 'vs/platform/extensionManagement/common/extensionManagementUtil'; +import { ChatCodeBlockContextProviderService } from 'vs/workbench/contrib/chat/browser/codeBlockContextProviderService'; // Register configuration const configurationRegistry = Registry.as(ConfigurationExtensions.Configuration); @@ -95,7 +92,12 @@ configurationRegistry.registerConfiguration({ type: 'number', description: nls.localize('interactiveSession.editor.lineHeight', "Controls the line height in pixels in chat codeblocks. Use 0 to compute the line height from the font size."), default: 0 - } + }, + 'chat.experimental.implicitContext': { + type: 'boolean', + description: nls.localize('chat.experimental.implicitContext', "Controls whether a checkbox is shown to allow the user to determine which implicit context is included with a chat participant's prompt."), + default: false + }, } }); @@ -198,7 +200,7 @@ class ChatAccessibleViewContribution extends Disposable { accessibleViewService.show({ id: AccessibleViewProviderId.Chat, verbositySettingKey: AccessibilityVerbositySettingId.Chat, - provideContent(): string { return responseContent; }, + provideContent(): string { return responseContent!; }, onClose() { verifiedWidget.reveal(focusedItem); if (chatInputFocused) { @@ -231,12 +233,13 @@ class ChatSlashStaticSlashCommandsContribution extends Disposable { @IChatSlashCommandService slashCommandService: IChatSlashCommandService, @ICommandService commandService: ICommandService, @IChatAgentService chatAgentService: IChatAgentService, + @IChatVariablesService chatVariablesService: IChatVariablesService, ) { super(); this._store.add(slashCommandService.registerSlashCommand({ - command: 'newChat', - detail: nls.localize('newChat', "Start a new chat"), - sortText: 'z2_newChat', + command: 'clear', + detail: nls.localize('clear', "Start a new chat"), + sortText: 'z2_clear', executeImmediately: true }, async () => { commandService.executeCommand(ACTION_ID_NEW_CHAT); @@ -247,8 +250,10 @@ class ChatSlashStaticSlashCommandsContribution extends Disposable { sortText: 'z1_help', executeImmediately: true }, async (prompt, progress) => { - const defaultAgent = chatAgentService.getDefaultAgent(); + const defaultAgent = chatAgentService.getDefaultAgent(ChatAgentLocation.Panel); const agents = chatAgentService.getAgents(); + + // Report prefix if (defaultAgent?.metadata.helpTextPrefix) { if (isMarkdownString(defaultAgent.metadata.helpTextPrefix)) { progress.report({ content: defaultAgent.metadata.helpTextPrefix, kind: 'markdownContent' }); @@ -258,15 +263,15 @@ class ChatSlashStaticSlashCommandsContribution extends Disposable { progress.report({ content: '\n\n', kind: 'content' }); } + // Report agent list const agentText = (await Promise.all(agents .filter(a => a.id !== defaultAgent?.id) .map(async a => { - const agentWithLeader = `${chatAgentLeader}${a.id}`; + const agentWithLeader = `${chatAgentLeader}${a.name}`; const actionArg: IChatExecuteActionContext = { inputValue: `${agentWithLeader} ${a.metadata.sampleRequest}` }; const urlSafeArg = encodeURIComponent(JSON.stringify(actionArg)); - const agentLine = `* [\`${agentWithLeader}\`](command:${SubmitAction.ID}?${urlSafeArg}) - ${a.metadata.description}`; - const commands = await a.provideSlashCommands(CancellationToken.None); - const commandText = commands.map(c => { + const agentLine = `* [\`${agentWithLeader}\`](command:${SubmitAction.ID}?${urlSafeArg}) - ${a.description}`; + const commandText = a.slashCommands.map(c => { const actionArg: IChatExecuteActionContext = { inputValue: `${agentWithLeader} ${chatSubcommandLeader}${c.name} ${c.sampleRequest ?? ''}` }; const urlSafeArg = encodeURIComponent(JSON.stringify(actionArg)); return `\t* [\`${chatSubcommandLeader}${c.name}\`](command:${SubmitAction.ID}?${urlSafeArg}) - ${c.description}`; @@ -275,6 +280,23 @@ class ChatSlashStaticSlashCommandsContribution extends Disposable { return (agentLine + '\n' + commandText).trim(); }))).join('\n'); progress.report({ content: new MarkdownString(agentText, { isTrusted: { enabledCommands: [SubmitAction.ID] } }), kind: 'markdownContent' }); + + // Report variables + if (defaultAgent?.metadata.helpTextVariablesPrefix) { + progress.report({ content: '\n\n', kind: 'content' }); + if (isMarkdownString(defaultAgent.metadata.helpTextVariablesPrefix)) { + progress.report({ content: defaultAgent.metadata.helpTextVariablesPrefix, kind: 'markdownContent' }); + } else { + progress.report({ content: defaultAgent.metadata.helpTextVariablesPrefix, kind: 'content' }); + } + + const variableText = Array.from(chatVariablesService.getVariables()) + .map(v => `* \`${chatVariableLeader}${v.name}\` - ${v.description}`) + .join('\n'); + progress.report({ content: '\n' + variableText, kind: 'content' }); + } + + // Report help text ending if (defaultAgent?.metadata.helpTextPostfix) { progress.report({ content: '\n\n', kind: 'content' }); if (isMarkdownString(defaultAgent.metadata.helpTextPostfix)) { @@ -287,63 +309,6 @@ class ChatSlashStaticSlashCommandsContribution extends Disposable { } } -class ChatFeatureMarkdowneRenderer extends Disposable implements IExtensionFeatureMarkdownRenderer { - - readonly type = 'markdown'; - - constructor( - @IExtensionFeaturesManagementService private readonly extensionFeaturesManagementService: IExtensionFeaturesManagementService, - ) { - super(); - } - - shouldRender(manifest: IExtensionManifest): boolean { - const extensionId = new ExtensionIdentifier(getExtensionId(manifest.publisher, manifest.name)); - const accessData = this.extensionFeaturesManagementService.getAccessData(extensionId, CHAT_FEATURE_ID); - return !!accessData; - } - - render(manifest: IExtensionManifest): IRenderedData { - const disposables = new DisposableStore(); - const emitter = disposables.add(new Emitter()); - const extensionId = new ExtensionIdentifier(getExtensionId(manifest.publisher, manifest.name)); - disposables.add(this.extensionFeaturesManagementService.onDidChangeAccessData(e => { - if (ExtensionIdentifier.equals(e.extension, extensionId) && e.featureId === CHAT_FEATURE_ID) { - emitter.fire(this.getMarkdownData(extensionId)); - } - })); - return { - data: this.getMarkdownData(extensionId), - onDidChange: emitter.event, - dispose: () => { disposables.dispose(); } - }; - } - - private getMarkdownData(extensionId: ExtensionIdentifier): IMarkdownString { - const markdown = new MarkdownString(); - const accessData = this.extensionFeaturesManagementService.getAccessData(extensionId, CHAT_FEATURE_ID); - if (accessData && accessData.totalCount) { - if (accessData.current) { - markdown.appendMarkdown(nls.localize('requests count session', "Requests (Session) : `{0}`", accessData.current.count)); - markdown.appendText('\n'); - } - markdown.appendMarkdown(nls.localize('requests count total', "Requests (Overall): `{0}`", accessData.totalCount)); - } - return markdown; - } -} - -Registry.as(ExtensionFeaturesExtensions.ExtensionFeaturesRegistry).registerExtensionFeature({ - id: CHAT_FEATURE_ID, - label: nls.localize('chat', "Chat"), - description: nls.localize('chatFeatureDescription', "Allows the extension to make requests to the Large Language Model (LLM)."), - access: { - canToggle: true, - requireUserConsent: true, - }, - renderer: new SyncDescriptor(ChatFeatureMarkdowneRenderer), -}); - const workbenchContributionsRegistry = Registry.as(WorkbenchExtensions.Workbench); registerWorkbenchContribution2(ChatResolverContribution.ID, ChatResolverContribution, WorkbenchPhase.BlockStartup); workbenchContributionsRegistry.registerWorkbenchContribution(ChatAccessibleViewContribution, LifecyclePhase.Eventually); @@ -353,6 +318,7 @@ Registry.as(EditorExtensions.EditorFactory).registerEdit registerChatActions(); registerChatCopyActions(); registerChatCodeBlockActions(); +registerChatCodeCompareBlockActions(); registerChatFileTreeActions(); registerChatTitleActions(); registerChatExecuteActions(); @@ -367,7 +333,9 @@ registerSingleton(IChatWidgetService, ChatWidgetService, InstantiationType.Delay registerSingleton(IQuickChatService, QuickChatService, InstantiationType.Delayed); registerSingleton(IChatAccessibilityService, ChatAccessibilityService, InstantiationType.Delayed); registerSingleton(IChatWidgetHistoryService, ChatWidgetHistoryService, InstantiationType.Delayed); -registerSingleton(IChatProviderService, ChatProviderService, InstantiationType.Delayed); +registerSingleton(ILanguageModelsService, LanguageModelsService, InstantiationType.Delayed); registerSingleton(IChatSlashCommandService, ChatSlashCommandService, InstantiationType.Delayed); registerSingleton(IChatAgentService, ChatAgentService, InstantiationType.Delayed); registerSingleton(IChatVariablesService, ChatVariablesService, InstantiationType.Delayed); +registerSingleton(IVoiceChatService, VoiceChatService, InstantiationType.Delayed); +registerSingleton(IChatCodeBlockContextProviderService, ChatCodeBlockContextProviderService, InstantiationType.Delayed); diff --git a/src/vs/workbench/contrib/chat/browser/chat.ts b/src/vs/workbench/contrib/chat/browser/chat.ts index ff95527a9828c..98028d2d803e9 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.ts @@ -4,17 +4,20 @@ *--------------------------------------------------------------------------------------------*/ import { Event } from 'vs/base/common/event'; +import { IDisposable } from 'vs/base/common/lifecycle'; import { URI } from 'vs/base/common/uri'; import { ICodeEditor } from 'vs/editor/browser/editorBrowser'; import { Selection } from 'vs/editor/common/core/selection'; +import { localize } from 'vs/nls'; +import { MenuId } from 'vs/platform/actions/common/actions'; import { createDecorator } from 'vs/platform/instantiation/common/instantiation'; import { IChatWidgetContrib } from 'vs/workbench/contrib/chat/browser/chatWidget'; +import { ICodeBlockActionContext } from 'vs/workbench/contrib/chat/browser/codeBlockPart'; +import { ChatAgentLocation, IChatAgentCommand, IChatAgentData } from 'vs/workbench/contrib/chat/common/chatAgents'; import { IParsedChatRequest } from 'vs/workbench/contrib/chat/common/chatParserTypes'; import { IChatRequestViewModel, IChatResponseViewModel, IChatViewModel, IChatWelcomeMessageViewModel } from 'vs/workbench/contrib/chat/common/chatViewModel'; export const IChatWidgetService = createDecorator('chatWidgetService'); -export const IQuickChatService = createDecorator('quickChatService'); -export const IChatAccessibilityService = createDecorator('chatAccessibilityService'); export interface IChatWidgetService { @@ -35,6 +38,7 @@ export interface IChatWidgetService { getWidgetBySessionId(sessionId: string): IChatWidget | undefined; } +export const IQuickChatService = createDecorator('quickChatService'); export interface IQuickChatService { readonly _serviceBrand: undefined; readonly onDidClose: Event; @@ -62,6 +66,7 @@ export interface IQuickChatOpenOptions { selection?: Selection; } +export const IChatAccessibilityService = createDecorator('chatAccessibilityService'); export interface IChatAccessibilityService { readonly _serviceBrand: undefined; acceptRequest(): number; @@ -82,10 +87,27 @@ export interface IChatFileTreeInfo { export type ChatTreeItem = IChatRequestViewModel | IChatResponseViewModel | IChatWelcomeMessageViewModel; +export interface IChatListItemRendererOptions { + readonly renderStyle?: 'default' | 'compact'; + readonly noHeader?: boolean; + readonly noPadding?: boolean; + readonly editableCodeBlock?: boolean; + readonly renderTextEditsAsSummary?: (uri: URI) => boolean; +} + export interface IChatWidgetViewOptions { renderInputOnTop?: boolean; renderStyle?: 'default' | 'compact'; supportsFileReferences?: boolean; + filter?: (item: ChatTreeItem) => boolean; + rendererOptions?: IChatListItemRendererOptions; + menus?: { + executeToolbar?: MenuId; + inputSideToolbar?: MenuId; + telemetrySource?: string; + }; + defaultElementHeight?: number; + editorOverflowWidgetsDomNode?: HTMLElement; } export interface IChatViewViewContext { @@ -101,12 +123,16 @@ export type IChatWidgetViewContext = IChatViewViewContext | IChatResourceViewCon export interface IChatWidget { readonly onDidChangeViewModel: Event; readonly onDidAcceptInput: Event; + readonly onDidSubmitAgent: Event<{ agent: IChatAgentData; slashCommand?: IChatAgentCommand }>; + readonly onDidChangeParsedInput: Event; + readonly location: ChatAgentLocation; readonly viewContext: IChatWidgetViewContext; readonly viewModel: IChatViewModel | undefined; readonly inputEditor: ICodeEditor; readonly providerId: string; readonly supportsFileReferences: boolean; readonly parsedInput: IParsedChatRequest; + lastSelectedAgent: IChatAgentData | undefined; getContrib(id: string): T | undefined; reveal(item: ChatTreeItem): void; @@ -132,3 +158,17 @@ export interface IChatWidget { export interface IChatViewPane { clear(): void; } + + +export interface ICodeBlockActionContextProvider { + getCodeBlockContext(editor?: ICodeEditor): ICodeBlockActionContext | undefined; +} + +export const IChatCodeBlockContextProviderService = createDecorator('chatCodeBlockContextProviderService'); +export interface IChatCodeBlockContextProviderService { + readonly _serviceBrand: undefined; + readonly providers: ICodeBlockActionContextProvider[]; + registerProvider(provider: ICodeBlockActionContextProvider, id: string): IDisposable; +} + +export const GeneratingPhrase = localize('generating', "Generating"); diff --git a/src/vs/workbench/contrib/chat/browser/chatAccessibilityProvider.ts b/src/vs/workbench/contrib/chat/browser/chatAccessibilityProvider.ts new file mode 100644 index 0000000000000..933a89408695a --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/chatAccessibilityProvider.ts @@ -0,0 +1,79 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { AriaRole } from 'vs/base/browser/ui/aria/aria'; +import { IListAccessibilityProvider } from 'vs/base/browser/ui/list/listWidget'; +import { marked } from 'vs/base/common/marked/marked'; +import { localize } from 'vs/nls'; +import { AccessibilityVerbositySettingId } from 'vs/workbench/contrib/accessibility/browser/accessibilityConfiguration'; +import { IAccessibleViewService } from 'vs/workbench/contrib/accessibility/browser/accessibleView'; +import { ChatTreeItem } from 'vs/workbench/contrib/chat/browser/chat'; +import { isRequestVM, isResponseVM, isWelcomeVM, IChatResponseViewModel } from 'vs/workbench/contrib/chat/common/chatViewModel'; + +export class ChatAccessibilityProvider implements IListAccessibilityProvider { + + constructor( + @IAccessibleViewService private readonly _accessibleViewService: IAccessibleViewService + ) { + + } + getWidgetRole(): AriaRole { + return 'list'; + } + + getRole(element: ChatTreeItem): AriaRole | undefined { + return 'listitem'; + } + + getWidgetAriaLabel(): string { + return localize('chat', "Chat"); + } + + getAriaLabel(element: ChatTreeItem): string { + if (isRequestVM(element)) { + return element.messageText; + } + + if (isResponseVM(element)) { + return this._getLabelWithCodeBlockCount(element); + } + + if (isWelcomeVM(element)) { + return element.content.map(c => 'value' in c ? c.value : c.map(followup => followup.message).join('\n')).join('\n'); + } + + return ''; + } + + private _getLabelWithCodeBlockCount(element: IChatResponseViewModel): string { + const accessibleViewHint = this._accessibleViewService.getOpenAriaHint(AccessibilityVerbositySettingId.Chat); + let label: string = ''; + const fileTreeCount = element.response.value.filter((v) => !('value' in v))?.length ?? 0; + let fileTreeCountHint = ''; + switch (fileTreeCount) { + case 0: + break; + case 1: + fileTreeCountHint = localize('singleFileTreeHint', "1 file tree"); + break; + default: + fileTreeCountHint = localize('multiFileTreeHint', "{0} file trees", fileTreeCount); + break; + } + const codeBlockCount = marked.lexer(element.response.asString()).filter(token => token.type === 'code')?.length ?? 0; + switch (codeBlockCount) { + case 0: + label = accessibleViewHint ? localize('noCodeBlocksHint', "{0} {1} {2}", fileTreeCountHint, element.response.asString(), accessibleViewHint) : localize('noCodeBlocks', "{0} {1}", fileTreeCountHint, element.response.asString()); + break; + case 1: + label = accessibleViewHint ? localize('singleCodeBlockHint', "{0} 1 code block: {1} {2}", fileTreeCountHint, element.response.asString(), accessibleViewHint) : localize('singleCodeBlock', "{0} 1 code block: {1}", fileTreeCountHint, element.response.asString()); + break; + default: + label = accessibleViewHint ? localize('multiCodeBlockHint', "{0} {1} code blocks: {2}", fileTreeCountHint, codeBlockCount, element.response.asString(), accessibleViewHint) : localize('multiCodeBlock', "{0} {1} code blocks", fileTreeCountHint, codeBlockCount, element.response.asString()); + break; + } + return label; + } +} diff --git a/src/vs/workbench/contrib/chat/browser/chatAccessibilityService.ts b/src/vs/workbench/contrib/chat/browser/chatAccessibilityService.ts index fa522294c2635..73a0ebd76a919 100644 --- a/src/vs/workbench/contrib/chat/browser/chatAccessibilityService.ts +++ b/src/vs/workbench/contrib/chat/browser/chatAccessibilityService.ts @@ -6,7 +6,7 @@ import { status } from 'vs/base/browser/ui/aria/aria'; import { RunOnceScheduler } from 'vs/base/common/async'; import { Disposable, DisposableMap, IDisposable } from 'vs/base/common/lifecycle'; -import { AudioCue, IAudioCueService } from 'vs/platform/audioCues/browser/audioCueService'; +import { AccessibilitySignal, IAccessibilitySignalService } from 'vs/platform/accessibilitySignal/browser/accessibilitySignalService'; import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; import { IChatAccessibilityService } from 'vs/workbench/contrib/chat/browser/chat'; import { IChatResponseViewModel } from 'vs/workbench/contrib/chat/common/chatViewModel'; @@ -15,24 +15,24 @@ export class ChatAccessibilityService extends Disposable implements IChatAccessi declare readonly _serviceBrand: undefined; - private _pendingCueMap: DisposableMap = this._register(new DisposableMap()); + private _pendingSignalMap: DisposableMap = this._register(new DisposableMap()); private _requestId: number = 0; - constructor(@IAudioCueService private readonly _audioCueService: IAudioCueService, @IInstantiationService private readonly _instantiationService: IInstantiationService) { + constructor(@IAccessibilitySignalService private readonly _accessibilitySignalService: IAccessibilitySignalService, @IInstantiationService private readonly _instantiationService: IInstantiationService) { super(); } acceptRequest(): number { this._requestId++; - this._audioCueService.playAudioCue(AudioCue.chatRequestSent, { allowManyInParallel: true }); - this._pendingCueMap.set(this._requestId, this._instantiationService.createInstance(AudioCueScheduler)); + this._accessibilitySignalService.playSignal(AccessibilitySignal.chatRequestSent, { allowManyInParallel: true }); + this._pendingSignalMap.set(this._requestId, this._instantiationService.createInstance(AccessibilitySignalScheduler)); return this._requestId; } acceptResponse(response: IChatResponseViewModel | string | undefined, requestId: number): void { - this._pendingCueMap.deleteAndDispose(requestId); + this._pendingSignalMap.deleteAndDispose(requestId); const isPanelChat = typeof response !== 'string'; const responseContent = typeof response === 'string' ? response : response?.response.asString(); - this._audioCueService.playAudioCue(AudioCue.chatResponseReceived, { allowManyInParallel: true }); + this._accessibilitySignalService.playSignal(AccessibilitySignal.chatResponseReceived, { allowManyInParallel: true }); if (!response) { return; } @@ -46,19 +46,19 @@ const CHAT_RESPONSE_PENDING_ALLOWANCE_MS = 4000; /** * Schedules an audio cue to play when a chat response is pending for too long. */ -class AudioCueScheduler extends Disposable { +class AccessibilitySignalScheduler extends Disposable { private _scheduler: RunOnceScheduler; - private _audioCueLoop: IDisposable | undefined; - constructor(@IAudioCueService private readonly _audioCueService: IAudioCueService) { + private _signalLoop: IDisposable | undefined; + constructor(@IAccessibilitySignalService private readonly _accessibilitySignalService: IAccessibilitySignalService) { super(); this._scheduler = new RunOnceScheduler(() => { - this._audioCueLoop = this._audioCueService.playAudioCueLoop(AudioCue.chatResponsePending, CHAT_RESPONSE_PENDING_AUDIO_CUE_LOOP_MS); + this._signalLoop = this._accessibilitySignalService.playSignalLoop(AccessibilitySignal.chatResponsePending, CHAT_RESPONSE_PENDING_AUDIO_CUE_LOOP_MS); }, CHAT_RESPONSE_PENDING_ALLOWANCE_MS); this._scheduler.schedule(); } override dispose(): void { super.dispose(); - this._audioCueLoop?.dispose(); + this._signalLoop?.dispose(); this._scheduler.cancel(); this._scheduler.dispose(); } diff --git a/src/vs/workbench/contrib/chat/browser/chatContributionServiceImpl.ts b/src/vs/workbench/contrib/chat/browser/chatContributionServiceImpl.ts index 8498292395a5b..ea834161ce286 100644 --- a/src/vs/workbench/contrib/chat/browser/chatContributionServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/browser/chatContributionServiceImpl.ts @@ -3,12 +3,16 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { isNonEmptyArray } from 'vs/base/common/arrays'; import { Codicon } from 'vs/base/common/codicons'; -import { DisposableStore, IDisposable } from 'vs/base/common/lifecycle'; +import { DisposableMap, DisposableStore, IDisposable } from 'vs/base/common/lifecycle'; import { localize, localize2 } from 'vs/nls'; import { registerAction2 } from 'vs/platform/actions/common/actions'; -import { ContextKeyExpr } from 'vs/platform/contextkey/common/contextkey'; +import { ContextKeyExpr, IContextKeyService } from 'vs/platform/contextkey/common/contextkey'; +import { ExtensionIdentifier } from 'vs/platform/extensions/common/extensions'; import { SyncDescriptor } from 'vs/platform/instantiation/common/descriptors'; +import { ILogService } from 'vs/platform/log/common/log'; +import { IProductService } from 'vs/platform/product/common/productService'; import { Registry } from 'vs/platform/registry/common/platform'; import { ViewPaneContainer } from 'vs/workbench/browser/parts/views/viewPaneContainer'; import { IWorkbenchContribution, WorkbenchPhase, registerWorkbenchContribution2 } from 'vs/workbench/common/contributions'; @@ -18,10 +22,11 @@ import { getNewChatAction } from 'vs/workbench/contrib/chat/browser/actions/chat import { getMoveToEditorAction, getMoveToNewWindowAction } from 'vs/workbench/contrib/chat/browser/actions/chatMoveActions'; import { getQuickChatActionForProvider } from 'vs/workbench/contrib/chat/browser/actions/chatQuickInputActions'; import { CHAT_SIDEBAR_PANEL_ID, ChatViewPane, IChatViewOptions } from 'vs/workbench/contrib/chat/browser/chatViewPane'; -import { IChatContributionService, IChatProviderContribution, IRawChatProviderContribution } from 'vs/workbench/contrib/chat/common/chatContributionService'; +import { ChatAgentLocation, IChatAgentData, IChatAgentService } from 'vs/workbench/contrib/chat/common/chatAgents'; +import { IChatContributionService, IChatProviderContribution, IRawChatParticipantContribution, IRawChatProviderContribution } from 'vs/workbench/contrib/chat/common/chatContributionService'; +import { isProposedApiEnabled } from 'vs/workbench/services/extensions/common/extensions'; import * as extensionsRegistry from 'vs/workbench/services/extensions/common/extensionsRegistry'; - const chatExtensionPoint = extensionsRegistry.ExtensionsRegistry.registerExtensionPoint({ extensionPoint: 'interactiveSession', jsonSchema: { @@ -59,20 +64,161 @@ const chatExtensionPoint = extensionsRegistry.ExtensionsRegistry.registerExtensi }, }); +const chatParticipantExtensionPoint = extensionsRegistry.ExtensionsRegistry.registerExtensionPoint({ + extensionPoint: 'chatParticipants', + jsonSchema: { + description: localize('vscode.extension.contributes.chatParticipant', 'Contributes a chat participant'), + type: 'array', + items: { + additionalProperties: false, + type: 'object', + defaultSnippets: [{ body: { name: '', description: '' } }], + required: ['name', 'id'], + properties: { + id: { + description: localize('chatParticipantId', "A unique id for this chat participant."), + type: 'string' + }, + name: { + description: localize('chatParticipantName', "User-facing display name for this chat participant. The user will use '@' with this name to invoke the participant."), + type: 'string' + }, + description: { + description: localize('chatParticipantDescription', "A description of this chat participant, shown in the UI."), + type: 'string' + }, + isDefault: { + markdownDescription: localize('chatParticipantIsDefaultDescription', "**Only** allowed for extensions that have the `defaultChatParticipant` proposal."), + type: 'boolean', + }, + isSticky: { + description: localize('chatCommandSticky', "Whether invoking the command puts the chat into a persistent mode, where the command is automatically added to the chat input for the next message."), + type: 'boolean' + }, + defaultImplicitVariables: { + markdownDescription: '**Only** allowed for extensions that have the `chatParticipantAdditions` proposal. The names of the variables that are invoked by default', + type: 'array', + items: { + type: 'string' + } + }, + commands: { + markdownDescription: localize('chatCommandsDescription', "Commands available for this chat participant, which the user can invoke with a `/`."), + type: 'array', + items: { + additionalProperties: false, + type: 'object', + defaultSnippets: [{ body: { name: '', description: '' } }], + required: ['name'], + properties: { + name: { + description: localize('chatCommand', "A short name by which this command is referred to in the UI, e.g. `fix` or * `explain` for commands that fix an issue or explain code. The name should be unique among the commands provided by this participant."), + type: 'string' + }, + description: { + description: localize('chatCommandDescription', "A description of this command."), + type: 'string' + }, + when: { + description: localize('chatCommandWhen', "A condition which must be true to enable this command."), + type: 'string' + }, + sampleRequest: { + description: localize('chatCommandSampleRequest', "When the user clicks this command in `/help`, this text will be submitted to this participant."), + type: 'string' + }, + isSticky: { + description: localize('chatCommandSticky', "Whether invoking the command puts the chat into a persistent mode, where the command is automatically added to the chat input for the next message."), + type: 'boolean' + }, + defaultImplicitVariables: { + markdownDescription: localize('defaultImplicitVariables', "**Only** allowed for extensions that have the `chatParticipantAdditions` proposal. The names of the variables that are invoked by default"), + type: 'array', + items: { + type: 'string' + } + }, + } + } + }, + locations: { + markdownDescription: localize('chatLocationsDescription', "Locations in which this chat participant is available."), + type: 'array', + default: ['panel'], + items: { + type: 'string', + enum: ['panel', 'terminal', 'notebook'] + } + + } + } + } + }, + activationEventsGenerator: (contributions: IRawChatParticipantContribution[], result: { push(item: string): void }) => { + for (const contrib of contributions) { + result.push(`onChatParticipant:${contrib.id}`); + } + }, +}); + export class ChatExtensionPointHandler implements IWorkbenchContribution { static readonly ID = 'workbench.contrib.chatExtensionPointHandler'; + private readonly disposables = new DisposableStore(); + private _welcomeViewDescriptor?: IViewDescriptor; private _viewContainer: ViewContainer; private _registrationDisposables = new Map(); + private _participantRegistrationDisposables = new DisposableMap(); constructor( - @IChatContributionService readonly _chatContributionService: IChatContributionService + @IChatContributionService private readonly _chatContributionService: IChatContributionService, + @IChatAgentService private readonly _chatAgentService: IChatAgentService, + @IProductService private readonly productService: IProductService, + @IContextKeyService private readonly contextService: IContextKeyService, + @ILogService private readonly logService: ILogService, ) { this._viewContainer = this.registerViewContainer(); + this.registerListeners(); this.handleAndRegisterChatExtensions(); } + private registerListeners() { + this.contextService.onDidChangeContext(e => { + + if (!this.productService.chatWelcomeView) { + return; + } + + const showWelcomeViewConfigKey = 'workbench.chat.experimental.showWelcomeView'; + const keys = new Set([showWelcomeViewConfigKey]); + if (e.affectsSome(keys)) { + const contextKeyExpr = ContextKeyExpr.equals(showWelcomeViewConfigKey, true); + const viewsRegistry = Registry.as(ViewExtensions.ViewsRegistry); + if (this.contextService.contextMatchesRules(contextKeyExpr)) { + const viewId = this._chatContributionService.getViewIdForProvider(this.productService.chatWelcomeView.welcomeViewId); + + this._welcomeViewDescriptor = { + id: viewId, + name: { original: this.productService.chatWelcomeView.welcomeViewTitle, value: this.productService.chatWelcomeView.welcomeViewTitle }, + containerIcon: this._viewContainer.icon, + ctorDescriptor: new SyncDescriptor(ChatViewPane, [{ providerId: this.productService.chatWelcomeView.welcomeViewId }]), + canToggleVisibility: false, + canMoveView: true, + order: 100 + }; + viewsRegistry.registerViews([this._welcomeViewDescriptor], this._viewContainer); + + viewsRegistry.registerViewWelcomeContent(viewId, { + content: this.productService.chatWelcomeView.welcomeViewContent, + }); + } else if (this._welcomeViewDescriptor) { + viewsRegistry.deregisterViews([this._welcomeViewDescriptor], this._viewContainer); + } + } + }, null, this.disposables); + } + private handleAndRegisterChatExtensions(): void { chatExtensionPoint.setHandler((extensions, delta) => { for (const extension of delta.added) { @@ -96,6 +242,53 @@ export class ChatExtensionPointHandler implements IWorkbenchContribution { } } }); + + chatParticipantExtensionPoint.setHandler((extensions, delta) => { + for (const extension of delta.added) { + for (const providerDescriptor of extension.value) { + if (providerDescriptor.isDefault && !isProposedApiEnabled(extension.description, 'defaultChatParticipant')) { + this.logService.error(`Extension '${extension.description.identifier.value}' CANNOT use API proposal: defaultChatParticipant.`); + continue; + } + + if (providerDescriptor.defaultImplicitVariables && !isProposedApiEnabled(extension.description, 'chatParticipantAdditions')) { + this.logService.error(`Extension '${extension.description.identifier.value}' CANNOT use API proposal: chatParticipantAdditions.`); + continue; + } + + if (!providerDescriptor.id || !providerDescriptor.name) { + this.logService.error(`Extension '${extension.description.identifier.value}' CANNOT register participant without both id and name.`); + continue; + } + + this._participantRegistrationDisposables.set( + getParticipantKey(extension.description.identifier, providerDescriptor.name), + this._chatAgentService.registerAgent( + providerDescriptor.id, + { + extensionId: extension.description.identifier, + id: providerDescriptor.id, + description: providerDescriptor.description, + metadata: { + isSticky: providerDescriptor.isSticky, + }, + name: providerDescriptor.name, + isDefault: providerDescriptor.isDefault, + defaultImplicitVariables: providerDescriptor.defaultImplicitVariables, + locations: isNonEmptyArray(providerDescriptor.locations) ? + providerDescriptor.locations.map(ChatAgentLocation.fromRaw) : + [ChatAgentLocation.Panel], + slashCommands: providerDescriptor.commands ?? [] + } satisfies IChatAgentData)); + } + } + + for (const extension of delta.removed) { + for (const providerDescriptor of extension.value) { + this._participantRegistrationDisposables.deleteAndDispose(getParticipantKey(extension.description.identifier, providerDescriptor.name)); + } + } + }); } private registerViewContainer(): ViewContainer { @@ -123,6 +316,7 @@ export class ChatExtensionPointHandler implements IWorkbenchContribution { id: viewId, containerIcon: this._viewContainer.icon, containerTitle: this._viewContainer.title.value, + singleViewPaneContainerTitle: this._viewContainer.title.value, name: { value: providerDescriptor.label, original: providerDescriptor.label }, canToggleVisibility: false, canMoveView: true, @@ -156,6 +350,10 @@ export class ChatExtensionPointHandler implements IWorkbenchContribution { registerWorkbenchContribution2(ChatExtensionPointHandler.ID, ChatExtensionPointHandler, WorkbenchPhase.BlockStartup); +function getParticipantKey(extensionId: ExtensionIdentifier, participantName: string): string { + return `${extensionId.value}_${participantName}`; +} + export class ChatContributionService implements IChatContributionService { declare _serviceBrand: undefined; diff --git a/src/vs/workbench/contrib/chat/browser/chatEditor.ts b/src/vs/workbench/contrib/chat/browser/chatEditor.ts index 8401da7b17226..34f10a76088ee 100644 --- a/src/vs/workbench/contrib/chat/browser/chatEditor.ts +++ b/src/vs/workbench/contrib/chat/browser/chatEditor.ts @@ -20,6 +20,8 @@ import { ChatEditorInput } from 'vs/workbench/contrib/chat/browser/chatEditorInp import { IChatViewState, ChatWidget } from 'vs/workbench/contrib/chat/browser/chatWidget'; import { IChatModel, ISerializableChatData } from 'vs/workbench/contrib/chat/common/chatModel'; import { clearChatEditor } from 'vs/workbench/contrib/chat/browser/actions/chatClear'; +import { IEditorGroup } from 'vs/workbench/services/editor/common/editorGroupsService'; +import { ChatAgentLocation } from 'vs/workbench/contrib/chat/common/chatAgents'; export interface IChatEditorOptions extends IEditorOptions { target: { sessionId: string } | { providerId: string } | { data: ISerializableChatData }; @@ -37,13 +39,14 @@ export class ChatEditor extends EditorPane { private _viewState: IChatViewState | undefined; constructor( + group: IEditorGroup, @ITelemetryService telemetryService: ITelemetryService, @IThemeService themeService: IThemeService, @IInstantiationService private readonly instantiationService: IInstantiationService, @IStorageService private readonly storageService: IStorageService, @IContextKeyService private readonly contextKeyService: IContextKeyService, ) { - super(ChatEditorInput.EditorID, telemetryService, themeService, storageService); + super(ChatEditorInput.EditorID, group, telemetryService, themeService, storageService); } public async clear() { @@ -57,6 +60,7 @@ export class ChatEditor extends EditorPane { this.widget = this._register( scopedInstantiationService.createInstance( ChatWidget, + ChatAgentLocation.Panel, { resource: true }, { supportsFileReferences: true }, { @@ -81,7 +85,7 @@ export class ChatEditor extends EditorPane { super.clearInput(); } - override async setInput(input: ChatEditorInput, options: IChatEditorOptions, context: IEditorOpenContext, token: CancellationToken): Promise { + override async setInput(input: ChatEditorInput, options: IChatEditorOptions | undefined, context: IEditorOpenContext, token: CancellationToken): Promise { super.setInput(input, options, context, token); const editorModel = await input.resolve(); @@ -93,7 +97,7 @@ export class ChatEditor extends EditorPane { throw new Error('ChatEditor lifecycle issue: no editor widget'); } - this.updateModel(editorModel.model, options.viewState); + this.updateModel(editorModel.model, options?.viewState ?? input.options.viewState); } private updateModel(model: IChatModel, viewState?: IChatViewState): void { diff --git a/src/vs/workbench/contrib/chat/browser/chatEditorInput.ts b/src/vs/workbench/contrib/chat/browser/chatEditorInput.ts index f59f81e2ffb16..e6cc6c81ff000 100644 --- a/src/vs/workbench/contrib/chat/browser/chatEditorInput.ts +++ b/src/vs/workbench/contrib/chat/browser/chatEditorInput.ts @@ -183,16 +183,12 @@ interface ISerializedChatEditorInput { } export class ChatEditorInputSerializer implements IEditorSerializer { - canSerialize(input: EditorInput): boolean { - return input instanceof ChatEditorInput; + canSerialize(input: EditorInput): input is ChatEditorInput & { readonly sessionId: string } { + return input instanceof ChatEditorInput && typeof input.sessionId === 'string'; } serialize(input: EditorInput): string | undefined { - if (!(input instanceof ChatEditorInput)) { - return undefined; - } - - if (typeof input.sessionId !== 'string') { + if (!this.canSerialize(input)) { return undefined; } diff --git a/src/vs/workbench/contrib/chat/browser/chatFollowups.ts b/src/vs/workbench/contrib/chat/browser/chatFollowups.ts index 6c6e0d8f6d729..29a5ba75b7e2e 100644 --- a/src/vs/workbench/contrib/chat/browser/chatFollowups.ts +++ b/src/vs/workbench/contrib/chat/browser/chatFollowups.ts @@ -9,17 +9,22 @@ import { MarkdownString } from 'vs/base/common/htmlContent'; import { Disposable } from 'vs/base/common/lifecycle'; import { localize } from 'vs/nls'; import { ContextKeyExpr, IContextKeyService } from 'vs/platform/contextkey/common/contextkey'; +import { ChatAgentLocation, IChatAgentService } from 'vs/workbench/contrib/chat/common/chatAgents'; +import { chatAgentLeader, chatSubcommandLeader } from 'vs/workbench/contrib/chat/common/chatParserTypes'; import { IChatFollowup } from 'vs/workbench/contrib/chat/common/chatService'; +import { IInlineChatFollowup } from 'vs/workbench/contrib/inlineChat/common/inlineChat'; const $ = dom.$; -export class ChatFollowups extends Disposable { +export class ChatFollowups extends Disposable { constructor( container: HTMLElement, followups: T[], + private readonly location: ChatAgentLocation, private readonly options: IButtonStyles | undefined, private readonly clickHandler: (followup: T) => void, - private readonly contextService: IContextKeyService, + @IContextKeyService private readonly contextService: IContextKeyService, + @IChatAgentService private readonly chatAgentService: IChatAgentService ) { super(); @@ -33,7 +38,31 @@ export class ChatFollowups extends Disposable { return; } - const tooltip = 'tooltip' in followup ? followup.tooltip : undefined; + if (!this.chatAgentService.getDefaultAgent(this.location)) { + // No default agent yet, which affects how followups are rendered, so can't render this yet + return; + } + + let tooltipPrefix = ''; + if ('agentId' in followup && followup.agentId && followup.agentId !== this.chatAgentService.getDefaultAgent(this.location)?.id) { + const agent = this.chatAgentService.getAgent(followup.agentId); + if (!agent) { + // Refers to agent that doesn't exist + return; + } + + tooltipPrefix += `${chatAgentLeader}${agent.name} `; + if ('subCommand' in followup && followup.subCommand) { + tooltipPrefix += `${chatSubcommandLeader}${followup.subCommand} `; + } + } + + const baseTitle = followup.kind === 'reply' ? + (followup.title || followup.message) + : followup.title; + + const tooltip = tooltipPrefix + + ('tooltip' in followup && followup.tooltip || baseTitle); const button = this._register(new Button(container, { ...this.options, supportIcons: true, title: tooltip })); if (followup.kind === 'reply') { button.element.classList.add('interactive-followup-reply'); @@ -41,9 +70,12 @@ export class ChatFollowups extends Disposable { button.element.classList.add('interactive-followup-command'); } button.element.ariaLabel = localize('followUpAriaLabel', "Follow up question: {0}", followup.title); - const label = followup.kind === 'reply' ? - '$(sparkle) ' + (followup.title || followup.message) : - followup.title; + let label = ''; + if (followup.kind === 'reply') { + label = '$(sparkle) ' + baseTitle; + } else { + label = baseTitle; + } button.label = new MarkdownString(label, { supportThemeIcons: true }); this._register(button.onDidClick(() => this.clickHandler(followup))); diff --git a/src/vs/workbench/contrib/chat/browser/chatInputPart.ts b/src/vs/workbench/contrib/chat/browser/chatInputPart.ts index eb906af265ff2..90e2c48a6b1fa 100644 --- a/src/vs/workbench/contrib/chat/browser/chatInputPart.ts +++ b/src/vs/workbench/contrib/chat/browser/chatInputPart.ts @@ -4,50 +4,69 @@ *--------------------------------------------------------------------------------------------*/ import * as dom from 'vs/base/browser/dom'; +import { DEFAULT_FONT_FAMILY } from 'vs/base/browser/fonts'; import { IHistoryNavigationWidget } from 'vs/base/browser/history'; -import { ActionViewItem, IActionViewItemOptions } from 'vs/base/browser/ui/actionbar/actionViewItems'; import * as aria from 'vs/base/browser/ui/aria/aria'; +import { Checkbox } from 'vs/base/browser/ui/toggle/toggle'; import { IAction } from 'vs/base/common/actions'; +import { Codicon } from 'vs/base/common/codicons'; import { Emitter } from 'vs/base/common/event'; import { HistoryNavigator } from 'vs/base/common/history'; import { Disposable, DisposableStore } from 'vs/base/common/lifecycle'; import { isMacintosh } from 'vs/base/common/platform'; import { URI } from 'vs/base/common/uri'; +import { IEditorConstructionOptions } from 'vs/editor/browser/config/editorConfiguration'; import { EditorExtensionsRegistry } from 'vs/editor/browser/editorExtensions'; -import { CodeEditorWidget } from 'vs/editor/browser/widget/codeEditorWidget'; +import { CodeEditorWidget } from 'vs/editor/browser/widget/codeEditor/codeEditorWidget'; +import { IDimension } from 'vs/editor/common/core/dimension'; +import { IPosition } from 'vs/editor/common/core/position'; import { ITextModel } from 'vs/editor/common/model'; import { IModelService } from 'vs/editor/common/services/model'; import { HoverController } from 'vs/editor/contrib/hover/browser/hover'; import { localize } from 'vs/nls'; import { IAccessibilityService } from 'vs/platform/accessibility/common/accessibility'; +import { DropdownWithPrimaryActionViewItem } from 'vs/platform/actions/browser/dropdownWithPrimaryActionViewItem'; +import { createAndFillInActionBarActions } from 'vs/platform/actions/browser/menuEntryActionViewItem'; import { HiddenItemStrategy, MenuWorkbenchToolBar } from 'vs/platform/actions/browser/toolbar'; -import { MenuId } from 'vs/platform/actions/common/actions'; +import { IMenuService, MenuId, MenuItemAction } from 'vs/platform/actions/common/actions'; import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; import { IContextKey, IContextKeyService } from 'vs/platform/contextkey/common/contextkey'; +import { IContextMenuService } from 'vs/platform/contextview/browser/contextView'; import { registerAndCreateHistoryNavigationContext } from 'vs/platform/history/browser/contextScopedHistoryWidget'; import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; import { ServiceCollection } from 'vs/platform/instantiation/common/serviceCollection'; import { IKeybindingService } from 'vs/platform/keybinding/common/keybinding'; -import { DEFAULT_FONT_FAMILY } from 'vs/workbench/browser/style'; +import { INotificationService } from 'vs/platform/notification/common/notification'; +import { defaultCheckboxStyles } from 'vs/platform/theme/browser/defaultStyles'; +import { asCssVariableWithDefault, checkboxBorder, inputBackground } from 'vs/platform/theme/common/colorRegistry'; +import { IThemeService } from 'vs/platform/theme/common/themeService'; import { AccessibilityVerbositySettingId } from 'vs/workbench/contrib/accessibility/browser/accessibilityConfiguration'; import { AccessibilityCommandId } from 'vs/workbench/contrib/accessibility/common/accessibilityCommands'; -import { IChatExecuteActionContext, SubmitAction } from 'vs/workbench/contrib/chat/browser/actions/chatExecuteActions'; +import { CancelAction, ChatSubmitSecondaryAgentAction, IChatExecuteActionContext, SubmitAction } from 'vs/workbench/contrib/chat/browser/actions/chatExecuteActions'; import { IChatWidget } from 'vs/workbench/contrib/chat/browser/chat'; import { ChatFollowups } from 'vs/workbench/contrib/chat/browser/chatFollowups'; -import { IChatAgentService } from 'vs/workbench/contrib/chat/common/chatAgents'; -import { CONTEXT_CHAT_INPUT_CURSOR_AT_TOP, CONTEXT_CHAT_INPUT_HAS_TEXT, CONTEXT_IN_CHAT_INPUT } from 'vs/workbench/contrib/chat/common/chatContextKeys'; -import { chatAgentLeader } from 'vs/workbench/contrib/chat/common/chatParserTypes'; -import { IChatReplyFollowup } from 'vs/workbench/contrib/chat/common/chatService'; +import { ChatAgentLocation, IChatAgentService } from 'vs/workbench/contrib/chat/common/chatAgents'; +import { CONTEXT_CHAT_INPUT_CURSOR_AT_TOP, CONTEXT_CHAT_INPUT_HAS_FOCUS, CONTEXT_CHAT_INPUT_HAS_TEXT, CONTEXT_IN_CHAT_INPUT } from 'vs/workbench/contrib/chat/common/chatContextKeys'; +import { IChatFollowup } from 'vs/workbench/contrib/chat/common/chatService'; import { IChatResponseViewModel } from 'vs/workbench/contrib/chat/common/chatViewModel'; import { IChatHistoryEntry, IChatWidgetHistoryService } from 'vs/workbench/contrib/chat/common/chatWidgetHistoryService'; import { getSimpleCodeEditorWidgetOptions, getSimpleEditorOptions } from 'vs/workbench/contrib/codeEditor/browser/simpleEditorOptions'; -import { ChatSubmitEditorAction, ChatSubmitSecondaryAgentEditorAction } from 'vs/workbench/contrib/chat/browser/actions/chatActions'; -import { IPosition } from 'vs/editor/common/core/position'; const $ = dom.$; const INPUT_EDITOR_MAX_HEIGHT = 250; +interface IChatInputPartOptions { + renderFollowups: boolean; + renderStyle?: 'default' | 'compact'; + menus: { + executeToolbar: MenuId; + inputSideToolbar?: MenuId; + telemetrySource?: string; + }; + editorOverflowWidgetsDomNode?: HTMLElement; +} + export class ChatInputPart extends Disposable implements IHistoryNavigationWidget { static readonly INPUT_SCHEME = 'chatSessionInput'; private static _counter = 0; @@ -64,14 +83,29 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge private _onDidBlur = this._register(new Emitter()); readonly onDidBlur = this._onDidBlur.event; - private _onDidAcceptFollowup = this._register(new Emitter<{ followup: IChatReplyFollowup; response: IChatResponseViewModel | undefined }>()); + private _onDidAcceptFollowup = this._register(new Emitter<{ followup: IChatFollowup; response: IChatResponseViewModel | undefined }>()); readonly onDidAcceptFollowup = this._onDidAcceptFollowup.event; private inputEditorHeight = 0; private container!: HTMLElement; + private inputSideToolbarContainer?: HTMLElement; + private followupsContainer!: HTMLElement; - private followupsDisposables = this._register(new DisposableStore()); + private readonly followupsDisposables = this._register(new DisposableStore()); + + private implicitContextContainer!: HTMLElement; + private implicitContextLabel!: HTMLElement; + private implicitContextCheckbox!: Checkbox; + private implicitContextSettingEnabled = false; + get implicitContextEnabled() { + return this.implicitContextCheckbox.checked; + } + + private _inputPartHeight: number = 0; + get inputPartHeight() { + return this._inputPartHeight; + } private _inputEditor!: CodeEditorWidget; private _inputEditorElement!: HTMLElement; @@ -86,9 +120,11 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge private historyNavigationBackwardsEnablement!: IContextKey; private historyNavigationForewardsEnablement!: IContextKey; private onHistoryEntry = false; + private inHistoryNavigation = false; private inputModel: ITextModel | undefined; private inputEditorHasText: IContextKey; private chatCursorAtTop: IContextKey; + private inputEditorHasFocus: IContextKey; private providerId: string | undefined; private cachedDimensions: dom.Dimension | undefined; @@ -98,26 +134,34 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge constructor( // private readonly editorOptions: ChatEditorOptions, // TODO this should be used - private readonly options: { renderFollowups: boolean; renderStyle?: 'default' | 'compact' }, + private readonly location: ChatAgentLocation, + private readonly options: IChatInputPartOptions, @IChatWidgetHistoryService private readonly historyService: IChatWidgetHistoryService, @IModelService private readonly modelService: IModelService, @IInstantiationService private readonly instantiationService: IInstantiationService, @IContextKeyService private readonly contextKeyService: IContextKeyService, @IConfigurationService private readonly configurationService: IConfigurationService, @IKeybindingService private readonly keybindingService: IKeybindingService, - @IAccessibilityService private readonly accessibilityService: IAccessibilityService + @IAccessibilityService private readonly accessibilityService: IAccessibilityService, ) { super(); this.inputEditorHasText = CONTEXT_CHAT_INPUT_HAS_TEXT.bindTo(contextKeyService); this.chatCursorAtTop = CONTEXT_CHAT_INPUT_CURSOR_AT_TOP.bindTo(contextKeyService); + this.inputEditorHasFocus = CONTEXT_CHAT_INPUT_HAS_FOCUS.bindTo(contextKeyService); this.history = new HistoryNavigator([], 5); this._register(this.historyService.onDidClearHistory(() => this.history.clear())); + + this.implicitContextSettingEnabled = this.configurationService.getValue('chat.experimental.implicitContext'); this._register(this.configurationService.onDidChangeConfiguration(e => { if (e.affectsConfiguration(AccessibilityVerbositySettingId.Chat)) { this.inputEditor.updateOptions({ ariaLabel: this._getAriaLabel() }); } + + if (e.affectsConfiguration('chat.experimental.implicitContext')) { + this.implicitContextSettingEnabled = this.configurationService.getValue('chat.experimental.implicitContext'); + } })); } @@ -160,7 +204,11 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge this.onHistoryEntry = previous || this.history.current() !== null; aria.status(historyEntry.text); + + this.inHistoryNavigation = true; this.setValue(historyEntry.text); + this.inHistoryNavigation = false; + this._onDidLoadInputState.fire(historyEntry.state); if (previous) { this._inputEditor.setPosition({ lineNumber: 1, column: 1 }); @@ -194,7 +242,13 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge */ async acceptInput(userQuery?: string, inputState?: any): Promise { if (userQuery) { - this.history.add({ text: userQuery, state: inputState }); + let element = this.history.getHistory().find(candidate => candidate.text === userQuery); + if (!element) { + element = { text: userQuery, state: inputState }; + } else { + element.state = inputState; + } + this.history.add(element); } if (this.accessibilityService.isScreenReaderOptimized() && isMacintosh) { @@ -220,8 +274,11 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge render(container: HTMLElement, initialValue: string, widget: IChatWidget) { this.container = dom.append(container, $('.interactive-input-part')); + this.container.classList.toggle('compact', this.options.renderStyle === 'compact'); this.followupsContainer = dom.append(this.container, $('.interactive-input-followups')); + this.implicitContextContainer = dom.append(this.container, $('.chat-implicit-context')); + this.initImplicitContext(this.implicitContextContainer); const inputAndSideToolbar = dom.append(this.container, $('.interactive-input-and-side-toolbar')); const inputContainer = dom.append(inputAndSideToolbar, $('.interactive-input-and-execute-toolbar')); @@ -233,7 +290,8 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge this.historyNavigationBackwardsEnablement = historyNavigationBackwardsEnablement; this.historyNavigationForewardsEnablement = historyNavigationForwardsEnablement; - const options = getSimpleEditorOptions(this.configurationService); + const options: IEditorConstructionOptions = getSimpleEditorOptions(this.configurationService); + options.overflowWidgetsDomNode = this.options.editorOverflowWidgetsDomNode; options.readOnly = false; options.ariaLabel = this._getAriaLabel(); options.fontFamily = DEFAULT_FONT_FAMILY; @@ -267,18 +325,26 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge // Only allow history navigation when the input is empty. // (If this model change happened as a result of a history navigation, this is canceled out by a call in this.navigateHistory) const model = this._inputEditor.getModel(); - const inputHasText = !!model && model.getValueLength() > 0; + const inputHasText = !!model && model.getValue().trim().length > 0; this.inputEditorHasText.set(inputHasText); + + // If the user is typing on a history entry, then reset the onHistoryEntry flag so that history navigation can be disabled + if (!this.inHistoryNavigation) { + this.onHistoryEntry = false; + } + if (!this.onHistoryEntry) { this.historyNavigationForewardsEnablement.set(!inputHasText); this.historyNavigationBackwardsEnablement.set(!inputHasText); } })); this._register(this._inputEditor.onDidFocusEditorText(() => { + this.inputEditorHasFocus.set(true); this._onDidFocus.fire(); inputContainer.classList.toggle('focused', true); })); this._register(this._inputEditor.onDidBlurEditorText(() => { + this.inputEditorHasFocus.set(false); inputContainer.classList.toggle('focused', false); this._onDidBlur.fire(); @@ -298,14 +364,18 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge } })); - this.toolbar = this._register(this.instantiationService.createInstance(MenuWorkbenchToolBar, inputContainer, MenuId.ChatExecute, { + this.toolbar = this._register(this.instantiationService.createInstance(MenuWorkbenchToolBar, inputContainer, this.options.menus.executeToolbar, { + telemetrySource: this.options.menus.telemetrySource, menuOptions: { shouldForwardArgs: true }, hiddenItemStrategy: HiddenItemStrategy.Ignore, // keep it lean when hiding items and avoid a "..." overflow menu actionViewItemProvider: (action, options) => { - if (action.id === SubmitAction.ID) { - return this.instantiationService.createInstance(SubmitButtonActionViewItem, { widget } satisfies IChatExecuteActionContext, action, options); + if (this.location === ChatAgentLocation.Panel) { + if ((action.id === SubmitAction.ID || action.id === CancelAction.ID) && action instanceof MenuItemAction) { + const dropdownAction = this.instantiationService.createInstance(MenuItemAction, { id: 'chat.moreExecuteActions', title: localize('notebook.moreExecuteActionsLabel', "More..."), icon: Codicon.chevronDown }, undefined, undefined, undefined, undefined); + return this.instantiationService.createInstance(ChatSubmitDropdownActionItem, action, dropdownAction); + } } return undefined; @@ -319,17 +389,25 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge } })); - if (this.options.renderStyle === 'compact') { - const toolbarSide = this._register(this.instantiationService.createInstance(MenuWorkbenchToolBar, inputAndSideToolbar, MenuId.ChatInputSide, { + if (this.options.menus.inputSideToolbar) { + const toolbarSide = this._register(this.instantiationService.createInstance(MenuWorkbenchToolBar, inputAndSideToolbar, this.options.menus.inputSideToolbar, { + telemetrySource: this.options.menus.telemetrySource, menuOptions: { shouldForwardArgs: true } })); + this.inputSideToolbarContainer = toolbarSide.getElement(); toolbarSide.getElement().classList.add('chat-side-toolbar'); toolbarSide.context = { widget } satisfies IChatExecuteActionContext; } - this.inputModel = this.modelService.getModel(this.inputUri) || this.modelService.createModel('', null, this.inputUri, true); + let inputModel = this.modelService.getModel(this.inputUri); + if (!inputModel) { + inputModel = this.modelService.createModel('', null, this.inputUri, true); + this._register(inputModel); + } + + this.inputModel = inputModel; this.inputModel.updateOptions({ bracketColorizationOptions: { enabled: false, independentColorPoolPerBracketType: false } }); this._inputEditor.setModel(this.inputModel); if (initialValue) { @@ -339,7 +417,19 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge } } - async renderFollowups(items: IChatReplyFollowup[] | undefined, response: IChatResponseViewModel | undefined): Promise { + private initImplicitContext(container: HTMLElement) { + this.implicitContextCheckbox = new Checkbox('#selection', true, { ...defaultCheckboxStyles, checkboxBorder: asCssVariableWithDefault(checkboxBorder, inputBackground) }); + container.append(this.implicitContextCheckbox.domNode); + this.implicitContextLabel = dom.append(container, $('span.chat-implicit-context-label')); + this.implicitContextLabel.textContent = '#selection'; + } + + setImplicitContextKinds(kinds: string[]) { + dom.setVisibility(this.implicitContextSettingEnabled && kinds.length > 0, this.implicitContextContainer); + this.implicitContextLabel.textContent = localize('use', "Use") + ' ' + kinds.map(k => `#${k}`).join(', '); + } + + async renderFollowups(items: IChatFollowup[] | undefined, response: IChatResponseViewModel | undefined): Promise { if (!this.options.renderFollowups) { return; } @@ -347,41 +437,60 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge dom.clearNode(this.followupsContainer); if (items && items.length > 0) { - this.followupsDisposables.add(new ChatFollowups(this.followupsContainer, items, undefined, followup => this._onDidAcceptFollowup.fire({ followup, response }), this.contextKeyService)); + this.followupsDisposables.add(this.instantiationService.createInstance, ChatFollowups>(ChatFollowups, this.followupsContainer, items, this.location, undefined, followup => this._onDidAcceptFollowup.fire({ followup, response }))); } } - layout(height: number, width: number): number { + get contentHeight(): number { + const data = this.getLayoutData(); + return data.followupsHeight + data.inputPartEditorHeight + data.inputPartVerticalPadding + data.inputEditorBorder + data.implicitContextHeight; + } + + layout(height: number, width: number) { this.cachedDimensions = new dom.Dimension(width, height); return this._layout(height, width); } - private _layout(height: number, width: number, allowRecurse = true): number { - const followupsHeight = this.followupsContainer.offsetHeight; + private previousInputEditorDimension: IDimension | undefined; + private _layout(height: number, width: number, allowRecurse = true): void { - const inputPartBorder = 1; - const inputPartHorizontalPadding = 40; - const inputPartVerticalPadding = 24; - const inputEditorHeight = Math.min(this._inputEditor.getContentHeight(), height - followupsHeight - inputPartHorizontalPadding - inputPartBorder, INPUT_EDITOR_MAX_HEIGHT); + const data = this.getLayoutData(); - const inputEditorBorder = 2; - const inputPartHeight = followupsHeight + inputEditorHeight + inputPartVerticalPadding + inputPartBorder + inputEditorBorder; + const inputEditorHeight = Math.min(data.inputPartEditorHeight, height - data.followupsHeight - data.inputPartVerticalPadding); - const editorBorder = 2; - const editorPadding = 8; - const executeToolbarWidth = this.cachedToolbarWidth = this.toolbar.getItemsWidth(); - const sideToolbarWidth = this.options.renderStyle === 'compact' ? 20 : 0; + this._inputPartHeight = data.followupsHeight + inputEditorHeight + data.inputPartVerticalPadding + data.inputEditorBorder + data.implicitContextHeight; const initialEditorScrollWidth = this._inputEditor.getScrollWidth(); - this._inputEditor.layout({ width: width - inputPartHorizontalPadding - editorBorder - editorPadding - executeToolbarWidth - sideToolbarWidth, height: inputEditorHeight }); + const newEditorWidth = width - data.inputPartHorizontalPadding - data.editorBorder - data.editorPadding - data.executeToolbarWidth - data.sideToolbarWidth - data.toolbarPadding; + const newDimension = { width: newEditorWidth, height: inputEditorHeight }; + if (!this.previousInputEditorDimension || (this.previousInputEditorDimension.width !== newDimension.width || this.previousInputEditorDimension.height !== newDimension.height)) { + // This layout call has side-effects that are hard to understand. eg if we are calling this inside a onDidChangeContent handler, this can trigger the next onDidChangeContent handler + // to be invoked, and we have a lot of these on this editor. Only doing a layout this when the editor size has actually changed makes it much easier to follow. + this._inputEditor.layout(newDimension); + this.previousInputEditorDimension = newDimension; + } if (allowRecurse && initialEditorScrollWidth < 10) { // This is probably the initial layout. Now that the editor is layed out with its correct width, it should report the correct contentHeight return this._layout(height, width, false); } + } - return inputPartHeight; + private getLayoutData() { + return { + inputEditorBorder: 2, + followupsHeight: this.followupsContainer.offsetHeight, + inputPartEditorHeight: Math.min(this._inputEditor.getContentHeight(), INPUT_EDITOR_MAX_HEIGHT), + inputPartHorizontalPadding: this.options.renderStyle === 'compact' ? 8 : 40, + inputPartVerticalPadding: this.options.renderStyle === 'compact' ? 12 : 24, + implicitContextHeight: this.implicitContextContainer.offsetHeight, + editorBorder: 2, + editorPadding: 12, + toolbarPadding: 4, + executeToolbarWidth: this.cachedToolbarWidth = this.toolbar.getItemsWidth(), + sideToolbarWidth: this.inputSideToolbarContainer ? dom.getTotalWidth(this.inputSideToolbarContainer) + 4 /*gap*/ : 0, + }; } saveState(): void { @@ -390,40 +499,57 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge } } -class SubmitButtonActionViewItem extends ActionViewItem { - private readonly _tooltip: string; +function getLastPosition(model: ITextModel): IPosition { + return { lineNumber: model.getLineCount(), column: model.getLineLength(model.getLineCount()) + 1 }; +} +// This does seems like a lot just to customize an item with dropdown. This whole class exists just because we need an +// onDidChange listener on the submenu, which is apparently not needed in other cases. +class ChatSubmitDropdownActionItem extends DropdownWithPrimaryActionViewItem { constructor( - context: unknown, - action: IAction, - options: IActionViewItemOptions, - @IKeybindingService keybindingService: IKeybindingService, + action: MenuItemAction, + dropdownAction: IAction, + @IMenuService menuService: IMenuService, + @IContextMenuService contextMenuService: IContextMenuService, @IChatAgentService chatAgentService: IChatAgentService, + @IContextKeyService contextKeyService: IContextKeyService, + @IKeybindingService keybindingService: IKeybindingService, + @INotificationService notificationService: INotificationService, + @IThemeService themeService: IThemeService, + @IAccessibilityService accessibilityService: IAccessibilityService ) { - super(context, action, options); - - const primaryKeybinding = keybindingService.lookupKeybinding(ChatSubmitEditorAction.ID)?.getLabel(); - let tooltip = action.label; - if (primaryKeybinding) { - tooltip += ` (${primaryKeybinding})`; - } - - const secondaryAgent = chatAgentService.getSecondaryAgent(); - if (secondaryAgent) { - const secondaryKeybinding = keybindingService.lookupKeybinding(ChatSubmitSecondaryAgentEditorAction.ID)?.getLabel(); - if (secondaryKeybinding) { - tooltip += `\n${chatAgentLeader}${secondaryAgent.id} (${secondaryKeybinding})`; + super( + action, + dropdownAction, + [], + '', + contextMenuService, + { + getKeyBinding: (action: IAction) => keybindingService.lookupKeybinding(action.id, contextKeyService) + }, + keybindingService, + notificationService, + contextKeyService, + themeService, + accessibilityService); + const menu = menuService.createMenu(MenuId.ChatExecuteSecondary, contextKeyService); + const setActions = () => { + const secondary: IAction[] = []; + createAndFillInActionBarActions(menu, { shouldForwardArgs: true }, secondary); + const secondaryAgent = chatAgentService.getSecondaryAgent(); + if (secondaryAgent) { + secondary.forEach(a => { + if (a.id === ChatSubmitSecondaryAgentAction.ID) { + a.label = localize('chat.submitToSecondaryAgent', "Send to @{0}", secondaryAgent.name); + } + + return a; + }); } - } - - this._tooltip = tooltip; - } - protected override getTooltip(): string | undefined { - return this._tooltip; + this.update(dropdownAction, secondary); + }; + setActions(); + this._register(menu.onDidChange(() => setActions())); } } - -function getLastPosition(model: ITextModel): IPosition { - return { lineNumber: model.getLineCount(), column: model.getLineLength(model.getLineCount()) + 1 }; -} diff --git a/src/vs/workbench/contrib/chat/browser/chatListRenderer.ts b/src/vs/workbench/contrib/chat/browser/chatListRenderer.ts index 9983b28dd4ecb..55567f4fc3fd2 100644 --- a/src/vs/workbench/contrib/chat/browser/chatListRenderer.ts +++ b/src/vs/workbench/contrib/chat/browser/chatListRenderer.ts @@ -5,11 +5,10 @@ import * as dom from 'vs/base/browser/dom'; import { IActionViewItemOptions } from 'vs/base/browser/ui/actionbar/actionViewItems'; -import { AriaRole, alert } from 'vs/base/browser/ui/aria/aria'; +import { alert } from 'vs/base/browser/ui/aria/aria'; import { Button } from 'vs/base/browser/ui/button/button'; import { renderIcon } from 'vs/base/browser/ui/iconLabel/iconLabels'; import { IListRenderer, IListVirtualDelegate } from 'vs/base/browser/ui/list/list'; -import { IListAccessibilityProvider } from 'vs/base/browser/ui/list/listWidget'; import { ITreeCompressionDelegate } from 'vs/base/browser/ui/tree/asyncDataTree'; import { ICompressedTreeNode } from 'vs/base/browser/ui/tree/compressedObjectTreeModel'; import { ICompressibleTreeRenderer } from 'vs/base/browser/ui/tree/objectTree'; @@ -21,27 +20,25 @@ import { Codicon } from 'vs/base/common/codicons'; import { Emitter, Event } from 'vs/base/common/event'; import { FuzzyScore } from 'vs/base/common/filters'; import { IMarkdownString, MarkdownString } from 'vs/base/common/htmlContent'; -import { Disposable, DisposableStore, IDisposable, toDisposable } from 'vs/base/common/lifecycle'; +import { Disposable, DisposableStore, IDisposable, IReference, toDisposable } from 'vs/base/common/lifecycle'; import { ResourceMap } from 'vs/base/common/map'; -import { marked } from 'vs/base/common/marked/marked'; -import { FileAccess, Schemas } from 'vs/base/common/network'; +import { FileAccess, Schemas, matchesSomeScheme } from 'vs/base/common/network'; import { clamp } from 'vs/base/common/numbers'; import { basename } from 'vs/base/common/path'; +import { basenameOrAuthority } from 'vs/base/common/resources'; import { equalsIgnoreCase } from 'vs/base/common/strings'; import { ThemeIcon } from 'vs/base/common/themables'; import { URI } from 'vs/base/common/uri'; -import { ICodeEditor } from 'vs/editor/browser/editorBrowser'; -import { ICodeEditorService } from 'vs/editor/browser/services/codeEditorService'; import { IMarkdownRenderResult, MarkdownRenderer } from 'vs/editor/browser/widget/markdownRenderer/browser/markdownRenderer'; import { Range } from 'vs/editor/common/core/range'; +import { IResolvedTextEditorModel, ITextModelService } from 'vs/editor/common/services/resolverService'; import { localize } from 'vs/nls'; -import { IMenuEntryActionViewItemOptions, MenuEntryActionViewItem } from 'vs/platform/actions/browser/menuEntryActionViewItem'; +import { IMenuEntryActionViewItemOptions, MenuEntryActionViewItem, createActionViewItem } from 'vs/platform/actions/browser/menuEntryActionViewItem'; import { MenuWorkbenchToolBar } from 'vs/platform/actions/browser/toolbar'; import { MenuId, MenuItemAction } from 'vs/platform/actions/common/actions'; import { ICommandService } from 'vs/platform/commands/common/commands'; import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; import { IContextKeyService } from 'vs/platform/contextkey/common/contextkey'; -import { ITextResourceEditorInput } from 'vs/platform/editor/common/editor'; import { FileKind, FileType } from 'vs/platform/files/common/files'; import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; import { ServiceCollection } from 'vs/platform/instantiation/common/serviceCollection'; @@ -52,23 +49,28 @@ import { defaultButtonStyles } from 'vs/platform/theme/browser/defaultStyles'; import { ColorScheme } from 'vs/platform/theme/common/theme'; import { IThemeService } from 'vs/platform/theme/common/themeService'; import { IResourceLabel, ResourceLabels } from 'vs/workbench/browser/labels'; -import { AccessibilityVerbositySettingId } from 'vs/workbench/contrib/accessibility/browser/accessibilityConfiguration'; -import { IAccessibleViewService } from 'vs/workbench/contrib/accessibility/browser/accessibleView'; -import { ChatTreeItem, IChatCodeBlockInfo, IChatFileTreeInfo } from 'vs/workbench/contrib/chat/browser/chat'; +import { ChatTreeItem, GeneratingPhrase, IChatCodeBlockInfo, IChatFileTreeInfo } from 'vs/workbench/contrib/chat/browser/chat'; import { ChatFollowups } from 'vs/workbench/contrib/chat/browser/chatFollowups'; -import { ChatMarkdownDecorationsRenderer, annotateSpecialMarkdownContent, extractVulnerabilitiesFromText } from 'vs/workbench/contrib/chat/browser/chatMarkdownDecorationsRenderer'; +import { ChatMarkdownDecorationsRenderer } from 'vs/workbench/contrib/chat/browser/chatMarkdownDecorationsRenderer'; import { ChatEditorOptions } from 'vs/workbench/contrib/chat/browser/chatOptions'; -import { ChatCodeBlockContentProvider, ICodeBlockData, ICodeBlockPart, LocalFileCodeBlockPart, SimpleCodeBlockPart, localFileLanguageId, parseLocalFileData } from 'vs/workbench/contrib/chat/browser/codeBlockPart'; -import { IChatAgentMetadata } from 'vs/workbench/contrib/chat/common/chatAgents'; -import { CONTEXT_CHAT_RESPONSE_SUPPORT_ISSUE_REPORTING, CONTEXT_REQUEST, CONTEXT_RESPONSE, CONTEXT_RESPONSE_FILTERED, CONTEXT_RESPONSE_VOTE } from 'vs/workbench/contrib/chat/common/chatContextKeys'; +import { ChatCodeBlockContentProvider, CodeBlockPart, CodeCompareBlockPart, ICodeBlockData, localFileLanguageId, parseLocalFileData } from 'vs/workbench/contrib/chat/browser/codeBlockPart'; +import { ChatAgentLocation, IChatAgentMetadata } from 'vs/workbench/contrib/chat/common/chatAgents'; +import { CONTEXT_CHAT_RESPONSE_SUPPORT_ISSUE_REPORTING, CONTEXT_REQUEST, CONTEXT_RESPONSE, CONTEXT_RESPONSE_DETECTED_AGENT_COMMAND, CONTEXT_RESPONSE_FILTERED, CONTEXT_RESPONSE_VOTE } from 'vs/workbench/contrib/chat/common/chatContextKeys'; import { IChatProgressRenderableResponseContent } from 'vs/workbench/contrib/chat/common/chatModel'; import { chatAgentLeader, chatSubcommandLeader } from 'vs/workbench/contrib/chat/common/chatParserTypes'; -import { IChatContentReference, IChatProgressMessage, IChatReplyFollowup, IChatResponseProgressFileTreeData, IChatService, InteractiveSessionVoteDirection } from 'vs/workbench/contrib/chat/common/chatService'; +import { IChatCommandButton, IChatContentReference, IChatFollowup, IChatProgressMessage, IChatResponseProgressFileTreeData, IChatTextEdit, InteractiveSessionVoteDirection } from 'vs/workbench/contrib/chat/common/chatService'; +import { IChatVariablesService } from 'vs/workbench/contrib/chat/common/chatVariables'; import { IChatProgressMessageRenderData, IChatRenderData, IChatResponseMarkdownRenderData, IChatResponseViewModel, IChatWelcomeMessageViewModel, isRequestVM, isResponseVM, isWelcomeVM } from 'vs/workbench/contrib/chat/common/chatViewModel'; import { IWordCountResult, getNWords } from 'vs/workbench/contrib/chat/common/chatWordCounter'; import { createFileIconThemableTreeContainerScope } from 'vs/workbench/contrib/files/browser/views/explorerView'; import { IFilesConfiguration } from 'vs/workbench/contrib/files/common/files'; -import { IEditorService } from 'vs/workbench/services/editor/common/editorService'; +import { IMarkdownVulnerability, annotateSpecialMarkdownContent } from '../common/annotations'; +import { CodeBlockModelCollection } from '../common/codeBlockModelCollection'; +import { IModelService } from 'vs/editor/common/services/model'; +import { createTextBufferFactoryFromSnapshot } from 'vs/editor/common/model/textModel'; +import { TextEdit } from 'vs/editor/common/languages'; +import { IChatListItemRendererOptions } from './chat'; +import { CancellationTokenSource } from 'vs/base/common/cancellation'; const $ = dom.$; @@ -99,12 +101,6 @@ export interface IChatRendererDelegate { readonly onDidScroll?: Event; } -export interface IChatListItemRendererOptions { - readonly renderStyle?: 'default' | 'compact'; - readonly noHeader?: boolean; - readonly noPadding?: boolean; -} - export class ChatListItemRenderer extends Disposable implements ITreeRenderer { static readonly ID = 'item'; @@ -117,13 +113,14 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer()); - readonly onDidClickFollowup: Event = this._onDidClickFollowup.event; + protected readonly _onDidClickFollowup = this._register(new Emitter()); + readonly onDidClickFollowup: Event = this._onDidClickFollowup.event; protected readonly _onDidChangeItemHeight = this._register(new Emitter()); readonly onDidChangeItemHeight: Event = this._onDidChangeItemHeight.event; private readonly _editorPool: EditorPool; + private readonly _diffEditorPool: DiffEditorPool; private readonly _treePool: TreePool; private readonly _contentReferencesListPool: ContentReferencesListPool; @@ -134,49 +131,33 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer => { - if (input.resource.scheme !== Schemas.vscodeChatCodeBlock) { - return null; - } - const block = this._editorPool.find(input.resource); - if (!block) { - return null; - } - if (input.options?.selection) { - block.editor.setSelection({ - startLineNumber: input.options.selection.startLineNumber, - startColumn: input.options.selection.startColumn, - endLineNumber: input.options.selection.startLineNumber ?? input.options.selection.endLineNumber, - endColumn: input.options.selection.startColumn ?? input.options.selection.endColumn - }); - } - return block.editor; - })); - this._usedReferencesEnabled = configService.getValue('chat.experimental.usedReferences') ?? true; this._register(configService.onDidChangeConfiguration(e => { if (e.affectsConfiguration('chat.experimental.usedReferences')) { @@ -189,6 +170,10 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer submenu.actions.length <= 1 + }, actionViewItemProvider: (action: IAction, options: IActionViewItemOptions) => { if (action instanceof MenuItemAction && (action.item.id === 'workbench.action.chat.voteDown' || action.item.id === 'workbench.action.chat.voteUp')) { return scopedInstantiationService.createInstance(ChatVoteButton, action, options as IMenuEntryActionViewItemOptions); } - - return undefined; + return createActionViewItem(scopedInstantiationService, action, options); } })); } @@ -309,6 +299,7 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer('img.icon'); - avatarImgIcon.src = FileAccess.uriToBrowserUri(element.avatarIconUri).toString(true); + avatarImgIcon.src = FileAccess.uriToBrowserUri(element.avatarIcon).toString(true); templateData.avatarContainer.replaceChildren(dom.$('.avatar', undefined, avatarImgIcon)); } else { const defaultIcon = isRequestVM(element) ? Codicon.account : Codicon.copilot; - const avatarIcon = dom.$(ThemeIcon.asCSSSelector(defaultIcon)); + const icon = element.avatarIcon ?? defaultIcon; + const avatarIcon = dom.$(ThemeIcon.asCSSSelector(icon)); templateData.avatarContainer.replaceChildren(dom.$('.avatar.codicon-avatar', undefined, avatarIcon)); } - if (isResponseVM(element) && element.agent && !element.agent.metadata.isDefault) { + if (isResponseVM(element) && element.agent && !element.agent.isDefault) { dom.show(templateData.agentAvatarContainer); const icon = this.getAgentIcon(element.agent.metadata); if (icon instanceof URI) { @@ -468,8 +475,10 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer { - this.chatService.notifyUserAction({ - providerId: element.providerId, - agentId: element.agent?.id, - sessionId: element.sessionId, - requestId: element.requestId, - action: { - kind: 'command', - command: followup, - } - }); - return this.commandService.executeCommand(followup.commandId, ...(followup.args ?? [])); - }, - templateData.contextKeyService)); - } + const newHeight = templateData.rowContainer.offsetHeight; const fireEvent = !element.currentRenderedHeight || element.currentRenderedHeight !== newHeight; @@ -519,15 +508,19 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer this._onDidClickFollowup.fire(followup), - templateData.contextKeyService)); + const scopedInstaService = this.instantiationService.createChild(new ServiceCollection([IContextKeyService, templateData.contextKeyService])); + templateData.elementDisposables.add( + scopedInstaService.createInstance, ChatFollowups>( + ChatFollowups, + templateData.value, + item, + this.location, + undefined, + followup => this._onDidClickFollowup.fire(followup))); } else { const result = this.renderMarkdown(item as IMarkdownString, element, templateData); templateData.value.appendChild(result.element); @@ -581,6 +574,10 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer { if (e.element) { - this.editorService.openEditor({ - resource: 'uri' in e.element.reference ? e.element.reference.uri : e.element.reference, - options: { - ...e.editorOptions, - ...{ - selection: 'range' in e.element.reference ? e.element.reference.range : undefined - } - } - }); + const uriOrLocation = 'variableName' in e.element.reference ? e.element.reference.value : e.element.reference; + const uri = URI.isUri(uriOrLocation) ? uriOrLocation : + uriOrLocation?.uri; + if (uri) { + this.openerService.open( + uri, + { + fromUserGesture: true, + editorOptions: { + ...e.editorOptions, + ...{ + selection: uriOrLocation && 'range' in uriOrLocation ? uriOrLocation.range : undefined + } + } + }); + } } })); listDisposables.add(list.onContextMenu((e) => { @@ -851,9 +860,98 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer this.commandService.executeCommand(commandButton.command.id, ...(commandButton.command.arguments ?? [])))); + return { + dispose() { + disposables.dispose(); + }, + element: container + }; + } + + private renderTextEdit(element: ChatTreeItem, textEdit: IChatTextEdit, templateData: IChatListItemTemplate): IMarkdownRenderResult | undefined { + + // TODO@jrieken move this into the CompareCodeBlock and properly say what kind of changes happen + if (this.rendererOptions.renderTextEditsAsSummary?.(textEdit.uri)) { + if (isResponseVM(element) && element.response.value.every(item => item.kind === 'textEdit')) { + return { + element: $('.interactive-edits-summary', undefined, localize('editsSummary', "Made changes.")), + dispose() { } + }; + } + return undefined; + } + + const store = new DisposableStore(); + const cts = new CancellationTokenSource(); + + let isDisposed = false; + store.add(toDisposable(() => { + isDisposed = true; + cts.dispose(true); + })); + + const ref = this._diffEditorPool.get(); + + // Attach this after updating text/layout of the editor, so it should only be fired when the size updates later (horizontal scrollbar, wrapping) + // not during a renderElement OR a progressive render (when we will be firing this event anyway at the end of the render) + store.add(ref.object.onDidChangeContentHeight(() => { + ref.object.layout(this._currentLayoutWidth); + this._onDidChangeItemHeight.fire({ element, height: templateData.rowContainer.offsetHeight }); + })); + const handleReference = (reference: IReference) => { + if (isDisposed) { + reference.dispose(); + return undefined; + } + store.add(reference); + return reference.object.textEditorModel; + }; + + ref.object.render({ + element, + edits: textEdit.edits, + originalTextModel: this.textModelService.createModelReference(textEdit.uri).then(handleReference), + modifiedTextModel: this.textModelService.createModelReference(textEdit.uri).then(handleReference).then(model => { + + if (!model) { + return undefined; + } + + const modelN = this.modelService.createModel( + createTextBufferFactoryFromSnapshot(model.createSnapshot()), + { languageId: model.getLanguageId(), onDidChange: Event.None }, + undefined, false + ); + store.add(modelN); + const edits = textEdit.edits.map(TextEdit.asEditOperation); + modelN.pushEditOperations(null, edits, () => null); + return modelN; + }), + }, this._currentLayoutWidth, cts.token); + + return { + element: ref.object.element, + dispose() { + store.dispose(); + }, + }; + } + private renderMarkdown(markdown: IMarkdownString, element: ChatTreeItem, templateData: IChatListItemTemplate, fillInIncompleteTokens = false): IMarkdownRenderResult { const disposables = new DisposableStore(); - let codeBlockIndex = 0; markdown = new MarkdownString(markdown.value, { isTrusted: { @@ -865,25 +963,36 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer { - let data: ICodeBlockData; + const index = codeBlockIndex++; + let textModel: Promise; + let range: Range | undefined; + let vulns: readonly IMarkdownVulnerability[] | undefined; if (equalsIgnoreCase(languageId, localFileLanguageId)) { try { const parsedBody = parseLocalFileData(text); - data = { type: 'localFile', uri: parsedBody.uri, range: parsedBody.range && Range.lift(parsedBody.range), codeBlockIndex: codeBlockIndex++, element, hideToolbar: false, parentContextKeyService: templateData.contextKeyService }; + range = parsedBody.range && Range.lift(parsedBody.range); + textModel = this.textModelService.createModelReference(parsedBody.uri).then(ref => ref.object); } catch (e) { - console.error(e); return $('div'); } } else { - const vulns = extractVulnerabilitiesFromText(text); - const hideToolbar = isResponseVM(element) && element.errorDetails?.responseIsFiltered; - data = { type: 'code', languageId, text: vulns.newText, codeBlockIndex: codeBlockIndex++, element, hideToolbar, parentContextKeyService: templateData.contextKeyService, vulns: vulns.vulnerabilities }; + if (!isRequestVM(element) && !isResponseVM(element)) { + console.error('Trying to render code block in welcome', element.id, index); + return $('div'); + } + + const sessionId = isResponseVM(element) || isRequestVM(element) ? element.sessionId : ''; + const modelEntry = this.codeBlockModelCollection.getOrCreate(sessionId, element, index); + vulns = modelEntry.vulns; + textModel = modelEntry.model; } - const ref = this.renderCodeBlock(data); + const hideToolbar = isResponseVM(element) && element.errorDetails?.responseIsFiltered; + const ref = this.renderCodeBlock({ languageId, textModel, codeBlockIndex: index, element, range, hideToolbar, parentContextKeyService: templateData.contextKeyService, vulns }, text); // Attach this after updating text/layout of the editor, so it should only be fired when the size updates later (horizontal scrollbar, wrapping) // not during a renderElement OR a progressive render (when we will be firing this event anyway at the end of the render) @@ -894,15 +1003,18 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer this.codeBlocksByEditorUri.delete(ref.object.uri))); + if (ref.object.uri) { + const uri = ref.object.uri; + this.codeBlocksByEditorUri.set(uri, info); + disposables.add(toDisposable(() => this.codeBlocksByEditorUri.delete(uri))); + } } orderedDisposablesList.push(ref); return ref.object.element; @@ -927,10 +1039,14 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer { - const ref = this._editorPool.get(data); + private renderCodeBlock(data: ICodeBlockData, text: string): IDisposableReference { + const ref = this._editorPool.get(); const editorInfo = ref.object; - editorInfo.render(data, this._currentLayoutWidth); + if (isResponseVM(data.element)) { + this.codeBlockModelCollection.update(data.element.sessionId, data.element, data.codeBlockIndex, { text, languageId: data.languageId }); + } + + editorInfo.render(data, this._currentLayoutWidth, this.rendererOptions.editableCodeBlock); return ref; } @@ -964,6 +1080,7 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer { constructor( + private readonly defaultElementHeight: number, @ILogService private readonly logService: ILogService ) { } @@ -977,7 +1094,7 @@ export class ChatListDelegate implements IListVirtualDelegate { getHeight(element: ChatTreeItem): number { const kind = isRequestVM(element) ? 'request' : 'response'; - const height = ('currentRenderedHeight' in element ? element.currentRenderedHeight : undefined) ?? 200; + const height = ('currentRenderedHeight' in element ? element.currentRenderedHeight : undefined) ?? this.defaultElementHeight; this._traceLayout('getHeight', `${kind}, height=${height}`); return height; } @@ -991,83 +1108,6 @@ export class ChatListDelegate implements IListVirtualDelegate { } } -export class ChatAccessibilityProvider implements IListAccessibilityProvider { - - constructor( - @IAccessibleViewService private readonly _accessibleViewService: IAccessibleViewService - ) { - - } - getWidgetRole(): AriaRole { - return 'list'; - } - - getRole(element: ChatTreeItem): AriaRole | undefined { - return 'listitem'; - } - - getWidgetAriaLabel(): string { - return localize('chat', "Chat"); - } - - getAriaLabel(element: ChatTreeItem): string { - if (isRequestVM(element)) { - return element.messageText; - } - - if (isResponseVM(element)) { - return this._getLabelWithCodeBlockCount(element); - } - - if (isWelcomeVM(element)) { - return element.content.map(c => 'value' in c ? c.value : c.map(followup => followup.message).join('\n')).join('\n'); - } - - return ''; - } - - private _getLabelWithCodeBlockCount(element: IChatResponseViewModel): string { - const accessibleViewHint = this._accessibleViewService.getOpenAriaHint(AccessibilityVerbositySettingId.Chat); - let label: string = ''; - let commandFollowUpInfo; - const commandFollowupLength = element.commandFollowups?.length ?? 0; - switch (commandFollowupLength) { - case 0: - break; - case 1: - commandFollowUpInfo = localize('commandFollowUpInfo', "Command: {0}", element.commandFollowups![0].title); - break; - default: - commandFollowUpInfo = localize('commandFollowUpInfoMany', "Commands: {0}", element.commandFollowups!.map(followup => followup.title).join(', ')); - } - const fileTreeCount = element.response.value.filter((v) => !('value' in v))?.length ?? 0; - let fileTreeCountHint = ''; - switch (fileTreeCount) { - case 0: - break; - case 1: - fileTreeCountHint = localize('singleFileTreeHint', "1 file tree"); - break; - default: - fileTreeCountHint = localize('multiFileTreeHint', "{0} file trees", fileTreeCount); - break; - } - const codeBlockCount = marked.lexer(element.response.asString()).filter(token => token.type === 'code')?.length ?? 0; - switch (codeBlockCount) { - case 0: - label = accessibleViewHint ? localize('noCodeBlocksHint', "{0} {1} {2}", fileTreeCountHint, element.response.asString(), accessibleViewHint) : localize('noCodeBlocks', "{0} {1}", fileTreeCountHint, element.response.asString()); - break; - case 1: - label = accessibleViewHint ? localize('singleCodeBlockHint', "{0} 1 code block: {1} {2}", fileTreeCountHint, element.response.asString(), accessibleViewHint) : localize('singleCodeBlock', "{0} 1 code block: {1}", fileTreeCountHint, element.response.asString()); - break; - default: - label = accessibleViewHint ? localize('multiCodeBlockHint', "{0} {1} code blocks: {2}", fileTreeCountHint, codeBlockCount, element.response.asString(), accessibleViewHint) : localize('multiCodeBlock', "{0} {1} code blocks", fileTreeCountHint, codeBlockCount, element.response.asString()); - break; - } - return commandFollowUpInfo ? commandFollowUpInfo + ', ' + label : label; - } -} - interface IDisposableReference extends IDisposable { object: T; @@ -1076,39 +1116,61 @@ interface IDisposableReference extends IDisposable { class EditorPool extends Disposable { - private readonly _simpleEditorPool: ResourcePool; - private readonly _localFileEditorPool: ResourcePool; + private readonly _pool: ResourcePool; - public *inUse(): Iterable { - yield* this._simpleEditorPool.inUse; - yield* this._localFileEditorPool.inUse; + public inUse(): Iterable { + return this._pool.inUse; } constructor( - private readonly options: ChatEditorOptions, + options: ChatEditorOptions, delegate: IChatRendererDelegate, overflowWidgetsDomNode: HTMLElement | undefined, - @IInstantiationService private readonly instantiationService: IInstantiationService, + @IInstantiationService instantiationService: IInstantiationService, ) { super(); - this._simpleEditorPool = this._register(new ResourcePool(() => { - return this.instantiationService.createInstance(SimpleCodeBlockPart, this.options, MenuId.ChatCodeBlock, delegate, overflowWidgetsDomNode); - })); - this._localFileEditorPool = this._register(new ResourcePool(() => { - return this.instantiationService.createInstance(LocalFileCodeBlockPart, this.options, MenuId.ChatCodeBlock, delegate, overflowWidgetsDomNode); + this._pool = this._register(new ResourcePool(() => { + return instantiationService.createInstance(CodeBlockPart, options, MenuId.ChatCodeBlock, delegate, overflowWidgetsDomNode); })); } - get(data: ICodeBlockData): IDisposableReference { - return this.getFromPool(data.type === 'localFile' ? this._localFileEditorPool : this._simpleEditorPool); + get(): IDisposableReference { + const codeBlock = this._pool.get(); + let stale = false; + return { + object: codeBlock, + isStale: () => stale, + dispose: () => { + codeBlock.reset(); + stale = true; + this._pool.release(codeBlock); + } + }; + } +} + +class DiffEditorPool extends Disposable { + + private readonly _pool: ResourcePool; + + public inUse(): Iterable { + return this._pool.inUse; } - find(resource: URI): SimpleCodeBlockPart | undefined { - return Array.from(this._simpleEditorPool.inUse).find(part => part.uri?.toString() === resource.toString()); + constructor( + options: ChatEditorOptions, + delegate: IChatRendererDelegate, + overflowWidgetsDomNode: HTMLElement | undefined, + @IInstantiationService instantiationService: IInstantiationService, + ) { + super(); + this._pool = this._register(new ResourcePool(() => { + return instantiationService.createInstance(CodeCompareBlockPart, options, MenuId.ChatCompareBlock, delegate, overflowWidgetsDomNode); + })); } - private getFromPool(pool: ResourcePool): IDisposableReference { - const codeBlock = pool.get(); + get(): IDisposableReference { + const codeBlock = this._pool.get(); let stale = false; return { object: codeBlock, @@ -1116,7 +1178,7 @@ class EditorPool extends Disposable { dispose: () => { codeBlock.reset(); stale = true; - pool.release(codeBlock); + this._pool.release(codeBlock); } }; } @@ -1140,10 +1202,10 @@ class TreePool extends Disposable { } private treeFactory(): WorkbenchCompressibleAsyncDataTree { - const resourceLabels = this.instantiationService.createInstance(ResourceLabels, { onDidChangeVisibility: this._onDidChangeVisibility }); + const resourceLabels = this._register(this.instantiationService.createInstance(ResourceLabels, { onDidChangeVisibility: this._onDidChangeVisibility })); const container = $('.interactive-response-progress-tree'); - createFileIconThemableTreeContainerScope(container, this.themeService); + this._register(createFileIconThemableTreeContainerScope(container, this.themeService)); const tree = >this.instantiationService.createInstance( WorkbenchCompressibleAsyncDataTree, @@ -1200,30 +1262,47 @@ class ContentReferencesListPool extends Disposable { } private listFactory(): WorkbenchList { - const resourceLabels = this.instantiationService.createInstance(ResourceLabels, { onDidChangeVisibility: this._onDidChangeVisibility }); + const resourceLabels = this._register(this.instantiationService.createInstance(ResourceLabels, { onDidChangeVisibility: this._onDidChangeVisibility })); const container = $('.chat-used-context-list'); - createFileIconThemableTreeContainerScope(container, this.themeService); + this._register(createFileIconThemableTreeContainerScope(container, this.themeService)); const list = >this.instantiationService.createInstance( WorkbenchList, 'ChatListRenderer', container, new ContentReferencesListDelegate(), - [new ContentReferencesListRenderer(resourceLabels)], + [this.instantiationService.createInstance(ContentReferencesListRenderer, resourceLabels)], { alwaysConsumeMouseWheel: false, accessibilityProvider: { getAriaLabel: (element: IChatContentReference) => { - if (URI.isUri(element.reference)) { - return basename(element.reference.path); + const reference = element.reference; + if ('variableName' in reference) { + return reference.variableName; + } else if (URI.isUri(reference)) { + return basename(reference.path); } else { - return basename(element.reference.uri.path); + return basename(reference.uri.path); } }, getWidgetAriaLabel: () => localize('usedReferences', "Used References") }, + dnd: { + getDragURI: ({ reference }: IChatContentReference) => { + if ('variableName' in reference) { + return null; + } else if (URI.isUri(reference)) { + return reference.toString(); + } else { + return reference.uri.toString(); + } + }, + dispose: () => { }, + onDragOver: () => false, + drop: () => { }, + }, }); return list; @@ -1262,7 +1341,10 @@ class ContentReferencesListRenderer implements IListRenderer, i: number): boolean { return items.slice(i).every(isProgressMessage); } diff --git a/src/vs/workbench/contrib/chat/browser/chatMarkdownDecorationsRenderer.ts b/src/vs/workbench/contrib/chat/browser/chatMarkdownDecorationsRenderer.ts index fc1f0795b3c0a..bbacc39b7c85f 100644 --- a/src/vs/workbench/contrib/chat/browser/chatMarkdownDecorationsRenderer.ts +++ b/src/vs/workbench/contrib/chat/browser/chatMarkdownDecorationsRenderer.ts @@ -5,18 +5,14 @@ import * as dom from 'vs/base/browser/dom'; import { toErrorMessage } from 'vs/base/common/errorMessage'; -import { MarkdownString } from 'vs/base/common/htmlContent'; import { revive } from 'vs/base/common/marshalling'; -import { basename } from 'vs/base/common/resources'; import { URI } from 'vs/base/common/uri'; -import { IRange } from 'vs/editor/common/core/range'; import { Location } from 'vs/editor/common/languages'; import { IKeybindingService } from 'vs/platform/keybinding/common/keybinding'; import { ILabelService } from 'vs/platform/label/common/label'; import { ILogService } from 'vs/platform/log/common/log'; -import { IChatProgressRenderableResponseContent, IChatProgressResponseContent } from 'vs/workbench/contrib/chat/common/chatModel'; -import { ChatRequestDynamicVariablePart, ChatRequestTextPart, IParsedChatRequest } from 'vs/workbench/contrib/chat/common/chatParserTypes'; -import { IChatAgentMarkdownContentWithVulnerability, IChatAgentVulnerabilityDetails, IChatContentInlineReference } from 'vs/workbench/contrib/chat/common/chatService'; +import { ChatRequestAgentPart, ChatRequestDynamicVariablePart, ChatRequestTextPart, IParsedChatRequest } from 'vs/workbench/contrib/chat/common/chatParserTypes'; +import { contentRefUrl } from '../common/annotations'; const variableRefUrl = 'http://_vscodedecoration_'; @@ -35,7 +31,9 @@ export class ChatMarkdownDecorationsRenderer { } else { const uri = part instanceof ChatRequestDynamicVariablePart && part.data.map(d => d.value).find((d): d is URI => d instanceof URI) || undefined; - const title = uri ? encodeURIComponent(this.labelService.getUriLabel(uri, { relative: true })) : ''; + const title = uri ? encodeURIComponent(this.labelService.getUriLabel(uri, { relative: true })) : + part instanceof ChatRequestAgentPart ? part.agent.id : + ''; result += `[${part.text}](${variableRefUrl}?${title})`; } @@ -110,74 +108,3 @@ export class ChatMarkdownDecorationsRenderer { } } } - -export interface IMarkdownVulnerability { - title: string; - description: string; - range: IRange; -} - -export function extractVulnerabilitiesFromText(text: string): { newText: string; vulnerabilities: IMarkdownVulnerability[] } { - const vulnerabilities: IMarkdownVulnerability[] = []; - let newText = text; - let match: RegExpExecArray | null; - while ((match = /(.*?)<\/vscode_annotation>/ms.exec(newText)) !== null) { - const [full, details, content] = match; - const start = match.index; - const textBefore = newText.substring(0, start); - const linesBefore = textBefore.split('\n').length - 1; - const linesInside = content.split('\n').length - 1; - - const previousNewlineIdx = textBefore.lastIndexOf('\n'); - const startColumn = start - (previousNewlineIdx + 1) + 1; - const endPreviousNewlineIdx = (textBefore + content).lastIndexOf('\n'); - const endColumn = start + content.length - (endPreviousNewlineIdx + 1) + 1; - - try { - const vulnDetails: IChatAgentVulnerabilityDetails[] = JSON.parse(decodeURIComponent(details)); - vulnDetails.forEach(({ title, description }) => - vulnerabilities.push({ - title, description, range: - { startLineNumber: linesBefore + 1, startColumn, endLineNumber: linesBefore + linesInside + 1, endColumn } - })); - } catch (err) { - // Something went wrong with encoding this text, just ignore it - } - newText = newText.substring(0, start) + content + newText.substring(start + full.length); - } - - return { newText, vulnerabilities }; -} - -const contentRefUrl = 'http://_vscodecontentref_'; // must be lowercase for URI - -export function annotateSpecialMarkdownContent(response: ReadonlyArray): ReadonlyArray { - const result: Exclude[] = []; - for (const item of response) { - const previousItem = result[result.length - 1]; - if (item.kind === 'inlineReference') { - const location = 'uri' in item.inlineReference ? item.inlineReference : { uri: item.inlineReference }; - const printUri = URI.parse(contentRefUrl).with({ fragment: JSON.stringify(location) }); - const markdownText = `[${item.name || basename(location.uri)}](${printUri.toString()})`; - if (previousItem?.kind === 'markdownContent') { - result[result.length - 1] = { content: new MarkdownString(previousItem.content.value + markdownText, { isTrusted: previousItem.content.isTrusted }), kind: 'markdownContent' }; - } else { - result.push({ content: new MarkdownString(markdownText), kind: 'markdownContent' }); - } - } else if (item.kind === 'markdownContent' && previousItem?.kind === 'markdownContent') { - result[result.length - 1] = { content: new MarkdownString(previousItem.content.value + item.content.value, { isTrusted: previousItem.content.isTrusted }), kind: 'markdownContent' }; - } else if (item.kind === 'markdownVuln') { - const vulnText = encodeURIComponent(JSON.stringify(item.vulnerabilities)); - const markdownText = `${item.content.value}`; - if (previousItem?.kind === 'markdownContent') { - result[result.length - 1] = { content: new MarkdownString(previousItem.content.value + markdownText, { isTrusted: previousItem.content.isTrusted }), kind: 'markdownContent' }; - } else { - result.push({ content: new MarkdownString(markdownText), kind: 'markdownContent' }); - } - } else { - result.push(item); - } - } - - return result; -} diff --git a/src/vs/workbench/contrib/chat/browser/chatOptions.ts b/src/vs/workbench/contrib/chat/browser/chatOptions.ts index e76519d260ad0..1995598eba936 100644 --- a/src/vs/workbench/contrib/chat/browser/chatOptions.ts +++ b/src/vs/workbench/contrib/chat/browser/chatOptions.ts @@ -79,7 +79,7 @@ export class ChatEditorOptions extends Disposable { private readonly resultEditorBackgroundColor: string, @IConfigurationService private readonly configurationService: IConfigurationService, @IThemeService private readonly themeService: IThemeService, - @IViewDescriptorService readonly viewDescriptorService: IViewDescriptorService + @IViewDescriptorService private readonly viewDescriptorService: IViewDescriptorService ) { super(); diff --git a/src/vs/workbench/contrib/chat/browser/chatQuick.ts b/src/vs/workbench/contrib/chat/browser/chatQuick.ts index a23257610dc32..ffe1bb7ccba74 100644 --- a/src/vs/workbench/contrib/chat/browser/chatQuick.ts +++ b/src/vs/workbench/contrib/chat/browser/chatQuick.ts @@ -10,6 +10,7 @@ import { CancellationToken } from 'vs/base/common/cancellation'; import { Emitter, Event } from 'vs/base/common/event'; import { Disposable, DisposableStore, IDisposable, MutableDisposable } from 'vs/base/common/lifecycle'; import { Selection } from 'vs/editor/common/core/selection'; +import { MenuId } from 'vs/platform/actions/common/actions'; import { IContextKeyService } from 'vs/platform/contextkey/common/contextkey'; import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; import { ServiceCollection } from 'vs/platform/instantiation/common/serviceCollection'; @@ -19,6 +20,7 @@ import { editorBackground, inputBackground, quickInputBackground, quickInputFore import { IChatWidgetService, IQuickChatService, IQuickChatOpenOptions } from 'vs/workbench/contrib/chat/browser/chat'; import { IChatViewOptions } from 'vs/workbench/contrib/chat/browser/chatViewPane'; import { ChatWidget } from 'vs/workbench/contrib/chat/browser/chatWidget'; +import { ChatAgentLocation } from 'vs/workbench/contrib/chat/common/chatAgents'; import { ChatModel } from 'vs/workbench/contrib/chat/common/chatModel'; import { IParsedChatRequest } from 'vs/workbench/contrib/chat/common/chatParserTypes'; import { IChatService } from 'vs/workbench/contrib/chat/common/chatService'; @@ -75,10 +77,12 @@ export class QuickChatService extends Disposable implements IQuickChatService { open(providerId?: string, options?: IQuickChatOpenOptions): void { if (this._input) { if (this._currentChat && options?.query) { + this._currentChat.focus(); this._currentChat.setValue(options.query, options.selection); if (!options.isPartialQuery) { this._currentChat.acceptInput(); } + return; } return this.focus(); } @@ -152,7 +156,7 @@ class QuickChat extends Disposable { private sash!: Sash; private model: ChatModel | undefined; private _currentQuery: string | undefined; - private maintainScrollTimer: MutableDisposable = this._register(new MutableDisposable()); + private readonly maintainScrollTimer: MutableDisposable = this._register(new MutableDisposable()); private _deferUpdatingDynamicLayout: boolean = false; constructor( @@ -225,8 +229,9 @@ class QuickChat extends Disposable { this.widget = this._register( scopedInstantiationService.createInstance( ChatWidget, + ChatAgentLocation.Panel, { resource: true }, - { renderInputOnTop: true, renderStyle: 'compact' }, + { renderInputOnTop: true, renderStyle: 'compact', menus: { inputSideToolbar: MenuId.ChatInputSide } }, { listForeground: quickInputForeground, listBackground: quickInputBackground, @@ -289,13 +294,13 @@ class QuickChat extends Disposable { } for (const request of this.model.getRequests()) { - if (request.response?.response.value || request.response?.errorDetails) { + if (request.response?.response.value || request.response?.result) { this.chatService.addCompleteRequest(widget.viewModel.sessionId, request.message as IParsedChatRequest, request.variableData, { message: request.response.response.value, - errorDetails: request.response.errorDetails, + result: request.response.result, followups: request.response.followups }); } else if (request.message) { diff --git a/src/vs/workbench/contrib/chat/browser/chatSlashCommandContentWidget.ts b/src/vs/workbench/contrib/chat/browser/chatSlashCommandContentWidget.ts deleted file mode 100644 index 39aa0aafad3f1..0000000000000 --- a/src/vs/workbench/contrib/chat/browser/chatSlashCommandContentWidget.ts +++ /dev/null @@ -1,96 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import 'vs/css!./chatSlashCommandContentWidget'; -import { IKeyboardEvent } from 'vs/base/browser/keyboardEvent'; -import { Range } from 'vs/editor/common/core/range'; -import { Disposable } from 'vs/base/common/lifecycle'; -import { ContentWidgetPositionPreference, ICodeEditor, IContentWidget } from 'vs/editor/browser/editorBrowser'; -import { KeyCode } from 'vs/base/common/keyCodes'; -import { localize } from 'vs/nls'; -import * as aria from 'vs/base/browser/ui/aria/aria'; -import { EditorOption } from 'vs/editor/common/config/editorOptions'; - -export class SlashCommandContentWidget extends Disposable implements IContentWidget { - private _domNode = document.createElement('div'); - private _lastSlashCommandText: string | undefined; - private _isVisible = false; - - constructor(private _editor: ICodeEditor) { - super(); - - this._domNode.toggleAttribute('hidden', true); - this._domNode.classList.add('chat-slash-command-content-widget'); - - // If backspace at a slash command boundary, remove the slash command - this._register(this._editor.onKeyDown((e) => this._handleKeyDown(e))); - } - - override dispose() { - this.hide(); - super.dispose(); - } - - show() { - if (!this._isVisible) { - this._isVisible = true; - this._domNode.toggleAttribute('hidden', false); - this._editor.addContentWidget(this); - } - } - - hide() { - if (this._isVisible) { - this._isVisible = false; - this._domNode.toggleAttribute('hidden', true); - this._editor.removeContentWidget(this); - } - } - - setCommandText(slashCommand: string) { - this._domNode.innerText = `/${slashCommand} `; - this._lastSlashCommandText = slashCommand; - } - - getId() { - return 'chat-slash-command-content-widget'; - } - - getDomNode() { - return this._domNode; - } - - getPosition() { - return { position: { lineNumber: 1, column: 1 }, preference: [ContentWidgetPositionPreference.EXACT] }; - } - - beforeRender(): null { - const lineHeight = this._editor.getOption(EditorOption.lineHeight); - this._domNode.style.lineHeight = `${lineHeight - 2 /*padding*/}px`; - return null; - } - - private _handleKeyDown(e: IKeyboardEvent) { - if (e.keyCode !== KeyCode.Backspace) { - return; - } - - const firstLine = this._editor.getModel()?.getLineContent(1); - const selection = this._editor.getSelection(); - const withSlash = `/${this._lastSlashCommandText} `; - if (!firstLine?.startsWith(withSlash) || !selection?.isEmpty() || selection?.startLineNumber !== 1 || selection?.startColumn !== withSlash.length + 1) { - return; - } - - // Allow to undo the backspace - this._editor.executeEdits('chat-slash-command', [{ - range: new Range(1, 1, 1, selection.startColumn), - text: null - }]); - - // Announce the deletion - aria.alert(localize('exited slash command mode', 'Exited {0} mode', this._lastSlashCommandText)); - } -} diff --git a/src/vs/workbench/contrib/chat/browser/chatVariables.ts b/src/vs/workbench/contrib/chat/browser/chatVariables.ts index 3bb6fa6b7cabf..9547c45ba3624 100644 --- a/src/vs/workbench/contrib/chat/browser/chatVariables.ts +++ b/src/vs/workbench/contrib/chat/browser/chatVariables.ts @@ -3,15 +3,17 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { coalesce } from 'vs/base/common/arrays'; import { CancellationToken } from 'vs/base/common/cancellation'; import { onUnexpectedExternalError } from 'vs/base/common/errors'; import { Iterable } from 'vs/base/common/iterator'; import { IDisposable, toDisposable } from 'vs/base/common/lifecycle'; import { IChatWidgetService } from 'vs/workbench/contrib/chat/browser/chat'; import { ChatDynamicVariableModel } from 'vs/workbench/contrib/chat/browser/contrib/chatDynamicVariables'; -import { IChatModel, IChatRequestVariableData } from 'vs/workbench/contrib/chat/common/chatModel'; -import { IParsedChatRequest, ChatRequestVariablePart, ChatRequestDynamicVariablePart } from 'vs/workbench/contrib/chat/common/chatParserTypes'; -import { IChatVariablesService, IChatRequestVariableValue, IChatVariableData, IChatVariableResolver, IDynamicVariable } from 'vs/workbench/contrib/chat/common/chatVariables'; +import { IChatModel, IChatRequestVariableData, IChatRequestVariableEntry } from 'vs/workbench/contrib/chat/common/chatModel'; +import { ChatRequestDynamicVariablePart, ChatRequestVariablePart, IParsedChatRequest } from 'vs/workbench/contrib/chat/common/chatParserTypes'; +import { IChatContentReference } from 'vs/workbench/contrib/chat/common/chatService'; +import { IChatRequestVariableValue, IChatVariableData, IChatVariableResolver, IChatVariableResolverProgress, IChatVariablesService, IDynamicVariable } from 'vs/workbench/contrib/chat/common/chatVariables'; interface IChatData { data: IChatVariableData; @@ -28,56 +30,61 @@ export class ChatVariablesService implements IChatVariablesService { ) { } - async resolveVariables(prompt: IParsedChatRequest, model: IChatModel, token: CancellationToken): Promise { - const resolvedVariables: Record = {}; + async resolveVariables(prompt: IParsedChatRequest, model: IChatModel, progress: (part: IChatVariableResolverProgress) => void, token: CancellationToken): Promise { + let resolvedVariables: IChatRequestVariableEntry[] = []; const jobs: Promise[] = []; - const parsedPrompt: string[] = []; prompt.parts .forEach((part, i) => { if (part instanceof ChatRequestVariablePart) { const data = this._resolver.get(part.variableName.toLowerCase()); if (data) { - jobs.push(data.resolver(prompt.text, part.variableArg, model, token).then(value => { - if (value) { - resolvedVariables[part.variableName] = value; - parsedPrompt[i] = `[${part.text}](values:${part.variableName})`; - } else { - parsedPrompt[i] = part.promptText; + const references: IChatContentReference[] = []; + const variableProgressCallback = (item: IChatVariableResolverProgress) => { + if (item.kind === 'reference') { + references.push(item); + return; } + progress(item); + }; + jobs.push(data.resolver(prompt.text, part.variableArg, model, variableProgressCallback, token).then(values => { + resolvedVariables[i] = { name: part.variableName, range: part.range, values: values ?? [], references }; }).catch(onUnexpectedExternalError)); } } else if (part instanceof ChatRequestDynamicVariablePart) { - const referenceName = this.getUniqueReferenceName(part.referenceText, resolvedVariables); - resolvedVariables[referenceName] = part.data; - const safeText = part.text.replace(/[\[\]]/g, '_'); - const safeTarget = referenceName.replace(/[\(\)]/g, '_'); - parsedPrompt[i] = `[${safeText}](values:${safeTarget})`; - } else { - parsedPrompt[i] = part.promptText; + resolvedVariables[i] = { name: part.referenceText, range: part.range, values: part.data }; } }); await Promise.allSettled(jobs); + resolvedVariables = coalesce(resolvedVariables); + + // "reverse", high index first so that replacement is simple + resolvedVariables.sort((a, b) => b.range!.start - a.range!.start); + return { variables: resolvedVariables, - message: parsedPrompt.join('').trim() }; } - private getUniqueReferenceName(name: string, vars: Record): string { - let i = 1; - while (vars[name]) { - name = `${name}_${i++}`; + async resolveVariable(variableName: string, promptText: string, model: IChatModel, progress: (part: IChatVariableResolverProgress) => void, token: CancellationToken): Promise { + const data = this._resolver.get(variableName.toLowerCase()); + if (!data) { + return Promise.resolve([]); } - return name; + + return (await data.resolver(promptText, undefined, model, progress, token)) ?? []; } hasVariable(name: string): boolean { return this._resolver.has(name.toLowerCase()); } + getVariable(name: string): IChatVariableData | undefined { + return this._resolver.get(name.toLowerCase())?.data; + } + getVariables(): Iterable> { const all = Iterable.map(this._resolver.values(), data => data.data); return Iterable.filter(all, data => !data.hidden); diff --git a/src/vs/workbench/contrib/chat/browser/chatViewPane.ts b/src/vs/workbench/contrib/chat/browser/chatViewPane.ts index fabf8073013c7..5da2b97061344 100644 --- a/src/vs/workbench/contrib/chat/browser/chatViewPane.ts +++ b/src/vs/workbench/contrib/chat/browser/chatViewPane.ts @@ -8,6 +8,7 @@ import { DisposableStore } from 'vs/base/common/lifecycle'; import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; import { IContextKeyService } from 'vs/platform/contextkey/common/contextkey'; import { IContextMenuService } from 'vs/platform/contextview/browser/contextView'; +import { IHoverService } from 'vs/platform/hover/browser/hover'; import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; import { ServiceCollection } from 'vs/platform/instantiation/common/serviceCollection'; import { IKeybindingService } from 'vs/platform/keybinding/common/keybinding'; @@ -23,6 +24,7 @@ import { SIDE_BAR_FOREGROUND } from 'vs/workbench/common/theme'; import { IViewDescriptorService } from 'vs/workbench/common/views'; import { IChatViewPane } from 'vs/workbench/contrib/chat/browser/chat'; import { IChatViewState, ChatWidget } from 'vs/workbench/contrib/chat/browser/chatWidget'; +import { ChatAgentLocation } from 'vs/workbench/contrib/chat/common/chatAgents'; import { IChatModel } from 'vs/workbench/contrib/chat/common/chatModel'; import { IChatService } from 'vs/workbench/contrib/chat/common/chatService'; @@ -41,7 +43,7 @@ export class ChatViewPane extends ViewPane implements IChatViewPane { private _widget!: ChatWidget; get widget(): ChatWidget { return this._widget; } - private modelDisposables = this._register(new DisposableStore()); + private readonly modelDisposables = this._register(new DisposableStore()); private memento: Memento; private readonly viewState: IViewPaneState; private didProviderRegistrationFail = false; @@ -59,11 +61,12 @@ export class ChatViewPane extends ViewPane implements IChatViewPane { @IOpenerService openerService: IOpenerService, @IThemeService themeService: IThemeService, @ITelemetryService telemetryService: ITelemetryService, + @IHoverService hoverService: IHoverService, @IStorageService private readonly storageService: IStorageService, @IChatService private readonly chatService: IChatService, @ILogService private readonly logService: ILogService, ) { - super(options, keybindingService, contextMenuService, configurationService, contextKeyService, viewDescriptorService, instantiationService, openerService, themeService, telemetryService); + super(options, keybindingService, contextMenuService, configurationService, contextKeyService, viewDescriptorService, instantiationService, openerService, themeService, telemetryService, hoverService); // View state for the ViewPane is currently global per-provider basically, but some other strictly per-model state will require a separate memento. this.memento = new Memento('interactive-session-view-' + this.chatViewOptions.providerId, this.storageService); @@ -130,15 +133,16 @@ export class ChatViewPane extends ViewPane implements IChatViewPane { super.renderBody(parent); const scopedInstantiationService = this.instantiationService.createChild(new ServiceCollection([IContextKeyService, this.scopedContextKeyService])); - + const locationBasedColors = this.getLocationBasedColors(); this._widget = this._register(scopedInstantiationService.createInstance( ChatWidget, + ChatAgentLocation.Panel, { viewId: this.id }, { supportsFileReferences: true }, { listForeground: SIDE_BAR_FOREGROUND, - listBackground: this.getBackgroundColor(), - inputEditorBackground: this.getBackgroundColor(), + listBackground: locationBasedColors.background, + inputEditorBackground: locationBasedColors.background, resultEditorBackground: editorBackground })); this._register(this.onDidChangeBodyVisibility(visible => { diff --git a/src/vs/workbench/contrib/chat/browser/chatWidget.ts b/src/vs/workbench/contrib/chat/browser/chatWidget.ts index ed99c1c6409c4..de76221655aad 100644 --- a/src/vs/workbench/contrib/chat/browser/chatWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/chatWidget.ts @@ -5,37 +5,44 @@ import * as dom from 'vs/base/browser/dom'; import { ITreeContextMenuEvent, ITreeElement } from 'vs/base/browser/ui/tree/tree'; -import { disposableTimeout } from 'vs/base/common/async'; +import { disposableTimeout, timeout } from 'vs/base/common/async'; import { toErrorMessage } from 'vs/base/common/errorMessage'; -import { Emitter } from 'vs/base/common/event'; +import { Emitter, Event } from 'vs/base/common/event'; import { Disposable, DisposableStore, IDisposable, MutableDisposable, combinedDisposable, toDisposable } from 'vs/base/common/lifecycle'; +import { Schemas } from 'vs/base/common/network'; import { isEqual } from 'vs/base/common/resources'; import { isDefined } from 'vs/base/common/types'; import { URI } from 'vs/base/common/uri'; import 'vs/css!./media/chat'; import { ICodeEditor } from 'vs/editor/browser/editorBrowser'; +import { ICodeEditorService } from 'vs/editor/browser/services/codeEditorService'; import { MenuId } from 'vs/platform/actions/common/actions'; import { IContextKey, IContextKeyService } from 'vs/platform/contextkey/common/contextkey'; import { IContextMenuService } from 'vs/platform/contextview/browser/contextView'; +import { ITextResourceEditorInput } from 'vs/platform/editor/common/editor'; import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; import { ServiceCollection } from 'vs/platform/instantiation/common/serviceCollection'; import { WorkbenchObjectTree } from 'vs/platform/list/browser/listService'; import { ILogService } from 'vs/platform/log/common/log'; -import { IViewsService } from 'vs/workbench/services/views/common/viewsService'; +import { IThemeService } from 'vs/platform/theme/common/themeService'; import { ChatTreeItem, IChatAccessibilityService, IChatCodeBlockInfo, IChatFileTreeInfo, IChatWidget, IChatWidgetService, IChatWidgetViewContext, IChatWidgetViewOptions } from 'vs/workbench/contrib/chat/browser/chat'; +import { ChatAccessibilityProvider } from 'vs/workbench/contrib/chat/browser/chatAccessibilityProvider'; import { ChatInputPart } from 'vs/workbench/contrib/chat/browser/chatInputPart'; -import { ChatAccessibilityProvider, ChatListDelegate, ChatListItemRenderer, IChatListItemRendererOptions, IChatRendererDelegate } from 'vs/workbench/contrib/chat/browser/chatListRenderer'; +import { ChatListDelegate, ChatListItemRenderer, IChatRendererDelegate } from 'vs/workbench/contrib/chat/browser/chatListRenderer'; +import { IChatListItemRendererOptions } from './chat'; import { ChatEditorOptions } from 'vs/workbench/contrib/chat/browser/chatOptions'; import { ChatViewPane } from 'vs/workbench/contrib/chat/browser/chatViewPane'; -import { CONTEXT_CHAT_REQUEST_IN_PROGRESS, CONTEXT_IN_CHAT_SESSION, CONTEXT_RESPONSE_FILTERED } from 'vs/workbench/contrib/chat/common/chatContextKeys'; +import { ChatAgentLocation, IChatAgentCommand, IChatAgentData, IChatAgentService } from 'vs/workbench/contrib/chat/common/chatAgents'; +import { CONTEXT_CHAT_INPUT_HAS_AGENT, CONTEXT_CHAT_LOCATION, CONTEXT_CHAT_REQUEST_IN_PROGRESS, CONTEXT_IN_CHAT_SESSION, CONTEXT_RESPONSE_FILTERED } from 'vs/workbench/contrib/chat/common/chatContextKeys'; import { IChatContributionService } from 'vs/workbench/contrib/chat/common/chatContributionService'; import { ChatModelInitState, IChatModel } from 'vs/workbench/contrib/chat/common/chatModel'; -import { IChatReplyFollowup, IChatService } from 'vs/workbench/contrib/chat/common/chatService'; -import { ChatViewModel, IChatResponseViewModel, isRequestVM, isResponseVM, isWelcomeVM } from 'vs/workbench/contrib/chat/common/chatViewModel'; -import { IThemeService } from 'vs/platform/theme/common/themeService'; -import { IParsedChatRequest } from 'vs/workbench/contrib/chat/common/chatParserTypes'; +import { ChatRequestAgentPart, IParsedChatRequest, chatAgentLeader, chatSubcommandLeader, extractAgentAndCommand } from 'vs/workbench/contrib/chat/common/chatParserTypes'; import { ChatRequestParser } from 'vs/workbench/contrib/chat/common/chatRequestParser'; -import { IChatAgentService } from 'vs/workbench/contrib/chat/common/chatAgents'; +import { IChatFollowup, IChatService } from 'vs/workbench/contrib/chat/common/chatService'; +import { IChatSlashCommandService } from 'vs/workbench/contrib/chat/common/chatSlashCommands'; +import { ChatViewModel, IChatResponseViewModel, isRequestVM, isResponseVM, isWelcomeVM } from 'vs/workbench/contrib/chat/common/chatViewModel'; +import { CodeBlockModelCollection } from 'vs/workbench/contrib/chat/common/codeBlockModelCollection'; +import { IViewsService } from 'vs/workbench/services/views/common/viewsService'; const $ = dom.$; @@ -73,6 +80,9 @@ export interface IChatWidgetContrib extends IDisposable { export class ChatWidget extends Disposable implements IChatWidget { public static readonly CONTRIBS: { new(...args: [IChatWidget, ...any]): IChatWidgetContrib }[] = []; + private readonly _onDidSubmitAgent = this._register(new Emitter<{ agent: IChatAgentData; slashCommand?: IChatAgentCommand }>()); + public readonly onDidSubmitAgent = this._onDidSubmitAgent.event; + private _onDidFocus = this._register(new Emitter()); readonly onDidFocus = this._onDidFocus.event; @@ -88,13 +98,20 @@ export class ChatWidget extends Disposable implements IChatWidget { private _onDidAcceptInput = this._register(new Emitter()); readonly onDidAcceptInput = this._onDidAcceptInput.event; + private _onDidChangeParsedInput = this._register(new Emitter()); + readonly onDidChangeParsedInput = this._onDidChangeParsedInput.event; + private _onDidChangeHeight = this._register(new Emitter()); readonly onDidChangeHeight = this._onDidChangeHeight.event; + private readonly _onDidChangeContentHeight = new Emitter(); + readonly onDidChangeContentHeight: Event = this._onDidChangeContentHeight.event; + private contribs: IChatWidgetContrib[] = []; private tree!: WorkbenchObjectTree; private renderer!: ChatListItemRenderer; + private readonly _codeBlockModelCollection: CodeBlockModelCollection; private inputPart!: ChatInputPart; private editorOptions!: ChatEditorOptions; @@ -105,6 +122,8 @@ export class ChatWidget extends Disposable implements IChatWidget { private bodyDimension: dom.Dimension | undefined; private visibleChangeCount = 0; private requestInProgress: IContextKey; + private agentInInput: IContextKey; + private _visible = false; public get visible() { return this._visible; @@ -112,7 +131,7 @@ export class ChatWidget extends Disposable implements IChatWidget { private previousTreeScrollHeight: number = 0; - private viewModelDisposables = this._register(new DisposableStore()); + private readonly viewModelDisposables = this._register(new DisposableStore()); private _viewModel: ChatViewModel | undefined; private set viewModel(viewModel: ChatViewModel | undefined) { if (this._viewModel === viewModel) { @@ -136,32 +155,87 @@ export class ChatWidget extends Disposable implements IChatWidget { private parsedChatRequest: IParsedChatRequest | undefined; get parsedInput() { if (this.parsedChatRequest === undefined) { - this.parsedChatRequest = this.instantiationService.createInstance(ChatRequestParser).parseChatRequest(this.viewModel!.sessionId, this.getInput()); + this.parsedChatRequest = this.instantiationService.createInstance(ChatRequestParser).parseChatRequest(this.viewModel!.sessionId, this.getInput(), this.location, { selectedAgent: this._lastSelectedAgent }); + + this.agentInInput.set((!!this.parsedChatRequest.parts.find(part => part instanceof ChatRequestAgentPart))); } return this.parsedChatRequest; } constructor( + readonly location: ChatAgentLocation, readonly viewContext: IChatWidgetViewContext, private readonly viewOptions: IChatWidgetViewOptions, private readonly styles: IChatWidgetStyles, + @ICodeEditorService codeEditorService: ICodeEditorService, @IContextKeyService private readonly contextKeyService: IContextKeyService, @IInstantiationService private readonly instantiationService: IInstantiationService, @IChatService private readonly chatService: IChatService, @IChatAgentService private readonly chatAgentService: IChatAgentService, @IChatWidgetService chatWidgetService: IChatWidgetService, @IContextMenuService private readonly contextMenuService: IContextMenuService, - @IChatAccessibilityService private readonly _chatAccessibilityService: IChatAccessibilityService, - @IInstantiationService private readonly _instantiationService: IInstantiationService, - @ILogService private readonly _logService: ILogService, - @IThemeService private readonly _themeService: IThemeService + @IChatAccessibilityService private readonly chatAccessibilityService: IChatAccessibilityService, + @ILogService private readonly logService: ILogService, + @IThemeService private readonly themeService: IThemeService, + @IChatSlashCommandService private readonly chatSlashCommandService: IChatSlashCommandService, ) { super(); CONTEXT_IN_CHAT_SESSION.bindTo(contextKeyService).set(true); + CONTEXT_CHAT_LOCATION.bindTo(contextKeyService).set(location); + this.agentInInput = CONTEXT_CHAT_INPUT_HAS_AGENT.bindTo(contextKeyService); this.requestInProgress = CONTEXT_CHAT_REQUEST_IN_PROGRESS.bindTo(contextKeyService); this._register((chatWidgetService as ChatWidgetService).register(this)); + + this._codeBlockModelCollection = this._register(instantiationService.createInstance(CodeBlockModelCollection)); + + this._register(codeEditorService.registerCodeEditorOpenHandler(async (input: ITextResourceEditorInput, _source: ICodeEditor | null, _sideBySide?: boolean): Promise => { + if (input.resource.scheme !== Schemas.vscodeChatCodeBlock) { + return null; + } + + const responseId = input.resource.path.split('/').at(1); + if (!responseId) { + return null; + } + + const item = this.viewModel?.getItems().find(item => item.id === responseId); + if (!item) { + return null; + } + + this.reveal(item); + + await timeout(0); // wait for list to actually render + + for (const editor of this.renderer.editorsInUse() ?? []) { + if (editor.uri?.toString() === input.resource.toString()) { + const inner = editor.editor; + if (input.options?.selection) { + inner.setSelection({ + startLineNumber: input.options.selection.startLineNumber, + startColumn: input.options.selection.startColumn, + endLineNumber: input.options.selection.startLineNumber ?? input.options.selection.endLineNumber, + endColumn: input.options.selection.startColumn ?? input.options.selection.endColumn + }); + } + return inner; + } + } + return null; + })); + } + + private _lastSelectedAgent: IChatAgentData | undefined; + set lastSelectedAgent(agent: IChatAgentData | undefined) { + this.parsedChatRequest = undefined; + this._lastSelectedAgent = agent; + this._onDidChangeParsedInput.fire(); + } + + get lastSelectedAgent(): IChatAgentData | undefined { + return this._lastSelectedAgent; } get supportsFileReferences(): boolean { @@ -172,6 +246,10 @@ export class ChatWidget extends Disposable implements IChatWidget { return this.viewModel?.providerId || ''; } + get input(): ChatInputPart { + return this.inputPart; + } + get inputEditor(): ICodeEditor { return this.inputPart.inputEditor; } @@ -180,6 +258,10 @@ export class ChatWidget extends Disposable implements IChatWidget { return this.inputPart.inputUri; } + get contentHeight(): number { + return this.inputPart.contentHeight + this.tree.contentHeight; + } + render(parent: HTMLElement): void { const viewId = 'viewId' in this.viewContext ? this.viewContext.viewId : undefined; this.editorOptions = this._register(this.instantiationService.createInstance(ChatEditorOptions, viewId, this.styles.listForeground, this.styles.inputEditorBackground, this.styles.resultEditorBackground)); @@ -192,10 +274,10 @@ export class ChatWidget extends Disposable implements IChatWidget { this.listContainer = dom.append(this.container, $(`.interactive-list`)); } else { this.listContainer = dom.append(this.container, $(`.interactive-list`)); - this.createInput(this.container); + this.createInput(this.container, { renderFollowups: true, renderStyle }); } - this.createList(this.listContainer, { renderStyle }); + this.createList(this.listContainer, { ...this.viewOptions.rendererOptions, renderStyle }); this._register(this.editorOptions.onDidChange(() => this.onDidStyleChange())); this.onDidStyleChange(); @@ -210,7 +292,7 @@ export class ChatWidget extends Disposable implements IChatWidget { try { return this._register(this.instantiationService.createInstance(contrib, this)); } catch (err) { - this._logService.error('Failed to instantiate chat widget contrib', toErrorMessage(err)); + this.logService.error('Failed to instantiate chat widget contrib', toErrorMessage(err)); return undefined; } }).filter(isDefined); @@ -229,6 +311,9 @@ export class ChatWidget extends Disposable implements IChatWidget { } moveFocus(item: ChatTreeItem, type: 'next' | 'previous'): void { + if (!isResponseVM(item)) { + return; + } const items = this.viewModel?.getItems(); if (!items) { return; @@ -295,7 +380,7 @@ export class ChatWidget extends Disposable implements IChatWidget { } } - private async renderFollowups(items: IChatReplyFollowup[] | undefined, response?: IChatResponseViewModel): Promise { + private async renderFollowups(items: IChatFollowup[] | undefined, response?: IChatResponseViewModel): Promise { this.inputPart.renderFollowups(items, response); if (this.bodyDimension) { @@ -321,7 +406,7 @@ export class ChatWidget extends Disposable implements IChatWidget { private createList(listContainer: HTMLElement, options: IChatListItemRendererOptions): void { const scopedInstantiationService = this.instantiationService.createChild(new ServiceCollection([IContextKeyService, this.contextKeyService])); - const delegate = scopedInstantiationService.createInstance(ChatListDelegate); + const delegate = scopedInstantiationService.createInstance(ChatListDelegate, this.viewOptions.defaultElementHeight ?? 200); const rendererDelegate: IChatRendererDelegate = { getListLength: () => this.tree.getNode(null).visibleChildrenCount, onDidScroll: this.onDidScroll, @@ -335,8 +420,10 @@ export class ChatWidget extends Disposable implements IChatWidget { this.renderer = this._register(scopedInstantiationService.createInstance( ChatListItemRenderer, this.editorOptions, + this.location, options, rendererDelegate, + this._codeBlockModelCollection, overflowWidgetsContainer, )); this._register(this.renderer.onDidClickFollowup(item => { @@ -355,9 +442,10 @@ export class ChatWidget extends Disposable implements IChatWidget { horizontalScrolling: false, supportDynamicHeights: true, hideTwistiesOfChildlessElements: true, - accessibilityProvider: this._instantiationService.createInstance(ChatAccessibilityProvider), + accessibilityProvider: this.instantiationService.createInstance(ChatAccessibilityProvider), keyboardNavigationLabelProvider: { getKeyboardNavigationLabel: (e: ChatTreeItem) => isRequestVM(e) ? e.message : isResponseVM(e) ? e.response.value : '' }, // TODO setRowLineHeight: false, + filter: this.viewOptions.filter ? { filter: this.viewOptions.filter.bind(this.viewOptions), } : undefined, overrideStyles: { listFocusBackground: this.styles.listBackground, listInactiveFocusBackground: this.styles.listBackground, @@ -374,7 +462,7 @@ export class ChatWidget extends Disposable implements IChatWidget { listFocusAndSelectionForeground: this.styles.listForeground, } }); - this.tree.onContextMenu(e => this.onContextMenu(e)); + this._register(this.tree.onContextMenu(e => this.onContextMenu(e))); this._register(this.tree.onDidChangeContentHeight(() => { this.onDidChangeTreeContentHeight(); @@ -421,13 +509,19 @@ export class ChatWidget extends Disposable implements IChatWidget { } this.previousTreeScrollHeight = this.tree.scrollHeight; + this._onDidChangeContentHeight.fire(); } private createInput(container: HTMLElement, options?: { renderFollowups: boolean; renderStyle?: 'default' | 'compact' }): void { - this.inputPart = this._register(this.instantiationService.createInstance(ChatInputPart, { - renderFollowups: options?.renderFollowups ?? true, - renderStyle: options?.renderStyle, - })); + this.inputPart = this._register(this.instantiationService.createInstance(ChatInputPart, + this.location, + { + renderFollowups: options?.renderFollowups ?? true, + renderStyle: options?.renderStyle, + menus: { executeToolbar: MenuId.ChatExecute, ...this.viewOptions.menus }, + editorOverflowWidgetsDomNode: this.viewOptions.editorOverflowWidgetsDomNode, + } + )); this.inputPart.render(container, '', this); this._register(this.inputPart.onDidLoadInputState(state => { @@ -443,7 +537,24 @@ export class ChatWidget extends Disposable implements IChatWidget { return; } - this.acceptInput(e.followup.message); + let msg = ''; + if (e.followup.agentId && e.followup.agentId !== this.chatAgentService.getDefaultAgent(this.location)?.id) { + const agent = this.chatAgentService.getAgent(e.followup.agentId); + if (!agent) { + return; + } + + this.lastSelectedAgent = agent; + msg = `${chatAgentLeader}${agent.name} `; + if (e.followup.subCommand) { + msg += `${chatSubcommandLeader}${e.followup.subCommand} `; + } + } else if (!e.followup.agentId && e.followup.subCommand && this.chatSlashCommandService.hasCommand(e.followup.subCommand)) { + msg = `${chatSubcommandLeader}${e.followup.subCommand} `; + } + + msg += e.followup.message; + this.acceptInput(msg); if (!e.response) { // Followups can be shown by the welcome message, then there is no response associated. @@ -455,22 +566,49 @@ export class ChatWidget extends Disposable implements IChatWidget { providerId: this.viewModel.providerId, sessionId: this.viewModel.sessionId, requestId: e.response.requestId, - agentId: e.response?.agent?.id, + agentId: e.response.agent?.id, + result: e.response.result, action: { kind: 'followUp', followup: e.followup }, }); })); - this._register(this.inputPart.onDidChangeHeight(() => this.bodyDimension && this.layout(this.bodyDimension.height, this.bodyDimension.width))); - this._register(this.inputEditor.onDidChangeModelContent(() => this.parsedChatRequest = undefined)); - this._register(this.chatAgentService.onDidChangeAgents(() => this.parsedChatRequest = undefined)); + this._register(this.inputPart.onDidChangeHeight(() => { + if (this.bodyDimension) { + this.layout(this.bodyDimension.height, this.bodyDimension.width); + } + this._onDidChangeContentHeight.fire(); + })); + this._register(this.inputEditor.onDidChangeModelContent(() => this.updateImplicitContextKinds())); + this._register(this.chatAgentService.onDidChangeAgents(() => { + if (this.viewModel) { + this.updateImplicitContextKinds(); + } + })); } private onDidStyleChange(): void { this.container.style.setProperty('--vscode-interactive-result-editor-background-color', this.editorOptions.configuration.resultEditor.backgroundColor?.toString() ?? ''); this.container.style.setProperty('--vscode-interactive-session-foreground', this.editorOptions.configuration.foreground?.toString() ?? ''); - this.container.style.setProperty('--vscode-chat-list-background', this._themeService.getColorTheme().getColor(this.styles.listBackground)?.toString() ?? ''); + this.container.style.setProperty('--vscode-chat-list-background', this.themeService.getColorTheme().getColor(this.styles.listBackground)?.toString() ?? ''); + } + + private updateImplicitContextKinds() { + if (!this.viewModel) { + return; + } + this.parsedChatRequest = undefined; + const agentAndSubcommand = extractAgentAndCommand(this.parsedInput); + const currentAgent = agentAndSubcommand.agentPart?.agent ?? this.chatAgentService.getDefaultAgent(this.location); + const implicitVariables = agentAndSubcommand.commandPart ? + agentAndSubcommand.commandPart.command.defaultImplicitVariables : + currentAgent?.defaultImplicitVariables; + this.inputPart.setImplicitContextKinds(implicitVariables ?? []); + + if (this.bodyDimension) { + this.layout(this.bodyDimension.height, this.bodyDimension.width); + } } setModel(model: IChatModel, viewState: IChatViewState): void { @@ -478,12 +616,19 @@ export class ChatWidget extends Disposable implements IChatWidget { throw new Error('Call render() before setModel()'); } + this._codeBlockModelCollection.clear(); + this.container.setAttribute('data-session-id', model.sessionId); - this.viewModel = this.instantiationService.createInstance(ChatViewModel, model); - this.viewModelDisposables.add(this.viewModel.onDidChange(e => { - this.requestInProgress.set(this.viewModel!.requestInProgress); + this.viewModel = this.instantiationService.createInstance(ChatViewModel, model, this._codeBlockModelCollection); + this.viewModelDisposables.add(Event.accumulate(this.viewModel.onDidChange, 0)(events => { + if (!this.viewModel) { + return; + } + + this.requestInProgress.set(this.viewModel.requestInProgress); + this.onDidChangeItems(); - if (e?.kind === 'addRequest') { + if (events.some(e => e?.kind === 'addRequest')) { revealLastElement(this.tree); this.focusInput(); } @@ -507,6 +652,8 @@ export class ChatWidget extends Disposable implements IChatWidget { this.onDidChangeItems(); revealLastElement(this.tree); } + + this.updateImplicitContextKinds(); } getFocus(): ChatTreeItem | undefined { @@ -528,6 +675,10 @@ export class ChatWidget extends Disposable implements IChatWidget { this.tree.domFocus(); } + refilter() { + this.tree.refilter(); + } + setInputPlaceholder(placeholder: string): void { this.viewModel?.setInputPlaceholder(placeholder); } @@ -567,20 +718,21 @@ export class ChatWidget extends Disposable implements IChatWidget { this._onDidAcceptInput.fire(); const editorValue = this.getInput(); - const requestId = this._chatAccessibilityService.acceptRequest(); + const requestId = this.chatAccessibilityService.acceptRequest(); const input = !opts ? editorValue : 'query' in opts ? opts.query : `${opts.prefix} ${editorValue}`; const isUserQuery = !opts || 'prefix' in opts; - const result = await this.chatService.sendRequest(this.viewModel.sessionId, input); + const result = await this.chatService.sendRequest(this.viewModel.sessionId, input, this.inputPart.implicitContextEnabled, this.location, { selectedAgent: this._lastSelectedAgent }); if (result) { const inputState = this.collectInputState(); this.inputPart.acceptInput(isUserQuery ? input : undefined, isUserQuery ? inputState : undefined); + this._onDidSubmitAgent.fire({ agent: result.agent, slashCommand: result.slashCommand }); result.responseCompletePromise.then(async () => { const responses = this.viewModel?.getItems().filter(isResponseVM); const lastResponse = responses?.[responses.length - 1]; - this._chatAccessibilityService.acceptResponse(lastResponse, requestId); + this.chatAccessibilityService.acceptResponse(lastResponse, requestId); }); } } @@ -621,7 +773,8 @@ export class ChatWidget extends Disposable implements IChatWidget { width = Math.min(width, 850); this.bodyDimension = new dom.Dimension(width, height); - const inputPartHeight = this.inputPart.layout(height, width); + this.inputPart.layout(height, width); + const inputPartHeight = this.inputPart.inputPartHeight; const lastElementVisible = this.tree.scrollTop + this.tree.renderHeight >= this.tree.scrollHeight; const listHeight = height - inputPartHeight; @@ -667,7 +820,8 @@ export class ChatWidget extends Disposable implements IChatWidget { const possibleMaxHeight = (this._dynamicMessageLayoutData?.maxHeight ?? maxHeight); const width = this.bodyDimension?.width ?? this.container.offsetWidth; - const inputPartHeight = this.inputPart.layout(possibleMaxHeight, width); + this.inputPart.layout(possibleMaxHeight, width); + const inputPartHeight = this.inputPart.inputPartHeight; const newHeight = Math.min(renderHeight + diff, possibleMaxHeight - inputPartHeight); this.layout(newHeight + inputPartHeight, width); }); @@ -710,7 +864,8 @@ export class ChatWidget extends Disposable implements IChatWidget { } const width = this.bodyDimension?.width ?? this.container.offsetWidth; - const inputHeight = this.inputPart.layout(this._dynamicMessageLayoutData.maxHeight, width); + this.inputPart.layout(this._dynamicMessageLayoutData.maxHeight, width); + const inputHeight = this.inputPart.inputPartHeight; const totalMessages = this.viewModel.getItems(); // grab the last N messages @@ -744,6 +899,8 @@ export class ChatWidget extends Disposable implements IChatWidget { this.inputPart.saveState(); return { inputValue: this.getInput(), inputState: this.collectInputState() }; } + + } export class ChatWidgetService implements IChatWidgetService { diff --git a/src/vs/workbench/contrib/chat/browser/codeBlockContextProviderService.ts b/src/vs/workbench/contrib/chat/browser/codeBlockContextProviderService.ts new file mode 100644 index 0000000000000..8c790c540402b --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/codeBlockContextProviderService.ts @@ -0,0 +1,20 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IDisposable, toDisposable } from 'vs/base/common/lifecycle'; +import { ICodeBlockActionContextProvider, IChatCodeBlockContextProviderService } from 'vs/workbench/contrib/chat/browser/chat'; + +export class ChatCodeBlockContextProviderService implements IChatCodeBlockContextProviderService { + declare _serviceBrand: undefined; + private readonly _providers = new Map(); + + get providers(): ICodeBlockActionContextProvider[] { + return [...this._providers.values()]; + } + registerProvider(provider: ICodeBlockActionContextProvider, id: string): IDisposable { + this._providers.set(id, provider); + return toDisposable(() => this._providers.delete(id)); + } +} diff --git a/src/vs/workbench/contrib/chat/browser/codeBlockPart.ts b/src/vs/workbench/contrib/chat/browser/codeBlockPart.ts index a68207d86c8c2..c02f4e48d916e 100644 --- a/src/vs/workbench/contrib/chat/browser/codeBlockPart.ts +++ b/src/vs/workbench/contrib/chat/browser/codeBlockPart.ts @@ -8,19 +8,16 @@ import 'vs/css!./codeBlockPart'; import * as dom from 'vs/base/browser/dom'; import { Button } from 'vs/base/browser/ui/button/button'; import { Codicon } from 'vs/base/common/codicons'; -import { Emitter, Event } from 'vs/base/common/event'; -import { Disposable, IReference, MutableDisposable } from 'vs/base/common/lifecycle'; +import { Emitter } from 'vs/base/common/event'; +import { Disposable, DisposableStore, MutableDisposable } from 'vs/base/common/lifecycle'; import { Schemas } from 'vs/base/common/network'; import { URI, UriComponents } from 'vs/base/common/uri'; -import { generateUuid } from 'vs/base/common/uuid'; import { IEditorConstructionOptions } from 'vs/editor/browser/config/editorConfiguration'; import { EditorExtensionsRegistry } from 'vs/editor/browser/editorExtensions'; -import { CodeEditorWidget } from 'vs/editor/browser/widget/codeEditorWidget'; +import { CodeEditorWidget, ICodeEditorWidgetOptions } from 'vs/editor/browser/widget/codeEditor/codeEditorWidget'; import { EDITOR_FONT_DEFAULTS, EditorOption, IEditorOptions } from 'vs/editor/common/config/editorOptions'; import { IRange, Range } from 'vs/editor/common/core/range'; -import { ScrollType } from 'vs/editor/common/editorCommon'; -import { ILanguageService } from 'vs/editor/common/languages/language'; -import { PLAINTEXT_LANGUAGE_ID } from 'vs/editor/common/languages/modesRegistry'; +import { IDiffEditorViewModel, ScrollType } from 'vs/editor/common/editorCommon'; import { EndOfLinePreference, ITextModel } from 'vs/editor/common/model'; import { IModelService } from 'vs/editor/common/services/model'; import { IResolvedTextEditorModel, ITextModelContentProvider, ITextModelService } from 'vs/editor/common/services/resolverService'; @@ -41,36 +38,33 @@ import { IInstantiationService } from 'vs/platform/instantiation/common/instanti import { ServiceCollection } from 'vs/platform/instantiation/common/serviceCollection'; import { AccessibilityVerbositySettingId } from 'vs/workbench/contrib/accessibility/browser/accessibilityConfiguration'; import { IChatRendererDelegate } from 'vs/workbench/contrib/chat/browser/chatListRenderer'; -import { IMarkdownVulnerability } from 'vs/workbench/contrib/chat/browser/chatMarkdownDecorationsRenderer'; import { ChatEditorOptions } from 'vs/workbench/contrib/chat/browser/chatOptions'; import { IChatResponseViewModel, isResponseVM } from 'vs/workbench/contrib/chat/common/chatViewModel'; import { MenuPreventer } from 'vs/workbench/contrib/codeEditor/browser/menuPreventer'; import { SelectionClipboardContributionID } from 'vs/workbench/contrib/codeEditor/browser/selectionClipboard'; import { getSimpleEditorOptions } from 'vs/workbench/contrib/codeEditor/browser/simpleEditorOptions'; +import { IMarkdownVulnerability } from '../common/annotations'; +import { TabFocus } from 'vs/editor/browser/config/tabFocus'; +import { DiffEditorWidget } from 'vs/editor/browser/widget/diffEditor/diffEditorWidget'; +import { ChatTreeItem } from 'vs/workbench/contrib/chat/browser/chat'; +import { TextEdit } from 'vs/editor/common/languages'; +import { CancellationToken } from 'vs/base/common/cancellation'; const $ = dom.$; -interface ICodeBlockDataCommon { - codeBlockIndex: number; - element: unknown; - parentContextKeyService?: IContextKeyService; - hideToolbar?: boolean; -} +export interface ICodeBlockData { + readonly codeBlockIndex: number; + readonly element: unknown; -export interface ISimpleCodeBlockData extends ICodeBlockDataCommon { - type: 'code'; - text: string; - languageId: string; - vulns?: IMarkdownVulnerability[]; -} + readonly textModel: Promise; + readonly languageId: string; -export interface ILocalFileCodeBlockData extends ICodeBlockDataCommon { - type: 'localFile'; - uri: URI; - range?: Range; -} + readonly vulns?: readonly IMarkdownVulnerability[]; + readonly range?: Range; -export type ICodeBlockData = ISimpleCodeBlockData | ILocalFileCodeBlockData; + readonly parentContextKeyService?: IContextKeyService; + readonly hideToolbar?: boolean; +} /** * Special markdown code block language id used to render a local file. @@ -112,25 +106,13 @@ export function parseLocalFileData(text: string) { export interface ICodeBlockActionContext { code: string; - languageId: string; + languageId?: string; codeBlockIndex: number; element: unknown; } - -export interface ICodeBlockPart { - readonly onDidChangeContentHeight: Event; - readonly element: HTMLElement; - readonly uri: URI; - layout(width: number): void; - render(data: Data, width: number): Promise; - focus(): void; - reset(): unknown; - dispose(): void; -} - const defaultCodeblockPadding = 10; -abstract class BaseCodeBlockPart extends Disposable implements ICodeBlockPart { +export class CodeBlockPart extends Disposable { protected readonly _onDidChangeContentHeight = this._register(new Emitter()); public readonly onDidChangeContentHeight = this._onDidChangeContentHeight.event; @@ -138,11 +120,16 @@ abstract class BaseCodeBlockPart extends Disposable protected readonly toolbar: MenuWorkbenchToolBar; private readonly contextKeyService: IContextKeyService; - abstract readonly uri: URI; public readonly element: HTMLElement; + private readonly vulnsButton: Button; + private readonly vulnsListElement: HTMLElement; + + private currentCodeBlockData: ICodeBlockData | undefined; private currentScrollWidth = 0; + private readonly disposableStore = this._register(new DisposableStore()); + constructor( private readonly options: ChatEditorOptions, readonly menuId: MenuId, @@ -152,7 +139,7 @@ abstract class BaseCodeBlockPart extends Disposable @IContextKeyService contextKeyService: IContextKeyService, @IModelService protected readonly modelService: IModelService, @IConfigurationService private readonly configurationService: IConfigurationService, - @IAccessibilityService private readonly accessibilityService: IAccessibilityService + @IAccessibilityService private readonly accessibilityService: IAccessibilityService, ) { super(); this.element = $('.interactive-result-code-block'); @@ -171,8 +158,16 @@ abstract class BaseCodeBlockPart extends Disposable padding: { top: defaultCodeblockPadding, bottom: defaultCodeblockPadding }, mouseWheelZoom: false, scrollbar: { + vertical: 'hidden', alwaysConsumeMouseWheel: false }, + definitionLinkOpensInPeek: false, + gotoLocation: { + multiple: 'goto', + multipleDeclarations: 'goto', + multipleDefinitions: 'goto', + multipleImplementations: 'goto', + }, ariaLabel: localize('chat.codeBlockHelp', 'Code block'), overflowWidgetsDomNode, ...this.getEditorOptionsFromConfig(), @@ -187,6 +182,31 @@ abstract class BaseCodeBlockPart extends Disposable } })); + const vulnsContainer = dom.append(this.element, $('.interactive-result-vulns')); + const vulnsHeaderElement = dom.append(vulnsContainer, $('.interactive-result-vulns-header', undefined)); + this.vulnsButton = this._register(new Button(vulnsHeaderElement, { + buttonBackground: undefined, + buttonBorder: undefined, + buttonForeground: undefined, + buttonHoverBackground: undefined, + buttonSecondaryBackground: undefined, + buttonSecondaryForeground: undefined, + buttonSecondaryHoverBackground: undefined, + buttonSeparator: undefined, + supportIcons: true + })); + + this.vulnsListElement = dom.append(vulnsContainer, $('ul.interactive-result-vulns-list')); + + this._register(this.vulnsButton.onDidClick(() => { + const element = this.currentCodeBlockData!.element as IChatResponseViewModel; + element.vulnerabilitiesListExpanded = !element.vulnerabilitiesListExpanded; + this.vulnsButton.label = this.getVulnerabilitiesLabel(); + this.element.classList.toggle('chat-vulnerabilities-collapsed', !element.vulnerabilitiesListExpanded); + this._onDidChangeContentHeight.fire(); + // this.updateAriaLabel(collapseButton.element, referencesLabel, element.usedReferencesExpanded); + })); + this._register(this.toolbar.onDidChangeDropdownVisibility(e => { toolbarElement.classList.toggle('force-visibility', e); })); @@ -229,7 +249,27 @@ abstract class BaseCodeBlockPart extends Disposable } } - protected abstract createEditor(instantiationService: IInstantiationService, parent: HTMLElement, options: Readonly): CodeEditorWidget; + get uri(): URI | undefined { + return this.editor.getModel()?.uri; + } + + private createEditor(instantiationService: IInstantiationService, parent: HTMLElement, options: Readonly): CodeEditorWidget { + return this._register(instantiationService.createInstance(CodeEditorWidget, parent, options, { + isSimpleWidget: false, + contributions: EditorExtensionsRegistry.getSomeEditorContributions([ + MenuPreventer.ID, + SelectionClipboardContributionID, + ContextMenuController.ID, + + WordHighlighterContribution.ID, + ViewportSemanticTokensContribution.ID, + BracketMatchingController.ID, + SmartSelectController.ID, + HoverController.ID, + GotoDefinitionAtPositionEditorContribution.ID, + ]) + })); + } focus(): void { this.editor.focus(); @@ -277,17 +317,23 @@ abstract class BaseCodeBlockPart extends Disposable this.updatePaddingForLayout(); } - protected getContentHeight() { + private getContentHeight() { + if (this.currentCodeBlockData?.range) { + const lineCount = this.currentCodeBlockData.range.endLineNumber - this.currentCodeBlockData.range.startLineNumber + 1; + const lineHeight = this.editor.getOption(EditorOption.lineHeight); + return lineCount * lineHeight; + } return this.editor.getContentHeight(); } - async render(data: Data, width: number) { + async render(data: ICodeBlockData, width: number, editable: boolean | undefined) { + this.currentCodeBlockData = data; if (data.parentContextKeyService) { this.contextKeyService.updateParent(data.parentContextKeyService); } if (this.options.configuration.resultEditor.wordWrap === 'on') { - // Intialize the editor with the new proper width so that getContentHeight + // Initialize the editor with the new proper width so that getContentHeight // will be computed correctly in the next call to layout() this.layout(width); } @@ -295,16 +341,29 @@ abstract class BaseCodeBlockPart extends Disposable await this.updateEditor(data); this.layout(width); - this.editor.updateOptions({ ariaLabel: localize('chat.codeBlockLabel', "Code block {0}", data.codeBlockIndex + 1) }); + if (editable) { + this.disposableStore.clear(); + this.disposableStore.add(this.editor.onDidFocusEditorWidget(() => TabFocus.setTabFocusMode(true))); + this.disposableStore.add(this.editor.onDidBlurEditorWidget(() => TabFocus.setTabFocusMode(false))); + } + this.editor.updateOptions({ ariaLabel: localize('chat.codeBlockLabel', "Code block {0}", data.codeBlockIndex + 1), readOnly: !editable }); if (data.hideToolbar) { dom.hide(this.toolbar.getElement()); } else { dom.show(this.toolbar.getElement()); } - } - protected abstract updateEditor(data: Data): void | Promise; + if (data.vulns?.length && isResponseVM(data.element)) { + dom.clearNode(this.vulnsListElement); + this.element.classList.remove('no-vulns'); + this.element.classList.toggle('chat-vulnerabilities-collapsed', !data.element.vulnerabilitiesListExpanded); + dom.append(this.vulnsListElement, ...data.vulns.map(v => $('li', undefined, $('span.chat-vuln-title', undefined, v.title), ' ' + v.description))); + this.vulnsButton.label = this.getVulnerabilitiesLabel(); + } else { + this.element.classList.add('no-vulns'); + } + } reset() { this.clearWidgets(); @@ -313,74 +372,191 @@ abstract class BaseCodeBlockPart extends Disposable private clearWidgets() { HoverController.get(this.editor)?.hideContentHover(); } + + private async updateEditor(data: ICodeBlockData): Promise { + const textModel = (await data.textModel).textEditorModel; + this.editor.setModel(textModel); + if (data.range) { + this.editor.setSelection(data.range); + this.editor.revealRangeInCenter(data.range, ScrollType.Immediate); + } + + this.toolbar.context = { + code: textModel.getTextBuffer().getValueInRange(data.range ?? textModel.getFullModelRange(), EndOfLinePreference.TextDefined), + codeBlockIndex: data.codeBlockIndex, + element: data.element, + languageId: textModel.getLanguageId() + } satisfies ICodeBlockActionContext; + } + + private getVulnerabilitiesLabel(): string { + if (!this.currentCodeBlockData || !this.currentCodeBlockData.vulns) { + return ''; + } + + const referencesLabel = this.currentCodeBlockData.vulns.length > 1 ? + localize('vulnerabilitiesPlural', "{0} vulnerabilities", this.currentCodeBlockData.vulns.length) : + localize('vulnerabilitiesSingular', "{0} vulnerability", 1); + const icon = (element: IChatResponseViewModel) => element.vulnerabilitiesListExpanded ? Codicon.chevronDown : Codicon.chevronRight; + return `${referencesLabel} $(${icon(this.currentCodeBlockData.element as IChatResponseViewModel).id})`; + } } +export class ChatCodeBlockContentProvider extends Disposable implements ITextModelContentProvider { -export class SimpleCodeBlockPart extends BaseCodeBlockPart { + constructor( + @ITextModelService textModelService: ITextModelService, + @IModelService private readonly _modelService: IModelService, + ) { + super(); + this._register(textModelService.registerTextModelContentProvider(Schemas.vscodeChatCodeBlock, this)); + } - private readonly vulnsButton: Button; - private readonly vulnsListElement: HTMLElement; + async provideTextContent(resource: URI): Promise { + const existing = this._modelService.getModel(resource); + if (existing) { + return existing; + } + return this._modelService.createModel('', null, resource); + } +} - private currentCodeBlockData: ISimpleCodeBlockData | undefined; +// - private readonly textModel: Promise; +export interface ICodeCompareBlockActionContext { + element: ChatTreeItem; + readonly uri: URI; + readonly edits: readonly TextEdit[]; +} + +export interface ICodeCompareBlockData { + readonly element: ChatTreeItem; + + readonly originalTextModel: Promise; + readonly modifiedTextModel: Promise; + readonly edits: readonly TextEdit[]; + + readonly parentContextKeyService?: IContextKeyService; + readonly hideToolbar?: boolean; +} - private readonly _uri: URI; + +export class CodeCompareBlockPart extends Disposable { + protected readonly _onDidChangeContentHeight = this._register(new Emitter()); + public readonly onDidChangeContentHeight = this._onDidChangeContentHeight.event; + + private readonly contextKeyService: IContextKeyService; + public readonly diffEditor: DiffEditorWidget; + protected readonly toolbar: MenuWorkbenchToolBar; + public readonly element: HTMLElement; + + private readonly _lastDiffEditorViewModel = this._store.add(new MutableDisposable()); + private currentScrollWidth = 0; constructor( - options: ChatEditorOptions, - menuId: MenuId, + private readonly options: ChatEditorOptions, + readonly menuId: MenuId, delegate: IChatRendererDelegate, overflowWidgetsDomNode: HTMLElement | undefined, @IInstantiationService instantiationService: IInstantiationService, @IContextKeyService contextKeyService: IContextKeyService, - @IModelService modelService: IModelService, - @ITextModelService textModelService: ITextModelService, - @IConfigurationService configurationService: IConfigurationService, - @IAccessibilityService accessibilityService: IAccessibilityService, - @ILanguageService private readonly languageService: ILanguageService, + @IModelService protected readonly modelService: IModelService, + @IConfigurationService private readonly configurationService: IConfigurationService, + @IAccessibilityService private readonly accessibilityService: IAccessibilityService, ) { - super(options, menuId, delegate, overflowWidgetsDomNode, instantiationService, contextKeyService, modelService, configurationService, accessibilityService); + super(); + this.element = $('.interactive-result-code-block'); + this.element.classList.add('compare'); - const vulnsContainer = dom.append(this.element, $('.interactive-result-vulns')); - const vulnsHeaderElement = dom.append(vulnsContainer, $('.interactive-result-vulns-header', undefined)); - this.vulnsButton = new Button(vulnsHeaderElement, { - buttonBackground: undefined, - buttonBorder: undefined, - buttonForeground: undefined, - buttonHoverBackground: undefined, - buttonSecondaryBackground: undefined, - buttonSecondaryForeground: undefined, - buttonSecondaryHoverBackground: undefined, - buttonSeparator: undefined, - supportIcons: true - }); - this._uri = URI.from({ scheme: Schemas.vscodeChatCodeBlock, path: generateUuid() }); - this.textModel = textModelService.createModelReference(this._uri).then(ref => { - this.editor.setModel(ref.object.textEditorModel); - this._register(ref); - return ref.object.textEditorModel; + this.contextKeyService = this._register(contextKeyService.createScoped(this.element)); + const scopedInstantiationService = instantiationService.createChild(new ServiceCollection([IContextKeyService, this.contextKeyService])); + const editorElement = dom.append(this.element, $('.interactive-result-editor')); + this.diffEditor = this.createDiffEditor(scopedInstantiationService, editorElement, { + ...getSimpleEditorOptions(this.configurationService), + readOnly: true, + lineNumbers: 'on', + selectOnLineNumbers: true, + scrollBeyondLastLine: false, + lineDecorationsWidth: 12, + dragAndDrop: false, + padding: { top: defaultCodeblockPadding, bottom: defaultCodeblockPadding }, + mouseWheelZoom: false, + scrollbar: { + vertical: 'hidden', + alwaysConsumeMouseWheel: false + }, + definitionLinkOpensInPeek: false, + gotoLocation: { + multiple: 'goto', + multipleDeclarations: 'goto', + multipleDefinitions: 'goto', + multipleImplementations: 'goto', + }, + ariaLabel: localize('chat.codeBlockHelp', 'Code block'), + overflowWidgetsDomNode, + ...this.getEditorOptionsFromConfig(), }); - this.vulnsListElement = dom.append(vulnsContainer, $('ul.interactive-result-vulns-list')); + const toolbarElement = dom.append(this.element, $('.interactive-result-code-block-toolbar')); + const editorScopedService = this.diffEditor.getModifiedEditor().contextKeyService.createScoped(toolbarElement); + const editorScopedInstantiationService = scopedInstantiationService.createChild(new ServiceCollection([IContextKeyService, editorScopedService])); + this.toolbar = this._register(editorScopedInstantiationService.createInstance(MenuWorkbenchToolBar, toolbarElement, menuId, { + menuOptions: { + shouldForwardArgs: true + } + })); - this.vulnsButton.onDidClick(() => { - const element = this.currentCodeBlockData!.element as IChatResponseViewModel; - element.vulnerabilitiesListExpanded = !element.vulnerabilitiesListExpanded; - this.vulnsButton.label = this.getVulnerabilitiesLabel(); - this.element.classList.toggle('chat-vulnerabilities-collapsed', !element.vulnerabilitiesListExpanded); - this._onDidChangeContentHeight.fire(); - // this.updateAriaLabel(collapseButton.element, referencesLabel, element.usedReferencesExpanded); - }); + + this._register(this.toolbar.onDidChangeDropdownVisibility(e => { + toolbarElement.classList.toggle('force-visibility', e); + })); + + this._configureForScreenReader(); + this._register(this.accessibilityService.onDidChangeScreenReaderOptimized(() => this._configureForScreenReader())); + this._register(this.configurationService.onDidChangeConfiguration((e) => { + if (e.affectedKeys.has(AccessibilityVerbositySettingId.Chat)) { + this._configureForScreenReader(); + } + })); + + this._register(this.options.onDidChange(() => { + this.diffEditor.updateOptions(this.getEditorOptionsFromConfig()); + })); + + this._register(this.diffEditor.getModifiedEditor().onDidScrollChange(e => { + this.currentScrollWidth = e.scrollWidth; + })); + this._register(this.diffEditor.onDidContentSizeChange(e => { + if (e.contentHeightChanged) { + this._onDidChangeContentHeight.fire(); + } + })); + this._register(this.diffEditor.getModifiedEditor().onDidBlurEditorWidget(() => { + this.element.classList.remove('focused'); + WordHighlighterContribution.get(this.diffEditor.getModifiedEditor())?.stopHighlighting(); + this.clearWidgets(); + })); + this._register(this.diffEditor.getModifiedEditor().onDidFocusEditorWidget(() => { + this.element.classList.add('focused'); + WordHighlighterContribution.get(this.diffEditor.getModifiedEditor())?.restoreViewState(true); + })); + + + // Parent list scrolled + if (delegate.onDidScroll) { + this._register(delegate.onDidScroll(e => { + this.clearWidgets(); + })); + } } - get uri(): URI { - return this._uri; + get uri(): URI | undefined { + return this.diffEditor.getModifiedEditor().getModel()?.uri; } - protected override createEditor(instantiationService: IInstantiationService, parent: HTMLElement, options: Readonly): CodeEditorWidget { - return this._register(instantiationService.createInstance(CodeEditorWidget, parent, options, { - isSimpleWidget: true, + private createDiffEditor(instantiationService: IInstantiationService, parent: HTMLElement, options: Readonly): DiffEditorWidget { + const widgetOptions: ICodeEditorWidgetOptions = { + isSimpleWidget: false, contributions: EditorExtensionsRegistry.getSomeEditorContributions([ MenuPreventer.ID, SelectionClipboardContributionID, @@ -393,170 +569,137 @@ export class SimpleCodeBlockPart extends BaseCodeBlockPart HoverController.ID, GotoDefinitionAtPositionEditorContribution.ID, ]) - })); + }; + + return this._register(instantiationService.createInstance(DiffEditorWidget, parent, { + scrollbar: { useShadows: false, alwaysConsumeMouseWheel: false, ignoreHorizontalScrollbarInContentHeight: true, }, + renderMarginRevertIcon: false, + diffCodeLens: false, + scrollBeyondLastLine: false, + stickyScroll: { enabled: false }, + originalAriaLabel: localize('original', 'Original'), + modifiedAriaLabel: localize('modified', 'Modified'), + diffAlgorithm: 'advanced', + readOnly: true, + isInEmbeddedEditor: true, + useInlineViewWhenSpaceIsLimited: false, + hideUnchangedRegions: { enabled: true, contextLineCount: 1 }, + ...options + }, { originalEditor: widgetOptions, modifiedEditor: widgetOptions })); } - override async render(data: ISimpleCodeBlockData, width: number): Promise { - await super.render(data, width); + focus(): void { + this.diffEditor.focus(); + } - if (data.vulns?.length && isResponseVM(data.element)) { - dom.clearNode(this.vulnsListElement); - this.element.classList.remove('no-vulns'); - this.element.classList.toggle('chat-vulnerabilities-collapsed', !data.element.vulnerabilitiesListExpanded); - dom.append(this.vulnsListElement, ...data.vulns.map(v => $('li', undefined, $('span.chat-vuln-title', undefined, v.title), ' ' + v.description))); - this.vulnsButton.label = this.getVulnerabilitiesLabel(); + private updatePaddingForLayout() { + // scrollWidth = "the width of the content that needs to be scrolled" + // contentWidth = "the width of the area where content is displayed" + const horizontalScrollbarVisible = this.currentScrollWidth > this.diffEditor.getModifiedEditor().getLayoutInfo().contentWidth; + const scrollbarHeight = this.diffEditor.getModifiedEditor().getLayoutInfo().horizontalScrollbarHeight; + const bottomPadding = horizontalScrollbarVisible ? + Math.max(defaultCodeblockPadding - scrollbarHeight, 2) : + defaultCodeblockPadding; + this.diffEditor.updateOptions({ padding: { top: defaultCodeblockPadding, bottom: bottomPadding } }); + } + + private _configureForScreenReader(): void { + const toolbarElt = this.toolbar.getElement(); + if (this.accessibilityService.isScreenReaderOptimized()) { + toolbarElt.style.display = 'block'; + toolbarElt.ariaLabel = this.configurationService.getValue(AccessibilityVerbositySettingId.Chat) ? localize('chat.codeBlock.toolbarVerbose', 'Toolbar for code block which can be reached via tab') : localize('chat.codeBlock.toolbar', 'Code block toolbar'); } else { - this.element.classList.add('no-vulns'); + toolbarElt.style.display = ''; } } - protected override async updateEditor(data: ISimpleCodeBlockData): Promise { - this.editor.setModel(await this.textModel); - const text = this.fixCodeText(data.text, data.languageId); - this.setText(text); + private getEditorOptionsFromConfig(): IEditorOptions { + return { + wordWrap: this.options.configuration.resultEditor.wordWrap, + fontLigatures: this.options.configuration.resultEditor.fontLigatures, + bracketPairColorization: this.options.configuration.resultEditor.bracketPairColorization, + fontFamily: this.options.configuration.resultEditor.fontFamily === 'default' ? + EDITOR_FONT_DEFAULTS.fontFamily : + this.options.configuration.resultEditor.fontFamily, + fontSize: this.options.configuration.resultEditor.fontSize, + fontWeight: this.options.configuration.resultEditor.fontWeight, + lineHeight: this.options.configuration.resultEditor.lineHeight, + }; + } - const vscodeLanguageId = this.languageService.getLanguageIdByLanguageName(data.languageId) ?? undefined; - this.setLanguage(vscodeLanguageId); - data.languageId = vscodeLanguageId ?? 'plaintext'; + layout(width: number): void { + const contentHeight = this.getContentHeight(); + const editorBorder = 2; + const dimension = { width: width - editorBorder, height: contentHeight }; + this.element.style.height = `${dimension.height}px`; + this.element.style.width = `${dimension.width}px`; + this.diffEditor.layout(dimension); + this.updatePaddingForLayout(); + } - this.toolbar.context = { - code: data.text, - codeBlockIndex: data.codeBlockIndex, - element: data.element, - languageId: data.languageId - } satisfies ICodeBlockActionContext; + private getContentHeight() { + return this.diffEditor.getContentHeight(); } - private getVulnerabilitiesLabel(): string { - if (!this.currentCodeBlockData || !this.currentCodeBlockData.vulns) { - return ''; + async render(data: ICodeCompareBlockData, width: number, token: CancellationToken) { + if (data.parentContextKeyService) { + this.contextKeyService.updateParent(data.parentContextKeyService); } - const referencesLabel = this.currentCodeBlockData.vulns.length > 1 ? - localize('vulnerabilitiesPlural', "{0} vulnerabilities", this.currentCodeBlockData.vulns.length) : - localize('vulnerabilitiesSingular', "{0} vulnerability", 1); - const icon = (element: IChatResponseViewModel) => element.vulnerabilitiesListExpanded ? Codicon.chevronDown : Codicon.chevronRight; - return `${referencesLabel} $(${icon(this.currentCodeBlockData.element as IChatResponseViewModel).id})`; - } - - private fixCodeText(text: string, languageId: string): string { - if (languageId === 'php') { - if (!text.trim().startsWith('<')) { - return ``; - } + if (this.options.configuration.resultEditor.wordWrap === 'on') { + // Initialize the editor with the new proper width so that getContentHeight + // will be computed correctly in the next call to layout() + this.layout(width); } - return text; - } + await this.updateEditor(data, token); - private async setText(newText: string): Promise { - const model = await this.textModel; - const currentText = model.getValue(EndOfLinePreference.LF); - if (newText === currentText) { - return; - } + this.layout(width); + this.diffEditor.updateOptions({ ariaLabel: localize('chat.compareCodeBlockLabel', "Code Edits") }); - if (newText.startsWith(currentText)) { - const text = newText.slice(currentText.length); - const lastLine = model.getLineCount(); - const lastCol = model.getLineMaxColumn(lastLine); - model.applyEdits([{ range: new Range(lastLine, lastCol, lastLine, lastCol), text }]); + if (data.hideToolbar) { + dom.hide(this.toolbar.getElement()); } else { - // console.log(`Failed to optimize setText`); - model.setValue(newText); + dom.show(this.toolbar.getElement()); } } - private async setLanguage(vscodeLanguageId: string | undefined): Promise { - (await this.textModel).setLanguage(vscodeLanguageId ?? PLAINTEXT_LANGUAGE_ID); - } -} - -export class LocalFileCodeBlockPart extends BaseCodeBlockPart { - - private readonly textModelReference = this._register(new MutableDisposable>()); - private currentCodeBlockData?: ILocalFileCodeBlockData; - - constructor( - options: ChatEditorOptions, - menuId: MenuId, - delegate: IChatRendererDelegate, - overflowWidgetsDomNode: HTMLElement | undefined, - @IInstantiationService instantiationService: IInstantiationService, - @IContextKeyService contextKeyService: IContextKeyService, - @IModelService modelService: IModelService, - @ITextModelService private readonly textModelService: ITextModelService, - @IConfigurationService configurationService: IConfigurationService, - @IAccessibilityService accessibilityService: IAccessibilityService - ) { - super(options, menuId, delegate, overflowWidgetsDomNode, instantiationService, contextKeyService, modelService, configurationService, accessibilityService); + reset() { + this.clearWidgets(); } - get uri(): URI { - return this.currentCodeBlockData!.uri; + private clearWidgets() { + HoverController.get(this.diffEditor.getOriginalEditor())?.hideContentHover(); + HoverController.get(this.diffEditor.getModifiedEditor())?.hideContentHover(); } - protected override getContentHeight() { - if (this.currentCodeBlockData?.range) { - const lineCount = this.currentCodeBlockData.range.endLineNumber - this.currentCodeBlockData.range.startLineNumber + 1; - const lineHeight = this.editor.getOption(EditorOption.lineHeight); - return lineCount * lineHeight; - } - return super.getContentHeight(); - } + private async updateEditor(data: ICodeCompareBlockData, token: CancellationToken): Promise { - protected override createEditor(instantiationService: IInstantiationService, parent: HTMLElement, options: Readonly): CodeEditorWidget { - return this._register(instantiationService.createInstance(CodeEditorWidget, parent, { - ...options, - }, { - // TODO: be more selective about contributions - })); - } + const originalTextModel = await data.originalTextModel; + const modifiedTextModel = await data.modifiedTextModel; - protected override async updateEditor(data: ILocalFileCodeBlockData): Promise { - let model: ITextModel; - if (this.currentCodeBlockData?.uri.toString() === data.uri.toString()) { - this.currentCodeBlockData = data; - model = this.editor.getModel()!; - } else { - this.currentCodeBlockData = data; - const result = await this.textModelService.createModelReference(data.uri); - model = result.object.textEditorModel; - this.textModelReference.value = result; - this.editor.setModel(model); + if (!originalTextModel || !modifiedTextModel) { + return; } + const viewModel = this.diffEditor.createViewModel({ + original: originalTextModel, + modified: modifiedTextModel + }); - if (data.range) { - this.editor.setSelection(data.range); - this.editor.revealRangeInCenter(data.range, ScrollType.Immediate); + await viewModel.waitForDiff(); + + if (token.isCancellationRequested) { + return; } + this.diffEditor.setModel(viewModel); + this._lastDiffEditorViewModel.value = viewModel; + this.toolbar.context = { - code: model.getTextBuffer().getValueInRange(data.range ?? model.getFullModelRange(), EndOfLinePreference.TextDefined), - codeBlockIndex: data.codeBlockIndex, + uri: originalTextModel.uri, + edits: data.edits, element: data.element, - languageId: model.getLanguageId() - } satisfies ICodeBlockActionContext; - } -} - - -export class ChatCodeBlockContentProvider extends Disposable implements ITextModelContentProvider { - - constructor( - @ITextModelService textModelService: ITextModelService, - @IModelService private readonly _modelService: IModelService, - ) { - super(); - this._register(textModelService.registerTextModelContentProvider(Schemas.vscodeChatCodeBlock, this)); - } - - async provideTextContent(resource: URI): Promise { - const existing = this._modelService.getModel(resource); - if (existing) { - return existing; - } - return this._modelService.createModel('', null, resource); + } satisfies ICodeCompareBlockActionContext; } } diff --git a/src/vs/workbench/contrib/chat/browser/contrib/chatDynamicVariables.ts b/src/vs/workbench/contrib/chat/browser/contrib/chatDynamicVariables.ts index 7b6bb6c1c2b6b..43864b83521b7 100644 --- a/src/vs/workbench/contrib/chat/browser/contrib/chatDynamicVariables.ts +++ b/src/vs/workbench/contrib/chat/browser/contrib/chatDynamicVariables.ts @@ -11,14 +11,16 @@ import { URI } from 'vs/base/common/uri'; import { IRange, Range } from 'vs/editor/common/core/range'; import { IDecorationOptions } from 'vs/editor/common/editorCommon'; import { ITextModelService } from 'vs/editor/common/services/resolverService'; +import { localize } from 'vs/nls'; import { Action2, registerAction2 } from 'vs/platform/actions/common/actions'; import { ServicesAccessor } from 'vs/platform/instantiation/common/instantiation'; import { ILabelService } from 'vs/platform/label/common/label'; import { ILogService } from 'vs/platform/log/common/log'; +import { AnythingQuickAccessProviderRunOptions, IQuickAccessOptions } from 'vs/platform/quickinput/common/quickAccess'; import { IQuickInputService } from 'vs/platform/quickinput/common/quickInput'; import { IChatWidget } from 'vs/workbench/contrib/chat/browser/chat'; import { ChatWidget, IChatWidgetContrib } from 'vs/workbench/contrib/chat/browser/chatWidget'; -import { IChatRequestVariableValue, IDynamicVariable } from 'vs/workbench/contrib/chat/common/chatVariables'; +import { IChatRequestVariableValue, IChatVariablesService, IDynamicVariable } from 'vs/workbench/contrib/chat/common/chatVariables'; export const dynamicVariableDecorationType = 'chat-dynamic-variable'; @@ -135,6 +137,8 @@ export class SelectAndInsertFileAction extends Action2 { async run(accessor: ServicesAccessor, ...args: any[]) { const textModelService = accessor.get(ITextModelService); const logService = accessor.get(ILogService); + const quickInputService = accessor.get(IQuickInputService); + const chatVariablesService = accessor.get(IChatVariablesService); const context = args[0]; if (!isSelectAndInsertFileActionContext(context)) { @@ -146,14 +150,45 @@ export class SelectAndInsertFileAction extends Action2 { context.widget.inputEditor.executeEdits('chatInsertFile', [{ range: context.range, text: `` }]); }; - const quickInputService = accessor.get(IQuickInputService); - const picks = await quickInputService.quickAccess.pick(''); + let options: IQuickAccessOptions | undefined; + const filesVariableName = 'files'; + const filesItem = { + label: localize('allFiles', 'All Files'), + description: localize('allFilesDescription', 'Search for relevant files in the workspace and provide context from them'), + }; + // If we have a `files` variable, add an option to select all files in the picker. + // This of course assumes that the `files` variable has the behavior that it searches + // through files in the workspace. + if (chatVariablesService.hasVariable(filesVariableName)) { + options = { + providerOptions: { + additionPicks: [filesItem, { type: 'separator' }] + }, + }; + } + // TODO: have dedicated UX for this instead of using the quick access picker + const picks = await quickInputService.quickAccess.pick('', options); if (!picks?.length) { logService.trace('SelectAndInsertFileAction: no file selected'); doCleanup(); return; } + const editor = context.widget.inputEditor; + const range = context.range; + + // Handle the special case of selecting all files + if (picks[0] === filesItem) { + const text = `#${filesVariableName}`; + const success = editor.executeEdits('chatInsertFile', [{ range, text: text + ' ' }]); + if (!success) { + logService.trace(`SelectAndInsertFileAction: failed to insert "${text}"`); + doCleanup(); + } + return; + } + + // Handle the case of selecting a specific file const resource = (picks[0] as unknown as { resource: unknown }).resource as URI; if (!textModelService.canHandleResource(resource)) { logService.trace('SelectAndInsertFileAction: non-text resource selected'); @@ -162,9 +197,7 @@ export class SelectAndInsertFileAction extends Action2 { } const fileName = basename(resource); - const editor = context.widget.inputEditor; const text = `#file:${fileName}`; - const range = context.range; const success = editor.executeEdits('chatInsertFile', [{ range, text: text + ' ' }]); if (!success) { logService.trace(`SelectAndInsertFileAction: failed to insert "${text}"`); diff --git a/src/vs/workbench/contrib/chat/browser/contrib/chatHistoryVariables.ts b/src/vs/workbench/contrib/chat/browser/contrib/chatHistoryVariables.ts index 5aa6815b69843..5df9a406f5be2 100644 --- a/src/vs/workbench/contrib/chat/browser/contrib/chatHistoryVariables.ts +++ b/src/vs/workbench/contrib/chat/browser/contrib/chatHistoryVariables.ts @@ -15,7 +15,7 @@ class ChatHistoryVariables extends Disposable { ) { super(); - this._register(chatVariablesService.registerVariable({ name: 'response', description: '', canTakeArgument: true, hidden: true }, async (message, arg, model, token) => { + this._register(chatVariablesService.registerVariable({ name: 'response', description: '', canTakeArgument: true, hidden: true }, async (message, arg, model, progress, token) => { if (!arg) { return undefined; } diff --git a/src/vs/workbench/contrib/chat/browser/contrib/chatInputEditorContrib.ts b/src/vs/workbench/contrib/chat/browser/contrib/chatInputEditorContrib.ts index beeb2bdeeab90..45bdb56cecae9 100644 --- a/src/vs/workbench/contrib/chat/browser/contrib/chatInputEditorContrib.ts +++ b/src/vs/workbench/contrib/chat/browser/contrib/chatInputEditorContrib.ts @@ -3,8 +3,8 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { raceCancellation } from 'vs/base/common/async'; import { CancellationToken } from 'vs/base/common/cancellation'; +import { MarkdownString } from 'vs/base/common/htmlContent'; import { Disposable, MutableDisposable } from 'vs/base/common/lifecycle'; import { ICodeEditorService } from 'vs/editor/browser/services/codeEditorService'; import { Position } from 'vs/editor/common/core/position'; @@ -15,7 +15,8 @@ import { CompletionContext, CompletionItem, CompletionItemKind, CompletionList } import { ITextModel } from 'vs/editor/common/model'; import { ILanguageFeaturesService } from 'vs/editor/common/services/languageFeatures'; import { localize } from 'vs/nls'; -import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; +import { Action2, registerAction2 } from 'vs/platform/actions/common/actions'; +import { IInstantiationService, ServicesAccessor } from 'vs/platform/instantiation/common/instantiation'; import { Registry } from 'vs/platform/registry/common/platform'; import { inputPlaceholderForeground } from 'vs/platform/theme/common/colorRegistry'; import { IThemeService } from 'vs/platform/theme/common/themeService'; @@ -25,11 +26,10 @@ import { IChatWidget, IChatWidgetService } from 'vs/workbench/contrib/chat/brows import { ChatInputPart } from 'vs/workbench/contrib/chat/browser/chatInputPart'; import { ChatWidget } from 'vs/workbench/contrib/chat/browser/chatWidget'; import { SelectAndInsertFileAction, dynamicVariableDecorationType } from 'vs/workbench/contrib/chat/browser/contrib/chatDynamicVariables'; -import { IChatAgentCommand, IChatAgentData, IChatAgentService } from 'vs/workbench/contrib/chat/common/chatAgents'; +import { ChatAgentLocation, IChatAgentCommand, IChatAgentData, IChatAgentService } from 'vs/workbench/contrib/chat/common/chatAgents'; import { chatSlashCommandBackground, chatSlashCommandForeground } from 'vs/workbench/contrib/chat/common/chatColors'; import { ChatRequestAgentPart, ChatRequestAgentSubcommandPart, ChatRequestSlashCommandPart, ChatRequestTextPart, ChatRequestVariablePart, IParsedChatRequestPart, chatAgentLeader, chatSubcommandLeader, chatVariableLeader } from 'vs/workbench/contrib/chat/common/chatParserTypes'; import { ChatRequestParser } from 'vs/workbench/contrib/chat/common/chatRequestParser'; -import { IChatService } from 'vs/workbench/contrib/chat/common/chatService'; import { IChatSlashCommandService } from 'vs/workbench/contrib/chat/common/chatSlashCommands'; import { IChatVariablesService } from 'vs/workbench/contrib/chat/common/chatVariables'; import { LifecyclePhase } from 'vs/workbench/services/lifecycle/common/lifecycle'; @@ -39,8 +39,8 @@ const placeholderDecorationType = 'chat-session-detail'; const slashCommandTextDecorationType = 'chat-session-text'; const variableTextDecorationType = 'chat-variable-text'; -function agentAndCommandToKey(agent: string, subcommand: string): string { - return `${agent}__${subcommand}`; +function agentAndCommandToKey(agent: IChatAgentData, subcommand: string | undefined): string { + return subcommand ? `${agent.id}__${subcommand}` : agent.id; } class InputEditorDecorations extends Disposable { @@ -55,7 +55,6 @@ class InputEditorDecorations extends Disposable { private readonly widget: IChatWidget, @ICodeEditorService private readonly codeEditorService: ICodeEditorService, @IThemeService private readonly themeService: IThemeService, - @IChatService private readonly chatService: IChatService, @IChatAgentService private readonly chatAgentService: IChatAgentService, ) { super(); @@ -67,15 +66,14 @@ class InputEditorDecorations extends Disposable { this.updateInputEditorDecorations(); this._register(this.widget.inputEditor.onDidChangeModelContent(() => this.updateInputEditorDecorations())); + this._register(this.widget.onDidChangeParsedInput(() => this.updateInputEditorDecorations())); this._register(this.widget.onDidChangeViewModel(() => { this.registerViewModelListeners(); this.previouslyUsedAgents.clear(); this.updateInputEditorDecorations(); })); - this._register(this.chatService.onDidSubmitAgent((e) => { - if (e.sessionId === this.widget.viewModel?.sessionId) { - this.previouslyUsedAgents.add(agentAndCommandToKey(e.agent.id, e.slashCommand.name)); - } + this._register(this.widget.onDidSubmitAgent((e) => { + this.previouslyUsedAgents.add(agentAndCommandToKey(e.agent, e.slashCommand?.name)); })); this._register(this.chatAgentService.onDidChangeAgents(() => this.updateInputEditorDecorations())); @@ -129,8 +127,7 @@ class InputEditorDecorations extends Disposable { } if (!inputValue) { - const viewModelPlaceholder = this.widget.viewModel?.inputPlaceholder; - const placeholder = viewModelPlaceholder ?? ''; + const defaultAgent = this.chatAgentService.getDefaultAgent(this.widget.location); const decoration: IDecorationOptions[] = [ { range: { @@ -141,7 +138,7 @@ class InputEditorDecorations extends Disposable { }, renderOptions: { after: { - contentText: placeholder, + contentText: viewModel.inputPlaceholder ?? defaultAgent?.description ?? '', color: this.getPlaceholderColor() } } @@ -168,20 +165,24 @@ class InputEditorDecorations extends Disposable { return nextPart && nextPart instanceof ChatRequestTextPart && nextPart.text === ' '; }; + const getRangeForPlaceholder = (part: IParsedChatRequestPart) => ({ + startLineNumber: part.editorRange.startLineNumber, + endLineNumber: part.editorRange.endLineNumber, + startColumn: part.editorRange.endColumn + 1, + endColumn: 1000 + }); + const onlyAgentAndWhitespace = agentPart && parsedRequest.every(p => p instanceof ChatRequestTextPart && !p.text.trim().length || p instanceof ChatRequestAgentPart); if (onlyAgentAndWhitespace) { // Agent reference with no other text - show the placeholder - if (agentPart.agent.metadata.description && exactlyOneSpaceAfterPart(agentPart)) { + const isFollowupSlashCommand = this.previouslyUsedAgents.has(agentAndCommandToKey(agentPart.agent, undefined)); + const shouldRenderFollowupPlaceholder = isFollowupSlashCommand && agentPart.agent.metadata.followupPlaceholder; + if (agentPart.agent.description && exactlyOneSpaceAfterPart(agentPart)) { placeholderDecoration = [{ - range: { - startLineNumber: agentPart.editorRange.startLineNumber, - endLineNumber: agentPart.editorRange.endLineNumber, - startColumn: agentPart.editorRange.endColumn + 1, - endColumn: 1000 - }, + range: getRangeForPlaceholder(agentPart), renderOptions: { after: { - contentText: agentPart.agent.metadata.description, + contentText: shouldRenderFollowupPlaceholder ? agentPart.agent.metadata.followupPlaceholder : agentPart.agent.description, color: this.getPlaceholderColor(), } } @@ -192,16 +193,11 @@ class InputEditorDecorations extends Disposable { const onlyAgentCommandAndWhitespace = agentPart && agentSubcommandPart && parsedRequest.every(p => p instanceof ChatRequestTextPart && !p.text.trim().length || p instanceof ChatRequestAgentPart || p instanceof ChatRequestAgentSubcommandPart); if (onlyAgentCommandAndWhitespace) { // Agent reference and subcommand with no other text - show the placeholder - const isFollowupSlashCommand = this.previouslyUsedAgents.has(agentAndCommandToKey(agentPart.agent.id, agentSubcommandPart.command.name)); + const isFollowupSlashCommand = this.previouslyUsedAgents.has(agentAndCommandToKey(agentPart.agent, agentSubcommandPart.command.name)); const shouldRenderFollowupPlaceholder = isFollowupSlashCommand && agentSubcommandPart.command.followupPlaceholder; if (agentSubcommandPart?.command.description && exactlyOneSpaceAfterPart(agentSubcommandPart)) { placeholderDecoration = [{ - range: { - startLineNumber: agentSubcommandPart.editorRange.startLineNumber, - endLineNumber: agentSubcommandPart.editorRange.endLineNumber, - startColumn: agentSubcommandPart.editorRange.endColumn + 1, - endColumn: 1000 - }, + range: getRangeForPlaceholder(agentSubcommandPart), renderOptions: { after: { contentText: shouldRenderFollowupPlaceholder ? agentSubcommandPart.command.followupPlaceholder : agentSubcommandPart.command.description, @@ -212,34 +208,16 @@ class InputEditorDecorations extends Disposable { } } - const onlySlashCommandAndWhitespace = slashCommandPart && parsedRequest.every(p => p instanceof ChatRequestTextPart && !p.text.trim().length || p instanceof ChatRequestSlashCommandPart); - if (onlySlashCommandAndWhitespace) { - // Command reference with no other text - show the placeholder - if (slashCommandPart.slashCommand.detail && exactlyOneSpaceAfterPart(slashCommandPart)) { - placeholderDecoration = [{ - range: { - startLineNumber: slashCommandPart.editorRange.startLineNumber, - endLineNumber: slashCommandPart.editorRange.endLineNumber, - startColumn: slashCommandPart.editorRange.endColumn + 1, - endColumn: 1000 - }, - renderOptions: { - after: { - contentText: slashCommandPart.slashCommand.detail, - color: this.getPlaceholderColor(), - } - } - }]; - } - } - this.widget.inputEditor.setDecorationsByType(decorationDescription, placeholderDecorationType, placeholderDecoration ?? []); const textDecorations: IDecorationOptions[] | undefined = []; if (agentPart) { - textDecorations.push({ range: agentPart.editorRange }); + const isDupe = !!this.chatAgentService.getAgents().find(other => other.name === agentPart.agent.name && other.id !== agentPart.agent.id); + const id = isDupe ? `(${agentPart.agent.id}) ` : ''; + const agentHover = `${id}${agentPart.agent.description}`; + textDecorations.push({ range: agentPart.editorRange, hoverMessage: new MarkdownString(agentHover) }); if (agentSubcommandPart) { - textDecorations.push({ range: agentSubcommandPart.editorRange }); + textDecorations.push({ range: agentSubcommandPart.editorRange, hoverMessage: new MarkdownString(agentSubcommandPart.command.description) }); } } @@ -263,22 +241,23 @@ class InputEditorSlashCommandMode extends Disposable { public readonly id = 'InputEditorSlashCommandMode'; constructor( - private readonly widget: IChatWidget, - @IChatService private readonly chatService: IChatService + private readonly widget: IChatWidget ) { super(); - this._register(this.chatService.onDidSubmitAgent(e => { - if (this.widget.viewModel?.sessionId !== e.sessionId) { - return; - } - + this._register(this.widget.onDidSubmitAgent(e => { this.repopulateAgentCommand(e.agent, e.slashCommand); })); } - private async repopulateAgentCommand(agent: IChatAgentData, slashCommand: IChatAgentCommand) { - if (slashCommand.shouldRepopulate) { - const value = `${chatAgentLeader}${agent.id} ${chatSubcommandLeader}${slashCommand.name} `; + private async repopulateAgentCommand(agent: IChatAgentData, slashCommand: IChatAgentCommand | undefined) { + let value: string | undefined; + if (slashCommand && slashCommand.isSticky) { + value = `${chatAgentLeader}${agent.name} ${chatSubcommandLeader}${slashCommand.name} `; + } else if (agent.metadata.isSticky) { + value = `${chatAgentLeader}${agent.name} `; + } + + if (value) { this.widget.inputEditor.setValue(value); this.widget.inputEditor.setPosition({ lineNumber: 1, column: value.length + 1 }); } @@ -300,7 +279,7 @@ class SlashCommandCompletions extends Disposable { triggerCharacters: ['/'], provideCompletionItems: async (model: ITextModel, position: Position, _context: CompletionContext, _token: CancellationToken) => { const widget = this.chatWidgetService.getWidgetByInputUri(model.uri); - if (!widget || !widget.viewModel) { + if (!widget || !widget.viewModel || widget.location !== ChatAgentLocation.Panel /* TODO@jrieken - enable when agents are adopted*/) { return null; } @@ -355,7 +334,7 @@ class AgentCompletions extends Disposable { triggerCharacters: ['@'], provideCompletionItems: async (model: ITextModel, position: Position, _context: CompletionContext, _token: CancellationToken) => { const widget = this.chatWidgetService.getWidgetByInputUri(model.uri); - if (!widget || !widget.viewModel) { + if (!widget || !widget.viewModel || widget.location !== ChatAgentLocation.Panel /* TODO@jrieken - enable when agents are adopted*/) { return null; } @@ -372,15 +351,22 @@ class AgentCompletions extends Disposable { } const agents = this.chatAgentService.getAgents() - .filter(a => !a.metadata.isDefault); + .filter(a => !a.isDefault) + .filter(a => a.locations.includes(widget.location)); + return { - suggestions: agents.map((c, i) => { - const withAt = `@${c.id}`; + suggestions: agents.map((a, i) => { + const withAt = `@${a.name}`; + const isDupe = !!agents.find(other => other.name === a.name && other.id !== a.id); return { - label: withAt, + // Leading space is important because detail has no space at the start by design + label: isDupe ? + { label: withAt, description: a.description, detail: ` (${a.id})` } : + withAt, insertText: `${withAt} `, - detail: c.metadata.description, + detail: a.description, range: new Range(1, 1, 1, 1), + command: { id: AssignSelectedAgentAction.ID, title: AssignSelectedAgentAction.ID, arguments: [{ agent: a, widget } satisfies AssignSelectedAgentActionArgs] }, kind: CompletionItemKind.Text, // The icons are disabled here anyway }; }) @@ -393,7 +379,7 @@ class AgentCompletions extends Disposable { triggerCharacters: ['/'], provideCompletionItems: async (model: ITextModel, position: Position, _context: CompletionContext, token: CancellationToken) => { const widget = this.chatWidgetService.getWidgetByInputUri(model.uri); - if (!widget || !widget.viewModel) { + if (!widget || !widget.viewModel || widget.location !== ChatAgentLocation.Panel /* TODO@jrieken - enable when agents are adopted*/) { return; } @@ -423,10 +409,8 @@ class AgentCompletions extends Disposable { } const usedAgent = parsedRequest[usedAgentIdx] as ChatRequestAgentPart; - const commands = await usedAgent.agent.provideSlashCommands(token); // Refresh the cache here - return { - suggestions: commands.map((c, i) => { + suggestions: usedAgent.agent.slashCommands.map((c, i) => { const withSlash = `/${c.name}`; return { label: withSlash, @@ -446,7 +430,8 @@ class AgentCompletions extends Disposable { triggerCharacters: ['/'], provideCompletionItems: async (model: ITextModel, position: Position, _context: CompletionContext, token: CancellationToken) => { const widget = this.chatWidgetService.getWidgetByInputUri(model.uri); - if (!widget) { + const viewModel = widget?.viewModel; + if (!widget || !viewModel || widget.location !== ChatAgentLocation.Panel /* TODO@jrieken - enable when agents are adopted*/) { return; } @@ -455,42 +440,45 @@ class AgentCompletions extends Disposable { return null; } - const agents = this.chatAgentService.getAgents(); - const all = agents.map(agent => agent.provideSlashCommands(token)); - const commands = await raceCancellation(Promise.all(all), token); - - if (!commands) { - return; - } + const agents = this.chatAgentService.getAgents() + .filter(a => a.locations.includes(widget.location)); const justAgents: CompletionItem[] = agents - .filter(a => !a.metadata.isDefault) + .filter(a => !a.isDefault) .map(agent => { - const agentLabel = `${chatAgentLeader}${agent.id}`; + const isDupe = !!agents.find(other => other.name === agent.name && other.id !== agent.id); + const detail = agent.description; + const agentLabel = `${chatAgentLeader}${agent.name}`; + return { - label: { label: agentLabel, description: agent.metadata.description }, - filterText: `${chatSubcommandLeader}${agent.id}`, + label: isDupe ? + { label: agentLabel, description: agent.description, detail: ` (${agent.id})` } : + agentLabel, + detail, + filterText: `${chatSubcommandLeader}${agent.name}`, insertText: `${agentLabel} `, range: new Range(1, 1, 1, 1), kind: CompletionItemKind.Text, sortText: `${chatSubcommandLeader}${agent.id}`, + command: { id: AssignSelectedAgentAction.ID, title: AssignSelectedAgentAction.ID, arguments: [{ agent, widget } satisfies AssignSelectedAgentActionArgs] }, }; }); return { suggestions: justAgents.concat( - agents.flatMap((agent, i) => commands[i].map((c, i) => { - const agentLabel = `${chatAgentLeader}${agent.id}`; + agents.flatMap(agent => agent.slashCommands.map((c, i) => { + const agentLabel = `${chatAgentLeader}${agent.name}`; const withSlash = `${chatSubcommandLeader}${c.name}`; return { label: { label: withSlash, description: agentLabel }, - filterText: `${chatSubcommandLeader}${agent.id}${c.name}`, + filterText: `${chatSubcommandLeader}${agent.name}${c.name}`, commitCharacters: [' '], insertText: `${agentLabel} ${withSlash} `, - detail: `(${agentLabel}) ${c.description}`, + detail: `(${agentLabel}) ${c.description ?? ''}`, range: new Range(1, 1, 1, 1), kind: CompletionItemKind.Text, // The icons are disabled here anyway sortText: `${chatSubcommandLeader}${agent.id}${c.name}`, + command: { id: AssignSelectedAgentAction.ID, title: AssignSelectedAgentAction.ID, arguments: [{ agent, widget } satisfies AssignSelectedAgentActionArgs] }, } satisfies CompletionItem; }))) }; @@ -500,6 +488,32 @@ class AgentCompletions extends Disposable { } Registry.as(WorkbenchExtensions.Workbench).registerWorkbenchContribution(AgentCompletions, LifecyclePhase.Eventually); +interface AssignSelectedAgentActionArgs { + agent: IChatAgentData; + widget: IChatWidget; +} + +class AssignSelectedAgentAction extends Action2 { + static readonly ID = 'workbench.action.chat.assignSelectedAgent'; + + constructor() { + super({ + id: AssignSelectedAgentAction.ID, + title: '' // not displayed + }); + } + + async run(accessor: ServicesAccessor, ...args: any[]) { + const arg: AssignSelectedAgentActionArgs = args[0]; + if (!arg || !arg.widget || !arg.agent) { + return; + } + + arg.widget.lastSelectedAgent = arg.agent; + } +} +registerAction2(AssignSelectedAgentAction); + class BuiltinDynamicCompletions extends Disposable { private static readonly VariableNameDef = new RegExp(`${chatVariableLeader}\\w*`, 'g'); // MUST be using `g`-flag @@ -514,7 +528,7 @@ class BuiltinDynamicCompletions extends Disposable { triggerCharacters: [chatVariableLeader], provideCompletionItems: async (model: ITextModel, position: Position, _context: CompletionContext, _token: CancellationToken) => { const widget = this.chatWidgetService.getWidgetByInputUri(model.uri); - if (!widget || !widget.supportsFileReferences) { + if (!widget || !widget.supportsFileReferences || widget.location !== ChatAgentLocation.Panel /* TODO@jrieken - enable when agents are adopted*/) { return null; } @@ -580,7 +594,7 @@ class VariableCompletions extends Disposable { provideCompletionItems: async (model: ITextModel, position: Position, _context: CompletionContext, _token: CancellationToken) => { const widget = this.chatWidgetService.getWidgetByInputUri(model.uri); - if (!widget) { + if (!widget || widget.location !== ChatAgentLocation.Panel /* TODO@jrieken - enable when agents are adopted*/) { return null; } @@ -627,12 +641,14 @@ class ChatTokenDeleter extends Disposable { const parser = this.instantiationService.createInstance(ChatRequestParser); const inputValue = this.widget.inputEditor.getValue(); let previousInputValue: string | undefined; + let previousSelectedAgent: IChatAgentData | undefined; // A simple heuristic to delete the previous token when the user presses backspace. // The sophisticated way to do this would be to have a parse tree that can be updated incrementally. - this.widget.inputEditor.onDidChangeModelContent(e => { + this._register(this.widget.inputEditor.onDidChangeModelContent(e => { if (!previousInputValue) { previousInputValue = inputValue; + previousSelectedAgent = this.widget.lastSelectedAgent; } // Don't try to handle multicursor edits right now @@ -640,14 +656,14 @@ class ChatTokenDeleter extends Disposable { // If this was a simple delete, try to find out whether it was inside a token if (!change.text && this.widget.viewModel) { - const previousParsedValue = parser.parseChatRequest(this.widget.viewModel.sessionId, previousInputValue); + const previousParsedValue = parser.parseChatRequest(this.widget.viewModel.sessionId, previousInputValue, ChatAgentLocation.Panel, { selectedAgent: previousSelectedAgent }); // For dynamic variables, this has to happen in ChatDynamicVariableModel with the other bookkeeping const deletableTokens = previousParsedValue.parts.filter(p => p instanceof ChatRequestAgentPart || p instanceof ChatRequestAgentSubcommandPart || p instanceof ChatRequestSlashCommandPart || p instanceof ChatRequestVariablePart); deletableTokens.forEach(token => { const deletedRangeOfToken = Range.intersectRanges(token.editorRange, change.range); - // Part of this token was deleted, and the deletion range doesn't go off the front of the token, for simpler math - if ((deletedRangeOfToken && !deletedRangeOfToken.isEmpty()) && Range.compareRangesUsingStarts(token.editorRange, change.range) < 0) { + // Part of this token was deleted, or the space after it was deleted, and the deletion range doesn't go off the front of the token, for simpler math + if (deletedRangeOfToken && Range.compareRangesUsingStarts(token.editorRange, change.range) < 0) { // Assume single line tokens const length = deletedRangeOfToken.endColumn - deletedRangeOfToken.startColumn; const rangeToDelete = new Range(token.editorRange.startLineNumber, token.editorRange.startColumn, token.editorRange.endLineNumber, token.editorRange.endColumn - length); @@ -660,7 +676,8 @@ class ChatTokenDeleter extends Disposable { } previousInputValue = this.widget.inputEditor.getValue(); - }); + previousSelectedAgent = this.widget.lastSelectedAgent; + })); } } ChatWidget.CONTRIBS.push(ChatTokenDeleter); diff --git a/src/vs/workbench/contrib/chat/browser/media/chat.css b/src/vs/workbench/contrib/chat/browser/media/chat.css index fdbd39a596129..4360f8f1cc224 100644 --- a/src/vs/workbench/contrib/chat/browser/media/chat.css +++ b/src/vs/workbench/contrib/chat/browser/media/chat.css @@ -166,6 +166,11 @@ background-color: var(--vscode-chat-list-background); } +.interactive-item-container.interactive-request .header .monaco-toolbar { + /* Take the partially-transparent background color override for request rows */ + background-color: inherit; +} + .interactive-item-container .header .monaco-toolbar .checked.action-label, .interactive-item-container .header .monaco-toolbar .checked.action-label:hover { color: var(--vscode-inputOption-activeForeground) !important; @@ -207,13 +212,6 @@ color: var(--vscode-textPreformat-foreground); } -.hc-black .interactive-item-container .value .rendered-markdown a:hover, -.hc-black .interactive-item-container .value .rendered-markdown a:active, -.hc-light .interactive-item-container .value .rendered-markdown a:hover, -.hc-light .interactive-item-container .value .rendered-markdown a:active { - color: var(--vscode-textPreformat-foreground); -} - .interactive-list { overflow: hidden; } @@ -221,6 +219,13 @@ .interactive-request { border-bottom: 1px solid var(--vscode-chat-requestBorder); border-top: 1px solid var(--vscode-chat-requestBorder); + background-color: var(--vscode-chat-requestBackground); +} + +.hc-black .interactive-request, +.hc-light .interactive-request { + border-left: 3px solid var(--vscode-chat-requestBorder); + border-right: 3px solid var(--vscode-chat-requestBorder); } .interactive-item-container .value { @@ -338,17 +343,20 @@ display: flex; box-sizing: border-box; cursor: text; - margin: 0px 20px; background-color: var(--vscode-input-background); border: 1px solid var(--vscode-input-border, transparent); border-radius: 4px; position: relative; padding: 0 6px; margin-bottom: 4px; - align-items: center; + align-items: flex-end; justify-content: space-between; } +.interactive-session .interactive-input-part.compact .interactive-input-and-execute-toolbar { + margin-bottom: 0; +} + .interactive-session .interactive-input-and-side-toolbar { display: flex; gap: 4px; @@ -359,6 +367,10 @@ border-color: var(--vscode-focusBorder); } +.interactive-session .interactive-input-and-execute-toolbar .monaco-editor .mtk1 { + color: var(--vscode-input-foreground); +} + .interactive-session .interactive-input-and-execute-toolbar .monaco-editor, .interactive-session .interactive-input-and-execute-toolbar .monaco-editor .monaco-editor-background { background-color: var(--vscode-input-background) !important; @@ -370,6 +382,14 @@ .interactive-session .interactive-input-part .interactive-execute-toolbar { height: 22px; + + /* It's bottom-aligned, make it appear centered within the container */ + margin-bottom: 7px; +} + +.interactive-session .interactive-input-part .interactive-execute-toolbar .monaco-action-bar .actions-container { + display: flex; + gap: 4px; } .interactive-session .interactive-input-part .interactive-execute-toolbar .codicon-debug-stop { @@ -413,11 +433,24 @@ } .interactive-session .interactive-input-part { + margin: 0px 20px; padding: 12px 0px; display: flex; flex-direction: column; } +.interactive-session .interactive-input-part.compact { + margin: 0; + padding: 6px 0px; +} + +.interactive-session .chat-implicit-context { + padding: 8px 8px 13px; + margin-bottom: -5px; + border: 1px solid var(--vscode-input-border, var(--vscode-input-background, transparent)); + border-radius: 6px 6px 0px 0px; +} + .interactive-session-followups { display: flex; flex-direction: column; @@ -439,10 +472,6 @@ padding: 4px 8px; } -.interactive-session .interactive-input-part .interactive-input-followups { - margin: 0px 20px; -} - .interactive-session .interactive-input-part .interactive-input-followups .interactive-session-followups { margin-bottom: 8px; } @@ -486,10 +515,11 @@ .quick-input-widget .interactive-session .interactive-input-part { padding: 8px 6px 6px 6px; + margin: 0 3px; } .quick-input-widget .interactive-session .interactive-input-part .interactive-execute-toolbar { - bottom: 1px; + margin-bottom: 1px; } .quick-input-widget .interactive-session .interactive-input-and-execute-toolbar { @@ -633,3 +663,19 @@ font-size: 14px; color: var(--vscode-debugIcon-startForeground) !important; } + +.interactive-item-container .chat-command-button { + display: flex; + margin-bottom: 16px; +} + +.interactive-item-container .chat-command-button .monaco-button { + text-align: left; + width: initial; + padding: 4px 8px; +} + +.interactive-item-container .chat-command-button .monaco-button .codicon { + margin-left: 0; + margin-top: 1px; +} diff --git a/src/vs/workbench/contrib/chat/common/annotations.ts b/src/vs/workbench/contrib/chat/common/annotations.ts new file mode 100644 index 0000000000000..f55eb1ffd9a56 --- /dev/null +++ b/src/vs/workbench/contrib/chat/common/annotations.ts @@ -0,0 +1,104 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +import { MarkdownString } from 'vs/base/common/htmlContent'; +import { basename } from 'vs/base/common/resources'; +import { URI } from 'vs/base/common/uri'; +import { IRange } from 'vs/editor/common/core/range'; +import { IChatProgressRenderableResponseContent, IChatProgressResponseContent } from 'vs/workbench/contrib/chat/common/chatModel'; +import { IChatAgentMarkdownContentWithVulnerability, IChatAgentVulnerabilityDetails, IChatContentInlineReference, IChatMarkdownContent } from 'vs/workbench/contrib/chat/common/chatService'; + +export const contentRefUrl = 'http://_vscodecontentref_'; // must be lowercase for URI + +export function annotateSpecialMarkdownContent(response: ReadonlyArray): ReadonlyArray { + const result: Exclude[] = []; + for (const item of response) { + const previousItem = result[result.length - 1]; + if (item.kind === 'inlineReference') { + const location = 'uri' in item.inlineReference ? item.inlineReference : { uri: item.inlineReference }; + const printUri = URI.parse(contentRefUrl).with({ fragment: JSON.stringify(location) }); + const markdownText = `[${item.name || basename(location.uri)}](${printUri.toString()})`; + if (previousItem?.kind === 'markdownContent') { + result[result.length - 1] = { content: new MarkdownString(previousItem.content.value + markdownText, { isTrusted: previousItem.content.isTrusted }), kind: 'markdownContent' }; + } else { + result.push({ content: new MarkdownString(markdownText), kind: 'markdownContent' }); + } + } else if (item.kind === 'markdownContent' && previousItem?.kind === 'markdownContent') { + result[result.length - 1] = { content: new MarkdownString(previousItem.content.value + item.content.value, { isTrusted: previousItem.content.isTrusted }), kind: 'markdownContent' }; + } else if (item.kind === 'markdownVuln') { + const vulnText = encodeURIComponent(JSON.stringify(item.vulnerabilities)); + const markdownText = `${item.content.value}`; + if (previousItem?.kind === 'markdownContent') { + result[result.length - 1] = { content: new MarkdownString(previousItem.content.value + markdownText, { isTrusted: previousItem.content.isTrusted }), kind: 'markdownContent' }; + } else { + result.push({ content: new MarkdownString(markdownText), kind: 'markdownContent' }); + } + } else { + result.push(item); + } + } + + return result; +} + +export interface IMarkdownVulnerability { + readonly title: string; + readonly description: string; + readonly range: IRange; +} + +export function annotateVulnerabilitiesInText(response: ReadonlyArray): readonly IChatMarkdownContent[] { + const result: IChatMarkdownContent[] = []; + for (const item of response) { + const previousItem = result[result.length - 1]; + if (item.kind === 'markdownContent') { + if (previousItem?.kind === 'markdownContent') { + result[result.length - 1] = { content: new MarkdownString(previousItem.content.value + item.content.value, { isTrusted: previousItem.content.isTrusted }), kind: 'markdownContent' }; + } else { + result.push(item); + } + } else if (item.kind === 'markdownVuln') { + const vulnText = encodeURIComponent(JSON.stringify(item.vulnerabilities)); + const markdownText = `${item.content.value}`; + if (previousItem?.kind === 'markdownContent') { + result[result.length - 1] = { content: new MarkdownString(previousItem.content.value + markdownText, { isTrusted: previousItem.content.isTrusted }), kind: 'markdownContent' }; + } else { + result.push({ content: new MarkdownString(markdownText), kind: 'markdownContent' }); + } + } + } + + return result; +} + +export function extractVulnerabilitiesFromText(text: string): { newText: string; vulnerabilities: IMarkdownVulnerability[] } { + const vulnerabilities: IMarkdownVulnerability[] = []; + let newText = text; + let match: RegExpExecArray | null; + while ((match = /(.*?)<\/vscode_annotation>/ms.exec(newText)) !== null) { + const [full, details, content] = match; + const start = match.index; + const textBefore = newText.substring(0, start); + const linesBefore = textBefore.split('\n').length - 1; + const linesInside = content.split('\n').length - 1; + + const previousNewlineIdx = textBefore.lastIndexOf('\n'); + const startColumn = start - (previousNewlineIdx + 1) + 1; + const endPreviousNewlineIdx = (textBefore + content).lastIndexOf('\n'); + const endColumn = start + content.length - (endPreviousNewlineIdx + 1) + 1; + + try { + const vulnDetails: IChatAgentVulnerabilityDetails[] = JSON.parse(decodeURIComponent(details)); + vulnDetails.forEach(({ title, description }) => vulnerabilities.push({ + title, description, range: { startLineNumber: linesBefore + 1, startColumn, endLineNumber: linesBefore + linesInside + 1, endColumn } + })); + } catch (err) { + // Something went wrong with encoding this text, just ignore it + } + newText = newText.substring(0, start) + content + newText.substring(start + full.length); + } + + return { newText, vulnerabilities }; +} + diff --git a/src/vs/workbench/contrib/chat/common/chatAgents.ts b/src/vs/workbench/contrib/chat/common/chatAgents.ts index 6b71960d11992..2aa2893de4932 100644 --- a/src/vs/workbench/contrib/chat/common/chatAgents.ts +++ b/src/vs/workbench/contrib/chat/common/chatAgents.ts @@ -7,14 +7,16 @@ import { CancellationToken } from 'vs/base/common/cancellation'; import { Emitter, Event } from 'vs/base/common/event'; import { IMarkdownString } from 'vs/base/common/htmlContent'; import { Iterable } from 'vs/base/common/iterator'; -import { Disposable, IDisposable, toDisposable } from 'vs/base/common/lifecycle'; +import { IDisposable, toDisposable } from 'vs/base/common/lifecycle'; import { ThemeIcon } from 'vs/base/common/themables'; import { URI } from 'vs/base/common/uri'; import { ProviderResult } from 'vs/editor/common/languages'; +import { ContextKeyExpr, IContextKeyService } from 'vs/platform/contextkey/common/contextkey'; +import { ExtensionIdentifier } from 'vs/platform/extensions/common/extensions'; import { createDecorator } from 'vs/platform/instantiation/common/instantiation'; -import { IChatProgressResponseContent } from 'vs/workbench/contrib/chat/common/chatModel'; -import { IChatFollowup, IChatProgress, IChatReplyFollowup, IChatResponseErrorDetails } from 'vs/workbench/contrib/chat/common/chatService'; -import { IChatRequestVariableValue } from 'vs/workbench/contrib/chat/common/chatVariables'; +import { IRawChatCommandContribution, RawChatParticipantLocation } from 'vs/workbench/contrib/chat/common/chatContributionService'; +import { IChatProgressResponseContent, IChatRequestVariableData } from 'vs/workbench/contrib/chat/common/chatModel'; +import { IChatFollowup, IChatProgress, IChatResponseErrorDetails } from 'vs/workbench/contrib/chat/common/chatService'; //#region agent service, commands etc @@ -24,51 +26,62 @@ export interface IChatAgentHistoryEntry { result: IChatAgentResult; } +export enum ChatAgentLocation { + Panel = 'panel', + Terminal = 'terminal', + Notebook = 'notebook', + Editor = 'editor' +} + +export namespace ChatAgentLocation { + export function fromRaw(value: RawChatParticipantLocation | string): ChatAgentLocation { + switch (value) { + case 'panel': return ChatAgentLocation.Panel; + case 'terminal': return ChatAgentLocation.Terminal; + case 'notebook': return ChatAgentLocation.Notebook; + } + return ChatAgentLocation.Panel; + } +} + export interface IChatAgentData { id: string; + name: string; + description?: string; + extensionId: ExtensionIdentifier; + /** The agent invoked when no agent is specified */ + isDefault?: boolean; metadata: IChatAgentMetadata; + slashCommands: IChatAgentCommand[]; + defaultImplicitVariables?: string[]; + locations: ChatAgentLocation[]; } -export interface IChatAgent extends IChatAgentData { +export interface IChatAgentImplementation { invoke(request: IChatAgentRequest, progress: (part: IChatProgress) => void, history: IChatAgentHistoryEntry[], token: CancellationToken): Promise; - provideFollowups?(sessionId: string, token: CancellationToken): Promise; - lastSlashCommands?: IChatAgentCommand[]; - provideSlashCommands(token: CancellationToken): Promise; + provideFollowups?(request: IChatAgentRequest, result: IChatAgentResult, history: IChatAgentHistoryEntry[], token: CancellationToken): Promise; provideWelcomeMessage?(token: CancellationToken): ProviderResult<(string | IMarkdownString)[] | undefined>; - provideSampleQuestions?(token: CancellationToken): ProviderResult; + provideSampleQuestions?(token: CancellationToken): ProviderResult; } -export interface IChatAgentCommand { - name: string; - description: string; +export type IChatAgent = IChatAgentData & IChatAgentImplementation; - /** - * Whether the command should execute as soon - * as it is entered. Defaults to `false`. - */ - executeImmediately?: boolean; +export interface IChatAgentCommand extends IRawChatCommandContribution { + followupPlaceholder?: string; +} - /** - * Whether executing the command puts the - * chat into a persistent mode, where the - * slash command is prepended to the chat input. - */ - shouldRepopulate?: boolean; +export interface IChatRequesterInformation { + name: string; /** - * Placeholder text to render in the chat input - * when the slash command has been repopulated. - * Has no effect if `shouldRepopulate` is `false`. + * A full URI for the icon of the requester. */ - followupPlaceholder?: string; - - sampleRequest?: string; + icon?: URI; } export interface IChatAgentMetadata { - description?: string; - isDefault?: boolean; // The agent invoked when no agent is specified helpTextPrefix?: string | IMarkdownString; + helpTextVariablesPrefix?: string | IMarkdownString; helpTextPostfix?: string | IMarkdownString; isSecondary?: boolean; // Invoked by ctrl/cmd+enter fullName?: string; @@ -77,123 +90,233 @@ export interface IChatAgentMetadata { themeIcon?: ThemeIcon; sampleRequest?: string; supportIssueReporting?: boolean; + followupPlaceholder?: string; + isSticky?: boolean; + requester?: IChatRequesterInformation; } + export interface IChatAgentRequest { sessionId: string; requestId: string; agentId: string; command?: string; message: string; - variables: Record; + variables: IChatRequestVariableData; + location: ChatAgentLocation; } export interface IChatAgentResult { - // delete, keep while people are still using the previous API - followUp?: IChatFollowup[]; errorDetails?: IChatResponseErrorDetails; timings?: { firstProgress?: number; totalElapsed: number; }; + /** Extra properties that the agent can use to identify a result */ + readonly metadata?: { readonly [key: string]: any }; } export const IChatAgentService = createDecorator('chatAgentService'); +interface IChatAgentEntry { + data: IChatAgentData; + impl?: IChatAgentImplementation; +} + export interface IChatAgentService { _serviceBrand: undefined; - readonly onDidChangeAgents: Event; - registerAgent(agent: IChatAgent): IDisposable; - invokeAgent(id: string, request: IChatAgentRequest, progress: (part: IChatProgress) => void, history: IChatAgentHistoryEntry[], token: CancellationToken): Promise; - getFollowups(id: string, sessionId: string, token: CancellationToken): Promise; - getAgents(): Array; - getAgent(id: string): IChatAgent | undefined; - getDefaultAgent(): IChatAgent | undefined; - getSecondaryAgent(): IChatAgent | undefined; - hasAgent(id: string): boolean; + /** + * undefined when an agent was removed IChatAgent + */ + readonly onDidChangeAgents: Event; + registerAgent(id: string, data: IChatAgentData): IDisposable; + registerAgentImplementation(id: string, agent: IChatAgentImplementation): IDisposable; + registerDynamicAgent(data: IChatAgentData, agentImpl: IChatAgentImplementation): IDisposable; + invokeAgent(agent: string, request: IChatAgentRequest, progress: (part: IChatProgress) => void, history: IChatAgentHistoryEntry[], token: CancellationToken): Promise; + getFollowups(id: string, request: IChatAgentRequest, result: IChatAgentResult, history: IChatAgentHistoryEntry[], token: CancellationToken): Promise; + getAgent(id: string): IChatAgentData | undefined; + getAgents(): IChatAgentData[]; + getActivatedAgents(): Array; + getAgentsByName(name: string): IChatAgentData[]; + getDefaultAgent(location: ChatAgentLocation): IChatAgent | undefined; + getSecondaryAgent(): IChatAgentData | undefined; updateAgent(id: string, updateMetadata: IChatAgentMetadata): void; } -export class ChatAgentService extends Disposable implements IChatAgentService { +export class ChatAgentService implements IChatAgentService { public static readonly AGENT_LEADER = '@'; declare _serviceBrand: undefined; - private readonly _agents = new Map(); + private _agents: IChatAgentEntry[] = []; + + private readonly _onDidChangeAgents = new Emitter(); + readonly onDidChangeAgents: Event = this._onDidChangeAgents.event; - private readonly _onDidChangeAgents = this._register(new Emitter()); - readonly onDidChangeAgents: Event = this._onDidChangeAgents.event; + constructor( + @IContextKeyService private readonly contextKeyService: IContextKeyService + ) { } - override dispose(): void { - super.dispose(); - this._agents.clear(); + registerAgent(id: string, data: IChatAgentData): IDisposable { + const existingAgent = this.getAgent(id); + if (existingAgent) { + throw new Error(`Agent already registered: ${JSON.stringify(id)}`); + } + + const that = this; + const commands = data.slashCommands; + data = { + ...data, + get slashCommands() { + return commands.filter(c => !c.when || that.contextKeyService.contextMatchesRules(ContextKeyExpr.deserialize(c.when))); + } + }; + const entry = { data }; + this._agents.push(entry); + return toDisposable(() => { + this._agents = this._agents.filter(a => a !== entry); + this._onDidChangeAgents.fire(undefined); + }); } - registerAgent(agent: IChatAgent): IDisposable { - if (this._agents.has(agent.id)) { - throw new Error(`Already registered an agent with id ${agent.id}`); + registerAgentImplementation(id: string, agentImpl: IChatAgentImplementation): IDisposable { + const entry = this._getAgentEntry(id); + if (!entry) { + throw new Error(`Unknown agent: ${JSON.stringify(id)}`); + } + + if (entry.impl) { + throw new Error(`Agent already has implementation: ${JSON.stringify(id)}`); } - this._agents.set(agent.id, { agent }); - this._onDidChangeAgents.fire(); + + entry.impl = agentImpl; + this._onDidChangeAgents.fire(new MergedChatAgent(entry.data, agentImpl)); return toDisposable(() => { - if (this._agents.delete(agent.id)) { - this._onDidChangeAgents.fire(); - } + entry.impl = undefined; + this._onDidChangeAgents.fire(undefined); + }); + } + + registerDynamicAgent(data: IChatAgentData, agentImpl: IChatAgentImplementation): IDisposable { + const agent = { data, impl: agentImpl }; + this._agents.push(agent); + this._onDidChangeAgents.fire(new MergedChatAgent(data, agentImpl)); + + return toDisposable(() => { + this._agents = this._agents.filter(a => a !== agent); + this._onDidChangeAgents.fire(undefined); }); } updateAgent(id: string, updateMetadata: IChatAgentMetadata): void { - const data = this._agents.get(id); - if (!data) { - throw new Error(`No agent with id ${id} registered`); + const agent = this._getAgentEntry(id); + if (!agent?.impl) { + throw new Error(`No activated agent with id ${JSON.stringify(id)} registered`); } - data.agent.metadata = { ...data.agent.metadata, ...updateMetadata }; - data.agent.provideSlashCommands(CancellationToken.None); // Update the cached slash commands - this._onDidChangeAgents.fire(); + agent.data.metadata = { ...agent.data.metadata, ...updateMetadata }; + this._onDidChangeAgents.fire(new MergedChatAgent(agent.data, agent.impl)); + } + + getDefaultAgent(location: ChatAgentLocation): IChatAgent | undefined { + return this.getActivatedAgents().find(a => !!a.isDefault && a.locations.includes(location)); } - getDefaultAgent(): IChatAgent | undefined { - return Iterable.find(this._agents.values(), a => !!a.agent.metadata.isDefault)?.agent; + getSecondaryAgent(): IChatAgentData | undefined { + // TODO also static + return Iterable.find(this._agents.values(), a => !!a.data.metadata.isSecondary)?.data; } - getSecondaryAgent(): IChatAgent | undefined { - return Iterable.find(this._agents.values(), a => !!a.agent.metadata.isSecondary)?.agent; + private _getAgentEntry(id: string): IChatAgentEntry | undefined { + return this._agents.find(a => a.data.id === id); } - getAgents(): Array { - return Array.from(this._agents.values(), v => v.agent); + getAgent(id: string): IChatAgentData | undefined { + return this._getAgentEntry(id)?.data; + } + + /** + * Returns all agent datas that exist- static registered and dynamic ones. + */ + getAgents(): IChatAgentData[] { + return this._agents.map(entry => entry.data); } - hasAgent(id: string): boolean { - return this._agents.has(id); + getActivatedAgents(): IChatAgent[] { + return Array.from(this._agents.values()) + .filter(a => !!a.impl) + .map(a => new MergedChatAgent(a.data, a.impl!)); } - getAgent(id: string): IChatAgent | undefined { - const data = this._agents.get(id); - return data?.agent; + getAgentsByName(name: string): IChatAgentData[] { + return this.getAgents().filter(a => a.name === name); } async invokeAgent(id: string, request: IChatAgentRequest, progress: (part: IChatProgress) => void, history: IChatAgentHistoryEntry[], token: CancellationToken): Promise { - const data = this._agents.get(id); - if (!data) { - throw new Error(`No agent with id ${id}`); + const data = this._getAgentEntry(id); + if (!data?.impl) { + throw new Error(`No activated agent with id ${id}`); } - return await data.agent.invoke(request, progress, history, token); + return await data.impl.invoke(request, progress, history, token); } - async getFollowups(id: string, sessionId: string, token: CancellationToken): Promise { - const data = this._agents.get(id); - if (!data) { - throw new Error(`No agent with id ${id}`); + async getFollowups(id: string, request: IChatAgentRequest, result: IChatAgentResult, history: IChatAgentHistoryEntry[], token: CancellationToken): Promise { + const data = this._getAgentEntry(id); + if (!data?.impl) { + throw new Error(`No activated agent with id ${id}`); } - if (!data.agent.provideFollowups) { + if (!data.impl?.provideFollowups) { return []; } - return data.agent.provideFollowups(sessionId, token); + return data.impl.provideFollowups(request, result, history, token); + } +} + +export class MergedChatAgent implements IChatAgent { + constructor( + private readonly data: IChatAgentData, + private readonly impl: IChatAgentImplementation + ) { } + + get id(): string { return this.data.id; } + get name(): string { return this.data.name ?? ''; } + get description(): string { return this.data.description ?? ''; } + get extensionId(): ExtensionIdentifier { return this.data.extensionId; } + get isDefault(): boolean | undefined { return this.data.isDefault; } + get metadata(): IChatAgentMetadata { return this.data.metadata; } + get slashCommands(): IChatAgentCommand[] { return this.data.slashCommands; } + get defaultImplicitVariables(): string[] | undefined { return this.data.defaultImplicitVariables; } + get locations(): ChatAgentLocation[] { return this.data.locations; } + + async invoke(request: IChatAgentRequest, progress: (part: IChatProgress) => void, history: IChatAgentHistoryEntry[], token: CancellationToken): Promise { + return this.impl.invoke(request, progress, history, token); + } + + async provideFollowups(request: IChatAgentRequest, result: IChatAgentResult, history: IChatAgentHistoryEntry[], token: CancellationToken): Promise { + if (this.impl.provideFollowups) { + return this.impl.provideFollowups(request, result, history, token); + } + + return []; + } + + provideWelcomeMessage(token: CancellationToken): ProviderResult<(string | IMarkdownString)[] | undefined> { + if (this.impl.provideWelcomeMessage) { + return this.impl.provideWelcomeMessage(token); + } + + return undefined; + } + + provideSampleQuestions(token: CancellationToken): ProviderResult { + if (this.impl.provideSampleQuestions) { + return this.impl.provideSampleQuestions(token); + } + + return undefined; } } diff --git a/src/vs/workbench/contrib/chat/common/chatColors.ts b/src/vs/workbench/contrib/chat/common/chatColors.ts index 97c8bc85f5a3b..385c0cf20c426 100644 --- a/src/vs/workbench/contrib/chat/common/chatColors.ts +++ b/src/vs/workbench/contrib/chat/common/chatColors.ts @@ -5,7 +5,7 @@ import { Color, RGBA } from 'vs/base/common/color'; import { localize } from 'vs/nls'; -import { badgeBackground, badgeForeground, contrastBorder, foreground, registerColor } from 'vs/platform/theme/common/colorRegistry'; +import { badgeBackground, badgeForeground, contrastBorder, editorBackground, editorWidgetBackground, foreground, registerColor, transparent } from 'vs/platform/theme/common/colorRegistry'; export const chatRequestBorder = registerColor( 'chat.requestBorder', @@ -13,6 +13,12 @@ export const chatRequestBorder = registerColor( localize('chat.requestBorder', 'The border color of a chat request.') ); +export const chatRequestBackground = registerColor( + 'chat.requestBackground', + { dark: transparent(editorBackground, 0.62), light: transparent(editorBackground, 0.62), hcDark: editorWidgetBackground, hcLight: null }, + localize('chat.requestBackground', 'The background color of a chat request.') +); + export const chatSlashCommandBackground = registerColor( 'chat.slashCommandBackground', { dark: '#34414B', light: '#D2ECFF', hcDark: Color.white, hcLight: badgeBackground }, diff --git a/src/vs/workbench/contrib/chat/common/chatContextKeys.ts b/src/vs/workbench/contrib/chat/common/chatContextKeys.ts index d5cb83c170ac5..417b72ff4100e 100644 --- a/src/vs/workbench/contrib/chat/common/chatContextKeys.ts +++ b/src/vs/workbench/contrib/chat/common/chatContextKeys.ts @@ -5,8 +5,10 @@ import { localize } from 'vs/nls'; import { RawContextKey } from 'vs/platform/contextkey/common/contextkey'; +import { ChatAgentLocation } from 'vs/workbench/contrib/chat/common/chatAgents'; export const CONTEXT_RESPONSE_VOTE = new RawContextKey('chatSessionResponseVote', '', { type: 'string', description: localize('interactiveSessionResponseVote', "When the response has been voted up, is set to 'up'. When voted down, is set to 'down'. Otherwise an empty string.") }); +export const CONTEXT_RESPONSE_DETECTED_AGENT_COMMAND = new RawContextKey('chatSessionResponseDetectedAgentOrCommand', false, { type: 'boolean', description: localize('chatSessionResponseDetectedAgentOrCommand', "When the agent or command was automatically detected") }); export const CONTEXT_CHAT_RESPONSE_SUPPORT_ISSUE_REPORTING = new RawContextKey('chatResponseSupportsIssueReporting', false, { type: 'boolean', description: localize('chatResponseSupportsIssueReporting', "True when the current chat response supports issue reporting.") }); export const CONTEXT_RESPONSE_FILTERED = new RawContextKey('chatSessionResponseFiltered', false, { type: 'boolean', description: localize('chatResponseFiltered', "True when the chat response was filtered out by the server.") }); export const CONTEXT_CHAT_REQUEST_IN_PROGRESS = new RawContextKey('chatSessionRequestInProgress', false, { type: 'boolean', description: localize('interactiveSessionRequestInProgress', "True when the current request is still in progress.") }); @@ -15,8 +17,11 @@ export const CONTEXT_RESPONSE = new RawContextKey('chatResponse', false export const CONTEXT_REQUEST = new RawContextKey('chatRequest', false, { type: 'boolean', description: localize('chatRequest', "The chat item is a request") }); export const CONTEXT_CHAT_INPUT_HAS_TEXT = new RawContextKey('chatInputHasText', false, { type: 'boolean', description: localize('interactiveInputHasText', "True when the chat input has text.") }); +export const CONTEXT_CHAT_INPUT_HAS_FOCUS = new RawContextKey('chatInputHasFocus', false, { type: 'boolean', description: localize('interactiveInputHasFocus', "True when the chat input has focus.") }); export const CONTEXT_IN_CHAT_INPUT = new RawContextKey('inChatInput', false, { type: 'boolean', description: localize('inInteractiveInput', "True when focus is in the chat input, false otherwise.") }); export const CONTEXT_IN_CHAT_SESSION = new RawContextKey('inChat', false, { type: 'boolean', description: localize('inChat', "True when focus is in the chat widget, false otherwise.") }); export const CONTEXT_PROVIDER_EXISTS = new RawContextKey('hasChatProvider', false, { type: 'boolean', description: localize('hasChatProvider', "True when some chat provider has been registered.") }); export const CONTEXT_CHAT_INPUT_CURSOR_AT_TOP = new RawContextKey('chatCursorAtTop', false); +export const CONTEXT_CHAT_INPUT_HAS_AGENT = new RawContextKey('chatInputHasAgent', false); +export const CONTEXT_CHAT_LOCATION = new RawContextKey('chatLocation', undefined); diff --git a/src/vs/workbench/contrib/chat/common/chatContributionService.ts b/src/vs/workbench/contrib/chat/common/chatContributionService.ts index 2c43c2af70397..7d7452b1957a0 100644 --- a/src/vs/workbench/contrib/chat/common/chatContributionService.ts +++ b/src/vs/workbench/contrib/chat/common/chatContributionService.ts @@ -3,6 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { ExtensionIdentifier } from 'vs/platform/extensions/common/extensions'; import { createDecorator } from 'vs/platform/instantiation/common/instantiation'; export interface IChatProviderContribution { @@ -26,3 +27,30 @@ export interface IRawChatProviderContribution { icon?: string; when?: string; } + +export interface IRawChatCommandContribution { + name: string; + description: string; + sampleRequest?: string; + isSticky?: boolean; + when?: string; + defaultImplicitVariables?: string[]; +} + +export type RawChatParticipantLocation = 'panel' | 'terminal' | 'notebook'; + +export interface IRawChatParticipantContribution { + id: string; + name: string; + description?: string; + isDefault?: boolean; + isSticky?: boolean; + commands?: IRawChatCommandContribution[]; + defaultImplicitVariables?: string[]; + locations?: RawChatParticipantLocation[]; +} + +export interface IChatParticipantContribution extends IRawChatParticipantContribution { + // Participant id is extensionId + name + extensionId: ExtensionIdentifier; +} diff --git a/src/vs/workbench/contrib/chat/common/chatModel.ts b/src/vs/workbench/contrib/chat/common/chatModel.ts index 235c547b2e46b..17813f0d46453 100644 --- a/src/vs/workbench/contrib/chat/common/chatModel.ts +++ b/src/vs/workbench/contrib/chat/common/chatModel.ts @@ -9,23 +9,31 @@ import { Emitter, Event } from 'vs/base/common/event'; import { IMarkdownString, MarkdownString, isMarkdownString } from 'vs/base/common/htmlContent'; import { Disposable } from 'vs/base/common/lifecycle'; import { revive } from 'vs/base/common/marshalling'; -import { basename } from 'vs/base/common/resources'; -import { URI, UriComponents, UriDto } from 'vs/base/common/uri'; +import { basename, isEqual } from 'vs/base/common/resources'; +import { ThemeIcon } from 'vs/base/common/themables'; +import { URI, UriComponents, UriDto, isUriComponents } from 'vs/base/common/uri'; import { generateUuid } from 'vs/base/common/uuid'; -import { OffsetRange } from 'vs/editor/common/core/offsetRange'; +import { IOffsetRange, OffsetRange } from 'vs/editor/common/core/offsetRange'; +import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; import { ILogService } from 'vs/platform/log/common/log'; -import { IChatAgentCommand, IChatAgentData, IChatAgentService } from 'vs/workbench/contrib/chat/common/chatAgents'; -import { ChatRequestTextPart, IParsedChatRequest, reviveParsedChatRequest } from 'vs/workbench/contrib/chat/common/chatParserTypes'; -import { IChat, IChatAgentMarkdownContentWithVulnerability, IChatContent, IChatContentInlineReference, IChatContentReference, IChatFollowup, IChatMarkdownContent, IChatProgress, IChatProgressMessage, IChatReplyFollowup, IChatResponse, IChatResponseErrorDetails, IChatResponseProgressFileTreeData, IChatTreeData, IChatUsedContext, InteractiveSessionVoteDirection, isIUsedContext } from 'vs/workbench/contrib/chat/common/chatService'; +import { ChatAgentLocation, IChatAgentCommand, IChatAgentData, IChatAgentHistoryEntry, IChatAgentRequest, IChatAgentResult, IChatAgentService } from 'vs/workbench/contrib/chat/common/chatAgents'; +import { ChatRequestTextPart, IParsedChatRequest, getPromptText, reviveParsedChatRequest } from 'vs/workbench/contrib/chat/common/chatParserTypes'; +import { IChat, IChatAgentMarkdownContentWithVulnerability, IChatCommandButton, IChatContent, IChatContentInlineReference, IChatContentReference, IChatFollowup, IChatMarkdownContent, IChatProgress, IChatProgressMessage, IChatResponseProgressFileTreeData, IChatTextEdit, IChatTreeData, IChatUsedContext, InteractiveSessionVoteDirection, isIUsedContext } from 'vs/workbench/contrib/chat/common/chatService'; import { IChatRequestVariableValue } from 'vs/workbench/contrib/chat/common/chatVariables'; -export interface IChatRequestVariableData { - /** - * The user's message with variable references for extension API. - */ - message: string; +export interface IChatPromptVariableData { + variables: { name: string; range: IOffsetRange; values: IChatRequestVariableValue[] }[]; +} - variables: Record; +export interface IChatRequestVariableEntry { + name: string; + range?: IOffsetRange; + values: IChatRequestVariableValue[]; + references?: IChatContentReference[]; +} + +export interface IChatRequestVariableData { + variables: IChatRequestVariableEntry[]; } export interface IChatRequestModel { @@ -33,7 +41,7 @@ export interface IChatRequestModel { readonly username: string; readonly avatarIconUri?: URI; readonly session: IChatModel; - readonly message: IParsedChatRequest | IChatReplyFollowup; + readonly message: IParsedChatRequest; readonly variableData: IChatRequestVariableData; readonly response?: IChatResponseModel; } @@ -43,7 +51,9 @@ export type IChatProgressResponseContent = | IChatAgentMarkdownContentWithVulnerability | IChatTreeData | IChatContentInlineReference - | IChatProgressMessage; + | IChatProgressMessage + | IChatCommandButton + | IChatTextEdit; export type IChatProgressRenderableResponseContent = Exclude; @@ -58,19 +68,22 @@ export interface IChatResponseModel { readonly providerId: string; readonly requestId: string; readonly username: string; - readonly avatarIconUri?: URI; + readonly avatarIcon?: ThemeIcon | URI; readonly session: IChatModel; readonly agent?: IChatAgentData; readonly usedContext: IChatUsedContext | undefined; readonly contentReferences: ReadonlyArray; readonly progressMessages: ReadonlyArray; readonly slashCommand?: IChatAgentCommand; + readonly agentOrSlashCommandDetected: boolean; readonly response: IResponse; readonly isComplete: boolean; readonly isCanceled: boolean; + /** A stale response is one that has been persisted and rehydrated, so e.g. Commands that have their arguments stored in the EH are gone. */ + readonly isStale: boolean; readonly vote: InteractiveSessionVoteDirection | undefined; readonly followups?: IChatFollowup[] | undefined; - readonly errorDetails?: IChatResponseErrorDetails; + readonly result?: IChatAgentResult; setVote(vote: InteractiveSessionVoteDirection): void; } @@ -92,10 +105,18 @@ export class ChatRequestModel implements IChatRequestModel { return this.session.requesterAvatarIconUri; } + public get variableData(): IChatRequestVariableData { + return this._variableData; + } + + public set variableData(v: IChatRequestVariableData) { + this._variableData = v; + } + constructor( public readonly session: ChatModel, public readonly message: IParsedChatRequest, - public readonly variableData: IChatRequestVariableData) { + private _variableData: IChatRequestVariableData) { this._id = 'request_' + ChatRequestModel.nextId++; } } @@ -157,9 +178,26 @@ export class Response implements IResponse { } else { this._responseParts[responsePartLength] = { content: new MarkdownString(lastResponsePart.content.value + progress.content, lastResponsePart.content), kind: 'markdownContent' }; } - this._updateRepr(quiet); - } else if (progress.kind === 'treeData' || progress.kind === 'inlineReference' || progress.kind === 'markdownVuln' || progress.kind === 'progressMessage') { + + } else if (progress.kind === 'textEdit') { + if (progress.edits.length > 0) { + // merge text edits for the same file no matter when they come in + let found = false; + for (let i = 0; !found && i < this._responseParts.length; i++) { + const candidate = this._responseParts[i]; + if (candidate.kind === 'textEdit' && isEqual(candidate.uri, progress.uri)) { + candidate.edits.push(...progress.edits); + found = true; + } + } + if (!found) { + this._responseParts.push(progress); + } + this._updateRepr(quiet); + } + + } else { this._responseParts.push(progress); this._updateRepr(quiet); } @@ -171,6 +209,10 @@ export class Response implements IResponse { return ''; } else if (part.kind === 'inlineReference') { return basename('uri' in part.inlineReference ? part.inlineReference.uri : part.inlineReference); + } else if (part.kind === 'command') { + return part.command.title; + } else if (part.kind === 'textEdit') { + return ''; } else { return part.content.value; } @@ -214,8 +256,8 @@ export class ChatResponseModel extends Disposable implements IChatResponseModel return this._response; } - public get errorDetails(): IChatResponseErrorDetails | undefined { - return this._errorDetails; + public get result(): IChatAgentResult | undefined { + return this._result; } public get providerId(): string { @@ -226,8 +268,8 @@ export class ChatResponseModel extends Disposable implements IChatResponseModel return this.session.responderUsername; } - public get avatarIconUri(): URI | undefined { - return this.session.responderAvatarIconUri; + public get avatarIcon(): ThemeIcon | URI | undefined { + return this.session.responderAvatarIcon; } private _followups?: IChatFollowup[]; @@ -240,6 +282,11 @@ export class ChatResponseModel extends Disposable implements IChatResponseModel return this._slashCommand; } + private _agentOrSlashCommandDetected: boolean | undefined; + public get agentOrSlashCommandDetected(): boolean { + return this._agentOrSlashCommandDetected ?? false; + } + private _usedContext: IChatUsedContext | undefined; public get usedContext(): IChatUsedContext | undefined { return this._usedContext; @@ -255,6 +302,11 @@ export class ChatResponseModel extends Disposable implements IChatResponseModel return this._progressMessages; } + private _isStale: boolean = false; + public get isStale(): boolean { + return this._isStale; + } + constructor( _response: IMarkdownString | ReadonlyArray, public readonly session: ChatModel, @@ -264,10 +316,14 @@ export class ChatResponseModel extends Disposable implements IChatResponseModel private _isComplete: boolean = false, private _isCanceled = false, private _vote?: InteractiveSessionVoteDirection, - private _errorDetails?: IChatResponseErrorDetails, + private _result?: IChatAgentResult, followups?: ReadonlyArray ) { super(); + + // If we are creating a response with some existing content, consider it stale + this._isStale = Array.isArray(_response) && (_response.length !== 0 || isMarkdownString(_response) && _response.value.length !== 0); + this._followups = followups ? [...followups] : undefined; this._response = new Response(_response); this._register(this._response.onDidChangeValue(() => this._onDidChange.fire())); @@ -296,16 +352,17 @@ export class ChatResponseModel extends Disposable implements IChatResponseModel setAgent(agent: IChatAgentData, slashCommand?: IChatAgentCommand) { this._agent = agent; this._slashCommand = slashCommand; + this._agentOrSlashCommandDetected = true; this._onDidChange.fire(); } - setErrorDetails(errorDetails?: IChatResponseErrorDetails): void { - this._errorDetails = errorDetails; + setResult(result: IChatAgentResult): void { + this._result = result; this._onDidChange.fire(); } - complete(errorDetails?: IChatResponseErrorDetails): void { - if (errorDetails?.responseIsRedacted) { + complete(): void { + if (this._result?.errorDetails?.responseIsRedacted) { this._response.clear(); } @@ -353,11 +410,13 @@ export type ISerializableChatAgentData = UriDto; export interface ISerializableChatRequestData { message: string | IParsedChatRequest; // string => old format - variableData: IChatRequestVariableData; // make optional + /** Is really like "prompt data". This is the message in the format in which the agent gets it + variable values. */ + variableData: IChatRequestVariableData; response: ReadonlyArray | undefined; agent?: ISerializableChatAgentData; slashCommand?: IChatAgentCommand; - responseErrorDetails: IChatResponseErrorDetails | undefined; + // responseErrorDetails: IChatResponseErrorDetails | undefined; + result?: IChatAgentResult; // Optional for backcompat followups: ReadonlyArray | undefined; isCanceled: boolean | undefined; vote: InteractiveSessionVoteDirection | undefined; @@ -368,12 +427,12 @@ export interface ISerializableChatRequestData { export interface IExportableChatData { providerId: string; - welcomeMessage: (string | IChatReplyFollowup[])[] | undefined; + welcomeMessage: (string | IChatFollowup[])[] | undefined; requests: ISerializableChatRequestData[]; requesterUsername: string; responderUsername: string; requesterAvatarIconUri: UriComponents | undefined; - responderAvatarIconUri: UriComponents | undefined; + responderAvatarIconUri: ThemeIcon | UriComponents | undefined; // Keeping Uri name for backcompat } export interface ISerializableChatData extends IExportableChatData { @@ -386,8 +445,7 @@ export function isExportableSessionData(obj: unknown): obj is IExportableChatDat const data = obj as IExportableChatData; return typeof data === 'object' && typeof data.providerId === 'string' && - typeof data.requesterUsername === 'string' && - typeof data.responderUsername === 'string'; + typeof data.requesterUsername === 'string'; } export function isSerializableSessionData(obj: unknown): obj is ISerializableChatData { @@ -429,6 +487,14 @@ export enum ChatModelInitState { } export class ChatModel extends Disposable implements IChatModel { + static getDefaultTitle(requests: (ISerializableChatRequestData | IChatRequestModel)[]): string { + const firstRequestMessage = firstOrDefault(requests)?.message ?? ''; + const message = typeof firstRequestMessage === 'string' ? + firstRequestMessage : + firstRequestMessage.text; + return message.split('\n')[0].substring(0, 50); + } + private readonly _onDidDispose = this._register(new Emitter()); readonly onDidDispose = this._onDidDispose.event; @@ -456,10 +522,6 @@ export class ChatModel extends Disposable implements IChatModel { return this._sessionId; } - get inputPlaceholder(): string | undefined { - return this._session?.inputPlaceholder; - } - get requestInProgress(): boolean { const lastRequest = this._requests[this._requests.length - 1]; return !!lastRequest && !!lastRequest.response && !lastRequest.response.isComplete; @@ -470,22 +532,34 @@ export class ChatModel extends Disposable implements IChatModel { return this._creationDate; } + private get _defaultAgent() { + return this.chatAgentService.getDefaultAgent(ChatAgentLocation.Panel); + } + get requesterUsername(): string { - return this._session?.requesterUsername ?? this.initialData?.requesterUsername ?? ''; + return (this._defaultAgent ? + this._defaultAgent.metadata.requester?.name : + this.initialData?.requesterUsername) ?? ''; } get responderUsername(): string { - return this._session?.responderUsername ?? this.initialData?.responderUsername ?? ''; + return (this._defaultAgent ? + this._defaultAgent.metadata.fullName : + this.initialData?.responderUsername) ?? ''; } private readonly _initialRequesterAvatarIconUri: URI | undefined; get requesterAvatarIconUri(): URI | undefined { - return this._session ? this._session.requesterAvatarIconUri : this._initialRequesterAvatarIconUri; + return this._defaultAgent ? + this._defaultAgent.metadata.requester?.icon : + this._initialRequesterAvatarIconUri; } - private readonly _initialResponderAvatarIconUri: URI | undefined; - get responderAvatarIconUri(): URI | undefined { - return this._session ? this._session.responderAvatarIconUri : this._initialResponderAvatarIconUri; + private readonly _initialResponderAvatarIconUri: ThemeIcon | URI | undefined; + get responderAvatarIcon(): ThemeIcon | URI | undefined { + return this._defaultAgent ? + this._defaultAgent?.metadata.themeIcon : + this._initialResponderAvatarIconUri; } get initState(): ChatModelInitState { @@ -498,9 +572,7 @@ export class ChatModel extends Disposable implements IChatModel { } get title(): string { - const firstRequestMessage = firstOrDefault(this._requests)?.message; - const message = firstRequestMessage?.text ?? ''; - return message.split('\n')[0].substring(0, 50); + return ChatModel.getDefaultTitle(this._requests); } constructor( @@ -508,6 +580,7 @@ export class ChatModel extends Disposable implements IChatModel { private readonly initialData: ISerializableChatData | IExportableChatData | undefined, @ILogService private readonly logService: ILogService, @IChatAgentService private readonly chatAgentService: IChatAgentService, + @IInstantiationService private readonly instantiationService: IInstantiationService, ) { super(); @@ -517,7 +590,7 @@ export class ChatModel extends Disposable implements IChatModel { this._creationDate = (isSerializableSessionData(initialData) && initialData.creationDate) || Date.now(); this._initialRequesterAvatarIconUri = initialData?.requesterAvatarIconUri && URI.revive(initialData.requesterAvatarIconUri); - this._initialResponderAvatarIconUri = initialData?.responderAvatarIconUri && URI.revive(initialData.responderAvatarIconUri); + this._initialResponderAvatarIconUri = isUriComponents(initialData?.responderAvatarIconUri) ? URI.revive(initialData.responderAvatarIconUri) : initialData?.responderAvatarIconUri; } private _deserialize(obj: IExportableChatData): ChatRequestModel[] { @@ -529,7 +602,7 @@ export class ChatModel extends Disposable implements IChatModel { if (obj.welcomeMessage) { const content = obj.welcomeMessage.map(item => typeof item === 'string' ? new MarkdownString(item) : item); - this._welcomeMessage = new ChatWelcomeMessageModel(this, content, []); + this._welcomeMessage = this.instantiationService.createInstance(ChatWelcomeMessageModel, content, []); } try { @@ -538,13 +611,20 @@ export class ChatModel extends Disposable implements IChatModel { typeof raw.message === 'string' ? this.getParsedRequestFromString(raw.message) : reviveParsedChatRequest(raw.message); - // Only old messages don't have variableData - const variableData: IChatRequestVariableData = raw.variableData ?? { message: parsedRequest.text, variables: {} }; + + // Old messages don't have variableData, or have it in the wrong (non-array) shape + const variableData: IChatRequestVariableData = raw.variableData && Array.isArray(raw.variableData.variables) + ? raw.variableData : + { variables: [] }; const request = new ChatRequestModel(this, parsedRequest, variableData); - if (raw.response || raw.responseErrorDetails) { + if (raw.response || raw.result || (raw as any).responseErrorDetails) { const agent = (raw.agent && 'metadata' in raw.agent) ? // Check for the new format, ignore entries in the old format - revive(raw.agent) : undefined; - request.response = new ChatResponseModel(raw.response ?? [new MarkdownString(raw.response)], this, agent, raw.slashCommand, request.id, true, raw.isCanceled, raw.vote, raw.responseErrorDetails, raw.followups); + this.reviveSerializedAgent(raw.agent) : undefined; + + // Port entries from old format + const result = 'responseErrorDetails' in raw ? + { errorDetails: raw.responseErrorDetails } as IChatAgentResult : raw.result; + request.response = new ChatResponseModel(raw.response ?? [new MarkdownString(raw.response)], this, agent, raw.slashCommand, request.id, true, raw.isCanceled, raw.vote, result, raw.followups); if (raw.usedContext) { // @ulugbekna: if this's a new vscode sessions, doc versions are incorrect anyway? request.response.applyReference(revive(raw.usedContext)); } @@ -561,6 +641,16 @@ export class ChatModel extends Disposable implements IChatModel { } } + private reviveSerializedAgent(raw: ISerializableChatAgentData): IChatAgentData { + const agent = 'name' in raw ? + raw : + { + ...(raw as any), + name: (raw as any).id, + }; + return revive(agent); + } + private getParsedRequestFromString(message: string): IParsedChatRequest { // TODO These offsets won't be used, but chat replies need to go through the parser as well const parts = [new ChatRequestTextPart(new OffsetRange(0, message.length), { startColumn: 1, startLineNumber: 1, endColumn: 1, endLineNumber: 1 }, message)]; @@ -646,12 +736,12 @@ export class ChatModel extends Disposable implements IChatModel { if (progress.kind === 'vulnerability') { request.response.updateContent({ kind: 'markdownVuln', content: { value: progress.content }, vulnerabilities: progress.vulnerabilities }, quiet); - } else if (progress.kind === 'content' || progress.kind === 'markdownContent' || progress.kind === 'treeData' || progress.kind === 'inlineReference' || progress.kind === 'markdownVuln' || progress.kind === 'progressMessage') { + } else if (progress.kind === 'content' || progress.kind === 'markdownContent' || progress.kind === 'treeData' || progress.kind === 'inlineReference' || progress.kind === 'markdownVuln' || progress.kind === 'progressMessage' || progress.kind === 'command' || progress.kind === 'textEdit') { request.response.updateContent(progress, quiet); } else if (progress.kind === 'usedContext' || progress.kind === 'reference') { request.response.applyReference(progress); } else if (progress.kind === 'agentDetection') { - const agent = this.chatAgentService.getAgent(progress.agentName); + const agent = this.chatAgentService.getAgent(progress.agentId); if (agent) { request.response.setAgent(agent, progress.command); } @@ -677,7 +767,7 @@ export class ChatModel extends Disposable implements IChatModel { } } - setResponse(request: ChatRequestModel, rawResponse: IChatResponse): void { + setResponse(request: ChatRequestModel, result: IChatAgentResult): void { if (!this._session) { throw new Error('completeResponse: No session'); } @@ -686,15 +776,15 @@ export class ChatModel extends Disposable implements IChatModel { request.response = new ChatResponseModel([], this, undefined, undefined, request.id); } - request.response.setErrorDetails(rawResponse.errorDetails); + request.response.setResult(result); } - completeResponse(request: ChatRequestModel, errorDetails: IChatResponseErrorDetails | undefined): void { + completeResponse(request: ChatRequestModel): void { if (!request.response) { throw new Error('Call setResponse before completeResponse'); } - request.response.complete(errorDetails); + request.response.complete(); } setFollowups(request: ChatRequestModel, followups: IChatFollowup[] | undefined): void { @@ -716,7 +806,7 @@ export class ChatModel extends Disposable implements IChatModel { requesterUsername: this.requesterUsername, requesterAvatarIconUri: this.requesterAvatarIconUri, responderUsername: this.responderUsername, - responderAvatarIconUri: this.responderAvatarIconUri, + responderAvatarIconUri: this.responderAvatarIcon, welcomeMessage: this._welcomeMessage?.content.map(c => { if (Array.isArray(c)) { return c; @@ -725,8 +815,12 @@ export class ChatModel extends Disposable implements IChatModel { } }), requests: this._requests.map((r): ISerializableChatRequestData => { + const message = { + ...r.message, + parts: r.message.parts.map(p => p && 'toJSON' in p ? (p.toJSON as Function)() : p) + }; return { - message: r.message, + message, variableData: r.variableData, response: r.response ? r.response.response.value.map(item => { @@ -740,11 +834,14 @@ export class ChatModel extends Disposable implements IChatModel { } }) : undefined, - responseErrorDetails: r.response?.errorDetails, + result: r.response?.result, followups: r.response?.followups, isCanceled: r.response?.isCanceled, vote: r.response?.vote, - agent: r.response?.agent ? { id: r.response.agent.id, metadata: r.response.agent.metadata } : undefined, // May actually be the full IChatAgent instance, just take the data props + agent: r.response?.agent ? + // May actually be the full IChatAgent instance, just take the data props. slashCommands don't matter here. + { id: r.response.agent.id, name: r.response.agent.name, description: r.response.agent.description, extensionId: r.response.agent.extensionId, metadata: r.response.agent.metadata, slashCommands: [], locations: r.response.agent.locations, isDefault: r.response.agent.isDefault } + : undefined, slashCommand: r.response?.slashCommand, usedContext: r.response?.usedContext, contentReferences: r.response?.contentReferences @@ -772,14 +869,14 @@ export class ChatModel extends Disposable implements IChatModel { } } -export type IChatWelcomeMessageContent = IMarkdownString | IChatReplyFollowup[]; +export type IChatWelcomeMessageContent = IMarkdownString | IChatFollowup[]; export interface IChatWelcomeMessageModel { readonly id: string; readonly content: IChatWelcomeMessageContent[]; - readonly sampleQuestions: IChatReplyFollowup[]; + readonly sampleQuestions: IChatFollowup[]; readonly username: string; - readonly avatarIconUri?: URI; + readonly avatarIcon?: ThemeIcon; } @@ -792,18 +889,53 @@ export class ChatWelcomeMessageModel implements IChatWelcomeMessageModel { } constructor( - private readonly session: ChatModel, public readonly content: IChatWelcomeMessageContent[], - public readonly sampleQuestions: IChatReplyFollowup[] + public readonly sampleQuestions: IChatFollowup[], + @IChatAgentService private readonly chatAgentService: IChatAgentService, ) { this._id = 'welcome_' + ChatWelcomeMessageModel.nextId++; } public get username(): string { - return this.session.responderUsername; + return this.chatAgentService.getDefaultAgent(ChatAgentLocation.Panel)?.metadata.fullName ?? ''; } - public get avatarIconUri(): URI | undefined { - return this.session.responderAvatarIconUri; + public get avatarIcon(): ThemeIcon | undefined { + return this.chatAgentService.getDefaultAgent(ChatAgentLocation.Panel)?.metadata.themeIcon; + } +} + +export function getHistoryEntriesFromModel(model: IChatModel): IChatAgentHistoryEntry[] { + const history: IChatAgentHistoryEntry[] = []; + for (const request of model.getRequests()) { + if (!request.response) { + continue; + } + + const promptTextResult = getPromptText(request.message); + const historyRequest: IChatAgentRequest = { + sessionId: model.sessionId, + requestId: request.id, + agentId: request.response.agent?.id ?? '', + message: promptTextResult.message, + command: request.response.slashCommand?.name, + variables: updateRanges(request.variableData, promptTextResult.diff), // TODO bit of a hack + location: ChatAgentLocation.Panel + }; + history.push({ request: historyRequest, response: request.response.response.value, result: request.response.result ?? {} }); } + + return history; +} + +export function updateRanges(variableData: IChatRequestVariableData, diff: number): IChatRequestVariableData { + return { + variables: variableData.variables.map(v => ({ + ...v, + range: v.range && { + start: v.range.start - diff, + endExclusive: v.range.endExclusive - diff + } + })) + }; } diff --git a/src/vs/workbench/contrib/chat/common/chatParserTypes.ts b/src/vs/workbench/contrib/chat/common/chatParserTypes.ts index 7363f5cffcbb2..7edda68e10e52 100644 --- a/src/vs/workbench/contrib/chat/common/chatParserTypes.ts +++ b/src/vs/workbench/contrib/chat/common/chatParserTypes.ts @@ -6,7 +6,7 @@ import { revive } from 'vs/base/common/marshalling'; import { IOffsetRange, OffsetRange } from 'vs/editor/common/core/offsetRange'; import { IRange } from 'vs/editor/common/core/range'; -import { IChatAgent, IChatAgentCommand } from 'vs/workbench/contrib/chat/common/chatAgents'; +import { IChatAgentCommand, IChatAgentData } from 'vs/workbench/contrib/chat/common/chatAgents'; import { IChatSlashData } from 'vs/workbench/contrib/chat/common/chatSlashCommands'; import { IChatRequestVariableValue } from 'vs/workbench/contrib/chat/common/chatVariables'; @@ -22,9 +22,17 @@ export interface IParsedChatRequestPart { readonly range: IOffsetRange; readonly editorRange: IRange; readonly text: string; + /** How this part is represented in the prompt going to the agent */ readonly promptText: string; } +export function getPromptText(request: IParsedChatRequest): { message: string; diff: number } { + const message = request.parts.map(r => r.promptText).join('').trimStart(); + const diff = request.text.length - message.length; + + return { message, diff }; +} + export class ChatRequestTextPart implements IParsedChatRequestPart { static readonly Kind = 'text'; readonly kind = ChatRequestTextPart.Kind; @@ -64,15 +72,32 @@ export class ChatRequestVariablePart implements IParsedChatRequestPart { export class ChatRequestAgentPart implements IParsedChatRequestPart { static readonly Kind = 'agent'; readonly kind = ChatRequestAgentPart.Kind; - constructor(readonly range: OffsetRange, readonly editorRange: IRange, readonly agent: IChatAgent) { } + constructor(readonly range: OffsetRange, readonly editorRange: IRange, readonly agent: IChatAgentData) { } get text(): string { - return `${chatAgentLeader}${this.agent.id}`; + return `${chatAgentLeader}${this.agent.name}`; } get promptText(): string { return ''; } + + /** + * Don't stringify all the agent methods, just data. + */ + toJSON(): any { + return { + kind: this.kind, + range: this.range, + editorRange: this.editorRange, + agent: { + id: this.agent.id, + name: this.agent.name, + description: this.agent.description, + metadata: this.agent.metadata + } + }; + } } /** @@ -118,12 +143,11 @@ export class ChatRequestDynamicVariablePart implements IParsedChatRequestPart { constructor(readonly range: OffsetRange, readonly editorRange: IRange, readonly text: string, readonly data: IChatRequestVariableValue[]) { } get referenceText(): string { - return this.text; + return this.text.replace(chatVariableLeader, ''); } get promptText(): string { - // This needs to be dynamically generated for de-duping - return ``; + return this.text; } } @@ -145,10 +169,19 @@ export function reviveParsedChatRequest(serialized: IParsedChatRequest): IParsed (part as ChatRequestVariablePart).variableArg ); } else if (part.kind === ChatRequestAgentPart.Kind) { + let agent = (part as ChatRequestAgentPart).agent; + if (!('name' in agent)) { + // Port old format + agent = { + ...(agent as any), + name: (agent as any).id + }; + } + return new ChatRequestAgentPart( new OffsetRange(part.range.start, part.range.endExclusive), part.editorRange, - (part as ChatRequestAgentPart).agent + agent ); } else if (part.kind === ChatRequestAgentSubcommandPart.Kind) { return new ChatRequestAgentSubcommandPart( @@ -175,3 +208,9 @@ export function reviveParsedChatRequest(serialized: IParsedChatRequest): IParsed }) }; } + +export function extractAgentAndCommand(parsed: IParsedChatRequest): { agentPart: ChatRequestAgentPart | undefined; commandPart: ChatRequestAgentSubcommandPart | undefined } { + const agentPart = parsed.parts.find((r): r is ChatRequestAgentPart => r instanceof ChatRequestAgentPart); + const commandPart = parsed.parts.find((r): r is ChatRequestAgentSubcommandPart => r instanceof ChatRequestAgentSubcommandPart); + return { agentPart, commandPart }; +} diff --git a/src/vs/workbench/contrib/chat/common/chatProvider.ts b/src/vs/workbench/contrib/chat/common/chatProvider.ts deleted file mode 100644 index 88349952e4e48..0000000000000 --- a/src/vs/workbench/contrib/chat/common/chatProvider.ts +++ /dev/null @@ -1,99 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { CancellationToken } from 'vs/base/common/cancellation'; -import { Emitter, Event } from 'vs/base/common/event'; -import { IDisposable, toDisposable } from 'vs/base/common/lifecycle'; -import { ExtensionIdentifier } from 'vs/platform/extensions/common/extensions'; -import { createDecorator } from 'vs/platform/instantiation/common/instantiation'; -import { IProgress } from 'vs/platform/progress/common/progress'; - -export const enum ChatMessageRole { - System, - User, - Assistant, -} - -export interface IChatMessage { - readonly role: ChatMessageRole; - readonly content: string; - readonly name?: string; -} - -export interface IChatResponseFragment { - index: number; - part: string; -} - -export interface IChatResponseProviderMetadata { - readonly extension: ExtensionIdentifier; - readonly model: string; - readonly description?: string; -} - -export interface IChatResponseProvider { - metadata: IChatResponseProviderMetadata; - provideChatResponse(messages: IChatMessage[], options: { [name: string]: any }, progress: IProgress, token: CancellationToken): Promise; -} - -export const IChatProviderService = createDecorator('chatProviderService'); - -export interface IChatProviderService { - - readonly _serviceBrand: undefined; - - onDidChangeProviders: Event<{ added?: string[]; removed?: string[] }>; - - getProviders(): string[]; - - lookupChatResponseProvider(identifier: string): IChatResponseProviderMetadata | undefined; - - registerChatResponseProvider(identifier: string, provider: IChatResponseProvider): IDisposable; - - fetchChatResponse(identifier: string, messages: IChatMessage[], options: { [name: string]: any }, progress: IProgress, token: CancellationToken): Promise; -} - -export class ChatProviderService implements IChatProviderService { - readonly _serviceBrand: undefined; - - private readonly _providers: Map = new Map(); - - private readonly _onDidChangeProviders = new Emitter<{ added?: string[]; removed?: string[] }>(); - readonly onDidChangeProviders: Event<{ added?: string[]; removed?: string[] }> = this._onDidChangeProviders.event; - - dispose() { - this._onDidChangeProviders.dispose(); - this._providers.clear(); - } - - getProviders(): string[] { - return Array.from(this._providers.keys()); - } - - lookupChatResponseProvider(identifier: string): IChatResponseProviderMetadata | undefined { - return this._providers.get(identifier)?.metadata; - } - - registerChatResponseProvider(identifier: string, provider: IChatResponseProvider): IDisposable { - if (this._providers.has(identifier)) { - throw new Error(`Chat response provider with identifier ${identifier} is already registered.`); - } - this._providers.set(identifier, provider); - this._onDidChangeProviders.fire({ added: [identifier] }); - return toDisposable(() => { - if (this._providers.delete(identifier)) { - this._onDidChangeProviders.fire({ removed: [identifier] }); - } - }); - } - - fetchChatResponse(identifier: string, messages: IChatMessage[], options: { [name: string]: any }, progress: IProgress, token: CancellationToken): Promise { - const provider = this._providers.get(identifier); - if (!provider) { - throw new Error(`Chat response provider with identifier ${identifier} is not registered.`); - } - return provider.provideChatResponse(messages, options, progress, token); - } -} diff --git a/src/vs/workbench/contrib/chat/common/chatRequestParser.ts b/src/vs/workbench/contrib/chat/common/chatRequestParser.ts index 007b8c8a1917e..f60b9cb5dc3a0 100644 --- a/src/vs/workbench/contrib/chat/common/chatRequestParser.ts +++ b/src/vs/workbench/contrib/chat/common/chatRequestParser.ts @@ -6,7 +6,7 @@ import { OffsetRange } from 'vs/editor/common/core/offsetRange'; import { IPosition, Position } from 'vs/editor/common/core/position'; import { Range } from 'vs/editor/common/core/range'; -import { IChatAgentService } from 'vs/workbench/contrib/chat/common/chatAgents'; +import { ChatAgentLocation, IChatAgentData, IChatAgentService } from 'vs/workbench/contrib/chat/common/chatAgents'; import { ChatRequestAgentPart, ChatRequestAgentSubcommandPart, ChatRequestDynamicVariablePart, ChatRequestSlashCommandPart, ChatRequestTextPart, ChatRequestVariablePart, IParsedChatRequest, IParsedChatRequestPart, chatAgentLeader, chatSubcommandLeader, chatVariableLeader } from 'vs/workbench/contrib/chat/common/chatParserTypes'; import { IChatSlashCommandService } from 'vs/workbench/contrib/chat/common/chatSlashCommands'; import { IChatVariablesService, IDynamicVariable } from 'vs/workbench/contrib/chat/common/chatVariables'; @@ -15,6 +15,11 @@ const agentReg = /^@([\w_\-]+)(?=(\s|$|\b))/i; // An @-agent const variableReg = /^#([\w_\-]+)(:\d+)?(?=(\s|$|\b))/i; // A #-variable with an optional numeric : arg (@response:2) const slashReg = /\/([\w_\-]+)(?=(\s|$|\b))/i; // A / command +export interface IChatParserContext { + /** Used only as a disambiguator, when the query references an agent that has a duplicate with the same name. */ + selectedAgent?: IChatAgentData; +} + export class ChatRequestParser { constructor( @IChatAgentService private readonly agentService: IChatAgentService, @@ -22,7 +27,7 @@ export class ChatRequestParser { @IChatSlashCommandService private readonly slashCommandService: IChatSlashCommandService ) { } - parseChatRequest(sessionId: string, message: string): IParsedChatRequest { + parseChatRequest(sessionId: string, message: string, location: ChatAgentLocation = ChatAgentLocation.Panel, context?: IChatParserContext): IParsedChatRequest { const parts: IParsedChatRequestPart[] = []; const references = this.variableService.getDynamicVariables(sessionId); // must access this list before any async calls @@ -36,7 +41,7 @@ export class ChatRequestParser { if (char === chatVariableLeader) { newPart = this.tryToParseVariable(message.slice(i), i, new Position(lineNumber, column), parts); } else if (char === chatAgentLeader) { - newPart = this.tryToParseAgent(message.slice(i), message, i, new Position(lineNumber, column), parts); + newPart = this.tryToParseAgent(message.slice(i), message, i, new Position(lineNumber, column), parts, location, context); } else if (char === chatSubcommandLeader) { newPart = this.tryToParseSlashCommand(message.slice(i), message, i, new Position(lineNumber, column), parts); } @@ -85,18 +90,24 @@ export class ChatRequestParser { }; } - private tryToParseAgent(message: string, fullMessage: string, offset: number, position: IPosition, parts: ReadonlyArray): ChatRequestAgentPart | ChatRequestVariablePart | undefined { - const nextVariableMatch = message.match(agentReg); - if (!nextVariableMatch) { + private tryToParseAgent(message: string, fullMessage: string, offset: number, position: IPosition, parts: ReadonlyArray, location: ChatAgentLocation, context: IChatParserContext | undefined): ChatRequestAgentPart | ChatRequestVariablePart | undefined { + const nextAgentMatch = message.match(agentReg); + if (!nextAgentMatch) { return; } - const [full, name] = nextVariableMatch; - const varRange = new OffsetRange(offset, offset + full.length); - const varEditorRange = new Range(position.lineNumber, position.column, position.lineNumber, position.column + full.length); + const [full, name] = nextAgentMatch; + const agentRange = new OffsetRange(offset, offset + full.length); + const agentEditorRange = new Range(position.lineNumber, position.column, position.lineNumber, position.column + full.length); + + const agents = this.agentService.getAgentsByName(name); - const agent = this.agentService.getAgent(name); - if (!agent) { + // If there is more than one agent with this name, and the user picked it from the suggest widget, then the selected agent should be in the + // context and we use that one. Otherwise just pick the first. + const agent = agents.length > 1 && context?.selectedAgent ? + context.selectedAgent : + agents[0]; + if (!agent || !agent.locations.includes(location)) { return; } @@ -117,7 +128,7 @@ export class ChatRequestParser { return; } - return new ChatRequestAgentPart(varRange, varEditorRange, agent); + return new ChatRequestAgentPart(agentRange, agentEditorRange, agent); } private tryToParseVariable(message: string, offset: number, position: IPosition, parts: ReadonlyArray): ChatRequestAgentPart | ChatRequestVariablePart | undefined { @@ -167,8 +178,7 @@ export class ChatRequestParser { return; } - const subCommands = usedAgent.agent.lastSlashCommands; - const subCommand = subCommands?.find(c => c.name === command); + const subCommand = usedAgent.agent.slashCommands.find(c => c.name === command); if (subCommand) { // Valid agent subcommand return new ChatRequestAgentSubcommandPart(slashRange, slashEditorRange, subCommand); diff --git a/src/vs/workbench/contrib/chat/common/chatService.ts b/src/vs/workbench/contrib/chat/common/chatService.ts index 5b4e36308a7a8..819c41f3045b8 100644 --- a/src/vs/workbench/contrib/chat/common/chatService.ts +++ b/src/vs/workbench/contrib/chat/common/chatService.ts @@ -9,21 +9,17 @@ import { IMarkdownString } from 'vs/base/common/htmlContent'; import { IDisposable } from 'vs/base/common/lifecycle'; import { URI } from 'vs/base/common/uri'; import { IRange, Range } from 'vs/editor/common/core/range'; -import { Location, ProviderResult } from 'vs/editor/common/languages'; +import { Command, Location, ProviderResult, TextEdit } from 'vs/editor/common/languages'; import { FileType } from 'vs/platform/files/common/files'; import { createDecorator } from 'vs/platform/instantiation/common/instantiation'; -import { IChatAgentCommand, IChatAgentData } from 'vs/workbench/contrib/chat/common/chatAgents'; +import { ChatAgentLocation, IChatAgentCommand, IChatAgentData, IChatAgentResult } from 'vs/workbench/contrib/chat/common/chatAgents'; import { ChatModel, IChatModel, IChatRequestVariableData, ISerializableChatData } from 'vs/workbench/contrib/chat/common/chatModel'; import { IParsedChatRequest } from 'vs/workbench/contrib/chat/common/chatParserTypes'; +import { IChatParserContext } from 'vs/workbench/contrib/chat/common/chatRequestParser'; import { IChatRequestVariableValue } from 'vs/workbench/contrib/chat/common/chatVariables'; export interface IChat { id: number; // TODO Maybe remove this and move to a subclass that only the provider knows about - requesterUsername: string; - requesterAvatarIconUri?: URI; - responderUsername: string; - responderAvatarIconUri?: URI; - inputPlaceholder?: string; dispose?(): void; } @@ -40,15 +36,6 @@ export interface IChatResponseErrorDetails { responseIsRedacted?: boolean; } -export interface IChatResponse { - session: IChat; - errorDetails?: IChatResponseErrorDetails; - timings?: { - firstProgress?: number; - totalElapsed: number; - }; -} - export interface IChatResponseProgressFileTreeData { label: string; uri: URI; @@ -87,8 +74,13 @@ export function isIUsedContext(obj: unknown): obj is IChatUsedContext { ); } +export interface IChatContentVariableReference { + variableName: string; + value?: URI | Location; +} + export interface IChatContentReference { - reference: URI | Location; + reference: URI | Location | IChatContentVariableReference; kind: 'reference'; } @@ -99,7 +91,7 @@ export interface IChatContentInlineReference { } export interface IChatAgentDetection { - agentName: string; + agentId: string; command?: IChatAgentCommand; kind: 'agentDetection'; } @@ -142,6 +134,17 @@ export interface IChatAgentMarkdownContentWithVulnerability { kind: 'markdownVuln'; } +export interface IChatCommandButton { + command: Command; + kind: 'command'; +} + +export interface IChatTextEdit { + uri: URI; + edits: TextEdit[]; + kind: 'textEdit'; +} + export type IChatProgress = | IChatContent | IChatMarkdownContent @@ -152,30 +155,24 @@ export type IChatProgress = | IChatContentReference | IChatContentInlineReference | IChatAgentDetection - | IChatProgressMessage; + | IChatProgressMessage + | IChatCommandButton + | IChatTextEdit; export interface IChatProvider { readonly id: string; prepareSession(token: CancellationToken): ProviderResult; } -export interface IChatReplyFollowup { +export interface IChatFollowup { kind: 'reply'; message: string; + agentId: string; + subCommand?: string; title?: string; tooltip?: string; } -export interface IChatResponseCommandFollowup { - kind: 'command'; - commandId: string; - args?: any[]; - title: string; // supports codicon strings - when?: string; -} - -export type IChatFollowup = IChatReplyFollowup | IChatResponseCommandFollowup; - // Name has to match the one in vscode.d.ts for some reason export enum InteractiveSessionVoteDirection { Down = 0, @@ -188,7 +185,7 @@ export interface IChatVoteAction { reportIssue?: boolean; } -export enum ChatAgentCopyKind { +export enum ChatCopyKind { // Keyboard shortcut or context menu Action = 1, Toolbar = 2 @@ -197,7 +194,7 @@ export enum ChatAgentCopyKind { export interface IChatCopyAction { kind: 'copy'; codeBlockIndex: number; - copyKind: ChatAgentCopyKind; + copyKind: ChatCopyKind; copiedCharacters: number; totalCharacters: number; copiedText: string; @@ -218,12 +215,12 @@ export interface IChatTerminalAction { export interface IChatCommandAction { kind: 'command'; - command: IChatResponseCommandFollowup; + commandButton: IChatCommandButton; } export interface IChatFollowupAction { kind: 'followUp'; - followup: IChatReplyFollowup; + followup: IChatFollowup; } export interface IChatBugReportAction { @@ -238,6 +235,7 @@ export interface IChatUserActionEvent { agentId: string | undefined; sessionId: string; requestId: string; + result: IChatAgentResult | undefined; } export interface IChatDynamicRequest { @@ -254,7 +252,7 @@ export interface IChatDynamicRequest { export interface IChatCompleteResponse { message: string | ReadonlyArray; - errorDetails?: IChatResponseErrorDetails; + result?: IChatAgentResult; followups?: IChatFollowup[]; } @@ -272,13 +270,18 @@ export interface IChatTransferredSessionData { inputValue: string; } +export interface IChatSendRequestData { + responseCompletePromise: Promise; + agent: IChatAgentData; + slashCommand?: IChatAgentCommand; +} + export const IChatService = createDecorator('IChatService'); export interface IChatService { _serviceBrand: undefined; transferredSessionData: IChatTransferredSessionData | undefined; - onDidSubmitAgent: Event<{ agent: IChatAgentData; slashCommand: IChatAgentCommand; sessionId: string }>; onDidRegisterProvider: Event<{ providerId: string }>; onDidUnregisterProvider: Event<{ providerId: string }>; registerProvider(provider: IChatProvider): IDisposable; @@ -293,12 +296,11 @@ export interface IChatService { /** * Returns whether the request was accepted. */ - sendRequest(sessionId: string, message: string): Promise<{ responseCompletePromise: Promise } | undefined>; + sendRequest(sessionId: string, message: string, implicitVariablesEnabled?: boolean, location?: ChatAgentLocation, parserContext?: IChatParserContext): Promise; removeRequest(sessionid: string, requestId: string): Promise; cancelCurrentRequestForSession(sessionId: string): void; clearSession(sessionId: string): void; addCompleteRequest(sessionId: string, message: IParsedChatRequest | string, variableData: IChatRequestVariableData | undefined, response: IChatCompleteResponse): void; - sendRequestToProvider(sessionId: string, message: IChatDynamicRequest): void; getHistory(): IChatDetail[]; clearAllHistoryEntries(): void; removeHistoryEntry(sessionId: string): void; @@ -311,4 +313,3 @@ export interface IChatService { } export const KEYWORD_ACTIVIATION_SETTING_ID = 'accessibility.voice.keywordActivation'; -export const CHAT_FEATURE_ID = 'chat'; diff --git a/src/vs/workbench/contrib/chat/common/chatServiceImpl.ts b/src/vs/workbench/contrib/chat/common/chatServiceImpl.ts index c55c438f22858..1667fd45bb96f 100644 --- a/src/vs/workbench/contrib/chat/common/chatServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/chatServiceImpl.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { CancellationToken, CancellationTokenSource } from 'vs/base/common/cancellation'; +import { ErrorNoTelemetry } from 'vs/base/common/errors'; import { Emitter, Event } from 'vs/base/common/event'; import { MarkdownString } from 'vs/base/common/htmlContent'; import { Iterable } from 'vs/base/common/iterator'; @@ -20,15 +21,15 @@ import { Progress } from 'vs/platform/progress/common/progress'; import { IStorageService, StorageScope, StorageTarget } from 'vs/platform/storage/common/storage'; import { ITelemetryService } from 'vs/platform/telemetry/common/telemetry'; import { IWorkspaceContextService } from 'vs/platform/workspace/common/workspace'; -import { IChatAgentCommand, IChatAgentData, IChatAgentHistoryEntry, IChatAgentRequest, IChatAgentService } from 'vs/workbench/contrib/chat/common/chatAgents'; +import { ChatAgentLocation, IChatAgent, IChatAgentRequest, IChatAgentResult, IChatAgentService } from 'vs/workbench/contrib/chat/common/chatAgents'; import { CONTEXT_PROVIDER_EXISTS } from 'vs/workbench/contrib/chat/common/chatContextKeys'; -import { ChatModel, ChatModelInitState, ChatRequestModel, ChatWelcomeMessageModel, IChatModel, IChatRequestVariableData, ISerializableChatData, ISerializableChatsData } from 'vs/workbench/contrib/chat/common/chatModel'; -import { ChatRequestAgentPart, ChatRequestAgentSubcommandPart, ChatRequestSlashCommandPart, IParsedChatRequest } from 'vs/workbench/contrib/chat/common/chatParserTypes'; -import { ChatMessageRole, IChatMessage } from 'vs/workbench/contrib/chat/common/chatProvider'; -import { ChatRequestParser } from 'vs/workbench/contrib/chat/common/chatRequestParser'; -import { ChatAgentCopyKind, IChat, IChatCompleteResponse, IChatDetail, IChatDynamicRequest, IChatFollowup, IChatProgress, IChatProvider, IChatProviderInfo, IChatResponse, IChatService, IChatTransferredSessionData, IChatUserActionEvent, InteractiveSessionVoteDirection } from 'vs/workbench/contrib/chat/common/chatService'; +import { ChatModel, ChatModelInitState, ChatRequestModel, ChatWelcomeMessageModel, IChatModel, IChatRequestVariableData, IChatRequestVariableEntry, ISerializableChatData, ISerializableChatsData, getHistoryEntriesFromModel, updateRanges } from 'vs/workbench/contrib/chat/common/chatModel'; +import { ChatRequestAgentPart, ChatRequestAgentSubcommandPart, ChatRequestSlashCommandPart, IParsedChatRequest, getPromptText } from 'vs/workbench/contrib/chat/common/chatParserTypes'; +import { ChatRequestParser, IChatParserContext } from 'vs/workbench/contrib/chat/common/chatRequestParser'; +import { ChatCopyKind, IChat, IChatCompleteResponse, IChatDetail, IChatFollowup, IChatProgress, IChatProvider, IChatProviderInfo, IChatSendRequestData, IChatService, IChatTransferredSessionData, IChatUserActionEvent, InteractiveSessionVoteDirection } from 'vs/workbench/contrib/chat/common/chatService'; import { IChatSlashCommandService } from 'vs/workbench/contrib/chat/common/chatSlashCommands'; import { IChatVariablesService } from 'vs/workbench/contrib/chat/common/chatVariables'; +import { ChatMessageRole, IChatMessage } from 'vs/workbench/contrib/chat/common/languageModels'; import { IExtensionService } from 'vs/workbench/services/extensions/common/extensions'; const serializedChatKey = 'interactive.sessions'; @@ -55,8 +56,8 @@ type ChatProviderInvokedEvent = { type ChatProviderInvokedClassification = { providerId: { classification: 'PublicNonPersonalData'; purpose: 'FeatureInsight'; comment: 'The identifier of the provider that was invoked.' }; - timeToFirstProgress: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'The time in milliseconds from invoking the provider to getting the first data.' }; - totalTime: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'The total time it took to run the provider\'s `provideResponseWithProgress`.' }; + timeToFirstProgress: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The time in milliseconds from invoking the provider to getting the first data.' }; + totalTime: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The total time it took to run the provider\'s `provideResponseWithProgress`.' }; result: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether invoking the ChatProvider resulted in an error.' }; requestType: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The type of request that the user made.' }; chatSessionId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'A random ID for the session.' }; @@ -146,9 +147,6 @@ export class ChatService extends Disposable implements IChatService { private readonly _onDidPerformUserAction = this._register(new Emitter()); public readonly onDidPerformUserAction: Event = this._onDidPerformUserAction.event; - private readonly _onDidSubmitAgent = this._register(new Emitter<{ agent: IChatAgentData; slashCommand: IChatAgentCommand; sessionId: string }>()); - public readonly onDidSubmitAgent = this._onDidSubmitAgent.event; - private readonly _onDidDisposeSession = this._register(new Emitter<{ sessionId: string; providerId: string; reason: 'initializationFailed' | 'cleared' }>()); public readonly onDidDisposeSession = this._onDidDisposeSession.event; @@ -158,6 +156,8 @@ export class ChatService extends Disposable implements IChatService { private readonly _onDidUnregisterProvider = this._register(new Emitter<{ providerId: string }>()); public readonly onDidUnregisterProvider = this._onDidUnregisterProvider.event; + private readonly _sessionFollowupCancelTokens = this._register(new DisposableMap()); + constructor( @IStorageService private readonly storageService: IStorageService, @ILogService private readonly logService: ILogService, @@ -227,7 +227,7 @@ export class ChatService extends Disposable implements IChatService { } else if (action.action.kind === 'copy') { this.telemetryService.publicLog2('interactiveSessionCopy', { providerId: action.providerId, - copyKind: action.action.copyKind === ChatAgentCopyKind.Action ? 'action' : 'toolbar' + copyKind: action.action.copyKind === ChatCopyKind.Action ? 'action' : 'toolbar' }); } else if (action.action.kind === 'insert') { this.telemetryService.publicLog2('interactiveSessionInsert', { @@ -235,8 +235,8 @@ export class ChatService extends Disposable implements IChatService { newFile: !!action.action.newFile }); } else if (action.action.kind === 'command') { - const command = CommandsRegistry.getCommand(action.action.command.commandId); - const commandId = command ? action.action.command.commandId : 'INVALID'; + const command = CommandsRegistry.getCommand(action.action.commandButton.command.id); + const commandId = command ? action.action.commandButton.command.id : 'INVALID'; this.telemetryService.publicLog2('interactiveSessionCommand', { providerId: action.providerId, commandId @@ -323,11 +323,10 @@ export class ChatService extends Disposable implements IChatService { .filter(session => !this._sessionModels.has(session.sessionId)) .filter(session => !session.isImported) .map(item => { - const firstRequestMessage = item.requests[0]?.message; + const title = ChatModel.getDefaultTitle(item.requests); return { sessionId: item.sessionId, - title: (typeof firstRequestMessage === 'string' ? firstRequestMessage : - firstRequestMessage?.text) ?? '', + title }; }); } @@ -368,7 +367,7 @@ export class ChatService extends Disposable implements IChatService { const provider = this._providers.get(model.providerId); if (!provider) { - throw new Error(`Unknown provider: ${model.providerId}`); + throw new ErrorNoTelemetry(`Unknown provider: ${model.providerId}`); } let session: IChat | undefined; @@ -384,14 +383,14 @@ export class ChatService extends Disposable implements IChatService { this.trace('startSession', `Provider returned session`); - const defaultAgent = this.chatAgentService.getDefaultAgent(); + const defaultAgent = this.chatAgentService.getDefaultAgent(ChatAgentLocation.Panel); if (!defaultAgent) { - throw new Error('No default agent'); + throw new ErrorNoTelemetry('No default agent'); } const welcomeMessage = model.welcomeMessage ? undefined : await defaultAgent.provideWelcomeMessage?.(token) ?? undefined; - const welcomeModel = welcomeMessage && new ChatWelcomeMessageModel( - model, + const welcomeModel = welcomeMessage && this.instantiationService.createInstance( + ChatWelcomeMessageModel, welcomeMessage.map(item => typeof item === 'string' ? new MarkdownString(item) : item), await defaultAgent.provideSampleQuestions?.(token) ?? [] ); @@ -436,7 +435,7 @@ export class ChatService extends Disposable implements IChatService { return this._startSession(data.providerId, data, CancellationToken.None); } - async sendRequest(sessionId: string, request: string): Promise<{ responseCompletePromise: Promise } | undefined> { + async sendRequest(sessionId: string, request: string, implicitVariablesEnabled?: boolean, location: ChatAgentLocation = ChatAgentLocation.Panel, parserContext?: IChatParserContext): Promise { this.trace('sendRequest', `sessionId: ${sessionId}, message: ${request.substring(0, 20)}${request.length > 20 ? '[...]' : ''}}`); if (!request.trim()) { this.trace('sendRequest', 'Rejected empty message'); @@ -459,13 +458,30 @@ export class ChatService extends Disposable implements IChatService { return; } + const defaultAgent = this.chatAgentService.getDefaultAgent(location)!; + + const parsedRequest = this.instantiationService.createInstance(ChatRequestParser).parseChatRequest(sessionId, request, location, parserContext); + const agent = parsedRequest.parts.find((r): r is ChatRequestAgentPart => r instanceof ChatRequestAgentPart)?.agent ?? defaultAgent; + const agentSlashCommandPart = parsedRequest.parts.find((r): r is ChatRequestAgentSubcommandPart => r instanceof ChatRequestAgentSubcommandPart); + // This method is only returning whether the request was accepted - don't block on the actual request - return { responseCompletePromise: this._sendRequestAsync(model, sessionId, provider, request) }; + return { + responseCompletePromise: this._sendRequestAsync(model, sessionId, provider, parsedRequest, implicitVariablesEnabled ?? false, defaultAgent, location), + agent, + slashCommand: agentSlashCommandPart?.command, + }; } - private async _sendRequestAsync(model: ChatModel, sessionId: string, provider: IChatProvider, message: string): Promise { - const parsedRequest = this.instantiationService.createInstance(ChatRequestParser).parseChatRequest(sessionId, message); + private refreshFollowupsCancellationToken(sessionId: string): CancellationToken { + this._sessionFollowupCancelTokens.get(sessionId)?.cancel(); + const newTokenSource = new CancellationTokenSource(); + this._sessionFollowupCancelTokens.set(sessionId, newTokenSource); + return newTokenSource.token; + } + + private async _sendRequestAsync(model: ChatModel, sessionId: string, provider: IChatProvider, parsedRequest: IParsedChatRequest, implicitVariablesEnabled: boolean, defaultAgent: IChatAgent, location: ChatAgentLocation): Promise { + const followupsCancelToken = this.refreshFollowupsCancellationToken(sessionId); let request: ChatRequestModel; const agentPart = 'kind' in parsedRequest ? undefined : parsedRequest.parts.find((r): r is ChatRequestAgentPart => r instanceof ChatRequestAgentPart); const agentSlashCommandPart = 'kind' in parsedRequest ? undefined : parsedRequest.parts.find((r): r is ChatRequestAgentSubcommandPart => r instanceof ChatRequestAgentSubcommandPart); @@ -512,54 +528,44 @@ export class ChatService extends Disposable implements IChatService { }); try { - if (agentPart && agentSlashCommandPart?.command) { - this._onDidSubmitAgent.fire({ agent: agentPart.agent, slashCommand: agentSlashCommandPart.command, sessionId: model.sessionId }); - } - - let rawResponse: IChatResponse | null | undefined; + let rawResult: IChatAgentResult | null | undefined; let agentOrCommandFollowups: Promise | undefined = undefined; - const defaultAgent = this.chatAgentService.getDefaultAgent(); if (agentPart || (defaultAgent && !commandPart)) { const agent = (agentPart?.agent ?? defaultAgent)!; - const history: IChatAgentHistoryEntry[] = []; - for (const request of model.getRequests()) { - if (!request.response) { - continue; + await this.extensionService.activateByEvent(`onChatParticipant:${agent.id}`); + const history = getHistoryEntriesFromModel(model); + + const initVariableData: IChatRequestVariableData = { variables: [] }; + request = model.addRequest(parsedRequest, initVariableData, agent, agentSlashCommandPart?.command); + const variableData = await this.chatVariablesService.resolveVariables(parsedRequest, model, progressCallback, token); + request.variableData = variableData; + + const promptTextResult = getPromptText(request.message); + const updatedVariableData = updateRanges(variableData, promptTextResult.diff); // TODO bit of a hack + if (implicitVariablesEnabled) { + const implicitVariables = agent.defaultImplicitVariables; + if (implicitVariables) { + const resolvedImplicitVariables = await Promise.all(implicitVariables.map(async v => ({ name: v, values: await this.chatVariablesService.resolveVariable(v, parsedRequest.text, model, progressCallback, token) } satisfies IChatRequestVariableEntry))); + updatedVariableData.variables.push(...resolvedImplicitVariables); } - - const historyRequest: IChatAgentRequest = { - sessionId, - requestId: request.id, - agentId: request.response.agent?.id ?? '', - message: request.variableData.message, - variables: request.variableData.variables, - command: request.response.slashCommand?.name - }; - history.push({ request: historyRequest, response: request.response.response.value, result: { errorDetails: request.response.errorDetails } }); } - const variableData = await this.chatVariablesService.resolveVariables(parsedRequest, model, token); - request = model.addRequest(parsedRequest, variableData, agent, agentSlashCommandPart?.command); const requestProps: IChatAgentRequest = { sessionId, requestId: request.id, agentId: agent.id, - message: variableData.message, - variables: variableData.variables, + message: promptTextResult.message, command: agentSlashCommandPart?.command.name, + variables: updatedVariableData, + location }; const agentResult = await this.chatAgentService.invokeAgent(agent.id, requestProps, progressCallback, history, token); - rawResponse = { - session: model.session!, - errorDetails: agentResult.errorDetails, - timings: agentResult.timings - }; - agentOrCommandFollowups = agentResult?.followUp ? Promise.resolve(agentResult.followUp) : - this.chatAgentService.getFollowups(agent.id, sessionId, CancellationToken.None); + rawResult = agentResult; + agentOrCommandFollowups = this.chatAgentService.getFollowups(agent.id, requestProps, agentResult, history, followupsCancelToken); } else if (commandPart && this.chatSlashCommandService.hasCommand(commandPart.slashCommand.command)) { - request = model.addRequest(parsedRequest, { message, variables: {} }); + request = model.addRequest(parsedRequest, { variables: [] }); // contributed slash commands // TODO: spell this out in the UI const history: IChatMessage[] = []; @@ -570,11 +576,12 @@ export class ChatService extends Disposable implements IChatService { history.push({ role: ChatMessageRole.User, content: request.message.text }); history.push({ role: ChatMessageRole.Assistant, content: request.response.response.asString() }); } + const message = parsedRequest.text; const commandResult = await this.chatSlashCommandService.executeCommand(commandPart.slashCommand.command, message.substring(commandPart.slashCommand.command.length + 1).trimStart(), new Progress(p => { progressCallback(p); }), history, token); agentOrCommandFollowups = Promise.resolve(commandResult?.followUp); - rawResponse = { session: model.session! }; + rawResult = {}; } else { throw new Error(`Cannot handle request`); @@ -583,36 +590,33 @@ export class ChatService extends Disposable implements IChatService { if (token.isCancellationRequested) { return; } else { - if (!rawResponse) { + if (!rawResult) { this.trace('sendRequest', `Provider returned no response for session ${model.sessionId}`); - rawResponse = { session: model.session!, errorDetails: { message: localize('emptyResponse', "Provider returned null response") } }; + rawResult = { errorDetails: { message: localize('emptyResponse', "Provider returned null response") } }; } - const result = rawResponse.errorDetails?.responseIsFiltered ? 'filtered' : - rawResponse.errorDetails && gotProgress ? 'errorWithOutput' : - rawResponse.errorDetails ? 'error' : + const result = rawResult.errorDetails?.responseIsFiltered ? 'filtered' : + rawResult.errorDetails && gotProgress ? 'errorWithOutput' : + rawResult.errorDetails ? 'error' : 'success'; this.telemetryService.publicLog2('interactiveSessionProviderInvoked', { providerId: provider.id, - timeToFirstProgress: rawResponse.timings?.firstProgress, - totalTime: rawResponse.timings?.totalElapsed, + timeToFirstProgress: rawResult.timings?.firstProgress, + totalTime: rawResult.timings?.totalElapsed, result, requestType, agent: agentPart?.agent.id ?? '', slashCommand: agentSlashCommandPart ? agentSlashCommandPart.command.name : commandPart?.slashCommand.command, chatSessionId: model.sessionId }); - model.setResponse(request, rawResponse); + model.setResponse(request, rawResult); this.trace('sendRequest', `Provider returned response for session ${model.sessionId}`); - // TODO refactor this or rethink the API https://github.com/microsoft/vscode-copilot/issues/593 + model.completeResponse(request); if (agentOrCommandFollowups) { agentOrCommandFollowups.then(followups => { model.setFollowups(request, followups); - model.completeResponse(request, rawResponse?.errorDetails); }); - } else { - model.completeResponse(request, rawResponse?.errorDetails); } } } finally { @@ -642,11 +646,6 @@ export class ChatService extends Disposable implements IChatService { model.removeRequest(requestId); } - async sendRequestToProvider(sessionId: string, message: IChatDynamicRequest): Promise<{ responseCompletePromise: Promise } | undefined> { - this.trace('sendRequestToProvider', `sessionId: ${sessionId}`); - return await this.sendRequest(sessionId, message.message); - } - getProviders(): string[] { return Array.from(this._providers.keys()); } @@ -663,7 +662,7 @@ export class ChatService extends Disposable implements IChatService { const parsedRequest = typeof message === 'string' ? this.instantiationService.createInstance(ChatRequestParser).parseChatRequest(sessionId, message) : message; - const request = model.addRequest(parsedRequest, variableData || { message: parsedRequest.text, variables: {} }); + const request = model.addRequest(parsedRequest, variableData || { variables: [] }); if (typeof response.message === 'string') { model.acceptResponseProgress(request, { content: response.message, kind: 'content' }); } else { @@ -671,14 +670,11 @@ export class ChatService extends Disposable implements IChatService { model.acceptResponseProgress(request, part, true); } } - model.setResponse(request, { - session: model.session!, - errorDetails: response.errorDetails, - }); + model.setResponse(request, response.result || {}); if (response.followups !== undefined) { model.setFollowups(request, response.followups); } - model.completeResponse(request, response.errorDetails); + model.completeResponse(request); } cancelCurrentRequestForSession(sessionId: string): void { diff --git a/src/vs/workbench/contrib/chat/common/chatSlashCommands.ts b/src/vs/workbench/contrib/chat/common/chatSlashCommands.ts index 3d758da31137e..2d43f1c039682 100644 --- a/src/vs/workbench/contrib/chat/common/chatSlashCommands.ts +++ b/src/vs/workbench/contrib/chat/common/chatSlashCommands.ts @@ -8,7 +8,7 @@ import { Emitter, Event } from 'vs/base/common/event'; import { Disposable, IDisposable, toDisposable } from 'vs/base/common/lifecycle'; import { createDecorator } from 'vs/platform/instantiation/common/instantiation'; import { IProgress } from 'vs/platform/progress/common/progress'; -import { IChatMessage } from 'vs/workbench/contrib/chat/common/chatProvider'; +import { IChatMessage } from 'vs/workbench/contrib/chat/common/languageModels'; import { IChatFollowup, IChatProgress, IChatResponseProgressFileTreeData } from 'vs/workbench/contrib/chat/common/chatService'; import { IExtensionService } from 'vs/workbench/services/extensions/common/extensions'; diff --git a/src/vs/workbench/contrib/chat/common/chatVariables.ts b/src/vs/workbench/contrib/chat/common/chatVariables.ts index f95b9da120e79..dc999f8081f3b 100644 --- a/src/vs/workbench/contrib/chat/common/chatVariables.ts +++ b/src/vs/workbench/contrib/chat/common/chatVariables.ts @@ -10,6 +10,7 @@ import { IRange } from 'vs/editor/common/core/range'; import { createDecorator } from 'vs/platform/instantiation/common/instantiation'; import { IChatModel, IChatRequestVariableData } from 'vs/workbench/contrib/chat/common/chatModel'; import { IParsedChatRequest } from 'vs/workbench/contrib/chat/common/chatParserTypes'; +import { IChatContentReference, IChatProgressMessage } from 'vs/workbench/contrib/chat/common/chatService'; export interface IChatVariableData { name: string; @@ -25,9 +26,13 @@ export interface IChatRequestVariableValue { description?: string; } +export type IChatVariableResolverProgress = + | IChatContentReference + | IChatProgressMessage; + export interface IChatVariableResolver { // TODO should we spec "zoom level" - (messageText: string, arg: string | undefined, model: IChatModel, token: CancellationToken): Promise; + (messageText: string, arg: string | undefined, model: IChatModel, progress: (part: IChatVariableResolverProgress) => void, token: CancellationToken): Promise; } export const IChatVariablesService = createDecorator('IChatVariablesService'); @@ -36,13 +41,15 @@ export interface IChatVariablesService { _serviceBrand: undefined; registerVariable(data: IChatVariableData, resolver: IChatVariableResolver): IDisposable; hasVariable(name: string): boolean; + getVariable(name: string): IChatVariableData | undefined; getVariables(): Iterable>; getDynamicVariables(sessionId: string): ReadonlyArray; // should be its own service? /** * Resolves all variables that occur in `prompt` */ - resolveVariables(prompt: IParsedChatRequest, model: IChatModel, token: CancellationToken): Promise; + resolveVariables(prompt: IParsedChatRequest, model: IChatModel, progress: (part: IChatVariableResolverProgress) => void, token: CancellationToken): Promise; + resolveVariable(variableName: string, promptText: string, model: IChatModel, progress: (part: IChatVariableResolverProgress) => void, token: CancellationToken): Promise; } export interface IDynamicVariable { diff --git a/src/vs/workbench/contrib/chat/common/chatViewModel.ts b/src/vs/workbench/contrib/chat/common/chatViewModel.ts index e3712490d9ad4..b03b90f16cf8f 100644 --- a/src/vs/workbench/contrib/chat/common/chatViewModel.ts +++ b/src/vs/workbench/contrib/chat/common/chatViewModel.ts @@ -5,14 +5,18 @@ import { Emitter, Event } from 'vs/base/common/event'; import { Disposable } from 'vs/base/common/lifecycle'; +import { marked } from 'vs/base/common/marked/marked'; +import { ThemeIcon } from 'vs/base/common/themables'; import { URI } from 'vs/base/common/uri'; import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; import { ILogService } from 'vs/platform/log/common/log'; -import { IChatAgentCommand, IChatAgentData } from 'vs/workbench/contrib/chat/common/chatAgents'; +import { annotateVulnerabilitiesInText } from 'vs/workbench/contrib/chat/common/annotations'; +import { IChatAgentCommand, IChatAgentData, IChatAgentResult } from 'vs/workbench/contrib/chat/common/chatAgents'; import { ChatModelInitState, IChatModel, IChatRequestModel, IChatResponseModel, IChatWelcomeMessageContent, IResponse } from 'vs/workbench/contrib/chat/common/chatModel'; import { IParsedChatRequest } from 'vs/workbench/contrib/chat/common/chatParserTypes'; -import { IChatContentReference, IChatProgressMessage, IChatReplyFollowup, IChatResponseCommandFollowup, IChatResponseErrorDetails, IChatResponseProgressFileTreeData, IChatUsedContext, InteractiveSessionVoteDirection } from 'vs/workbench/contrib/chat/common/chatService'; +import { IChatCommandButton, IChatContentReference, IChatFollowup, IChatProgressMessage, IChatResponseErrorDetails, IChatResponseProgressFileTreeData, IChatTextEdit, IChatUsedContext, InteractiveSessionVoteDirection } from 'vs/workbench/contrib/chat/common/chatService'; import { countWords } from 'vs/workbench/contrib/chat/common/chatWordCounter'; +import { CodeBlockModelCollection } from './codeBlockModelCollection'; export function isRequestVM(item: unknown): item is IChatRequestViewModel { return !!item && typeof item === 'object' && 'message' in item; @@ -41,6 +45,7 @@ export interface IChatSessionInitEvent { } export interface IChatViewModel { + readonly model: IChatModel; readonly initState: ChatModelInitState; readonly providerId: string; readonly sessionId: string; @@ -59,8 +64,8 @@ export interface IChatRequestViewModel { /** This ID updates every time the underlying data changes */ readonly dataId: string; readonly username: string; - readonly avatarIconUri?: URI; - readonly message: IParsedChatRequest | IChatReplyFollowup; + readonly avatarIcon?: URI | ThemeIcon; + readonly message: IParsedChatRequest | IChatFollowup; readonly messageText: string; currentRenderedHeight: number | undefined; } @@ -88,7 +93,7 @@ export interface IChatProgressMessageRenderData { isLast: boolean; } -export type IChatRenderData = IChatResponseProgressFileTreeData | IChatResponseMarkdownRenderData | IChatProgressMessageRenderData; +export type IChatRenderData = IChatResponseProgressFileTreeData | IChatResponseMarkdownRenderData | IChatProgressMessageRenderData | IChatCommandButton | IChatTextEdit; export interface IChatResponseRenderData { renderedParts: IChatRenderData[]; } @@ -109,19 +114,21 @@ export interface IChatResponseViewModel { /** The ID of the associated IChatRequestViewModel */ readonly requestId: string; readonly username: string; - readonly avatarIconUri?: URI; + readonly avatarIcon?: URI | ThemeIcon; readonly agent?: IChatAgentData; readonly slashCommand?: IChatAgentCommand; + readonly agentOrSlashCommandDetected: boolean; readonly response: IResponse; readonly usedContext: IChatUsedContext | undefined; readonly contentReferences: ReadonlyArray; readonly progressMessages: ReadonlyArray; readonly isComplete: boolean; readonly isCanceled: boolean; + readonly isStale: boolean; readonly vote: InteractiveSessionVoteDirection | undefined; - readonly replyFollowups?: IChatReplyFollowup[]; - readonly commandFollowups?: IChatResponseCommandFollowup[]; + readonly replyFollowups?: IChatFollowup[]; readonly errorDetails?: IChatResponseErrorDetails; + readonly result?: IChatAgentResult; readonly contentUpdateTimings?: IChatLiveUpdateData; renderData?: IChatResponseRenderData; agentAvatarHasBeenRendered?: boolean; @@ -132,6 +139,7 @@ export interface IChatResponseViewModel { } export class ChatViewModel extends Disposable implements IChatViewModel { + private readonly _onDidDisposeModel = this._register(new Emitter()); readonly onDidDisposeModel = this._onDidDisposeModel.event; @@ -142,7 +150,11 @@ export class ChatViewModel extends Disposable implements IChatViewModel { private _inputPlaceholder: string | undefined = undefined; get inputPlaceholder(): string | undefined { - return this._inputPlaceholder ?? this._model.inputPlaceholder; + return this._inputPlaceholder; + } + + get model(): IChatModel { + return this._model; } setInputPlaceholder(text: string): void { @@ -173,12 +185,16 @@ export class ChatViewModel extends Disposable implements IChatViewModel { constructor( private readonly _model: IChatModel, + public readonly codeBlockModelCollection: CodeBlockModelCollection, @IInstantiationService private readonly instantiationService: IInstantiationService, ) { super(); _model.getRequests().forEach((request, i) => { - this._items.push(new ChatRequestViewModel(request)); + const requestModel = this.instantiationService.createInstance(ChatRequestViewModel, request); + this._items.push(requestModel); + this.updateCodeBlockTextModels(requestModel); + if (request.response) { this.onAddResponse(request.response); } @@ -187,7 +203,10 @@ export class ChatViewModel extends Disposable implements IChatViewModel { this._register(_model.onDidDispose(() => this._onDidDisposeModel.fire())); this._register(_model.onDidChange(e => { if (e.kind === 'addRequest') { - this._items.push(new ChatRequestViewModel(e.request)); + const requestModel = this.instantiationService.createInstance(ChatRequestViewModel, e.request); + this._items.push(requestModel); + this.updateCodeBlockTextModels(requestModel); + if (e.request.response) { this.onAddResponse(e.request.response); } @@ -203,7 +222,7 @@ export class ChatViewModel extends Disposable implements IChatViewModel { if (typeof responseIdx === 'number' && responseIdx >= 0) { const items = this._items.splice(responseIdx, 1); const item = items[0]; - if (isResponseVM(item)) { + if (item instanceof ChatResponseViewModel) { item.dispose(); } } @@ -218,8 +237,14 @@ export class ChatViewModel extends Disposable implements IChatViewModel { private onAddResponse(responseModel: IChatResponseModel) { const response = this.instantiationService.createInstance(ChatResponseViewModel, responseModel); - this._register(response.onDidChange(() => this._onDidChange.fire(null))); + this._register(response.onDidChange(() => { + if (response.isComplete) { + this.updateCodeBlockTextModels(response); + } + return this._onDidChange.fire(null); + })); this._items.push(response); + this.updateCodeBlockTextModels(response); } getItems(): (IChatRequestViewModel | IChatResponseViewModel | IChatWelcomeMessageViewModel)[] { @@ -232,6 +257,60 @@ export class ChatViewModel extends Disposable implements IChatViewModel { .filter((item): item is ChatResponseViewModel => item instanceof ChatResponseViewModel) .forEach((item: ChatResponseViewModel) => item.dispose()); } + + updateCodeBlockTextModels(model: IChatRequestViewModel | IChatResponseViewModel) { + let content: string; + if (isRequestVM(model)) { + content = model.messageText; + } else { + content = annotateVulnerabilitiesInText(model.response.value).map(x => x.content.value).join(''); + } + + let codeBlockIndex = 0; + const renderer = new marked.Renderer(); + renderer.code = (value, languageId) => { + languageId ??= ''; + const newText = this.fixCodeText(value, languageId); + this.codeBlockModelCollection.update(this._model.sessionId, model, codeBlockIndex++, { text: newText, languageId }); + return ''; + }; + + marked.parse(this.ensureFencedCodeBlocksTerminated(content), { renderer }); + } + + private fixCodeText(text: string, languageId: string): string { + if (languageId === 'php') { + if (!text.trim().startsWith('<')) { + return ``; + } + } + + return text; + } + + /** + * Marked doesn't consistently render fenced code blocks that aren't terminated. + * + * Try to close them ourselves to workaround this. + */ + private ensureFencedCodeBlocksTerminated(content: string): string { + const lines = content.split('\n'); + let inCodeBlock = false; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + if (line.startsWith('```')) { + inCodeBlock = !inCodeBlock; + } + } + + // If we're still in a code block at the end of the content, add a closing fence + if (inCodeBlock) { + lines.push('```'); + } + + return lines.join('\n'); + } } export class ChatRequestViewModel implements IChatRequestViewModel { @@ -251,7 +330,7 @@ export class ChatRequestViewModel implements IChatRequestViewModel { return this._model.username; } - get avatarIconUri() { + get avatarIcon() { return this._model.avatarIconUri; } @@ -260,12 +339,14 @@ export class ChatRequestViewModel implements IChatRequestViewModel { } get messageText() { - return 'kind' in this.message ? this.message.message : this.message.text; + return this.message.text; } currentRenderedHeight: number | undefined; - constructor(readonly _model: IChatRequestModel) { } + constructor( + private readonly _model: IChatRequestModel, + ) { } } export class ChatResponseViewModel extends Disposable implements IChatResponseViewModel { @@ -294,8 +375,8 @@ export class ChatResponseViewModel extends Disposable implements IChatResponseVi return this._model.username; } - get avatarIconUri() { - return this._model.avatarIconUri; + get avatarIcon() { + return this._model.avatarIcon; } get agent() { @@ -306,6 +387,10 @@ export class ChatResponseViewModel extends Disposable implements IChatResponseVi return this._model.slashCommand; } + get agentOrSlashCommandDetected() { + return this._model.agentOrSlashCommandDetected; + } + get response(): IResponse { return this._model.response; } @@ -331,15 +416,15 @@ export class ChatResponseViewModel extends Disposable implements IChatResponseVi } get replyFollowups() { - return this._model.followups?.filter((f): f is IChatReplyFollowup => f.kind === 'reply'); + return this._model.followups?.filter((f): f is IChatFollowup => f.kind === 'reply'); } - get commandFollowups() { - return this._model.followups?.filter((f): f is IChatResponseCommandFollowup => f.kind === 'command'); + get result() { + return this._model.result; } - get errorDetails() { - return this._model.errorDetails; + get errorDetails(): IChatResponseErrorDetails | undefined { + return this.result?.errorDetails; } get vote() { @@ -350,6 +435,10 @@ export class ChatResponseViewModel extends Disposable implements IChatResponseVi return this._model.requestId; } + get isStale() { + return this._model.isStale; + } + renderData: IChatResponseRenderData | undefined = undefined; agentAvatarHasBeenRendered?: boolean; currentRenderedHeight: number | undefined; @@ -383,7 +472,7 @@ export class ChatResponseViewModel extends Disposable implements IChatResponseVi constructor( private readonly _model: IChatResponseModel, - @ILogService private readonly logService: ILogService + @ILogService private readonly logService: ILogService, ) { super(); @@ -434,8 +523,8 @@ export class ChatResponseViewModel extends Disposable implements IChatResponseVi export interface IChatWelcomeMessageViewModel { readonly id: string; readonly username: string; - readonly avatarIconUri?: URI; + readonly avatarIcon?: URI | ThemeIcon; readonly content: IChatWelcomeMessageContent[]; - readonly sampleQuestions: IChatReplyFollowup[]; + readonly sampleQuestions: IChatFollowup[]; currentRenderedHeight?: number; } diff --git a/src/vs/workbench/contrib/chat/common/codeBlockModelCollection.ts b/src/vs/workbench/contrib/chat/common/codeBlockModelCollection.ts new file mode 100644 index 0000000000000..f364b9e78ab0f --- /dev/null +++ b/src/vs/workbench/contrib/chat/common/codeBlockModelCollection.ts @@ -0,0 +1,139 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable, IReference } from 'vs/base/common/lifecycle'; +import { ResourceMap } from 'vs/base/common/map'; +import { Schemas } from 'vs/base/common/network'; +import { URI } from 'vs/base/common/uri'; +import { Range } from 'vs/editor/common/core/range'; +import { ILanguageService } from 'vs/editor/common/languages/language'; +import { EndOfLinePreference } from 'vs/editor/common/model'; +import { IResolvedTextEditorModel, ITextModelService } from 'vs/editor/common/services/resolverService'; +import { IChatRequestViewModel, IChatResponseViewModel, isResponseVM } from 'vs/workbench/contrib/chat/common/chatViewModel'; +import { extractVulnerabilitiesFromText, IMarkdownVulnerability } from './annotations'; + + +export class CodeBlockModelCollection extends Disposable { + + private readonly _models = new ResourceMap<{ + readonly model: Promise>; + vulns: readonly IMarkdownVulnerability[]; + }>(); + + constructor( + @ILanguageService private readonly languageService: ILanguageService, + @ITextModelService private readonly textModelService: ITextModelService + ) { + super(); + } + + public override dispose(): void { + super.dispose(); + this.clear(); + } + + get(sessionId: string, chat: IChatRequestViewModel | IChatResponseViewModel, codeBlockIndex: number): { model: Promise; readonly vulns: readonly IMarkdownVulnerability[] } | undefined { + const uri = this.getUri(sessionId, chat, codeBlockIndex); + const entry = this._models.get(uri); + if (!entry) { + return; + } + return { model: entry.model.then(ref => ref.object), vulns: entry.vulns }; + } + + getOrCreate(sessionId: string, chat: IChatRequestViewModel | IChatResponseViewModel, codeBlockIndex: number): { model: Promise; readonly vulns: readonly IMarkdownVulnerability[] } { + const existing = this.get(sessionId, chat, codeBlockIndex); + if (existing) { + return existing; + } + + const uri = this.getUri(sessionId, chat, codeBlockIndex); + const ref = this.textModelService.createModelReference(uri); + this._models.set(uri, { model: ref, vulns: [] }); + return { model: ref.then(ref => ref.object), vulns: [] }; + } + + clear(): void { + this._models.forEach(async entry => (await entry.model).dispose()); + this._models.clear(); + } + + async update(sessionId: string, chat: IChatRequestViewModel | IChatResponseViewModel, codeBlockIndex: number, content: { text: string; languageId?: string }) { + const entry = this.getOrCreate(sessionId, chat, codeBlockIndex); + + const extractedVulns = extractVulnerabilitiesFromText(content.text); + const newText = extractedVulns.newText; + this.setVulns(sessionId, chat, codeBlockIndex, extractedVulns.vulnerabilities); + + const textModel = (await entry.model).textEditorModel; + if (content.languageId) { + const vscodeLanguageId = this.languageService.getLanguageIdByLanguageName(content.languageId); + if (vscodeLanguageId && vscodeLanguageId !== textModel.getLanguageId()) { + textModel.setLanguage(vscodeLanguageId); + } + } + + const currentText = textModel.getValue(EndOfLinePreference.LF); + if (newText === currentText) { + return; + } + + if (newText.startsWith(currentText)) { + const text = newText.slice(currentText.length); + const lastLine = textModel.getLineCount(); + const lastCol = textModel.getLineMaxColumn(lastLine); + textModel.applyEdits([{ range: new Range(lastLine, lastCol, lastLine, lastCol), text }]); + } else { + // console.log(`Failed to optimize setText`); + textModel.setValue(newText); + } + } + + private setVulns(sessionId: string, chat: IChatRequestViewModel | IChatResponseViewModel, codeBlockIndex: number, vulnerabilities: IMarkdownVulnerability[]) { + const uri = this.getUri(sessionId, chat, codeBlockIndex); + const entry = this._models.get(uri); + if (entry) { + entry.vulns = vulnerabilities; + } + } + + private getUri(sessionId: string, chat: IChatRequestViewModel | IChatResponseViewModel, index: number): URI { + const metadata = this.getUriMetaData(chat); + return URI.from({ + scheme: Schemas.vscodeChatCodeBlock, + authority: sessionId, + path: `/${chat.id}/${index}`, + fragment: metadata ? JSON.stringify(metadata) : undefined, + }); + } + + private getUriMetaData(chat: IChatRequestViewModel | IChatResponseViewModel) { + if (!isResponseVM(chat)) { + return undefined; + } + + return { + references: chat.contentReferences.map(ref => { + const uriOrLocation = 'variableName' in ref.reference ? + ref.reference.value : + ref.reference; + if (!uriOrLocation) { + return; + } + + if (URI.isUri(uriOrLocation)) { + return { + uri: uriOrLocation.toJSON() + }; + } + + return { + uri: uriOrLocation.uri.toJSON(), + range: uriOrLocation.range, + }; + }) + }; + } +} diff --git a/src/vs/workbench/contrib/chat/common/languageModels.ts b/src/vs/workbench/contrib/chat/common/languageModels.ts new file mode 100644 index 0000000000000..7304d00262887 --- /dev/null +++ b/src/vs/workbench/contrib/chat/common/languageModels.ts @@ -0,0 +1,103 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { CancellationToken } from 'vs/base/common/cancellation'; +import { Emitter, Event } from 'vs/base/common/event'; +import { IDisposable, toDisposable } from 'vs/base/common/lifecycle'; +import { ExtensionIdentifier } from 'vs/platform/extensions/common/extensions'; +import { createDecorator } from 'vs/platform/instantiation/common/instantiation'; +import { IProgress } from 'vs/platform/progress/common/progress'; + +export const enum ChatMessageRole { + System, + User, + Assistant, +} + +export interface IChatMessage { + readonly role: ChatMessageRole; + readonly content: string; +} + +export interface IChatResponseFragment { + index: number; + part: string; +} + +export interface ILanguageModelChatMetadata { + readonly extension: ExtensionIdentifier; + readonly identifier: string; + readonly model: string; + readonly description?: string; + readonly auth?: { + readonly providerLabel: string; + readonly accountLabel?: string; + }; +} + +export interface ILanguageModelChat { + metadata: ILanguageModelChatMetadata; + provideChatResponse(messages: IChatMessage[], from: ExtensionIdentifier, options: { [name: string]: any }, progress: IProgress, token: CancellationToken): Promise; +} + +export const ILanguageModelsService = createDecorator('ILanguageModelsService'); + +export interface ILanguageModelsService { + + readonly _serviceBrand: undefined; + + onDidChangeLanguageModels: Event<{ added?: ILanguageModelChatMetadata[]; removed?: string[] }>; + + getLanguageModelIds(): string[]; + + lookupLanguageModel(identifier: string): ILanguageModelChatMetadata | undefined; + + registerLanguageModelChat(identifier: string, provider: ILanguageModelChat): IDisposable; + + makeLanguageModelChatRequest(identifier: string, from: ExtensionIdentifier, messages: IChatMessage[], options: { [name: string]: any }, progress: IProgress, token: CancellationToken): Promise; +} + +export class LanguageModelsService implements ILanguageModelsService { + readonly _serviceBrand: undefined; + + private readonly _providers: Map = new Map(); + + private readonly _onDidChangeProviders = new Emitter<{ added?: ILanguageModelChatMetadata[]; removed?: string[] }>(); + readonly onDidChangeLanguageModels: Event<{ added?: ILanguageModelChatMetadata[]; removed?: string[] }> = this._onDidChangeProviders.event; + + dispose() { + this._onDidChangeProviders.dispose(); + this._providers.clear(); + } + + getLanguageModelIds(): string[] { + return Array.from(this._providers.keys()); + } + + lookupLanguageModel(identifier: string): ILanguageModelChatMetadata | undefined { + return this._providers.get(identifier)?.metadata; + } + + registerLanguageModelChat(identifier: string, provider: ILanguageModelChat): IDisposable { + if (this._providers.has(identifier)) { + throw new Error(`Chat response provider with identifier ${identifier} is already registered.`); + } + this._providers.set(identifier, provider); + this._onDidChangeProviders.fire({ added: [provider.metadata] }); + return toDisposable(() => { + if (this._providers.delete(identifier)) { + this._onDidChangeProviders.fire({ removed: [identifier] }); + } + }); + } + + makeLanguageModelChatRequest(identifier: string, from: ExtensionIdentifier, messages: IChatMessage[], options: { [name: string]: any }, progress: IProgress, token: CancellationToken): Promise { + const provider = this._providers.get(identifier); + if (!provider) { + throw new Error(`Chat response provider with identifier ${identifier} is not registered.`); + } + return provider.provideChatResponse(messages, from, options, progress, token); + } +} diff --git a/src/vs/workbench/contrib/chat/common/voiceChat.ts b/src/vs/workbench/contrib/chat/common/voiceChat.ts new file mode 100644 index 0000000000000..1c93007b8cf95 --- /dev/null +++ b/src/vs/workbench/contrib/chat/common/voiceChat.ts @@ -0,0 +1,214 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { CancellationToken } from 'vs/base/common/cancellation'; +import { Emitter, Event } from 'vs/base/common/event'; +import { Disposable, DisposableStore } from 'vs/base/common/lifecycle'; +import { rtrim } from 'vs/base/common/strings'; +import { createDecorator } from 'vs/platform/instantiation/common/instantiation'; +import { IChatAgentService } from 'vs/workbench/contrib/chat/common/chatAgents'; +import { IChatModel } from 'vs/workbench/contrib/chat/common/chatModel'; +import { chatAgentLeader, chatSubcommandLeader } from 'vs/workbench/contrib/chat/common/chatParserTypes'; +import { ISpeechService, ISpeechToTextEvent, SpeechToTextStatus } from 'vs/workbench/contrib/speech/common/speechService'; + +export const IVoiceChatService = createDecorator('voiceChatService'); + +export interface IVoiceChatSessionOptions { + readonly usesAgents?: boolean; + readonly model?: IChatModel; +} + +export interface IVoiceChatService { + + readonly _serviceBrand: undefined; + + /** + * Similar to `ISpeechService.createSpeechToTextSession`, but with + * support for agent prefixes and command prefixes. For example, + * if the user says "at workspace slash fix this problem", the result + * will be "@workspace /fix this problem". + */ + createVoiceChatSession(token: CancellationToken, options: IVoiceChatSessionOptions): Promise; +} + +export interface IVoiceChatTextEvent extends ISpeechToTextEvent { + + /** + * This property will be `true` when the text recognized + * so far only consists of agent prefixes (`@workspace`) + * and/or command prefixes (`@workspace /fix`). + */ + readonly waitingForInput?: boolean; +} + +export interface IVoiceChatSession { + readonly onDidChange: Event; +} + +interface IPhraseValue { + readonly agent: string; + readonly command?: string; +} + +enum PhraseTextType { + AGENT = 1, + COMMAND = 2, + AGENT_AND_COMMAND = 3 +} + +export class VoiceChatService extends Disposable implements IVoiceChatService { + + readonly _serviceBrand: undefined; + + private static readonly AGENT_PREFIX = chatAgentLeader; + private static readonly COMMAND_PREFIX = chatSubcommandLeader; + + private static readonly PHRASES_LOWER = { + [VoiceChatService.AGENT_PREFIX]: 'at', + [VoiceChatService.COMMAND_PREFIX]: 'slash' + }; + + private static readonly PHRASES_UPPER = { + [VoiceChatService.AGENT_PREFIX]: 'At', + [VoiceChatService.COMMAND_PREFIX]: 'Slash' + }; + + private static readonly CHAT_AGENT_ALIAS = new Map([['vscode', 'code']]); + + constructor( + @ISpeechService private readonly speechService: ISpeechService, + @IChatAgentService private readonly chatAgentService: IChatAgentService + ) { + super(); + } + + private createPhrases(model?: IChatModel): Map { + const phrases = new Map(); + + for (const agent of this.chatAgentService.getActivatedAgents()) { + const agentPhrase = `${VoiceChatService.PHRASES_LOWER[VoiceChatService.AGENT_PREFIX]} ${VoiceChatService.CHAT_AGENT_ALIAS.get(agent.id) ?? agent.id}`.toLowerCase(); + phrases.set(agentPhrase, { agent: agent.id }); + + for (const slashCommand of agent.slashCommands) { + const slashCommandPhrase = `${VoiceChatService.PHRASES_LOWER[VoiceChatService.COMMAND_PREFIX]} ${slashCommand.name}`.toLowerCase(); + phrases.set(slashCommandPhrase, { agent: agent.id, command: slashCommand.name }); + + const agentSlashCommandPhrase = `${agentPhrase} ${slashCommandPhrase}`.toLowerCase(); + phrases.set(agentSlashCommandPhrase, { agent: agent.id, command: slashCommand.name }); + } + } + + return phrases; + } + + private toText(value: IPhraseValue, type: PhraseTextType): string { + switch (type) { + case PhraseTextType.AGENT: + return `${VoiceChatService.AGENT_PREFIX}${value.agent}`; + case PhraseTextType.COMMAND: + return `${VoiceChatService.COMMAND_PREFIX}${value.command}`; + case PhraseTextType.AGENT_AND_COMMAND: + return `${VoiceChatService.AGENT_PREFIX}${value.agent} ${VoiceChatService.COMMAND_PREFIX}${value.command}`; + } + } + + async createVoiceChatSession(token: CancellationToken, options: IVoiceChatSessionOptions): Promise { + const disposables = new DisposableStore(); + disposables.add(token.onCancellationRequested(() => disposables.dispose())); + + let detectedAgent = false; + let detectedSlashCommand = false; + + const emitter = disposables.add(new Emitter()); + const session = await this.speechService.createSpeechToTextSession(token, 'chat'); + + const phrases = this.createPhrases(options.model); + disposables.add(session.onDidChange(e => { + switch (e.status) { + case SpeechToTextStatus.Recognizing: + case SpeechToTextStatus.Recognized: + if (e.text) { + const startsWithAgent = e.text.startsWith(VoiceChatService.PHRASES_UPPER[VoiceChatService.AGENT_PREFIX]) || e.text.startsWith(VoiceChatService.PHRASES_LOWER[VoiceChatService.AGENT_PREFIX]); + const startsWithSlashCommand = e.text.startsWith(VoiceChatService.PHRASES_UPPER[VoiceChatService.COMMAND_PREFIX]) || e.text.startsWith(VoiceChatService.PHRASES_LOWER[VoiceChatService.COMMAND_PREFIX]); + if (startsWithAgent || startsWithSlashCommand) { + const originalWords = e.text.split(' '); + let transformedWords: string[] | undefined; + + let waitingForInput = false; + + // Check for agent + slash command + if (options.usesAgents && startsWithAgent && !detectedAgent && !detectedSlashCommand && originalWords.length >= 4) { + const phrase = phrases.get(originalWords.slice(0, 4).map(word => this.normalizeWord(word)).join(' ')); + if (phrase) { + transformedWords = [this.toText(phrase, PhraseTextType.AGENT_AND_COMMAND), ...originalWords.slice(4)]; + + waitingForInput = originalWords.length === 4; + + if (e.status === SpeechToTextStatus.Recognized) { + detectedAgent = true; + detectedSlashCommand = true; + } + } + } + + // Check for agent (if not done already) + if (options.usesAgents && startsWithAgent && !detectedAgent && !transformedWords && originalWords.length >= 2) { + const phrase = phrases.get(originalWords.slice(0, 2).map(word => this.normalizeWord(word)).join(' ')); + if (phrase) { + transformedWords = [this.toText(phrase, PhraseTextType.AGENT), ...originalWords.slice(2)]; + + waitingForInput = originalWords.length === 2; + + if (e.status === SpeechToTextStatus.Recognized) { + detectedAgent = true; + } + } + } + + // Check for slash command (if not done already) + if (startsWithSlashCommand && !detectedSlashCommand && !transformedWords && originalWords.length >= 2) { + const phrase = phrases.get(originalWords.slice(0, 2).map(word => this.normalizeWord(word)).join(' ')); + if (phrase) { + transformedWords = [this.toText(phrase, options.usesAgents && !detectedAgent ? + PhraseTextType.AGENT_AND_COMMAND : // rewrite `/fix` to `@workspace /foo` in this case + PhraseTextType.COMMAND // when we have not yet detected an agent before + ), ...originalWords.slice(2)]; + + waitingForInput = originalWords.length === 2; + + if (e.status === SpeechToTextStatus.Recognized) { + detectedSlashCommand = true; + } + } + } + + emitter.fire({ + status: e.status, + text: (transformedWords ?? originalWords).join(' '), + waitingForInput + }); + + break; + } + } + default: + emitter.fire(e); + break; + } + })); + + return { + onDidChange: emitter.event + }; + } + + private normalizeWord(word: string): string { + word = rtrim(word, '.'); + word = rtrim(word, ','); + word = rtrim(word, '?'); + + return word.toLowerCase(); + } +} diff --git a/src/vs/workbench/contrib/chat/electron-sandbox/actions/media/voiceChatActions.css b/src/vs/workbench/contrib/chat/electron-sandbox/actions/media/voiceChatActions.css index 7a43cef7d12df..f386a4a0089bc 100644 --- a/src/vs/workbench/contrib/chat/electron-sandbox/actions/media/voiceChatActions.css +++ b/src/vs/workbench/contrib/chat/electron-sandbox/actions/media/voiceChatActions.css @@ -6,23 +6,22 @@ /* * Replace with "microphone" icon. */ -.monaco-workbench .interactive-input-part .monaco-action-bar .action-label.codicon-loading.codicon-modifier-spin:not(.disabled)::before, -.monaco-workbench .inline-chat .monaco-action-bar .action-label.codicon-loading.codicon-modifier-spin:not(.disabled)::before { +.monaco-workbench .interactive-input-part .monaco-action-bar .action-label.codicon-loading.codicon-modifier-spin:not(.disabled)::before { content: "\ec1c"; + font-family: 'codicon'; } /* * Clear animation styles when reduced motion is enabled. */ -.monaco-workbench.reduce-motion .interactive-input-part .monaco-action-bar .action-label.codicon-loading.codicon-modifier-spin:not(.disabled), -.monaco-workbench.reduce-motion .inline-chat .monaco-action-bar .action-label.codicon-loading.codicon-modifier-spin:not(.disabled) { +.monaco-workbench.reduce-motion .interactive-input-part .monaco-action-bar .action-label.codicon-loading.codicon-modifier-spin:not(.disabled) { animation: none; } /* * Replace with "stop" icon when reduced motion is enabled. */ -.monaco-workbench.reduce-motion .interactive-input-part .monaco-action-bar .action-label.codicon-loading.codicon-modifier-spin:not(.disabled)::before, -.monaco-workbench.reduce-motion .inline-chat .monaco-action-bar .action-label.codicon-loading.codicon-modifier-spin:not(.disabled)::before { +.monaco-workbench.reduce-motion .interactive-input-part .monaco-action-bar .action-label.codicon-loading.codicon-modifier-spin:not(.disabled)::before { content: "\ead7"; + font-family: 'codicon'; } diff --git a/src/vs/workbench/contrib/chat/electron-sandbox/actions/voiceChatActions.ts b/src/vs/workbench/contrib/chat/electron-sandbox/actions/voiceChatActions.ts index 3b605a096d548..0e2bd19e8d5a2 100644 --- a/src/vs/workbench/contrib/chat/electron-sandbox/actions/voiceChatActions.ts +++ b/src/vs/workbench/contrib/chat/electron-sandbox/actions/voiceChatActions.ts @@ -6,60 +6,68 @@ import 'vs/css!./media/voiceChatActions'; import { Event } from 'vs/base/common/event'; import { firstOrDefault } from 'vs/base/common/arrays'; -import { CancellationToken, CancellationTokenSource } from 'vs/base/common/cancellation'; +import { CancellationTokenSource } from 'vs/base/common/cancellation'; import { Codicon } from 'vs/base/common/codicons'; import { Disposable, DisposableStore, MutableDisposable, toDisposable } from 'vs/base/common/lifecycle'; -import { ServicesAccessor } from 'vs/editor/browser/editorExtensions'; import { localize, localize2 } from 'vs/nls'; -import { Action2, MenuId } from 'vs/platform/actions/common/actions'; +import { Action2, IAction2Options, MenuId } from 'vs/platform/actions/common/actions'; import { ContextKeyExpr, IContextKeyService, RawContextKey } from 'vs/platform/contextkey/common/contextkey'; -import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; +import { IInstantiationService, ServicesAccessor } from 'vs/platform/instantiation/common/instantiation'; import { spinningLoading } from 'vs/platform/theme/common/iconRegistry'; import { CHAT_CATEGORY } from 'vs/workbench/contrib/chat/browser/actions/chatActions'; import { IChatWidget, IChatWidgetService, IQuickChatService } from 'vs/workbench/contrib/chat/browser/chat'; import { IChatService, KEYWORD_ACTIVIATION_SETTING_ID } from 'vs/workbench/contrib/chat/common/chatService'; -import { CTX_INLINE_CHAT_HAS_ACTIVE_REQUEST, MENU_INLINE_CHAT_INPUT } from 'vs/workbench/contrib/inlineChat/common/inlineChat'; -import { CONTEXT_CHAT_REQUEST_IN_PROGRESS, CONTEXT_PROVIDER_EXISTS } from 'vs/workbench/contrib/chat/common/chatContextKeys'; +import { CTX_INLINE_CHAT_FOCUSED, CTX_INLINE_CHAT_HAS_ACTIVE_REQUEST } from 'vs/workbench/contrib/inlineChat/common/inlineChat'; +import { CONTEXT_CHAT_REQUEST_IN_PROGRESS, CONTEXT_IN_CHAT_INPUT, CONTEXT_PROVIDER_EXISTS } from 'vs/workbench/contrib/chat/common/chatContextKeys'; import { InlineChatController } from 'vs/workbench/contrib/inlineChat/browser/inlineChatController'; import { CommandsRegistry, ICommandService } from 'vs/platform/commands/common/commands'; import { ActiveEditorContext } from 'vs/workbench/common/contextkeys'; import { IViewsService } from 'vs/workbench/services/views/common/viewsService'; import { IChatContributionService } from 'vs/workbench/contrib/chat/common/chatContributionService'; import { KeybindingWeight } from 'vs/platform/keybinding/common/keybindingsRegistry'; -import { KeyCode } from 'vs/base/common/keyCodes'; +import { KeyCode, KeyMod } from 'vs/base/common/keyCodes'; import { IWorkbenchLayoutService, Parts } from 'vs/workbench/services/layout/browser/layoutService'; import { HasSpeechProvider, ISpeechService, KeywordRecognitionStatus, SpeechToTextStatus } from 'vs/workbench/contrib/speech/common/speechService'; -import { RunOnceScheduler } from 'vs/base/common/async'; +import { RunOnceScheduler, disposableTimeout } from 'vs/base/common/async'; import { registerThemingParticipant } from 'vs/platform/theme/common/themeService'; import { ACTIVITY_BAR_BADGE_BACKGROUND } from 'vs/workbench/common/theme'; import { ColorScheme } from 'vs/platform/theme/common/theme'; import { Color } from 'vs/base/common/color'; import { contrastBorder, focusBorder } from 'vs/platform/theme/common/colorRegistry'; import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; -import { isNumber } from 'vs/base/common/types'; +import { assertIsDefined, isNumber } from 'vs/base/common/types'; import { AccessibilityVoiceSettingId, SpeechTimeoutDefault, accessibilityConfigurationNodeBase } from 'vs/workbench/contrib/accessibility/browser/accessibilityConfiguration'; import { IChatExecuteActionContext } from 'vs/workbench/contrib/chat/browser/actions/chatExecuteActions'; import { IWorkbenchContribution } from 'vs/workbench/common/contributions'; import { Registry } from 'vs/platform/registry/common/platform'; import { IConfigurationRegistry, Extensions } from 'vs/platform/configuration/common/configurationRegistry'; import { IStatusbarEntry, IStatusbarEntryAccessor, IStatusbarService, StatusbarAlignment } from 'vs/workbench/services/statusbar/browser/statusbar'; -import { IEditorGroupsService } from 'vs/workbench/services/editor/common/editorGroupsService'; import { IHostService } from 'vs/workbench/services/host/browser/host'; import { getCodeEditor } from 'vs/editor/browser/editorBrowser'; import { IEditorService } from 'vs/workbench/services/editor/common/editorService'; +import { IExtensionsWorkbenchService } from 'vs/workbench/contrib/extensions/common/extensions'; +import { IVoiceChatService } from 'vs/workbench/contrib/chat/common/voiceChat'; +import { IKeybindingService } from 'vs/platform/keybinding/common/keybinding'; +import { ThemeIcon } from 'vs/base/common/themables'; +import { EditorContextKeys } from 'vs/editor/common/editorContextKeys'; +import { ITerminalService } from 'vs/workbench/contrib/terminal/browser/terminal'; import { ProgressLocation } from 'vs/platform/progress/common/progress'; -import { IDialogService } from 'vs/platform/dialogs/common/dialogs'; -import { ExtensionState, IExtensionsWorkbenchService } from 'vs/workbench/contrib/extensions/common/extensions'; +import { TerminalChatController, TerminalChatContextKeys } from 'vs/workbench/contrib/terminal/browser/terminalContribExports'; +import { NOTEBOOK_EDITOR_FOCUSED } from 'vs/workbench/contrib/notebook/common/notebookContextKeys'; const CONTEXT_VOICE_CHAT_GETTING_READY = new RawContextKey('voiceChatGettingReady', false, { type: 'boolean', description: localize('voiceChatGettingReady', "True when getting ready for receiving voice input from the microphone for voice chat.") }); const CONTEXT_VOICE_CHAT_IN_PROGRESS = new RawContextKey('voiceChatInProgress', false, { type: 'boolean', description: localize('voiceChatInProgress', "True when voice recording from microphone is in progress for voice chat.") }); const CONTEXT_QUICK_VOICE_CHAT_IN_PROGRESS = new RawContextKey('quickVoiceChatInProgress', false, { type: 'boolean', description: localize('quickVoiceChatInProgress', "True when voice recording from microphone is in progress for quick chat.") }); const CONTEXT_INLINE_VOICE_CHAT_IN_PROGRESS = new RawContextKey('inlineVoiceChatInProgress', false, { type: 'boolean', description: localize('inlineVoiceChatInProgress', "True when voice recording from microphone is in progress for inline chat.") }); +const CONTEXT_TERMINAL_VOICE_CHAT_IN_PROGRESS = new RawContextKey('terminalVoiceChatInProgress', false, { type: 'boolean', description: localize('terminalVoiceChatInProgress', "True when voice recording from microphone is in progress for terminal chat.") }); const CONTEXT_VOICE_CHAT_IN_VIEW_IN_PROGRESS = new RawContextKey('voiceChatInViewInProgress', false, { type: 'boolean', description: localize('voiceChatInViewInProgress', "True when voice recording from microphone is in progress in the chat view.") }); const CONTEXT_VOICE_CHAT_IN_EDITOR_IN_PROGRESS = new RawContextKey('voiceChatInEditorInProgress', false, { type: 'boolean', description: localize('voiceChatInEditorInProgress', "True when voice recording from microphone is in progress in the chat editor.") }); -type VoiceChatSessionContext = 'inline' | 'quick' | 'view' | 'editor'; +const CanVoiceChat = ContextKeyExpr.and(CONTEXT_PROVIDER_EXISTS, HasSpeechProvider); +const FocusInChatInput = assertIsDefined(ContextKeyExpr.or(CTX_INLINE_CHAT_FOCUSED, CONTEXT_IN_CHAT_INPUT)); + +type VoiceChatSessionContext = 'inline' | 'terminal' | 'quick' | 'view' | 'editor'; interface IVoiceChatSessionController { @@ -83,18 +91,29 @@ class VoiceChatSessionControllerFactory { static create(accessor: ServicesAccessor, context: 'quick'): Promise; static create(accessor: ServicesAccessor, context: 'view'): Promise; static create(accessor: ServicesAccessor, context: 'focused'): Promise; - static async create(accessor: ServicesAccessor, context: 'inline' | 'quick' | 'view' | 'focused'): Promise { + static create(accessor: ServicesAccessor, context: 'terminal'): Promise; + static create(accessor: ServicesAccessor, context: 'inline' | 'terminal' | 'quick' | 'view' | 'focused'): Promise; + static async create(accessor: ServicesAccessor, context: 'inline' | 'terminal' | 'quick' | 'view' | 'focused'): Promise { const chatWidgetService = accessor.get(IChatWidgetService); - const chatService = accessor.get(IChatService); const viewsService = accessor.get(IViewsService); const chatContributionService = accessor.get(IChatContributionService); const quickChatService = accessor.get(IQuickChatService); const layoutService = accessor.get(IWorkbenchLayoutService); const editorService = accessor.get(IEditorService); + const terminalService = accessor.get(ITerminalService); // Currently Focused Context if (context === 'focused') { + // Try with the terminal chat + const activeInstance = terminalService.activeInstance; + if (activeInstance) { + const terminalChat = TerminalChatController.activeChatWidget || TerminalChatController.get(activeInstance); + if (terminalChat?.hasFocus()) { + return VoiceChatSessionControllerFactory.doCreateForTerminalChat(terminalChat); + } + } + // Try with the chat widget service, which currently // only supports the chat view and quick chat // https://github.com/microsoft/vscode/issues/191191 @@ -128,13 +147,10 @@ class VoiceChatSessionControllerFactory { } // View Chat - if (context === 'view') { - const provider = firstOrDefault(chatService.getProviderInfos()); - if (provider) { - const chatView = await chatWidgetService.revealViewForProvider(provider.id); - if (chatView) { - return VoiceChatSessionControllerFactory.doCreateForChatView(chatView, viewsService, chatContributionService); - } + if (context === 'view' || context === 'focused' /* fallback in case 'focused' was not successful */) { + const chatView = await VoiceChatSessionControllerFactory.revealChatView(accessor); + if (chatView) { + return VoiceChatSessionControllerFactory.doCreateForChatView(chatView, viewsService, chatContributionService); } } @@ -149,6 +165,17 @@ class VoiceChatSessionControllerFactory { } } + // Terminal Chat + if (context === 'terminal') { + const activeInstance = terminalService.activeInstance; + if (activeInstance) { + const terminalChat = TerminalChatController.activeChatWidget || TerminalChatController.get(activeInstance); + if (terminalChat) { + return VoiceChatSessionControllerFactory.doCreateForTerminalChat(terminalChat); + } + } + } + // Quick Chat if (context === 'quick') { quickChatService.open(); @@ -162,6 +189,18 @@ class VoiceChatSessionControllerFactory { return undefined; } + static async revealChatView(accessor: ServicesAccessor): Promise { + const chatWidgetService = accessor.get(IChatWidgetService); + const chatService = accessor.get(IChatService); + + const provider = firstOrDefault(chatService.getProviderInfos()); + if (provider) { + return chatWidgetService.revealViewForProvider(provider.id); + } + + return undefined; + } + private static doCreateForChatView(chatView: IChatWidget, viewsService: IViewsService, chatContributionService: IChatContributionService): IVoiceChatSessionController { return VoiceChatSessionControllerFactory.doCreateForChatViewOrEditor('view', chatView, viewsService, chatContributionService); } @@ -217,9 +256,30 @@ class VoiceChatSessionControllerFactory { clearInputPlaceholder: () => inlineChat.resetPlaceholder() }; } + + private static doCreateForTerminalChat(terminalChat: TerminalChatController): IVoiceChatSessionController { + return { + context: 'terminal', + onDidAcceptInput: terminalChat.onDidAcceptInput, + onDidCancelInput: terminalChat.onDidCancelInput, + focusInput: () => terminalChat.focus(), + acceptInput: () => terminalChat.acceptInput(), + updateInput: text => terminalChat.updateInput(text, false), + getInput: () => terminalChat.getInput(), + setInputPlaceholder: text => terminalChat.setPlaceholder(text), + clearInputPlaceholder: () => terminalChat.resetPlaceholder() + }; + } +} + +interface IVoiceChatSession { + setTimeoutDisabled(disabled: boolean): void; + + accept(): void; + stop(): void; } -interface ActiveVoiceChatSession { +interface IActiveVoiceChatSession extends IVoiceChatSession { readonly id: number; readonly controller: IVoiceChatSessionController; readonly disposables: DisposableStore; @@ -241,26 +301,32 @@ class VoiceChatSessions { private quickVoiceChatInProgressKey = CONTEXT_QUICK_VOICE_CHAT_IN_PROGRESS.bindTo(this.contextKeyService); private inlineVoiceChatInProgressKey = CONTEXT_INLINE_VOICE_CHAT_IN_PROGRESS.bindTo(this.contextKeyService); + private terminalVoiceChatInProgressKey = CONTEXT_TERMINAL_VOICE_CHAT_IN_PROGRESS.bindTo(this.contextKeyService); private voiceChatInViewInProgressKey = CONTEXT_VOICE_CHAT_IN_VIEW_IN_PROGRESS.bindTo(this.contextKeyService); private voiceChatInEditorInProgressKey = CONTEXT_VOICE_CHAT_IN_EDITOR_IN_PROGRESS.bindTo(this.contextKeyService); - private currentVoiceChatSession: ActiveVoiceChatSession | undefined = undefined; + private currentVoiceChatSession: IActiveVoiceChatSession | undefined = undefined; private voiceChatSessionIds = 0; constructor( @IContextKeyService private readonly contextKeyService: IContextKeyService, - @ISpeechService private readonly speechService: ISpeechService, + @IVoiceChatService private readonly voiceChatService: IVoiceChatService, @IConfigurationService private readonly configurationService: IConfigurationService ) { } - async start(controller: IVoiceChatSessionController, context?: IChatExecuteActionContext): Promise { + async start(controller: IVoiceChatSessionController, context?: IChatExecuteActionContext): Promise { this.stop(); + let disableTimeout = false; + const sessionId = ++this.voiceChatSessionIds; - const session = this.currentVoiceChatSession = { + const session: IActiveVoiceChatSession = this.currentVoiceChatSession = { id: sessionId, controller, - disposables: new DisposableStore() + disposables: new DisposableStore(), + setTimeoutDisabled: (disabled: boolean) => { disableTimeout = disabled; }, + accept: () => session.controller.acceptInput(), + stop: () => this.stop(sessionId, controller.context) }; const cts = new CancellationTokenSource(); @@ -273,7 +339,7 @@ class VoiceChatSessions { this.voiceChatGettingReadyKey.set(true); - const speechToTextSession = session.disposables.add(this.speechService.createSpeechToTextSession(cts.token)); + const voiceChatSession = await this.voiceChatService.createVoiceChatSession(cts.token, { usesAgents: controller.context !== 'inline', model: context?.widget?.viewModel?.model }); let inputValue = controller.getInput(); @@ -283,7 +349,7 @@ class VoiceChatSessions { } const acceptTranscriptionScheduler = session.disposables.add(new RunOnceScheduler(() => session.controller.acceptInput(), voiceChatTimeout)); - session.disposables.add(speechToTextSession.onDidChange(({ status, text }) => { + session.disposables.add(voiceChatSession.onDidChange(({ status, text, waitingForInput }) => { if (cts.token.isCancellationRequested) { return; } @@ -294,17 +360,17 @@ class VoiceChatSessions { break; case SpeechToTextStatus.Recognizing: if (text) { - session.controller.updateInput([inputValue, text].join(' ')); - if (voiceChatTimeout > 0 && context?.voice?.disableTimeout !== true) { + session.controller.updateInput(inputValue ? [inputValue, text].join(' ') : text); + if (voiceChatTimeout > 0 && context?.voice?.disableTimeout !== true && !disableTimeout) { acceptTranscriptionScheduler.cancel(); } } break; case SpeechToTextStatus.Recognized: if (text) { - inputValue = [inputValue, text].join(' '); + inputValue = inputValue ? [inputValue, text].join(' ') : text; session.controller.updateInput(inputValue); - if (voiceChatTimeout > 0 && context?.voice?.disableTimeout !== true) { + if (voiceChatTimeout > 0 && context?.voice?.disableTimeout !== true && !waitingForInput && !disableTimeout) { acceptTranscriptionScheduler.schedule(); } } @@ -314,6 +380,8 @@ class VoiceChatSessions { break; } })); + + return session; } private onDidSpeechToTextSessionStart(controller: IVoiceChatSessionController, disposables: DisposableStore): void { @@ -324,6 +392,9 @@ class VoiceChatSessions { case 'inline': this.inlineVoiceChatInProgressKey.set(true); break; + case 'terminal': + this.terminalVoiceChatInProgressKey.set(true); + break; case 'quick': this.quickVoiceChatInProgressKey.set(true); break; @@ -366,6 +437,7 @@ class VoiceChatSessions { this.quickVoiceChatInProgressKey.set(false); this.inlineVoiceChatInProgressKey.set(false); + this.terminalVoiceChatInProgressKey.set(false); this.voiceChatInViewInProgressKey.set(false); this.voiceChatInEditorInProgressKey.set(false); } @@ -382,31 +454,113 @@ class VoiceChatSessions { } } -export class VoiceChatInChatViewAction extends Action2 { +export const VOICE_KEY_HOLD_THRESHOLD = 500; + +async function startVoiceChatWithHoldMode(id: string, accessor: ServicesAccessor, target: 'inline' | 'quick' | 'view' | 'focused', context?: IChatExecuteActionContext): Promise { + const instantiationService = accessor.get(IInstantiationService); + const keybindingService = accessor.get(IKeybindingService); + + const holdMode = keybindingService.enableKeybindingHoldMode(id); + + const controller = await VoiceChatSessionControllerFactory.create(accessor, target); + if (!controller) { + return; + } + + const session = await VoiceChatSessions.getInstance(instantiationService).start(controller, context); + + let acceptVoice = false; + const handle = disposableTimeout(() => { + acceptVoice = true; + session?.setTimeoutDisabled(true); // disable accept on timeout when hold mode runs for VOICE_KEY_HOLD_THRESHOLD + }, VOICE_KEY_HOLD_THRESHOLD); + await holdMode; + handle.dispose(); + + if (acceptVoice) { + session.accept(); + } +} + +class VoiceChatWithHoldModeAction extends Action2 { + + constructor(desc: Readonly, private readonly target: 'inline' | 'quick' | 'view') { + super(desc); + } + + run(accessor: ServicesAccessor, context?: IChatExecuteActionContext): Promise { + return startVoiceChatWithHoldMode(this.desc.id, accessor, this.target, context); + } +} + +export class VoiceChatInChatViewAction extends VoiceChatWithHoldModeAction { static readonly ID = 'workbench.action.chat.voiceChatInChatView'; constructor() { super({ id: VoiceChatInChatViewAction.ID, - title: localize2('workbench.action.chat.voiceChatInView.label', "Voice Chat in Chat View"), + title: localize2('workbench.action.chat.voiceChatInView.label', "Voice Chat in View"), category: CHAT_CATEGORY, - precondition: ContextKeyExpr.and(HasSpeechProvider, CONTEXT_PROVIDER_EXISTS, CONTEXT_CHAT_REQUEST_IN_PROGRESS.negate()), + precondition: ContextKeyExpr.and(CanVoiceChat, CONTEXT_CHAT_REQUEST_IN_PROGRESS.negate()), f1: true + }, 'view'); + } +} + +export class HoldToVoiceChatInChatViewAction extends Action2 { + + static readonly ID = 'workbench.action.chat.holdToVoiceChatInChatView'; + + constructor() { + super({ + id: HoldToVoiceChatInChatViewAction.ID, + title: localize2('workbench.action.chat.holdToVoiceChatInChatView.label', "Hold to Voice Chat in View"), + keybinding: { + weight: KeybindingWeight.WorkbenchContrib, + when: ContextKeyExpr.and( + CanVoiceChat, + FocusInChatInput.negate(), // when already in chat input, disable this action and prefer to start voice chat directly + EditorContextKeys.focus.negate(), // do not steal the inline-chat keybinding + NOTEBOOK_EDITOR_FOCUSED.negate() // do not steal the notebook keybinding + ), + primary: KeyMod.CtrlCmd | KeyCode.KeyI + } }); } - async run(accessor: ServicesAccessor, context?: IChatExecuteActionContext): Promise { + override async run(accessor: ServicesAccessor, context?: IChatExecuteActionContext): Promise { + + // The intent of this action is to provide 2 modes to align with what `Ctrlcmd+I` in inline chat: + // - if the user press and holds, we start voice chat in the chat view + // - if the user press and releases quickly enough, we just open the chat view without voice chat + const instantiationService = accessor.get(IInstantiationService); + const keybindingService = accessor.get(IKeybindingService); + + const holdMode = keybindingService.enableKeybindingHoldMode(HoldToVoiceChatInChatViewAction.ID); - const controller = await VoiceChatSessionControllerFactory.create(accessor, 'view'); - if (controller) { - VoiceChatSessions.getInstance(instantiationService).start(controller, context); + let session: IVoiceChatSession | undefined; + const handle = disposableTimeout(async () => { + const controller = await VoiceChatSessionControllerFactory.create(accessor, 'view'); + if (controller) { + session = await VoiceChatSessions.getInstance(instantiationService).start(controller, context); + session.setTimeoutDisabled(true); + } + }, VOICE_KEY_HOLD_THRESHOLD); + + (await VoiceChatSessionControllerFactory.revealChatView(accessor))?.focusInput(); + + await holdMode; + handle.dispose(); + + if (session) { + session.accept(); } } } -export class InlineVoiceChatAction extends Action2 { +export class InlineVoiceChatAction extends VoiceChatWithHoldModeAction { static readonly ID = 'workbench.action.chat.inlineVoiceChat'; @@ -415,22 +569,13 @@ export class InlineVoiceChatAction extends Action2 { id: InlineVoiceChatAction.ID, title: localize2('workbench.action.chat.inlineVoiceChat', "Inline Voice Chat"), category: CHAT_CATEGORY, - precondition: ContextKeyExpr.and(HasSpeechProvider, CONTEXT_PROVIDER_EXISTS, ActiveEditorContext, CONTEXT_CHAT_REQUEST_IN_PROGRESS.negate()), + precondition: ContextKeyExpr.and(CanVoiceChat, ActiveEditorContext, CONTEXT_CHAT_REQUEST_IN_PROGRESS.negate()), f1: true - }); - } - - async run(accessor: ServicesAccessor, context?: IChatExecuteActionContext): Promise { - const instantiationService = accessor.get(IInstantiationService); - - const controller = await VoiceChatSessionControllerFactory.create(accessor, 'inline'); - if (controller) { - VoiceChatSessions.getInstance(instantiationService).start(controller, context); - } + }, 'inline'); } } -export class QuickVoiceChatAction extends Action2 { +export class QuickVoiceChatAction extends VoiceChatWithHoldModeAction { static readonly ID = 'workbench.action.chat.quickVoiceChat'; @@ -439,18 +584,9 @@ export class QuickVoiceChatAction extends Action2 { id: QuickVoiceChatAction.ID, title: localize2('workbench.action.chat.quickVoiceChat.label', "Quick Voice Chat"), category: CHAT_CATEGORY, - precondition: ContextKeyExpr.and(HasSpeechProvider, CONTEXT_PROVIDER_EXISTS, CONTEXT_CHAT_REQUEST_IN_PROGRESS.negate()), + precondition: ContextKeyExpr.and(CanVoiceChat, CONTEXT_CHAT_REQUEST_IN_PROGRESS.negate()), f1: true - }); - } - - async run(accessor: ServicesAccessor, context?: IChatExecuteActionContext): Promise { - const instantiationService = accessor.get(IInstantiationService); - - const controller = await VoiceChatSessionControllerFactory.create(accessor, 'quick'); - if (controller) { - VoiceChatSessions.getInstance(instantiationService).start(controller, context); - } + }, 'quick'); } } @@ -461,28 +597,40 @@ export class StartVoiceChatAction extends Action2 { constructor() { super({ id: StartVoiceChatAction.ID, - title: localize2('workbench.action.chat.startVoiceChat.label', "Use Microphone"), + title: localize2('workbench.action.chat.startVoiceChat.label', "Start Voice Chat"), category: CHAT_CATEGORY, + f1: true, + keybinding: { + weight: KeybindingWeight.WorkbenchContrib, + when: ContextKeyExpr.and( + FocusInChatInput, // scope this action to chat input fields only + EditorContextKeys.focus.negate(), // do not steal the inline-chat keybinding + NOTEBOOK_EDITOR_FOCUSED.negate(), // do not steal the notebook keybinding + CONTEXT_VOICE_CHAT_IN_VIEW_IN_PROGRESS.negate(), + CONTEXT_QUICK_VOICE_CHAT_IN_PROGRESS.negate(), + CONTEXT_VOICE_CHAT_IN_EDITOR_IN_PROGRESS.negate(), + CONTEXT_INLINE_VOICE_CHAT_IN_PROGRESS.negate(), + CONTEXT_TERMINAL_VOICE_CHAT_IN_PROGRESS.negate() + ), + primary: KeyMod.CtrlCmd | KeyCode.KeyI + }, icon: Codicon.mic, - precondition: ContextKeyExpr.and(HasSpeechProvider, CONTEXT_VOICE_CHAT_GETTING_READY.negate(), CONTEXT_CHAT_REQUEST_IN_PROGRESS.negate(), CTX_INLINE_CHAT_HAS_ACTIVE_REQUEST.negate()), + precondition: ContextKeyExpr.and(CanVoiceChat, CONTEXT_VOICE_CHAT_GETTING_READY.negate(), CONTEXT_CHAT_REQUEST_IN_PROGRESS.negate(), CTX_INLINE_CHAT_HAS_ACTIVE_REQUEST.negate(), TerminalChatContextKeys.requestActive.negate()), menu: [{ id: MenuId.ChatExecute, when: ContextKeyExpr.and(HasSpeechProvider, CONTEXT_VOICE_CHAT_IN_VIEW_IN_PROGRESS.negate(), CONTEXT_QUICK_VOICE_CHAT_IN_PROGRESS.negate(), CONTEXT_VOICE_CHAT_IN_EDITOR_IN_PROGRESS.negate()), group: 'navigation', order: -1 }, { - id: MENU_INLINE_CHAT_INPUT, - when: ContextKeyExpr.and(HasSpeechProvider, CONTEXT_INLINE_VOICE_CHAT_IN_PROGRESS.negate()), - group: 'main', + id: MenuId.for('terminalChatInput'), + when: ContextKeyExpr.and(HasSpeechProvider, CONTEXT_TERMINAL_VOICE_CHAT_IN_PROGRESS.negate()), + group: 'navigation', order: -1 }] }); } async run(accessor: ServicesAccessor, context?: IChatExecuteActionContext): Promise { - const instantiationService = accessor.get(IInstantiationService); - const commandService = accessor.get(ICommandService); - const widget = context?.widget; if (widget) { // if we already get a context when the action is executed @@ -495,13 +643,7 @@ export class StartVoiceChatAction extends Action2 { widget.focusInput(); } - const controller = await VoiceChatSessionControllerFactory.create(accessor, 'focused'); - if (controller) { - VoiceChatSessions.getInstance(instantiationService).start(controller, context); - } else { - // fallback to Quick Voice Chat command - commandService.executeCommand(QuickVoiceChatAction.ID, context); - } + return startVoiceChatWithHoldMode(this.desc.id, accessor, 'focused', context); } } @@ -516,8 +658,7 @@ export class InstallVoiceChatAction extends Action2 { constructor() { super({ id: InstallVoiceChatAction.ID, - title: localize2('workbench.action.chat.startVoiceChat.label', "Use Microphone"), - f1: false, + title: localize2('workbench.action.chat.startVoiceChat.label', "Start Voice Chat"), category: CHAT_CATEGORY, icon: Codicon.mic, precondition: InstallingSpeechProvider.negate(), @@ -527,9 +668,9 @@ export class InstallVoiceChatAction extends Action2 { group: 'navigation', order: -1 }, { - id: MENU_INLINE_CHAT_INPUT, + id: MenuId.for('terminalChatInput'), when: HasSpeechProvider.negate(), - group: 'main', + group: 'navigation', order: -1 }] }); @@ -537,183 +678,92 @@ export class InstallVoiceChatAction extends Action2 { async run(accessor: ServicesAccessor): Promise { const contextKeyService = accessor.get(IContextKeyService); - const dialogService = accessor.get(IDialogService); - const extensionManagementService = accessor.get(IExtensionsWorkbenchService); - - const extension = firstOrDefault((await extensionManagementService.getExtensions([{ id: InstallVoiceChatAction.SPEECH_EXTENSION_ID }], CancellationToken.None))); - if (!extension) { - return; - } - - if (extension.state === ExtensionState.Installed) { - await dialogService.info( - localize('enableExtensionMessage', "Microphone support requires an extension. Please enable it."), - localize('enableExtensionDetail', "Extension '{0}' is currently disabled.", InstallVoiceChatAction.SPEECH_EXTENSION_ID), - ); - - return extensionManagementService.open(extension); - } - - const { confirmed } = await dialogService.confirm({ - message: localize('confirmInstallMessage', "Microphone support requires an extension. Would you like to install it now?"), - detail: localize('confirmInstallDetail', "This will install the '{0}' extension.", InstallVoiceChatAction.SPEECH_EXTENSION_ID), - primaryButton: localize({ key: 'installButtonLabel', comment: ['&& denotes a mnemonic'] }, "&&Install") - }); - - if (!confirmed) { - return; - } - + const extensionsWorkbenchService = accessor.get(IExtensionsWorkbenchService); try { InstallingSpeechProvider.bindTo(contextKeyService).set(true); - await extensionManagementService.install(extension, undefined, ProgressLocation.Notification); + await extensionsWorkbenchService.install(InstallVoiceChatAction.SPEECH_EXTENSION_ID, { + justification: localize('confirmInstallDetail', "Microphone support requires this extension."), + enable: true + }, ProgressLocation.Notification); } finally { InstallingSpeechProvider.bindTo(contextKeyService).set(false); } } } -export class StopListeningAction extends Action2 { +class BaseStopListeningAction extends Action2 { - static readonly ID = 'workbench.action.chat.stopListening'; - - constructor() { + constructor( + desc: { id: string; icon?: ThemeIcon; f1?: boolean }, + private readonly target: 'inline' | 'terminal' | 'quick' | 'view' | 'editor' | undefined, + context: RawContextKey, + menu: MenuId | undefined, + ) { super({ - id: StopListeningAction.ID, + ...desc, title: localize2('workbench.action.chat.stopListening.label', "Stop Listening"), category: CHAT_CATEGORY, - f1: true, keybinding: { weight: KeybindingWeight.WorkbenchContrib + 100, - when: ContextKeyExpr.and(HasSpeechProvider, CONTEXT_VOICE_CHAT_IN_PROGRESS), primary: KeyCode.Escape }, - precondition: ContextKeyExpr.and(HasSpeechProvider, CONTEXT_VOICE_CHAT_IN_PROGRESS) + precondition: ContextKeyExpr.and(CanVoiceChat, context), + menu: menu ? [{ + id: menu, + when: ContextKeyExpr.and(CanVoiceChat, context), + group: 'navigation', + order: -1 + }] : undefined }); } - run(accessor: ServicesAccessor): void { - VoiceChatSessions.getInstance(accessor.get(IInstantiationService)).stop(); + async run(accessor: ServicesAccessor, context?: IChatExecuteActionContext): Promise { + VoiceChatSessions.getInstance(accessor.get(IInstantiationService)).stop(undefined, this.target); } } -export class StopListeningInChatViewAction extends Action2 { +export class StopListeningAction extends BaseStopListeningAction { - static readonly ID = 'workbench.action.chat.stopListeningInChatView'; + static readonly ID = 'workbench.action.chat.stopListening'; constructor() { - super({ - id: StopListeningInChatViewAction.ID, - title: localize2('workbench.action.chat.stopListeningInChatView.label', "Stop Listening"), - category: CHAT_CATEGORY, - keybinding: { - weight: KeybindingWeight.WorkbenchContrib + 100, - when: ContextKeyExpr.and(HasSpeechProvider, CONTEXT_VOICE_CHAT_IN_VIEW_IN_PROGRESS), - primary: KeyCode.Escape - }, - precondition: ContextKeyExpr.and(HasSpeechProvider, CONTEXT_VOICE_CHAT_IN_VIEW_IN_PROGRESS), - icon: spinningLoading, - menu: [{ - id: MenuId.ChatExecute, - when: ContextKeyExpr.and(HasSpeechProvider, CONTEXT_VOICE_CHAT_IN_VIEW_IN_PROGRESS), - group: 'navigation', - order: -1 - }] - }); + super({ id: StopListeningAction.ID, f1: true }, undefined, CONTEXT_VOICE_CHAT_IN_PROGRESS, undefined); } +} - run(accessor: ServicesAccessor): void { - VoiceChatSessions.getInstance(accessor.get(IInstantiationService)).stop(undefined, 'view'); +export class StopListeningInChatViewAction extends BaseStopListeningAction { + + static readonly ID = 'workbench.action.chat.stopListeningInChatView'; + + constructor() { + super({ id: StopListeningInChatViewAction.ID, icon: spinningLoading }, 'view', CONTEXT_VOICE_CHAT_IN_VIEW_IN_PROGRESS, MenuId.ChatExecute); } } -export class StopListeningInChatEditorAction extends Action2 { +export class StopListeningInChatEditorAction extends BaseStopListeningAction { static readonly ID = 'workbench.action.chat.stopListeningInChatEditor'; constructor() { - super({ - id: StopListeningInChatEditorAction.ID, - title: localize2('workbench.action.chat.stopListeningInChatEditor.label', "Stop Listening"), - category: CHAT_CATEGORY, - keybinding: { - weight: KeybindingWeight.WorkbenchContrib + 100, - when: ContextKeyExpr.and(HasSpeechProvider, CONTEXT_VOICE_CHAT_IN_EDITOR_IN_PROGRESS), - primary: KeyCode.Escape - }, - precondition: ContextKeyExpr.and(HasSpeechProvider, CONTEXT_VOICE_CHAT_IN_EDITOR_IN_PROGRESS), - icon: spinningLoading, - menu: [{ - id: MenuId.ChatExecute, - when: ContextKeyExpr.and(HasSpeechProvider, CONTEXT_VOICE_CHAT_IN_EDITOR_IN_PROGRESS), - group: 'navigation', - order: -1 - }] - }); - } - - run(accessor: ServicesAccessor): void { - VoiceChatSessions.getInstance(accessor.get(IInstantiationService)).stop(undefined, 'editor'); + super({ id: StopListeningInChatEditorAction.ID, icon: spinningLoading }, 'editor', CONTEXT_VOICE_CHAT_IN_EDITOR_IN_PROGRESS, MenuId.ChatExecute); } } -export class StopListeningInQuickChatAction extends Action2 { +export class StopListeningInQuickChatAction extends BaseStopListeningAction { static readonly ID = 'workbench.action.chat.stopListeningInQuickChat'; constructor() { - super({ - id: StopListeningInQuickChatAction.ID, - title: localize2('workbench.action.chat.stopListeningInQuickChat.label', "Stop Listening"), - category: CHAT_CATEGORY, - keybinding: { - weight: KeybindingWeight.WorkbenchContrib + 100, - when: ContextKeyExpr.and(HasSpeechProvider, CONTEXT_QUICK_VOICE_CHAT_IN_PROGRESS), - primary: KeyCode.Escape - }, - precondition: ContextKeyExpr.and(HasSpeechProvider, CONTEXT_QUICK_VOICE_CHAT_IN_PROGRESS), - icon: spinningLoading, - menu: [{ - id: MenuId.ChatExecute, - when: ContextKeyExpr.and(HasSpeechProvider, CONTEXT_QUICK_VOICE_CHAT_IN_PROGRESS), - group: 'navigation', - order: -1 - }] - }); - } - - run(accessor: ServicesAccessor): void { - VoiceChatSessions.getInstance(accessor.get(IInstantiationService)).stop(undefined, 'quick'); + super({ id: StopListeningInQuickChatAction.ID, icon: spinningLoading }, 'quick', CONTEXT_QUICK_VOICE_CHAT_IN_PROGRESS, MenuId.ChatExecute); } } -export class StopListeningInInlineChatAction extends Action2 { +export class StopListeningInTerminalChatAction extends BaseStopListeningAction { - static readonly ID = 'workbench.action.chat.stopListeningInInlineChat'; + static readonly ID = 'workbench.action.chat.stopListeningInTerminalChat'; constructor() { - super({ - id: StopListeningInInlineChatAction.ID, - title: localize2('workbench.action.chat.stopListeningInInlineChat.label', "Stop Listening"), - category: CHAT_CATEGORY, - keybinding: { - weight: KeybindingWeight.WorkbenchContrib + 100, - when: ContextKeyExpr.and(HasSpeechProvider, CONTEXT_INLINE_VOICE_CHAT_IN_PROGRESS), - primary: KeyCode.Escape - }, - precondition: ContextKeyExpr.and(HasSpeechProvider, CONTEXT_INLINE_VOICE_CHAT_IN_PROGRESS), - icon: spinningLoading, - menu: [{ - id: MENU_INLINE_CHAT_INPUT, - when: ContextKeyExpr.and(HasSpeechProvider, CONTEXT_INLINE_VOICE_CHAT_IN_PROGRESS), - group: 'main', - order: -1 - }] - }); - } - - run(accessor: ServicesAccessor): void { - VoiceChatSessions.getInstance(accessor.get(IInstantiationService)).stop(undefined, 'inline'); + super({ id: StopListeningInTerminalChatAction.ID, icon: spinningLoading }, 'terminal', CONTEXT_TERMINAL_VOICE_CHAT_IN_PROGRESS, MenuId.for('terminalChatInput')); } } @@ -727,7 +777,12 @@ export class StopListeningAndSubmitAction extends Action2 { title: localize2('workbench.action.chat.stopListeningAndSubmit.label', "Stop Listening and Submit"), category: CHAT_CATEGORY, f1: true, - precondition: ContextKeyExpr.and(HasSpeechProvider, CONTEXT_VOICE_CHAT_IN_PROGRESS) + keybinding: { + weight: KeybindingWeight.WorkbenchContrib, + when: FocusInChatInput, + primary: KeyMod.CtrlCmd | KeyCode.KeyI + }, + precondition: ContextKeyExpr.and(CanVoiceChat, CONTEXT_VOICE_CHAT_IN_PROGRESS) }); } @@ -741,7 +796,7 @@ registerThemingParticipant((theme, collector) => { let activeRecordingDimmedColor: Color | undefined; if (theme.type === ColorScheme.LIGHT || theme.type === ColorScheme.DARK) { activeRecordingColor = theme.getColor(ACTIVITY_BAR_BADGE_BACKGROUND) ?? theme.getColor(focusBorder); - activeRecordingDimmedColor = activeRecordingColor?.transparent(0.2); + activeRecordingDimmedColor = activeRecordingColor?.transparent(0.38); } else { activeRecordingColor = theme.getColor(contrastBorder); activeRecordingDimmedColor = theme.getColor(contrastBorder); @@ -749,8 +804,7 @@ registerThemingParticipant((theme, collector) => { // Show a "microphone" icon when recording is in progress that glows via outline. collector.addRule(` - .monaco-workbench:not(.reduce-motion) .interactive-input-part .monaco-action-bar .action-label.codicon-loading.codicon-modifier-spin:not(.disabled), - .monaco-workbench:not(.reduce-motion) .inline-chat .monaco-action-bar .action-label.codicon-loading.codicon-modifier-spin:not(.disabled) { + .monaco-workbench:not(.reduce-motion) .interactive-input-part .monaco-action-bar .action-label.codicon-loading.codicon-modifier-spin:not(.disabled) { color: ${activeRecordingColor}; outline: 1px solid ${activeRecordingColor}; outline-offset: -1px; @@ -758,8 +812,7 @@ registerThemingParticipant((theme, collector) => { border-radius: 50%; } - .monaco-workbench:not(.reduce-motion) .interactive-input-part .monaco-action-bar .action-label.codicon-loading.codicon-modifier-spin:not(.disabled)::before, - .monaco-workbench:not(.reduce-motion) .inline-chat .monaco-action-bar .action-label.codicon-loading.codicon-modifier-spin:not(.disabled)::before { + .monaco-workbench:not(.reduce-motion) .interactive-input-part .monaco-action-bar .action-label.codicon-loading.codicon-modifier-spin:not(.disabled)::before { position: absolute; outline: 1px solid ${activeRecordingColor}; outline-offset: 2px; @@ -768,13 +821,16 @@ registerThemingParticipant((theme, collector) => { height: 16px; } - .monaco-workbench:not(.reduce-motion) .interactive-input-part .monaco-action-bar .action-label.codicon-loading.codicon-modifier-spin:not(.disabled)::after, - .monaco-workbench:not(.reduce-motion) .inline-chat .monaco-action-bar .action-label.codicon-loading.codicon-modifier-spin:not(.disabled)::after { - content: ''; + .monaco-workbench:not(.reduce-motion) .interactive-input-part .monaco-action-bar .action-label.codicon-loading.codicon-modifier-spin:not(.disabled)::after { + outline: 2px solid ${activeRecordingColor}; + outline-offset: -1px; + animation: pulseAnimation 1500ms cubic-bezier(0.75, 0, 0.25, 1) infinite; + } + + .monaco-workbench:not(.reduce-motion) .interactive-input-part .monaco-action-bar .action-label.codicon-loading.codicon-modifier-spin:not(.disabled)::before { position: absolute; - outline: 1px solid ${activeRecordingDimmedColor}; - outline-offset: 3px; - animation: pulseAnimation 1s infinite; + outline: 1px solid ${activeRecordingColor}; + outline-offset: 2px; border-radius: 50%; width: 16px; height: 16px; @@ -782,14 +838,14 @@ registerThemingParticipant((theme, collector) => { @keyframes pulseAnimation { 0% { - outline-width: 1px; + outline-width: 2px; } - 50% { - outline-width: 3px; + 62% { + outline-width: 5px; outline-color: ${activeRecordingDimmedColor}; } 100% { - outline-width: 1px; + outline-width: 2px; } } `); @@ -807,6 +863,8 @@ function supportsKeywordActivation(configurationService: IConfigurationService, export class KeywordActivationContribution extends Disposable implements IWorkbenchContribution { + static readonly ID = 'workbench.contrib.keywordActivation'; + static SETTINGS_VALUE = { OFF: 'off', INLINE_CHAT: 'inlineChat', @@ -821,7 +879,6 @@ export class KeywordActivationContribution extends Disposable implements IWorkbe @ISpeechService private readonly speechService: ISpeechService, @IConfigurationService private readonly configurationService: IConfigurationService, @ICommandService private readonly commandService: ICommandService, - @IEditorGroupsService private readonly editorGroupService: IEditorGroupsService, @IInstantiationService instantiationService: IInstantiationService, @IEditorService private readonly editorService: IEditorService, @IHostService private readonly hostService: IHostService, @@ -835,7 +892,7 @@ export class KeywordActivationContribution extends Disposable implements IWorkbe } private registerListeners(): void { - this._register(Event.runAndSubscribe(this.speechService.onDidRegisterSpeechProvider, () => { + this._register(Event.runAndSubscribe(this.speechService.onDidChangeHasSpeechProvider, () => { this.updateConfiguration(); this.handleKeywordActivation(); })); @@ -850,10 +907,6 @@ export class KeywordActivationContribution extends Disposable implements IWorkbe this.handleKeywordActivation(); } })); - - this._register(this.editorGroupService.onDidCreateAuxiliaryEditorPart(({ instantiationService, disposables }) => { - disposables.add(instantiationService.createInstance(KeywordActivationStatusEntry)); - })); } private updateConfiguration(): void { @@ -980,7 +1033,7 @@ class KeywordActivationStatusEntry extends Disposable { ) { super(); - CommandsRegistry.registerCommand(KeywordActivationStatusEntry.STATUS_COMMAND, () => this.commandService.executeCommand('workbench.action.openSettings', KEYWORD_ACTIVIATION_SETTING_ID)); + this._register(CommandsRegistry.registerCommand(KeywordActivationStatusEntry.STATUS_COMMAND, () => this.commandService.executeCommand('workbench.action.openSettings', KEYWORD_ACTIVIATION_SETTING_ID))); this.registerListeners(); this.updateStatusEntry(); @@ -1020,7 +1073,8 @@ class KeywordActivationStatusEntry extends Disposable { tooltip: this.speechService.hasActiveKeywordRecognition ? KeywordActivationStatusEntry.STATUS_ACTIVE : KeywordActivationStatusEntry.STATUS_INACTIVE, ariaLabel: this.speechService.hasActiveKeywordRecognition ? KeywordActivationStatusEntry.STATUS_ACTIVE : KeywordActivationStatusEntry.STATUS_INACTIVE, command: KeywordActivationStatusEntry.STATUS_COMMAND, - kind: 'prominent' + kind: 'prominent', + showInAllWindows: true }; } diff --git a/src/vs/workbench/contrib/chat/electron-sandbox/chat.contribution.ts b/src/vs/workbench/contrib/chat/electron-sandbox/chat.contribution.ts index 127d13be18bb0..186a6a715c10d 100644 --- a/src/vs/workbench/contrib/chat/electron-sandbox/chat.contribution.ts +++ b/src/vs/workbench/contrib/chat/electron-sandbox/chat.contribution.ts @@ -3,16 +3,15 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { InlineVoiceChatAction, QuickVoiceChatAction, StartVoiceChatAction, StopListeningInInlineChatAction, StopListeningInQuickChatAction, StopListeningInChatEditorAction, StopListeningInChatViewAction, VoiceChatInChatViewAction, StopListeningAction, StopListeningAndSubmitAction, KeywordActivationContribution, InstallVoiceChatAction } from 'vs/workbench/contrib/chat/electron-sandbox/actions/voiceChatActions'; +import { InlineVoiceChatAction, QuickVoiceChatAction, StartVoiceChatAction, StopListeningInQuickChatAction, StopListeningInChatEditorAction, StopListeningInChatViewAction, VoiceChatInChatViewAction, StopListeningAction, StopListeningAndSubmitAction, KeywordActivationContribution, InstallVoiceChatAction, StopListeningInTerminalChatAction, HoldToVoiceChatInChatViewAction } from 'vs/workbench/contrib/chat/electron-sandbox/actions/voiceChatActions'; import { registerAction2 } from 'vs/platform/actions/common/actions'; -import { IWorkbenchContributionsRegistry, Extensions as WorkbenchExtensions } from 'vs/workbench/common/contributions'; -import { LifecyclePhase } from 'vs/workbench/services/lifecycle/common/lifecycle'; -import { Registry } from 'vs/platform/registry/common/platform'; +import { WorkbenchPhase, registerWorkbenchContribution2 } from 'vs/workbench/common/contributions'; registerAction2(StartVoiceChatAction); registerAction2(InstallVoiceChatAction); registerAction2(VoiceChatInChatViewAction); +registerAction2(HoldToVoiceChatInChatViewAction); registerAction2(QuickVoiceChatAction); registerAction2(InlineVoiceChatAction); @@ -22,7 +21,6 @@ registerAction2(StopListeningAndSubmitAction); registerAction2(StopListeningInChatViewAction); registerAction2(StopListeningInChatEditorAction); registerAction2(StopListeningInQuickChatAction); -registerAction2(StopListeningInInlineChatAction); +registerAction2(StopListeningInTerminalChatAction); -const workbenchRegistry = Registry.as(WorkbenchExtensions.Workbench); -workbenchRegistry.registerWorkbenchContribution(KeywordActivationContribution, LifecyclePhase.Restored); +registerWorkbenchContribution2(KeywordActivationContribution.ID, KeywordActivationContribution, WorkbenchPhase.AfterRestored); diff --git a/src/vs/workbench/contrib/chat/test/browser/__snapshots__/ChatMarkdownDecorationsRenderer_extractVulnerabilitiesFromText_multiple_vulns.0.snap b/src/vs/workbench/contrib/chat/test/browser/__snapshots__/ChatMarkdownDecorationsRenderer_extractVulnerabilitiesFromText_multiple_vulns.0.snap deleted file mode 100644 index c93e1967da39e..0000000000000 --- a/src/vs/workbench/contrib/chat/test/browser/__snapshots__/ChatMarkdownDecorationsRenderer_extractVulnerabilitiesFromText_multiple_vulns.0.snap +++ /dev/null @@ -1,11 +0,0 @@ -[ - { - content: { - value: "some code\nover\nmultiple lines content with vuln\nand\nnewlinesmore code\nwith newlinecontent with vuln\nand\nnewlines", - isTrusted: false, - supportThemeIcons: false, - supportHtml: false - }, - kind: "markdownContent" - } -] \ No newline at end of file diff --git a/src/vs/workbench/contrib/chat/test/browser/__snapshots__/ChatMarkdownDecorationsRenderer_extractVulnerabilitiesFromText_single_line.0.snap b/src/vs/workbench/contrib/chat/test/browser/__snapshots__/ChatMarkdownDecorationsRenderer_extractVulnerabilitiesFromText_single_line.0.snap deleted file mode 100644 index 50ceeb2374559..0000000000000 --- a/src/vs/workbench/contrib/chat/test/browser/__snapshots__/ChatMarkdownDecorationsRenderer_extractVulnerabilitiesFromText_single_line.0.snap +++ /dev/null @@ -1,11 +0,0 @@ -[ - { - content: { - value: "some code content with vuln after", - isTrusted: false, - supportThemeIcons: false, - supportHtml: false - }, - kind: "markdownContent" - } -] \ No newline at end of file diff --git a/src/vs/workbench/contrib/chat/test/browser/chatVariables.test.ts b/src/vs/workbench/contrib/chat/test/browser/chatVariables.test.ts index bda3f40baac91..6d6856156eee3 100644 --- a/src/vs/workbench/contrib/chat/test/browser/chatVariables.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/chatVariables.test.ts @@ -12,8 +12,10 @@ import { IStorageService } from 'vs/platform/storage/common/storage'; import { ChatVariablesService } from 'vs/workbench/contrib/chat/browser/chatVariables'; import { ChatAgentService, IChatAgentService } from 'vs/workbench/contrib/chat/common/chatAgents'; import { ChatRequestParser } from 'vs/workbench/contrib/chat/common/chatRequestParser'; +import { IChatService } from 'vs/workbench/contrib/chat/common/chatService'; import { IChatVariablesService } from 'vs/workbench/contrib/chat/common/chatVariables'; import { MockChatWidgetService } from 'vs/workbench/contrib/chat/test/browser/mockChatWidget'; +import { MockChatService } from 'vs/workbench/contrib/chat/test/common/mockChatService'; import { IExtensionService } from 'vs/workbench/services/extensions/common/extensions'; import { TestExtensionService, TestStorageService } from 'vs/workbench/test/common/workbenchTestServices'; @@ -30,7 +32,8 @@ suite('ChatVariables', function () { instantiationService.stub(ILogService, new NullLogService()); instantiationService.stub(IExtensionService, new TestExtensionService()); instantiationService.stub(IChatVariablesService, service); - instantiationService.stub(IChatAgentService, testDisposables.add(instantiationService.createInstance(ChatAgentService))); + instantiationService.stub(IChatService, new MockChatService()); + instantiationService.stub(IChatAgentService, instantiationService.createInstance(ChatAgentService)); }); test('ChatVariables - resolveVariables', async function () { @@ -41,48 +44,44 @@ suite('ChatVariables', function () { const parser = instantiationService.createInstance(ChatRequestParser); const resolveVariables = async (text: string) => { - const result = await parser.parseChatRequest('1', text); - return await service.resolveVariables(result, null!, CancellationToken.None); + const result = parser.parseChatRequest('1', text); + return await service.resolveVariables(result, null!, () => { }, CancellationToken.None); }; { const data = await resolveVariables('Hello #foo and#far'); - assert.strictEqual(Object.keys(data.variables).length, 1); - assert.deepEqual(Object.keys(data.variables).sort(), ['foo']); - assert.strictEqual(data.message, 'Hello [#foo](values:foo) and#far'); + assert.strictEqual(data.variables.length, 1); + assert.deepEqual(data.variables.map(v => v.name), ['foo']); } { const data = await resolveVariables('#foo Hello'); - assert.strictEqual(Object.keys(data.variables).length, 1); - assert.deepEqual(Object.keys(data.variables).sort(), ['foo']); - assert.strictEqual(data.message, '[#foo](values:foo) Hello'); + assert.strictEqual(data.variables.length, 1); + assert.deepEqual(data.variables.map(v => v.name), ['foo']); } { const data = await resolveVariables('Hello #foo'); - assert.strictEqual(Object.keys(data.variables).length, 1); - assert.deepEqual(Object.keys(data.variables).sort(), ['foo']); + assert.strictEqual(data.variables.length, 1); + assert.deepEqual(data.variables.map(v => v.name), ['foo']); } { const data = await resolveVariables('Hello #foo?'); - assert.strictEqual(Object.keys(data.variables).length, 1); - assert.deepEqual(Object.keys(data.variables).sort(), ['foo']); - assert.strictEqual(data.message, 'Hello [#foo](values:foo)?'); + assert.strictEqual(data.variables.length, 1); + assert.deepEqual(data.variables.map(v => v.name), ['foo']); } { const data = await resolveVariables('Hello #foo and#far #foo'); - assert.strictEqual(Object.keys(data.variables).length, 1); - assert.deepEqual(Object.keys(data.variables).sort(), ['foo']); + assert.strictEqual(data.variables.length, 2); + assert.deepEqual(data.variables.map(v => v.name), ['foo', 'foo']); } { const data = await resolveVariables('Hello #foo and #far #foo'); - assert.strictEqual(Object.keys(data.variables).length, 2); - assert.deepEqual(Object.keys(data.variables).sort(), ['far', 'foo']); + assert.strictEqual(data.variables.length, 3); + assert.deepEqual(data.variables.map(v => v.name), ['foo', 'far', 'foo']); } { const data = await resolveVariables('Hello #foo and #far #foo #unknown'); - assert.strictEqual(Object.keys(data.variables).length, 2); - assert.deepEqual(Object.keys(data.variables).sort(), ['far', 'foo']); - assert.strictEqual(data.message, 'Hello [#foo](values:foo) and [#far](values:far) [#foo](values:foo) #unknown'); + assert.strictEqual(data.variables.length, 3); + assert.deepEqual(data.variables.map(v => v.name), ['foo', 'far', 'foo']); } v1.dispose(); diff --git a/src/vs/workbench/contrib/chat/test/browser/__snapshots__/ChatMarkdownDecorationsRenderer_extractVulnerabilitiesFromText_multiline.0.snap b/src/vs/workbench/contrib/chat/test/common/__snapshots__/Annotations_extractVulnerabilitiesFromText_multiline.0.snap similarity index 55% rename from src/vs/workbench/contrib/chat/test/browser/__snapshots__/ChatMarkdownDecorationsRenderer_extractVulnerabilitiesFromText_multiline.0.snap rename to src/vs/workbench/contrib/chat/test/common/__snapshots__/Annotations_extractVulnerabilitiesFromText_multiline.0.snap index c0d74bb4d1524..11c9c2ef292ce 100644 --- a/src/vs/workbench/contrib/chat/test/browser/__snapshots__/ChatMarkdownDecorationsRenderer_extractVulnerabilitiesFromText_multiline.0.snap +++ b/src/vs/workbench/contrib/chat/test/common/__snapshots__/Annotations_extractVulnerabilitiesFromText_multiline.0.snap @@ -1,7 +1,7 @@ [ { content: { - value: "some code\nover\nmultiple lines content with vuln\nand\nnewlinesmore code\nwith newline", + value: "some code\nover\nmultiple lines content with vuln\nand\nnewlinesmore code\nwith newline", isTrusted: false, supportThemeIcons: false, supportHtml: false diff --git a/src/vs/workbench/contrib/chat/test/browser/__snapshots__/ChatMarkdownDecorationsRenderer_extractVulnerabilitiesFromText_multiline.1.snap b/src/vs/workbench/contrib/chat/test/common/__snapshots__/Annotations_extractVulnerabilitiesFromText_multiline.1.snap similarity index 100% rename from src/vs/workbench/contrib/chat/test/browser/__snapshots__/ChatMarkdownDecorationsRenderer_extractVulnerabilitiesFromText_multiline.1.snap rename to src/vs/workbench/contrib/chat/test/common/__snapshots__/Annotations_extractVulnerabilitiesFromText_multiline.1.snap diff --git a/src/vs/workbench/contrib/chat/test/common/__snapshots__/Annotations_extractVulnerabilitiesFromText_multiple_vulns.0.snap b/src/vs/workbench/contrib/chat/test/common/__snapshots__/Annotations_extractVulnerabilitiesFromText_multiple_vulns.0.snap new file mode 100644 index 0000000000000..bc1d5cda51e99 --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/common/__snapshots__/Annotations_extractVulnerabilitiesFromText_multiple_vulns.0.snap @@ -0,0 +1,11 @@ +[ + { + content: { + value: "some code\nover\nmultiple lines content with vuln\nand\nnewlinesmore code\nwith newlinecontent with vuln\nand\nnewlines", + isTrusted: false, + supportThemeIcons: false, + supportHtml: false + }, + kind: "markdownContent" + } +] \ No newline at end of file diff --git a/src/vs/workbench/contrib/chat/test/browser/__snapshots__/ChatMarkdownDecorationsRenderer_extractVulnerabilitiesFromText_multiple_vulns.1.snap b/src/vs/workbench/contrib/chat/test/common/__snapshots__/Annotations_extractVulnerabilitiesFromText_multiple_vulns.1.snap similarity index 100% rename from src/vs/workbench/contrib/chat/test/browser/__snapshots__/ChatMarkdownDecorationsRenderer_extractVulnerabilitiesFromText_multiple_vulns.1.snap rename to src/vs/workbench/contrib/chat/test/common/__snapshots__/Annotations_extractVulnerabilitiesFromText_multiple_vulns.1.snap diff --git a/src/vs/workbench/contrib/chat/test/common/__snapshots__/Annotations_extractVulnerabilitiesFromText_single_line.0.snap b/src/vs/workbench/contrib/chat/test/common/__snapshots__/Annotations_extractVulnerabilitiesFromText_single_line.0.snap new file mode 100644 index 0000000000000..229ab7c6ac4fe --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/common/__snapshots__/Annotations_extractVulnerabilitiesFromText_single_line.0.snap @@ -0,0 +1,11 @@ +[ + { + content: { + value: "some code content with vuln after", + isTrusted: false, + supportThemeIcons: false, + supportHtml: false + }, + kind: "markdownContent" + } +] \ No newline at end of file diff --git a/src/vs/workbench/contrib/chat/test/browser/__snapshots__/ChatMarkdownDecorationsRenderer_extractVulnerabilitiesFromText_single_line.1.snap b/src/vs/workbench/contrib/chat/test/common/__snapshots__/Annotations_extractVulnerabilitiesFromText_single_line.1.snap similarity index 100% rename from src/vs/workbench/contrib/chat/test/browser/__snapshots__/ChatMarkdownDecorationsRenderer_extractVulnerabilitiesFromText_single_line.1.snap rename to src/vs/workbench/contrib/chat/test/common/__snapshots__/Annotations_extractVulnerabilitiesFromText_single_line.1.snap diff --git a/src/vs/workbench/contrib/chat/test/common/__snapshots__/ChatRequestParser_agent_and_subcommand_after_newline.0.snap b/src/vs/workbench/contrib/chat/test/common/__snapshots__/ChatRequestParser_agent_and_subcommand_after_newline.0.snap index cc7ecaf508d4a..913d93883a307 100644 --- a/src/vs/workbench/contrib/chat/test/common/__snapshots__/ChatRequestParser_agent_and_subcommand_after_newline.0.snap +++ b/src/vs/workbench/contrib/chat/test/common/__snapshots__/ChatRequestParser_agent_and_subcommand_after_newline.0.snap @@ -27,9 +27,14 @@ }, agent: { id: "agent", + name: "agent", + extensionId: { + value: "nullExtensionDescription", + _lower: "nullextensiondescription" + }, + locations: [ "panel" ], metadata: { description: "" }, - provideSlashCommands: [Function provideSlashCommands], - lastSlashCommands: [ + slashCommands: [ { name: "subCommand", description: "" diff --git a/src/vs/workbench/contrib/chat/test/common/__snapshots__/ChatRequestParser_agent_and_subcommand_with_leading_whitespace.0.snap b/src/vs/workbench/contrib/chat/test/common/__snapshots__/ChatRequestParser_agent_and_subcommand_with_leading_whitespace.0.snap index d46c197f63314..847804842beec 100644 --- a/src/vs/workbench/contrib/chat/test/common/__snapshots__/ChatRequestParser_agent_and_subcommand_with_leading_whitespace.0.snap +++ b/src/vs/workbench/contrib/chat/test/common/__snapshots__/ChatRequestParser_agent_and_subcommand_with_leading_whitespace.0.snap @@ -27,9 +27,14 @@ }, agent: { id: "agent", + name: "agent", + extensionId: { + value: "nullExtensionDescription", + _lower: "nullextensiondescription" + }, + locations: [ "panel" ], metadata: { description: "" }, - provideSlashCommands: [Function provideSlashCommands], - lastSlashCommands: [ + slashCommands: [ { name: "subCommand", description: "" diff --git a/src/vs/workbench/contrib/chat/test/common/__snapshots__/ChatRequestParser_agent_with_question_mark.0.snap b/src/vs/workbench/contrib/chat/test/common/__snapshots__/ChatRequestParser_agent_with_question_mark.0.snap index 4891a73e6415b..42a9e4e71d028 100644 --- a/src/vs/workbench/contrib/chat/test/common/__snapshots__/ChatRequestParser_agent_with_question_mark.0.snap +++ b/src/vs/workbench/contrib/chat/test/common/__snapshots__/ChatRequestParser_agent_with_question_mark.0.snap @@ -13,9 +13,14 @@ }, agent: { id: "agent", + name: "agent", + extensionId: { + value: "nullExtensionDescription", + _lower: "nullextensiondescription" + }, + locations: [ "panel" ], metadata: { description: "" }, - provideSlashCommands: [Function provideSlashCommands], - lastSlashCommands: [ + slashCommands: [ { name: "subCommand", description: "" diff --git a/src/vs/workbench/contrib/chat/test/common/__snapshots__/ChatRequestParser_agent_with_subcommand_after_text.0.snap b/src/vs/workbench/contrib/chat/test/common/__snapshots__/ChatRequestParser_agent_with_subcommand_after_text.0.snap index d78899819150c..50301b0cf1c25 100644 --- a/src/vs/workbench/contrib/chat/test/common/__snapshots__/ChatRequestParser_agent_with_subcommand_after_text.0.snap +++ b/src/vs/workbench/contrib/chat/test/common/__snapshots__/ChatRequestParser_agent_with_subcommand_after_text.0.snap @@ -13,9 +13,14 @@ }, agent: { id: "agent", + name: "agent", + extensionId: { + value: "nullExtensionDescription", + _lower: "nullextensiondescription" + }, + locations: [ "panel" ], metadata: { description: "" }, - provideSlashCommands: [Function provideSlashCommands], - lastSlashCommands: [ + slashCommands: [ { name: "subCommand", description: "" diff --git a/src/vs/workbench/contrib/chat/test/common/__snapshots__/ChatRequestParser_agents__subCommand.0.snap b/src/vs/workbench/contrib/chat/test/common/__snapshots__/ChatRequestParser_agents__subCommand.0.snap index df42f889d0548..8de370325aa26 100644 --- a/src/vs/workbench/contrib/chat/test/common/__snapshots__/ChatRequestParser_agents__subCommand.0.snap +++ b/src/vs/workbench/contrib/chat/test/common/__snapshots__/ChatRequestParser_agents__subCommand.0.snap @@ -13,9 +13,14 @@ }, agent: { id: "agent", + name: "agent", + extensionId: { + value: "nullExtensionDescription", + _lower: "nullextensiondescription" + }, + locations: [ "panel" ], metadata: { description: "" }, - provideSlashCommands: [Function provideSlashCommands], - lastSlashCommands: [ + slashCommands: [ { name: "subCommand", description: "" diff --git a/src/vs/workbench/contrib/chat/test/common/__snapshots__/ChatRequestParser_agents_and_variables_and_multiline.0.snap b/src/vs/workbench/contrib/chat/test/common/__snapshots__/ChatRequestParser_agents_and_variables_and_multiline.0.snap index d3f091a95e8ef..855c14d60339e 100644 --- a/src/vs/workbench/contrib/chat/test/common/__snapshots__/ChatRequestParser_agents_and_variables_and_multiline.0.snap +++ b/src/vs/workbench/contrib/chat/test/common/__snapshots__/ChatRequestParser_agents_and_variables_and_multiline.0.snap @@ -13,9 +13,14 @@ }, agent: { id: "agent", + name: "agent", + extensionId: { + value: "nullExtensionDescription", + _lower: "nullextensiondescription" + }, + locations: [ "panel" ], metadata: { description: "" }, - provideSlashCommands: [Function provideSlashCommands], - lastSlashCommands: [ + slashCommands: [ { name: "subCommand", description: "" diff --git a/src/vs/workbench/contrib/chat/test/common/__snapshots__/ChatRequestParser_agents_and_variables_and_multiline__part2.0.snap b/src/vs/workbench/contrib/chat/test/common/__snapshots__/ChatRequestParser_agents_and_variables_and_multiline__part2.0.snap index c4b86b46fffb4..c33398a6d333c 100644 --- a/src/vs/workbench/contrib/chat/test/common/__snapshots__/ChatRequestParser_agents_and_variables_and_multiline__part2.0.snap +++ b/src/vs/workbench/contrib/chat/test/common/__snapshots__/ChatRequestParser_agents_and_variables_and_multiline__part2.0.snap @@ -13,9 +13,14 @@ }, agent: { id: "agent", + name: "agent", + extensionId: { + value: "nullExtensionDescription", + _lower: "nullextensiondescription" + }, + locations: [ "panel" ], metadata: { description: "" }, - provideSlashCommands: [Function provideSlashCommands], - lastSlashCommands: [ + slashCommands: [ { name: "subCommand", description: "" diff --git a/src/vs/workbench/contrib/chat/test/common/__snapshots__/ChatService_can_deserialize.0.snap b/src/vs/workbench/contrib/chat/test/common/__snapshots__/ChatService_can_deserialize.0.snap new file mode 100644 index 0000000000000..b9b4b15aa1f4a --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/common/__snapshots__/ChatService_can_deserialize.0.snap @@ -0,0 +1,96 @@ +{ + requesterUsername: "test", + requesterAvatarIconUri: undefined, + responderUsername: "test", + responderAvatarIconUri: undefined, + welcomeMessage: undefined, + requests: [ + { + message: { + text: "@ChatProviderWithUsedContext test request", + parts: [ + { + kind: "agent", + range: { + start: 0, + endExclusive: 28 + }, + editorRange: { + startLineNumber: 1, + startColumn: 1, + endLineNumber: 1, + endColumn: 29 + }, + agent: { + id: "ChatProviderWithUsedContext", + name: "ChatProviderWithUsedContext", + description: undefined, + metadata: { } + } + }, + { + range: { + start: 28, + endExclusive: 41 + }, + editorRange: { + startLineNumber: 1, + startColumn: 29, + endLineNumber: 1, + endColumn: 42 + }, + text: " test request", + kind: "text" + } + ] + }, + variableData: { variables: [ ] }, + response: [ ], + result: { metadata: { metadataKey: "value" } }, + followups: undefined, + isCanceled: false, + vote: undefined, + agent: { + id: "ChatProviderWithUsedContext", + name: "ChatProviderWithUsedContext", + description: undefined, + extensionId: { + value: "nullExtensionDescription", + _lower: "nullextensiondescription" + }, + metadata: { }, + slashCommands: [ ], + locations: [ "panel" ], + isDefault: undefined + }, + slashCommand: undefined, + usedContext: { + documents: [ + { + uri: { + scheme: "file", + authority: "", + path: "/test/path/to/file", + query: "", + fragment: "", + _formatted: null, + _fsPath: null + }, + version: 3, + ranges: [ + { + startLineNumber: 1, + startColumn: 1, + endLineNumber: 2, + endColumn: 2 + } + ] + } + ], + kind: "usedContext" + }, + contentReferences: [ ] + } + ], + providerId: "testProvider" +} \ No newline at end of file diff --git a/src/vs/workbench/contrib/chat/test/common/__snapshots__/ChatService_can_serialize.0.snap b/src/vs/workbench/contrib/chat/test/common/__snapshots__/ChatService_can_serialize.0.snap new file mode 100644 index 0000000000000..75c5fa71f40d4 --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/common/__snapshots__/ChatService_can_serialize.0.snap @@ -0,0 +1,9 @@ +{ + requesterUsername: "test", + requesterAvatarIconUri: undefined, + responderUsername: "test", + responderAvatarIconUri: undefined, + welcomeMessage: undefined, + requests: [ ], + providerId: "testProvider" +} \ No newline at end of file diff --git a/src/vs/workbench/contrib/chat/test/common/__snapshots__/ChatService_can_serialize.1.snap b/src/vs/workbench/contrib/chat/test/common/__snapshots__/ChatService_can_serialize.1.snap new file mode 100644 index 0000000000000..bad39b3eafcc8 --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/common/__snapshots__/ChatService_can_serialize.1.snap @@ -0,0 +1,102 @@ +{ + requesterUsername: "test", + requesterAvatarIconUri: undefined, + responderUsername: "test", + responderAvatarIconUri: undefined, + welcomeMessage: undefined, + requests: [ + { + message: { + parts: [ + { + kind: "agent", + range: { + start: 0, + endExclusive: 28 + }, + editorRange: { + startLineNumber: 1, + startColumn: 1, + endLineNumber: 1, + endColumn: 29 + }, + agent: { + id: "ChatProviderWithUsedContext", + name: "ChatProviderWithUsedContext", + description: undefined, + metadata: { + requester: { name: "test" }, + fullName: "test" + } + } + }, + { + range: { + start: 28, + endExclusive: 41 + }, + editorRange: { + startLineNumber: 1, + startColumn: 29, + endLineNumber: 1, + endColumn: 42 + }, + text: " test request", + kind: "text" + } + ], + text: "@ChatProviderWithUsedContext test request" + }, + variableData: { variables: [ ] }, + response: [ ], + result: { metadata: { metadataKey: "value" } }, + followups: undefined, + isCanceled: false, + vote: undefined, + agent: { + id: "ChatProviderWithUsedContext", + name: "ChatProviderWithUsedContext", + description: undefined, + extensionId: { + value: "nullExtensionDescription", + _lower: "nullextensiondescription" + }, + metadata: { + requester: { name: "test" }, + fullName: "test" + }, + slashCommands: [ ], + locations: [ "panel" ], + isDefault: undefined + }, + slashCommand: undefined, + usedContext: { + documents: [ + { + uri: { + scheme: "file", + authority: "", + path: "/test/path/to/file", + query: "", + fragment: "", + _formatted: null, + _fsPath: null + }, + version: 3, + ranges: [ + { + startLineNumber: 1, + startColumn: 1, + endLineNumber: 2, + endColumn: 2 + } + ] + } + ], + kind: "usedContext" + }, + contentReferences: [ ] + } + ], + providerId: "testProvider" +} \ No newline at end of file diff --git a/src/vs/workbench/contrib/chat/test/common/__snapshots__/Chat_can_deserialize.0.snap b/src/vs/workbench/contrib/chat/test/common/__snapshots__/Chat_can_deserialize.0.snap index e9ee90ae0210f..cfed0a5d0cae4 100644 --- a/src/vs/workbench/contrib/chat/test/common/__snapshots__/Chat_can_deserialize.0.snap +++ b/src/vs/workbench/contrib/chat/test/common/__snapshots__/Chat_can_deserialize.0.snap @@ -10,6 +10,7 @@ text: "@ChatProviderWithUsedContext test request", parts: [ { + kind: "agent", range: { start: 0, endExclusive: 28 @@ -22,11 +23,8 @@ }, agent: { id: "ChatProviderWithUsedContext", - metadata: { }, - provideSlashCommands: [Function provideSlashCommands], - invoke: [Function invoke] - }, - kind: "agent" + metadata: { description: undefined } + } }, { range: { @@ -44,18 +42,22 @@ } ] }, - variableData: { - message: "@ChatProviderWithUsedContext test request", - variables: { } - }, + variableData: { variables: [ ] }, response: [ ], - responseErrorDetails: undefined, - followups: [ ], + result: { metadata: { metadataKey: "value" } }, + followups: undefined, isCanceled: false, vote: undefined, agent: { id: "ChatProviderWithUsedContext", - metadata: { } + extensionId: { + value: "nullExtensionDescription", + _lower: "nullextensiondescription" + }, + metadata: { description: undefined }, + slashCommands: [ ], + locations: [ "panel" ], + isDefault: undefined }, slashCommand: undefined, usedContext: { diff --git a/src/vs/workbench/contrib/chat/test/common/__snapshots__/Chat_can_serialize.0.snap b/src/vs/workbench/contrib/chat/test/common/__snapshots__/Chat_can_serialize.0.snap index 0939983222fe8..75c5fa71f40d4 100644 --- a/src/vs/workbench/contrib/chat/test/common/__snapshots__/Chat_can_serialize.0.snap +++ b/src/vs/workbench/contrib/chat/test/common/__snapshots__/Chat_can_serialize.0.snap @@ -1,7 +1,7 @@ { - requesterUsername: "", + requesterUsername: "test", requesterAvatarIconUri: undefined, - responderUsername: "", + responderUsername: "test", responderAvatarIconUri: undefined, welcomeMessage: undefined, requests: [ ], diff --git a/src/vs/workbench/contrib/chat/test/common/__snapshots__/Chat_can_serialize.1.snap b/src/vs/workbench/contrib/chat/test/common/__snapshots__/Chat_can_serialize.1.snap index 0c270a4d7f307..cb9c94b5d2d54 100644 --- a/src/vs/workbench/contrib/chat/test/common/__snapshots__/Chat_can_serialize.1.snap +++ b/src/vs/workbench/contrib/chat/test/common/__snapshots__/Chat_can_serialize.1.snap @@ -9,6 +9,7 @@ message: { parts: [ { + kind: "agent", range: { start: 0, endExclusive: 28 @@ -21,11 +22,12 @@ }, agent: { id: "ChatProviderWithUsedContext", - metadata: { }, - provideSlashCommands: [Function provideSlashCommands], - invoke: [Function invoke] - }, - kind: "agent" + metadata: { + description: undefined, + requester: { name: "test" }, + fullName: "test" + } + } }, { range: { @@ -44,18 +46,26 @@ ], text: "@ChatProviderWithUsedContext test request" }, - variableData: { - message: "@ChatProviderWithUsedContext test request", - variables: { } - }, + variableData: { variables: [ ] }, response: [ ], - responseErrorDetails: undefined, + result: { metadata: { metadataKey: "value" } }, followups: undefined, isCanceled: false, vote: undefined, agent: { id: "ChatProviderWithUsedContext", - metadata: { } + extensionId: { + value: "nullExtensionDescription", + _lower: "nullextensiondescription" + }, + metadata: { + description: undefined, + requester: { name: "test" }, + fullName: "test" + }, + slashCommands: [ ], + locations: [ "panel" ], + isDefault: undefined }, slashCommand: undefined, usedContext: { diff --git a/src/vs/workbench/contrib/chat/test/browser/chatMarkdownDecorationsRenderer.test.ts b/src/vs/workbench/contrib/chat/test/common/annotations.test.ts similarity index 95% rename from src/vs/workbench/contrib/chat/test/browser/chatMarkdownDecorationsRenderer.test.ts rename to src/vs/workbench/contrib/chat/test/common/annotations.test.ts index 1e3dd5494326b..e16117965c41e 100644 --- a/src/vs/workbench/contrib/chat/test/browser/chatMarkdownDecorationsRenderer.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/annotations.test.ts @@ -6,14 +6,14 @@ import { MarkdownString } from 'vs/base/common/htmlContent'; import { assertSnapshot } from 'vs/base/test/common/snapshot'; import { ensureNoDisposablesAreLeakedInTestSuite } from 'vs/base/test/common/utils'; -import { annotateSpecialMarkdownContent, extractVulnerabilitiesFromText } from 'vs/workbench/contrib/chat/browser/chatMarkdownDecorationsRenderer'; import { IChatMarkdownContent } from 'vs/workbench/contrib/chat/common/chatService'; +import { annotateSpecialMarkdownContent, extractVulnerabilitiesFromText } from '../../common/annotations'; function content(str: string): IChatMarkdownContent { return { kind: 'markdownContent', content: new MarkdownString(str) }; } -suite('ChatMarkdownDecorationsRenderer', function () { +suite('Annotations', function () { ensureNoDisposablesAreLeakedInTestSuite(); suite('extractVulnerabilitiesFromText', () => { diff --git a/src/vs/workbench/contrib/chat/test/common/chatModel.test.ts b/src/vs/workbench/contrib/chat/test/common/chatModel.test.ts index ed18235fe4f8c..3d63e2ed9d3be 100644 --- a/src/vs/workbench/contrib/chat/test/common/chatModel.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/chatModel.test.ts @@ -30,7 +30,7 @@ suite('ChatModel', () => { instantiationService.stub(IStorageService, testDisposables.add(new TestStorageService())); instantiationService.stub(ILogService, new NullLogService()); instantiationService.stub(IExtensionService, new TestExtensionService()); - instantiationService.stub(IChatAgentService, testDisposables.add(instantiationService.createInstance(ChatAgentService))); + instantiationService.stub(IChatAgentService, instantiationService.createInstance(ChatAgentService)); }); test('Waits for initialization', async () => { @@ -111,7 +111,7 @@ suite('ChatModel', () => { model.startInitialize(); model.initialize({} as any, undefined); const text = 'hello'; - model.addRequest({ text, parts: [new ChatRequestTextPart(new OffsetRange(0, text.length), new Range(1, text.length, 1, text.length), text)] }, { message: text, variables: {} }); + model.addRequest({ text, parts: [new ChatRequestTextPart(new OffsetRange(0, text.length), new Range(1, text.length, 1, text.length), text)] }, { variables: [] }); const requests = model.getRequests(); assert.strictEqual(requests.length, 1); diff --git a/src/vs/workbench/contrib/chat/test/common/chatRequestParser.test.ts b/src/vs/workbench/contrib/chat/test/common/chatRequestParser.test.ts index 5585c30afa878..c28817bb0905c 100644 --- a/src/vs/workbench/contrib/chat/test/common/chatRequestParser.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/chatRequestParser.test.ts @@ -9,11 +9,13 @@ import { ensureNoDisposablesAreLeakedInTestSuite } from 'vs/base/test/common/uti import { TestInstantiationService } from 'vs/platform/instantiation/test/common/instantiationServiceMock'; import { ILogService, NullLogService } from 'vs/platform/log/common/log'; import { IStorageService } from 'vs/platform/storage/common/storage'; -import { ChatAgentService, IChatAgent, IChatAgentCommand, IChatAgentService } from 'vs/workbench/contrib/chat/common/chatAgents'; +import { ChatAgentLocation, ChatAgentService, IChatAgentCommand, IChatAgentData, IChatAgentService } from 'vs/workbench/contrib/chat/common/chatAgents'; import { ChatRequestParser } from 'vs/workbench/contrib/chat/common/chatRequestParser'; +import { IChatService } from 'vs/workbench/contrib/chat/common/chatService'; import { IChatSlashCommandService } from 'vs/workbench/contrib/chat/common/chatSlashCommands'; import { IChatVariablesService } from 'vs/workbench/contrib/chat/common/chatVariables'; -import { IExtensionService } from 'vs/workbench/services/extensions/common/extensions'; +import { MockChatService } from 'vs/workbench/contrib/chat/test/common/mockChatService'; +import { IExtensionService, nullExtensionDescription } from 'vs/workbench/services/extensions/common/extensions'; import { TestExtensionService, TestStorageService } from 'vs/workbench/test/common/workbenchTestServices'; suite('ChatRequestParser', () => { @@ -28,7 +30,8 @@ suite('ChatRequestParser', () => { instantiationService.stub(IStorageService, testDisposables.add(new TestStorageService())); instantiationService.stub(ILogService, new NullLogService()); instantiationService.stub(IExtensionService, new TestExtensionService()); - instantiationService.stub(IChatAgentService, testDisposables.add(instantiationService.createInstance(ChatAgentService))); + instantiationService.stub(IChatService, new MockChatService()); + instantiationService.stub(IChatAgentService, instantiationService.createInstance(ChatAgentService)); varService = mockObject()({}); varService.getDynamicVariables.returns([]); @@ -108,13 +111,13 @@ suite('ChatRequestParser', () => { await assertSnapshot(result); }); - const getAgentWithSlashcommands = (slashCommands: IChatAgentCommand[]) => { - return >{ id: 'agent', metadata: { description: '' }, provideSlashCommands: async () => [], lastSlashCommands: slashCommands }; + const getAgentWithSlashCommands = (slashCommands: IChatAgentCommand[]) => { + return { id: 'agent', name: 'agent', extensionId: nullExtensionDescription.identifier, locations: [ChatAgentLocation.Panel], metadata: { description: '' }, slashCommands }; }; test('agent with subcommand after text', async () => { const agentsService = mockObject()({}); - agentsService.getAgent.returns(getAgentWithSlashcommands([{ name: 'subCommand', description: '' }])); + agentsService.getAgentsByName.returns([getAgentWithSlashCommands([{ name: 'subCommand', description: '' }])]); instantiationService.stub(IChatAgentService, agentsService as any); parser = instantiationService.createInstance(ChatRequestParser); @@ -124,7 +127,7 @@ suite('ChatRequestParser', () => { test('agents, subCommand', async () => { const agentsService = mockObject()({}); - agentsService.getAgent.returns(getAgentWithSlashcommands([{ name: 'subCommand', description: '' }])); + agentsService.getAgentsByName.returns([getAgentWithSlashCommands([{ name: 'subCommand', description: '' }])]); instantiationService.stub(IChatAgentService, agentsService as any); parser = instantiationService.createInstance(ChatRequestParser); @@ -134,7 +137,7 @@ suite('ChatRequestParser', () => { test('agent with question mark', async () => { const agentsService = mockObject()({}); - agentsService.getAgent.returns(getAgentWithSlashcommands([{ name: 'subCommand', description: '' }])); + agentsService.getAgentsByName.returns([getAgentWithSlashCommands([{ name: 'subCommand', description: '' }])]); instantiationService.stub(IChatAgentService, agentsService as any); parser = instantiationService.createInstance(ChatRequestParser); @@ -144,7 +147,7 @@ suite('ChatRequestParser', () => { test('agent and subcommand with leading whitespace', async () => { const agentsService = mockObject()({}); - agentsService.getAgent.returns(getAgentWithSlashcommands([{ name: 'subCommand', description: '' }])); + agentsService.getAgentsByName.returns([getAgentWithSlashCommands([{ name: 'subCommand', description: '' }])]); instantiationService.stub(IChatAgentService, agentsService as any); parser = instantiationService.createInstance(ChatRequestParser); @@ -154,7 +157,7 @@ suite('ChatRequestParser', () => { test('agent and subcommand after newline', async () => { const agentsService = mockObject()({}); - agentsService.getAgent.returns(getAgentWithSlashcommands([{ name: 'subCommand', description: '' }])); + agentsService.getAgentsByName.returns([getAgentWithSlashCommands([{ name: 'subCommand', description: '' }])]); instantiationService.stub(IChatAgentService, agentsService as any); parser = instantiationService.createInstance(ChatRequestParser); @@ -164,7 +167,7 @@ suite('ChatRequestParser', () => { test('agent not first', async () => { const agentsService = mockObject()({}); - agentsService.getAgent.returns(getAgentWithSlashcommands([{ name: 'subCommand', description: '' }])); + agentsService.getAgentsByName.returns([getAgentWithSlashCommands([{ name: 'subCommand', description: '' }])]); instantiationService.stub(IChatAgentService, agentsService as any); parser = instantiationService.createInstance(ChatRequestParser); @@ -174,7 +177,7 @@ suite('ChatRequestParser', () => { test('agents and variables and multiline', async () => { const agentsService = mockObject()({}); - agentsService.getAgent.returns(getAgentWithSlashcommands([{ name: 'subCommand', description: '' }])); + agentsService.getAgentsByName.returns([getAgentWithSlashCommands([{ name: 'subCommand', description: '' }])]); instantiationService.stub(IChatAgentService, agentsService as any); varService.hasVariable.returns(true); @@ -186,7 +189,7 @@ suite('ChatRequestParser', () => { test('agents and variables and multiline, part2', async () => { const agentsService = mockObject()({}); - agentsService.getAgent.returns(getAgentWithSlashcommands([{ name: 'subCommand', description: '' }])); + agentsService.getAgentsByName.returns([getAgentWithSlashCommands([{ name: 'subCommand', description: '' }])]); instantiationService.stub(IChatAgentService, agentsService as any); varService.hasVariable.returns(true); diff --git a/src/vs/workbench/contrib/chat/test/common/chatService.test.ts b/src/vs/workbench/contrib/chat/test/common/chatService.test.ts index 4bd2322fc6eec..3125cf09d88a8 100644 --- a/src/vs/workbench/contrib/chat/test/common/chatService.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/chatService.test.ts @@ -20,16 +20,17 @@ import { IStorageService } from 'vs/platform/storage/common/storage'; import { ITelemetryService } from 'vs/platform/telemetry/common/telemetry'; import { NullTelemetryService } from 'vs/platform/telemetry/common/telemetryUtils'; import { IWorkspaceContextService } from 'vs/platform/workspace/common/workspace'; -import { IViewsService } from 'vs/workbench/services/views/common/viewsService'; -import { ChatAgentService, IChatAgent, IChatAgentService } from 'vs/workbench/contrib/chat/common/chatAgents'; +import { ChatAgentLocation, ChatAgentService, IChatAgent, IChatAgentImplementation, IChatAgentService } from 'vs/workbench/contrib/chat/common/chatAgents'; import { IChatContributionService } from 'vs/workbench/contrib/chat/common/chatContributionService'; import { ISerializableChatData } from 'vs/workbench/contrib/chat/common/chatModel'; -import { IChat, IChatProgress, IChatProvider, IChatRequest } from 'vs/workbench/contrib/chat/common/chatService'; +import { IChat, IChatFollowup, IChatProgress, IChatProvider, IChatRequest, IChatService } from 'vs/workbench/contrib/chat/common/chatService'; import { ChatService } from 'vs/workbench/contrib/chat/common/chatServiceImpl'; import { ChatSlashCommandService, IChatSlashCommandService } from 'vs/workbench/contrib/chat/common/chatSlashCommands'; import { IChatVariablesService } from 'vs/workbench/contrib/chat/common/chatVariables'; +import { MockChatService } from 'vs/workbench/contrib/chat/test/common/mockChatService'; import { MockChatVariablesService } from 'vs/workbench/contrib/chat/test/common/mockChatVariables'; -import { IExtensionService } from 'vs/workbench/services/extensions/common/extensions'; +import { IExtensionService, nullExtensionDescription } from 'vs/workbench/services/extensions/common/extensions'; +import { IViewsService } from 'vs/workbench/services/views/common/viewsService'; import { TestContextService, TestExtensionService, TestStorageService } from 'vs/workbench/test/common/workbenchTestServices'; class SimpleTestProvider extends Disposable implements IChatProvider { @@ -44,8 +45,6 @@ class SimpleTestProvider extends Disposable implements IChatProvider { async prepareSession(): Promise { return { id: SimpleTestProvider.sessionId++, - responderUsername: 'test', - requesterUsername: 'test', }; } @@ -57,10 +56,11 @@ class SimpleTestProvider extends Disposable implements IChatProvider { const chatAgentWithUsedContextId = 'ChatProviderWithUsedContext'; const chatAgentWithUsedContext: IChatAgent = { id: chatAgentWithUsedContextId, + name: chatAgentWithUsedContextId, + extensionId: nullExtensionDescription.identifier, + locations: [ChatAgentLocation.Panel], metadata: {}, - async provideSlashCommands(token) { - return []; - }, + slashCommands: [], async invoke(request, progress, history, token) { progress({ documents: [ @@ -75,11 +75,14 @@ const chatAgentWithUsedContext: IChatAgent = { kind: 'usedContext' }); - return {}; + return { metadata: { metadataKey: 'value' } }; + }, + async provideFollowups(sessionId, token) { + return [{ kind: 'reply', message: 'Something else', agentId: '', tooltip: 'a tooltip' } satisfies IChatFollowup]; }, }; -suite('Chat', () => { +suite('ChatService', () => { const testDisposables = ensureNoDisposablesAreLeakedInTestSuite(); let storageService: IStorageService; @@ -100,18 +103,20 @@ suite('Chat', () => { instantiationService.stub(IChatContributionService, new TestExtensionService()); instantiationService.stub(IWorkspaceContextService, new TestContextService()); instantiationService.stub(IChatSlashCommandService, testDisposables.add(instantiationService.createInstance(ChatSlashCommandService))); + instantiationService.stub(IChatService, new MockChatService()); - chatAgentService = testDisposables.add(instantiationService.createInstance(ChatAgentService)); + chatAgentService = instantiationService.createInstance(ChatAgentService); instantiationService.stub(IChatAgentService, chatAgentService); const agent = { - id: 'testAgent', - metadata: { isDefault: true }, async invoke(request, progress, history, token) { return {}; }, - } as IChatAgent; - testDisposables.add(chatAgentService.registerAgent(agent)); + } satisfies IChatAgentImplementation; + testDisposables.add(chatAgentService.registerAgent('testAgent', { name: 'testAgent', id: 'testAgent', isDefault: true, extensionId: nullExtensionDescription.identifier, locations: [ChatAgentLocation.Panel], metadata: {}, slashCommands: [] })); + testDisposables.add(chatAgentService.registerAgent(chatAgentWithUsedContextId, { name: chatAgentWithUsedContextId, id: chatAgentWithUsedContextId, extensionId: nullExtensionDescription.identifier, locations: [ChatAgentLocation.Panel], metadata: {}, slashCommands: [] })); + testDisposables.add(chatAgentService.registerAgentImplementation('testAgent', agent)); + chatAgentService.updateAgent('testAgent', { requester: { name: 'test' }, fullName: 'test' }); }); test('retrieveSession', async () => { @@ -123,11 +128,11 @@ suite('Chat', () => { const session1 = testDisposables.add(testService.startSession('provider1', CancellationToken.None)); await session1.waitForInitialization(); - session1.addRequest({ parts: [], text: 'request 1' }, { message: 'request 1', variables: {} }); + session1.addRequest({ parts: [], text: 'request 1' }, { variables: [] }); const session2 = testDisposables.add(testService.startSession('provider2', CancellationToken.None)); await session2.waitForInitialization(); - session2.addRequest({ parts: [], text: 'request 2' }, { message: 'request 2', variables: {} }); + session2.addRequest({ parts: [], text: 'request 2' }, { variables: [] }); storageService.flush(); const testService2 = testDisposables.add(instantiationService.createInstance(ChatService)); @@ -187,18 +192,6 @@ suite('Chat', () => { }, 'Expected to throw for dupe provider'); }); - test('sendRequestToProvider', async () => { - const testService = testDisposables.add(instantiationService.createInstance(ChatService)); - testDisposables.add(testService.registerProvider(testDisposables.add(new SimpleTestProvider('testProvider')))); - - const model = testDisposables.add(testService.startSession('testProvider', CancellationToken.None)); - assert.strictEqual(model.getRequests().length, 0); - - const response = await testService.sendRequestToProvider(model.sessionId, { message: 'test request' }); - await response?.responseCompletePromise; - assert.strictEqual(model.getRequests().length, 1); - }); - test('addCompleteRequest', async () => { const testService = testDisposables.add(instantiationService.createInstance(ChatService)); testDisposables.add(testService.registerProvider(testDisposables.add(new SimpleTestProvider('testProvider')))); @@ -213,7 +206,8 @@ suite('Chat', () => { }); test('can serialize', async () => { - testDisposables.add(chatAgentService.registerAgent(chatAgentWithUsedContext)); + testDisposables.add(chatAgentService.registerAgentImplementation(chatAgentWithUsedContextId, chatAgentWithUsedContext)); + chatAgentService.updateAgent(chatAgentWithUsedContextId, { requester: { name: 'test' }, fullName: 'test' }); const testService = testDisposables.add(instantiationService.createInstance(ChatService)); testDisposables.add(testService.registerProvider(testDisposables.add(new SimpleTestProvider('testProvider')))); @@ -224,6 +218,7 @@ suite('Chat', () => { const response = await testService.sendRequest(model.sessionId, `@${chatAgentWithUsedContextId} test request`); assert(response); + await response.responseCompletePromise; assert.strictEqual(model.getRequests().length, 1); @@ -232,7 +227,7 @@ suite('Chat', () => { test('can deserialize', async () => { let serializedChatData: ISerializableChatData; - testDisposables.add(chatAgentService.registerAgent(chatAgentWithUsedContext)); + testDisposables.add(chatAgentService.registerAgentImplementation(chatAgentWithUsedContextId, chatAgentWithUsedContext)); // create the first service, send request, get response, and serialize the state { // serapate block to not leak variables in outer scope diff --git a/src/vs/workbench/contrib/chat/test/common/mockChatContributionService.ts b/src/vs/workbench/contrib/chat/test/common/mockChatContributionService.ts new file mode 100644 index 0000000000000..27a687cb24d18 --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/common/mockChatContributionService.ts @@ -0,0 +1,31 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IChatContributionService, IChatParticipantContribution, IChatProviderContribution } from 'vs/workbench/contrib/chat/common/chatContributionService'; + +export class MockChatContributionService implements IChatContributionService { + _serviceBrand: undefined; + + constructor( + ) { } + + registeredProviders: IChatProviderContribution[] = []; + registerChatParticipant(participant: IChatParticipantContribution): void { + throw new Error('Method not implemented.'); + } + deregisterChatParticipant(participant: IChatParticipantContribution): void { + throw new Error('Method not implemented.'); + } + + registerChatProvider(provider: IChatProviderContribution): void { + throw new Error('Method not implemented.'); + } + deregisterChatProvider(providerId: string): void { + throw new Error('Method not implemented.'); + } + getViewIdForProvider(providerId: string): string { + throw new Error('Method not implemented.'); + } +} diff --git a/src/vs/workbench/contrib/chat/test/common/mockChatService.ts b/src/vs/workbench/contrib/chat/test/common/mockChatService.ts new file mode 100644 index 0000000000000..d961bbb74c589 --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/common/mockChatService.ts @@ -0,0 +1,82 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { CancellationToken } from 'vs/base/common/cancellation'; +import { Event } from 'vs/base/common/event'; +import { IDisposable } from 'vs/base/common/lifecycle'; +import { URI } from 'vs/base/common/uri'; +import { ChatModel, IChatModel, IChatRequestVariableData, ISerializableChatData } from 'vs/workbench/contrib/chat/common/chatModel'; +import { IParsedChatRequest } from 'vs/workbench/contrib/chat/common/chatParserTypes'; +import { IChatCompleteResponse, IChatDetail, IChatProvider, IChatProviderInfo, IChatSendRequestData, IChatService, IChatTransferredSessionData, IChatUserActionEvent } from 'vs/workbench/contrib/chat/common/chatService'; + +export class MockChatService implements IChatService { + _serviceBrand: undefined; + transferredSessionData: IChatTransferredSessionData | undefined; + + hasSessions(providerId: string): boolean { + throw new Error('Method not implemented.'); + } + getProviderInfos(): IChatProviderInfo[] { + throw new Error('Method not implemented.'); + } + startSession(providerId: string, token: CancellationToken): ChatModel | undefined { + throw new Error('Method not implemented.'); + } + getSession(sessionId: string): IChatModel | undefined { + return {} as IChatModel; + } + getSessionId(sessionProviderId: number): string | undefined { + throw new Error('Method not implemented.'); + } + getOrRestoreSession(sessionId: string): IChatModel | undefined { + throw new Error('Method not implemented.'); + } + loadSessionFromContent(data: ISerializableChatData): IChatModel | undefined { + throw new Error('Method not implemented.'); + } + onDidRegisterProvider: Event<{ providerId: string }> = undefined!; + onDidUnregisterProvider: Event<{ providerId: string }> = undefined!; + registerProvider(provider: IChatProvider): IDisposable { + throw new Error('Method not implemented.'); + } + + /** + * Returns whether the request was accepted. + */ + sendRequest(sessionId: string, message: string): Promise { + throw new Error('Method not implemented.'); + } + removeRequest(sessionid: string, requestId: string): Promise { + throw new Error('Method not implemented.'); + } + cancelCurrentRequestForSession(sessionId: string): void { + throw new Error('Method not implemented.'); + } + clearSession(sessionId: string): void { + throw new Error('Method not implemented.'); + } + addCompleteRequest(sessionId: string, message: IParsedChatRequest | string, variableData: IChatRequestVariableData | undefined, response: IChatCompleteResponse): void { + throw new Error('Method not implemented.'); + } + getHistory(): IChatDetail[] { + throw new Error('Method not implemented.'); + } + clearAllHistoryEntries(): void { + throw new Error('Method not implemented.'); + } + removeHistoryEntry(sessionId: string): void { + throw new Error('Method not implemented.'); + } + + onDidPerformUserAction: Event = undefined!; + notifyUserAction(event: IChatUserActionEvent): void { + throw new Error('Method not implemented.'); + } + onDidDisposeSession: Event<{ sessionId: string; providerId: string; reason: 'initializationFailed' | 'cleared' }> = undefined!; + + transferChatSession(transferredSessionData: IChatTransferredSessionData, toWorkspace: URI): void { + throw new Error('Method not implemented.'); + } +} diff --git a/src/vs/workbench/contrib/chat/test/common/mockChatVariables.ts b/src/vs/workbench/contrib/chat/test/common/mockChatVariables.ts index 73d64ca339b94..0df8e2b57e78f 100644 --- a/src/vs/workbench/contrib/chat/test/common/mockChatVariables.ts +++ b/src/vs/workbench/contrib/chat/test/common/mockChatVariables.ts @@ -7,7 +7,7 @@ import { CancellationToken } from 'vs/base/common/cancellation'; import { IDisposable } from 'vs/base/common/lifecycle'; import { IChatModel, IChatRequestVariableData } from 'vs/workbench/contrib/chat/common/chatModel'; import { IParsedChatRequest } from 'vs/workbench/contrib/chat/common/chatParserTypes'; -import { IChatVariableData, IChatVariableResolver, IChatVariablesService, IDynamicVariable } from 'vs/workbench/contrib/chat/common/chatVariables'; +import { IChatRequestVariableValue, IChatVariableData, IChatVariableResolver, IChatVariableResolverProgress, IChatVariablesService, IDynamicVariable } from 'vs/workbench/contrib/chat/common/chatVariables'; export class MockChatVariablesService implements IChatVariablesService { _serviceBrand: undefined; @@ -15,6 +15,10 @@ export class MockChatVariablesService implements IChatVariablesService { throw new Error('Method not implemented.'); } + getVariable(name: string): IChatVariableData | undefined { + throw new Error('Method not implemented.'); + } + hasVariable(name: string): boolean { throw new Error('Method not implemented.'); } @@ -27,10 +31,13 @@ export class MockChatVariablesService implements IChatVariablesService { return []; } - async resolveVariables(prompt: IParsedChatRequest, model: IChatModel, token: CancellationToken): Promise { + async resolveVariables(prompt: IParsedChatRequest, model: IChatModel, progress: (part: IChatVariableResolverProgress) => void, token: CancellationToken): Promise { return { - message: prompt.text, - variables: {} + variables: [] }; } + + resolveVariable(variableName: string, promptText: string, model: IChatModel, progress: (part: IChatVariableResolverProgress) => void, token: CancellationToken): Promise { + throw new Error('Method not implemented.'); + } } diff --git a/src/vs/workbench/contrib/chat/test/common/voiceChat.test.ts b/src/vs/workbench/contrib/chat/test/common/voiceChat.test.ts new file mode 100644 index 0000000000000..97e11f717ea51 --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/common/voiceChat.test.ts @@ -0,0 +1,330 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as assert from 'assert'; +import { CancellationToken, CancellationTokenSource } from 'vs/base/common/cancellation'; +import { Emitter, Event } from 'vs/base/common/event'; +import { IMarkdownString } from 'vs/base/common/htmlContent'; +import { DisposableStore, IDisposable, toDisposable } from 'vs/base/common/lifecycle'; +import { ensureNoDisposablesAreLeakedInTestSuite } from 'vs/base/test/common/utils'; +import { ProviderResult } from 'vs/editor/common/languages'; +import { ExtensionIdentifier } from 'vs/platform/extensions/common/extensions'; +import { ChatAgentLocation, IChatAgent, IChatAgentCommand, IChatAgentData, IChatAgentHistoryEntry, IChatAgentImplementation, IChatAgentMetadata, IChatAgentRequest, IChatAgentResult, IChatAgentService } from 'vs/workbench/contrib/chat/common/chatAgents'; +import { IChatModel } from 'vs/workbench/contrib/chat/common/chatModel'; +import { IChatProgress, IChatFollowup } from 'vs/workbench/contrib/chat/common/chatService'; +import { IVoiceChatSessionOptions, IVoiceChatTextEvent, VoiceChatService } from 'vs/workbench/contrib/chat/common/voiceChat'; +import { ISpeechProvider, ISpeechService, ISpeechToTextEvent, ISpeechToTextSession, KeywordRecognitionStatus, SpeechToTextStatus } from 'vs/workbench/contrib/speech/common/speechService'; +import { nullExtensionDescription } from 'vs/workbench/services/extensions/common/extensions'; + +suite('VoiceChat', () => { + + class TestChatAgentCommand implements IChatAgentCommand { + constructor(readonly name: string, readonly description: string) { } + } + + class TestChatAgent implements IChatAgent { + + extensionId: ExtensionIdentifier = nullExtensionDescription.identifier; + locations: ChatAgentLocation[] = [ChatAgentLocation.Panel]; + public readonly name: string; + constructor(readonly id: string, readonly slashCommands: IChatAgentCommand[]) { + this.name = id; + } + invoke(request: IChatAgentRequest, progress: (part: IChatProgress) => void, history: IChatAgentHistoryEntry[], token: CancellationToken): Promise { throw new Error('Method not implemented.'); } + provideWelcomeMessage?(token: CancellationToken): ProviderResult<(string | IMarkdownString)[] | undefined> { throw new Error('Method not implemented.'); } + metadata = {}; + } + + const agents: IChatAgent[] = [ + new TestChatAgent('workspace', [ + new TestChatAgentCommand('fix', 'fix'), + new TestChatAgentCommand('explain', 'explain') + ]), + new TestChatAgent('vscode', [ + new TestChatAgentCommand('search', 'search') + ]), + ]; + + class TestChatAgentService implements IChatAgentService { + _serviceBrand: undefined; + readonly onDidChangeAgents = Event.None; + registerAgentImplementation(id: string, agent: IChatAgentImplementation): IDisposable { throw new Error(); } + registerDynamicAgent(data: IChatAgentData, agentImpl: IChatAgentImplementation): IDisposable { throw new Error('Method not implemented.'); } + invokeAgent(id: string, request: IChatAgentRequest, progress: (part: IChatProgress) => void, history: IChatAgentHistoryEntry[], token: CancellationToken): Promise { throw new Error(); } + getFollowups(id: string, request: IChatAgentRequest, result: IChatAgentResult, history: IChatAgentHistoryEntry[], token: CancellationToken): Promise { throw new Error(); } + getActivatedAgents(): IChatAgent[] { return agents; } + getAgents(): IChatAgent[] { return agents; } + getDefaultAgent(): IChatAgent | undefined { throw new Error(); } + getSecondaryAgent(): IChatAgent | undefined { throw new Error(); } + registerAgent(id: string, data: IChatAgentData): IDisposable { throw new Error('Method not implemented.'); } + getAgent(id: string): IChatAgentData | undefined { throw new Error('Method not implemented.'); } + getAgentsByName(name: string): IChatAgentData[] { throw new Error('Method not implemented.'); } + updateAgent(id: string, updateMetadata: IChatAgentMetadata): void { throw new Error('Method not implemented.'); } + } + + class TestSpeechService implements ISpeechService { + _serviceBrand: undefined; + + onDidChangeHasSpeechProvider = Event.None; + + readonly hasSpeechProvider = true; + readonly hasActiveSpeechToTextSession = false; + readonly hasActiveKeywordRecognition = false; + + registerSpeechProvider(identifier: string, provider: ISpeechProvider): IDisposable { throw new Error('Method not implemented.'); } + onDidStartSpeechToTextSession = Event.None; + onDidEndSpeechToTextSession = Event.None; + + async createSpeechToTextSession(token: CancellationToken): Promise { + return { + onDidChange: emitter.event + }; + } + + onDidStartKeywordRecognition = Event.None; + onDidEndKeywordRecognition = Event.None; + recognizeKeyword(token: CancellationToken): Promise { throw new Error('Method not implemented.'); } + } + + const disposables = new DisposableStore(); + let emitter: Emitter; + + let service: VoiceChatService; + let event: IVoiceChatTextEvent | undefined; + + async function createSession(options: IVoiceChatSessionOptions) { + const cts = new CancellationTokenSource(); + disposables.add(toDisposable(() => cts.dispose(true))); + const session = await service.createVoiceChatSession(cts.token, options); + disposables.add(session.onDidChange(e => { + event = e; + })); + } + + setup(() => { + emitter = disposables.add(new Emitter()); + service = disposables.add(new VoiceChatService(new TestSpeechService(), new TestChatAgentService())); + }); + + teardown(() => { + disposables.clear(); + }); + + test('Agent and slash command detection (useAgents: false)', async () => { + await testAgentsAndSlashCommandsDetection({ usesAgents: false, model: {} as IChatModel }); + }); + + test('Agent and slash command detection (useAgents: true)', async () => { + await testAgentsAndSlashCommandsDetection({ usesAgents: true, model: {} as IChatModel }); + }); + + async function testAgentsAndSlashCommandsDetection(options: IVoiceChatSessionOptions) { + + // Nothing to detect + await createSession(options); + + emitter.fire({ status: SpeechToTextStatus.Started }); + assert.strictEqual(event?.status, SpeechToTextStatus.Started); + + emitter.fire({ status: SpeechToTextStatus.Recognizing, text: 'Hello' }); + assert.strictEqual(event?.status, SpeechToTextStatus.Recognizing); + assert.strictEqual(event?.text, 'Hello'); + assert.strictEqual(event?.waitingForInput, undefined); + + emitter.fire({ status: SpeechToTextStatus.Recognizing, text: 'Hello World' }); + assert.strictEqual(event?.status, SpeechToTextStatus.Recognizing); + assert.strictEqual(event?.text, 'Hello World'); + assert.strictEqual(event?.waitingForInput, undefined); + + emitter.fire({ status: SpeechToTextStatus.Recognized, text: 'Hello World' }); + assert.strictEqual(event?.status, SpeechToTextStatus.Recognized); + assert.strictEqual(event?.text, 'Hello World'); + assert.strictEqual(event?.waitingForInput, undefined); + + // Agent + await createSession(options); + + emitter.fire({ status: SpeechToTextStatus.Recognizing, text: 'At' }); + assert.strictEqual(event?.status, SpeechToTextStatus.Recognizing); + assert.strictEqual(event?.text, 'At'); + + emitter.fire({ status: SpeechToTextStatus.Recognizing, text: 'At workspace' }); + assert.strictEqual(event?.status, SpeechToTextStatus.Recognizing); + assert.strictEqual(event?.text, options.usesAgents ? '@workspace' : 'At workspace'); + assert.strictEqual(event?.waitingForInput, options.usesAgents); + + emitter.fire({ status: SpeechToTextStatus.Recognizing, text: 'at workspace' }); + assert.strictEqual(event?.status, SpeechToTextStatus.Recognizing); + assert.strictEqual(event?.text, options.usesAgents ? '@workspace' : 'at workspace'); + assert.strictEqual(event?.waitingForInput, options.usesAgents); + + emitter.fire({ status: SpeechToTextStatus.Recognizing, text: 'At workspace help' }); + assert.strictEqual(event?.status, SpeechToTextStatus.Recognizing); + assert.strictEqual(event?.text, options.usesAgents ? '@workspace help' : 'At workspace help'); + assert.strictEqual(event?.waitingForInput, false); + + emitter.fire({ status: SpeechToTextStatus.Recognized, text: 'At workspace help' }); + assert.strictEqual(event?.status, SpeechToTextStatus.Recognized); + assert.strictEqual(event?.text, options.usesAgents ? '@workspace help' : 'At workspace help'); + assert.strictEqual(event?.waitingForInput, false); + + // Agent with punctuation + await createSession(options); + + emitter.fire({ status: SpeechToTextStatus.Recognizing, text: 'At workspace, help' }); + assert.strictEqual(event?.status, SpeechToTextStatus.Recognizing); + assert.strictEqual(event?.text, options.usesAgents ? '@workspace help' : 'At workspace, help'); + assert.strictEqual(event?.waitingForInput, false); + + emitter.fire({ status: SpeechToTextStatus.Recognized, text: 'At workspace, help' }); + assert.strictEqual(event?.status, SpeechToTextStatus.Recognized); + assert.strictEqual(event?.text, options.usesAgents ? '@workspace help' : 'At workspace, help'); + assert.strictEqual(event?.waitingForInput, false); + + await createSession(options); + + emitter.fire({ status: SpeechToTextStatus.Recognizing, text: 'At Workspace. help' }); + assert.strictEqual(event?.status, SpeechToTextStatus.Recognizing); + assert.strictEqual(event?.text, options.usesAgents ? '@workspace help' : 'At Workspace. help'); + assert.strictEqual(event?.waitingForInput, false); + + emitter.fire({ status: SpeechToTextStatus.Recognized, text: 'At Workspace. help' }); + assert.strictEqual(event?.status, SpeechToTextStatus.Recognized); + assert.strictEqual(event?.text, options.usesAgents ? '@workspace help' : 'At Workspace. help'); + assert.strictEqual(event?.waitingForInput, false); + + // Slash Command + await createSession(options); + + emitter.fire({ status: SpeechToTextStatus.Recognizing, text: 'Slash fix' }); + assert.strictEqual(event?.status, SpeechToTextStatus.Recognizing); + assert.strictEqual(event?.text, options.usesAgents ? '@workspace /fix' : '/fix'); + assert.strictEqual(event?.waitingForInput, true); + + emitter.fire({ status: SpeechToTextStatus.Recognized, text: 'Slash fix' }); + assert.strictEqual(event?.status, SpeechToTextStatus.Recognized); + assert.strictEqual(event?.text, options.usesAgents ? '@workspace /fix' : '/fix'); + assert.strictEqual(event?.waitingForInput, true); + + // Agent + Slash Command + await createSession(options); + + emitter.fire({ status: SpeechToTextStatus.Recognizing, text: 'At code slash search help' }); + assert.strictEqual(event?.status, SpeechToTextStatus.Recognizing); + assert.strictEqual(event?.text, options.usesAgents ? '@vscode /search help' : 'At code slash search help'); + assert.strictEqual(event?.waitingForInput, false); + + emitter.fire({ status: SpeechToTextStatus.Recognized, text: 'At code slash search help' }); + assert.strictEqual(event?.status, SpeechToTextStatus.Recognized); + assert.strictEqual(event?.text, options.usesAgents ? '@vscode /search help' : 'At code slash search help'); + assert.strictEqual(event?.waitingForInput, false); + + // Agent + Slash Command with punctuation + await createSession(options); + + emitter.fire({ status: SpeechToTextStatus.Recognizing, text: 'At code, slash search, help' }); + assert.strictEqual(event?.status, SpeechToTextStatus.Recognizing); + assert.strictEqual(event?.text, options.usesAgents ? '@vscode /search help' : 'At code, slash search, help'); + assert.strictEqual(event?.waitingForInput, false); + + emitter.fire({ status: SpeechToTextStatus.Recognized, text: 'At code, slash search, help' }); + assert.strictEqual(event?.status, SpeechToTextStatus.Recognized); + assert.strictEqual(event?.text, options.usesAgents ? '@vscode /search help' : 'At code, slash search, help'); + assert.strictEqual(event?.waitingForInput, false); + + await createSession(options); + + emitter.fire({ status: SpeechToTextStatus.Recognizing, text: 'At code. slash, search help' }); + assert.strictEqual(event?.status, SpeechToTextStatus.Recognizing); + assert.strictEqual(event?.text, options.usesAgents ? '@vscode /search help' : 'At code. slash, search help'); + assert.strictEqual(event?.waitingForInput, false); + + emitter.fire({ status: SpeechToTextStatus.Recognized, text: 'At code. slash search, help' }); + assert.strictEqual(event?.status, SpeechToTextStatus.Recognized); + assert.strictEqual(event?.text, options.usesAgents ? '@vscode /search help' : 'At code. slash search, help'); + assert.strictEqual(event?.waitingForInput, false); + + // Agent not detected twice + await createSession(options); + + emitter.fire({ status: SpeechToTextStatus.Recognizing, text: 'At workspace, for at workspace' }); + assert.strictEqual(event?.status, SpeechToTextStatus.Recognizing); + assert.strictEqual(event?.text, options.usesAgents ? '@workspace for at workspace' : 'At workspace, for at workspace'); + assert.strictEqual(event?.waitingForInput, false); + + emitter.fire({ status: SpeechToTextStatus.Recognized, text: 'At workspace, for at workspace' }); + assert.strictEqual(event?.status, SpeechToTextStatus.Recognized); + assert.strictEqual(event?.text, options.usesAgents ? '@workspace for at workspace' : 'At workspace, for at workspace'); + assert.strictEqual(event?.waitingForInput, false); + + // Slash command detected after agent recognized + if (options.usesAgents) { + await createSession(options); + + emitter.fire({ status: SpeechToTextStatus.Recognized, text: 'At workspace' }); + assert.strictEqual(event?.status, SpeechToTextStatus.Recognized); + assert.strictEqual(event?.text, '@workspace'); + assert.strictEqual(event?.waitingForInput, true); + + emitter.fire({ status: SpeechToTextStatus.Recognizing, text: 'slash' }); + assert.strictEqual(event?.status, SpeechToTextStatus.Recognizing); + assert.strictEqual(event?.text, 'slash'); + assert.strictEqual(event?.waitingForInput, false); + + emitter.fire({ status: SpeechToTextStatus.Recognizing, text: 'slash fix' }); + assert.strictEqual(event?.status, SpeechToTextStatus.Recognizing); + assert.strictEqual(event?.text, '/fix'); + assert.strictEqual(event?.waitingForInput, true); + + emitter.fire({ status: SpeechToTextStatus.Recognized, text: 'slash fix' }); + assert.strictEqual(event?.status, SpeechToTextStatus.Recognized); + assert.strictEqual(event?.text, '/fix'); + assert.strictEqual(event?.waitingForInput, true); + + await createSession(options); + + emitter.fire({ status: SpeechToTextStatus.Recognized, text: 'At workspace' }); + assert.strictEqual(event?.status, SpeechToTextStatus.Recognized); + assert.strictEqual(event?.text, '@workspace'); + assert.strictEqual(event?.waitingForInput, true); + + emitter.fire({ status: SpeechToTextStatus.Recognized, text: 'slash fix' }); + assert.strictEqual(event?.status, SpeechToTextStatus.Recognized); + assert.strictEqual(event?.text, '/fix'); + assert.strictEqual(event?.waitingForInput, true); + } + } + + test('waiting for input', async () => { + + // Agent + await createSession({ usesAgents: true, model: {} as IChatModel }); + + emitter.fire({ status: SpeechToTextStatus.Recognizing, text: 'At workspace' }); + assert.strictEqual(event?.status, SpeechToTextStatus.Recognizing); + assert.strictEqual(event?.text, '@workspace'); + assert.strictEqual(event.waitingForInput, true); + + emitter.fire({ status: SpeechToTextStatus.Recognized, text: 'At workspace' }); + assert.strictEqual(event?.status, SpeechToTextStatus.Recognized); + assert.strictEqual(event?.text, '@workspace'); + assert.strictEqual(event.waitingForInput, true); + + // Slash Command + await createSession({ usesAgents: true, model: {} as IChatModel }); + + emitter.fire({ status: SpeechToTextStatus.Recognizing, text: 'At workspace slash explain' }); + assert.strictEqual(event?.status, SpeechToTextStatus.Recognizing); + assert.strictEqual(event?.text, '@workspace /explain'); + assert.strictEqual(event.waitingForInput, true); + + emitter.fire({ status: SpeechToTextStatus.Recognized, text: 'At workspace slash explain' }); + assert.strictEqual(event?.status, SpeechToTextStatus.Recognized); + assert.strictEqual(event?.text, '@workspace /explain'); + assert.strictEqual(event.waitingForInput, true); + }); + + ensureNoDisposablesAreLeakedInTestSuite(); +}); diff --git a/src/vs/workbench/contrib/codeActions/browser/codeActionsContribution.ts b/src/vs/workbench/contrib/codeActions/browser/codeActionsContribution.ts index 4225ffa7cd7d9..38fd5502ae2c8 100644 --- a/src/vs/workbench/contrib/codeActions/browser/codeActionsContribution.ts +++ b/src/vs/workbench/contrib/codeActions/browser/codeActionsContribution.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { Emitter } from 'vs/base/common/event'; +import { HierarchicalKind } from 'vs/base/common/hierarchicalKind'; import { IJSONSchema, IJSONSchemaMap } from 'vs/base/common/jsonSchema'; import { Disposable } from 'vs/base/common/lifecycle'; import { editorConfigurationBaseNode } from 'vs/editor/common/config/editorConfigurationSchema'; @@ -103,11 +104,11 @@ export class CodeActionsContribution extends Disposable implements IWorkbenchCon } private getSourceActions(contributions: readonly CodeActionsExtensionPoint[]) { - const defaultKinds = Object.keys(codeActionsOnSaveDefaultProperties).map(value => new CodeActionKind(value)); + const defaultKinds = Object.keys(codeActionsOnSaveDefaultProperties).map(value => new HierarchicalKind(value)); const sourceActions = new Map(); for (const contribution of contributions) { for (const action of contribution.actions) { - const kind = new CodeActionKind(action.kind); + const kind = new HierarchicalKind(action.kind); if (CodeActionKind.Source.contains(kind) // Exclude any we already included by default && !defaultKinds.some(defaultKind => defaultKind.contains(kind)) @@ -149,12 +150,12 @@ export class CodeActionsContribution extends Disposable implements IWorkbenchCon }; }; - const getActions = (ofKind: CodeActionKind): ContributedCodeAction[] => { + const getActions = (ofKind: HierarchicalKind): ContributedCodeAction[] => { const allActions = this._contributedCodeActions.flatMap(desc => desc.actions); const out = new Map(); for (const action of allActions) { - if (!out.has(action.kind) && ofKind.contains(new CodeActionKind(action.kind))) { + if (!out.has(action.kind) && ofKind.contains(new HierarchicalKind(action.kind))) { out.set(action.kind, action); } } @@ -162,7 +163,7 @@ export class CodeActionsContribution extends Disposable implements IWorkbenchCon }; return [ - conditionalSchema(codeActionCommandId, getActions(CodeActionKind.Empty)), + conditionalSchema(codeActionCommandId, getActions(HierarchicalKind.Empty)), conditionalSchema(refactorCommandId, getActions(CodeActionKind.Refactor)), conditionalSchema(sourceActionCommandId, getActions(CodeActionKind.Source)), ]; diff --git a/src/vs/workbench/contrib/codeActions/browser/documentationContribution.ts b/src/vs/workbench/contrib/codeActions/browser/documentationContribution.ts index ed964662b2e33..01f9be18e30df 100644 --- a/src/vs/workbench/contrib/codeActions/browser/documentationContribution.ts +++ b/src/vs/workbench/contrib/codeActions/browser/documentationContribution.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { CancellationToken } from 'vs/base/common/cancellation'; +import { HierarchicalKind } from 'vs/base/common/hierarchicalKind'; import { Disposable } from 'vs/base/common/lifecycle'; import { Range } from 'vs/editor/common/core/range'; import { Selection } from 'vs/editor/common/core/selection'; @@ -69,7 +70,7 @@ export class CodeActionDocumentationContribution extends Disposable implements I public _getAdditionalMenuItems(context: languages.CodeActionContext, actions: readonly languages.CodeAction[]): languages.Command[] { if (context.only !== CodeActionKind.Refactor.value) { - if (!actions.some(action => action.kind && CodeActionKind.Refactor.contains(new CodeActionKind(action.kind)))) { + if (!actions.some(action => action.kind && CodeActionKind.Refactor.contains(new HierarchicalKind(action.kind)))) { return []; } } diff --git a/src/vs/workbench/contrib/codeActions/common/codeActionsExtensionPoint.ts b/src/vs/workbench/contrib/codeActions/common/codeActionsExtensionPoint.ts index 4e119df579a76..d8581f7d67c96 100644 --- a/src/vs/workbench/contrib/codeActions/common/codeActionsExtensionPoint.ts +++ b/src/vs/workbench/contrib/codeActions/common/codeActionsExtensionPoint.ts @@ -11,6 +11,7 @@ import { Disposable } from 'vs/base/common/lifecycle'; import { SyncDescriptor } from 'vs/platform/instantiation/common/descriptors'; import { IExtensionManifest } from 'vs/platform/extensions/common/extensions'; import { Registry } from 'vs/platform/registry/common/platform'; +import { MarkdownString } from 'vs/base/common/htmlContent'; enum CodeActionExtensionPointFields { languages = 'languages', @@ -100,9 +101,9 @@ class CodeActionsTableRenderer extends Disposable implements IExtensionFeatureTa .map(action => { return [ action.title, - { data: action.kind, type: 'code' }, + new MarkdownString().appendMarkdown(`\`${action.kind}\``), action.description ?? '', - { data: [...action.languages], type: 'code' }, + new MarkdownString().appendMarkdown(`${action.languages.map(lang => `\`${lang}\``).join(' ')}`), ]; }); diff --git a/src/vs/workbench/contrib/codeEditor/browser/accessibility/accessibility.ts b/src/vs/workbench/contrib/codeEditor/browser/accessibility/accessibility.ts index 9e12975a5c96b..36ac99f6654ff 100644 --- a/src/vs/workbench/contrib/codeEditor/browser/accessibility/accessibility.ts +++ b/src/vs/workbench/contrib/codeEditor/browser/accessibility/accessibility.ts @@ -21,6 +21,9 @@ class ToggleScreenReaderMode extends Action2 { super({ id: 'editor.action.toggleScreenReaderAccessibilityMode', title: nls.localize2('toggleScreenReaderMode', "Toggle Screen Reader Accessibility Mode"), + metadata: { + description: nls.localize2('toggleScreenReaderModeDescription', "Toggles an optimized mode for usage with screen readers, braille devices, and other assistive technologies."), + }, f1: true, keybinding: [{ primary: KeyMod.CtrlCmd | KeyCode.KeyE, diff --git a/src/vs/workbench/contrib/codeEditor/browser/codeEditor.contribution.ts b/src/vs/workbench/contrib/codeEditor/browser/codeEditor.contribution.ts index b858435378cd3..ed593625401d9 100644 --- a/src/vs/workbench/contrib/codeEditor/browser/codeEditor.contribution.ts +++ b/src/vs/workbench/contrib/codeEditor/browser/codeEditor.contribution.ts @@ -23,3 +23,4 @@ import './toggleWordWrap'; import './emptyTextEditorHint/emptyTextEditorHint'; import './workbenchReferenceSearch'; import './editorLineNumberMenu'; +import './dictation/editorDictation'; diff --git a/src/vs/workbench/contrib/inlineChat/electron-sandbox/inlineChatQuickVoice.css b/src/vs/workbench/contrib/codeEditor/browser/dictation/editorDictation.css similarity index 51% rename from src/vs/workbench/contrib/inlineChat/electron-sandbox/inlineChatQuickVoice.css rename to src/vs/workbench/contrib/codeEditor/browser/dictation/editorDictation.css index 61d96056a006f..0a8d982123f12 100644 --- a/src/vs/workbench/contrib/inlineChat/electron-sandbox/inlineChatQuickVoice.css +++ b/src/vs/workbench/contrib/codeEditor/browser/dictation/editorDictation.css @@ -3,47 +3,34 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -.monaco-editor .inline-chat-quick-voice { +.monaco-editor .editor-dictation-widget { background-color: var(--vscode-editor-background); padding: 2px; border-radius: 8px; display: flex; align-items: center; - box-shadow: 0 4px 8px var(--vscode-inlineChat-shadow); + box-shadow: 0 4px 8px var(--vscode-widget-shadow); z-index: 1000; - min-height: var(--vscode-inline-chat-quick-voice-height); - line-height: var(--vscode-inline-chat-quick-voice-height); - max-width: var(--vscode-inline-chat-quick-voice-width); + min-height: var(--vscode-editor-dictation-widget-height); + line-height: var(--vscode-editor-dictation-widget-height); + max-width: var(--vscode-editor-dictation-widget-width); } -.monaco-editor .inline-chat-quick-voice .codicon.codicon-mic-filled { - display: flex; - align-items: center; - width: 16px; - height: 16px; -} - -.monaco-editor .inline-chat-quick-voice.recording .codicon.codicon-mic-filled { +.monaco-editor .editor-dictation-widget.recording .codicon.codicon-mic-filled { color: var(--vscode-activityBarBadge-background); - animation: ani-inline-chat 1s infinite; + animation: editor-dictation-animation 1s infinite; } -@keyframes ani-inline-chat { +@keyframes editor-dictation-animation { 0% { color: var(--vscode-editorCursor-background); } + 50% { color: var(--vscode-activityBarBadge-background); } + 100% { color: var(--vscode-editorCursor-background); } } - -.monaco-editor .inline-chat-quick-voice .preview { - opacity: .4; -} - -.monaco-editor .inline-chat-quick-voice .message:not(:empty) { - margin-right: 0.4ch; -} diff --git a/src/vs/workbench/contrib/codeEditor/browser/dictation/editorDictation.ts b/src/vs/workbench/contrib/codeEditor/browser/dictation/editorDictation.ts new file mode 100644 index 0000000000000..377f64adf0a14 --- /dev/null +++ b/src/vs/workbench/contrib/codeEditor/browser/dictation/editorDictation.ts @@ -0,0 +1,292 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import 'vs/css!./editorDictation'; +import { localize, localize2 } from 'vs/nls'; +import { IDimension } from 'vs/base/browser/dom'; +import { CancellationTokenSource } from 'vs/base/common/cancellation'; +import { Disposable, DisposableStore, MutableDisposable, toDisposable } from 'vs/base/common/lifecycle'; +import { ContentWidgetPositionPreference, ICodeEditor, IContentWidget, IContentWidgetPosition } from 'vs/editor/browser/editorBrowser'; +import { IEditorContribution } from 'vs/editor/common/editorCommon'; +import { ContextKeyExpr, IContextKeyService, RawContextKey } from 'vs/platform/contextkey/common/contextkey'; +import { HasSpeechProvider, ISpeechService, SpeechToTextStatus } from 'vs/workbench/contrib/speech/common/speechService'; +import { Codicon } from 'vs/base/common/codicons'; +import { EditorOption } from 'vs/editor/common/config/editorOptions'; +import { EditorAction2, EditorContributionInstantiation, registerEditorContribution } from 'vs/editor/browser/editorExtensions'; +import { EditorContextKeys } from 'vs/editor/common/editorContextKeys'; +import { KeyCode, KeyMod } from 'vs/base/common/keyCodes'; +import { KeybindingWeight } from 'vs/platform/keybinding/common/keybindingsRegistry'; +import { ServicesAccessor } from 'vs/platform/instantiation/common/instantiation'; +import { IKeybindingService } from 'vs/platform/keybinding/common/keybinding'; +import { EditOperation } from 'vs/editor/common/core/editOperation'; +import { Selection } from 'vs/editor/common/core/selection'; +import { Position } from 'vs/editor/common/core/position'; +import { Range } from 'vs/editor/common/core/range'; +import { registerAction2 } from 'vs/platform/actions/common/actions'; +import { assertIsDefined } from 'vs/base/common/types'; +import { ActionBar } from 'vs/base/browser/ui/actionbar/actionbar'; +import { toAction } from 'vs/base/common/actions'; +import { ThemeIcon } from 'vs/base/common/themables'; + +const EDITOR_DICTATION_IN_PROGRESS = new RawContextKey('editorDictation.inProgress', false); +const VOICE_CATEGORY = localize2('voiceCategory', "Voice"); + +export class EditorDictationStartAction extends EditorAction2 { + + constructor() { + super({ + id: 'workbench.action.editorDictation.start', + title: localize2('startDictation', "Start Dictation in Editor"), + category: VOICE_CATEGORY, + precondition: ContextKeyExpr.and(HasSpeechProvider, EDITOR_DICTATION_IN_PROGRESS.toNegated(), EditorContextKeys.readOnly.toNegated()), + f1: true, + keybinding: { + primary: KeyMod.CtrlCmd | KeyMod.Alt | KeyCode.KeyV, + weight: KeybindingWeight.WorkbenchContrib + } + }); + } + + override runEditorCommand(accessor: ServicesAccessor, editor: ICodeEditor): void { + const keybindingService = accessor.get(IKeybindingService); + + const holdMode = keybindingService.enableKeybindingHoldMode(this.desc.id); + if (holdMode) { + let shouldCallStop = false; + + const handle = setTimeout(() => { + shouldCallStop = true; + }, 500); + + holdMode.finally(() => { + clearTimeout(handle); + + if (shouldCallStop) { + EditorDictation.get(editor)?.stop(); + } + }); + } + + EditorDictation.get(editor)?.start(); + } +} + +export class EditorDictationStopAction extends EditorAction2 { + + static readonly ID = 'workbench.action.editorDictation.stop'; + + constructor() { + super({ + id: EditorDictationStopAction.ID, + title: localize2('stopDictation', "Stop Dictation in Editor"), + category: VOICE_CATEGORY, + precondition: EDITOR_DICTATION_IN_PROGRESS, + f1: true, + keybinding: { + primary: KeyCode.Escape, + weight: KeybindingWeight.WorkbenchContrib + 100 + } + }); + } + + override runEditorCommand(_accessor: ServicesAccessor, editor: ICodeEditor): void { + EditorDictation.get(editor)?.stop(); + } +} + +export class DictationWidget extends Disposable implements IContentWidget { + + readonly suppressMouseDown = true; + readonly allowEditorOverflow = true; + + private readonly domNode = document.createElement('div'); + + constructor(private readonly editor: ICodeEditor, keybindingService: IKeybindingService) { + super(); + + const actionBar = this._register(new ActionBar(this.domNode)); + const stopActionKeybinding = keybindingService.lookupKeybinding(EditorDictationStopAction.ID)?.getLabel(); + actionBar.push(toAction({ + id: EditorDictationStopAction.ID, + label: stopActionKeybinding ? localize('stopDictationShort1', "Stop Dictation ({0})", stopActionKeybinding) : localize('stopDictationShort2', "Stop Dictation"), + class: ThemeIcon.asClassName(Codicon.micFilled), + run: () => EditorDictation.get(editor)?.stop() + }), { icon: true, label: false, keybinding: stopActionKeybinding }); + + this.domNode.classList.add('editor-dictation-widget'); + this.domNode.appendChild(actionBar.domNode); + } + + getId(): string { + return 'editorDictation'; + } + + getDomNode(): HTMLElement { + return this.domNode; + } + + getPosition(): IContentWidgetPosition | null { + if (!this.editor.hasModel()) { + return null; + } + + const selection = this.editor.getSelection(); + + return { + position: selection.getPosition(), + preference: [ + selection.getPosition().equals(selection.getStartPosition()) ? ContentWidgetPositionPreference.ABOVE : ContentWidgetPositionPreference.BELOW, + ContentWidgetPositionPreference.EXACT + ] + }; + } + + beforeRender(): IDimension | null { + const lineHeight = this.editor.getOption(EditorOption.lineHeight); + const width = this.editor.getLayoutInfo().contentWidth * 0.7; + + this.domNode.style.setProperty('--vscode-editor-dictation-widget-height', `${lineHeight}px`); + this.domNode.style.setProperty('--vscode-editor-dictation-widget-width', `${width}px`); + + return null; + } + + show() { + this.editor.addContentWidget(this); + } + + layout(): void { + this.editor.layoutContentWidget(this); + } + + active(): void { + this.domNode.classList.add('recording'); + } + + hide() { + this.domNode.classList.remove('recording'); + this.editor.removeContentWidget(this); + } +} + +export class EditorDictation extends Disposable implements IEditorContribution { + + static readonly ID = 'editorDictation'; + + static get(editor: ICodeEditor): EditorDictation | null { + return editor.getContribution(EditorDictation.ID); + } + + private readonly widget = this._register(new DictationWidget(this.editor, this.keybindingService)); + private readonly editorDictationInProgress = EDITOR_DICTATION_IN_PROGRESS.bindTo(this.contextKeyService); + + private readonly sessionDisposables = this._register(new MutableDisposable()); + + constructor( + private readonly editor: ICodeEditor, + @ISpeechService private readonly speechService: ISpeechService, + @IContextKeyService private readonly contextKeyService: IContextKeyService, + @IKeybindingService private readonly keybindingService: IKeybindingService + ) { + super(); + } + + async start(): Promise { + const disposables = new DisposableStore(); + this.sessionDisposables.value = disposables; + + this.widget.show(); + disposables.add(toDisposable(() => this.widget.hide())); + + this.editorDictationInProgress.set(true); + disposables.add(toDisposable(() => this.editorDictationInProgress.reset())); + + const collection = this.editor.createDecorationsCollection(); + disposables.add(toDisposable(() => collection.clear())); + + disposables.add(this.editor.onDidChangeCursorPosition(() => this.widget.layout())); + + let previewStart: Position | undefined = undefined; + + let lastReplaceTextLength = 0; + const replaceText = (text: string, isPreview: boolean) => { + if (!previewStart) { + previewStart = assertIsDefined(this.editor.getPosition()); + } + + const endPosition = new Position(previewStart.lineNumber, previewStart.column + text.length); + this.editor.executeEdits(EditorDictation.ID, [ + EditOperation.replace(Range.fromPositions(previewStart, previewStart.with(undefined, previewStart.column + lastReplaceTextLength)), text) + ], [ + Selection.fromPositions(endPosition) + ]); + + if (isPreview) { + collection.set([ + { + range: Range.fromPositions(previewStart, previewStart.with(undefined, previewStart.column + text.length)), + options: { + description: 'editor-dictation-preview', + inlineClassName: 'ghost-text-decoration-preview' + } + } + ]); + } else { + collection.clear(); + } + + lastReplaceTextLength = text.length; + if (!isPreview) { + previewStart = undefined; + lastReplaceTextLength = 0; + } + + this.editor.revealPositionInCenterIfOutsideViewport(endPosition); + }; + + const cts = new CancellationTokenSource(); + disposables.add(toDisposable(() => cts.dispose(true))); + + const session = await this.speechService.createSpeechToTextSession(cts.token, 'editor'); + disposables.add(session.onDidChange(e => { + if (cts.token.isCancellationRequested) { + return; + } + + switch (e.status) { + case SpeechToTextStatus.Started: + this.widget.active(); + break; + case SpeechToTextStatus.Stopped: + disposables.dispose(); + break; + case SpeechToTextStatus.Recognizing: { + if (!e.text) { + return; + } + + replaceText(e.text, true); + break; + } + case SpeechToTextStatus.Recognized: { + if (!e.text) { + return; + } + + replaceText(`${e.text} `, false); + break; + } + } + })); + } + + stop(): void { + this.sessionDisposables.clear(); + } +} + +registerEditorContribution(EditorDictation.ID, EditorDictation, EditorContributionInstantiation.Lazy); +registerAction2(EditorDictationStartAction); +registerAction2(EditorDictationStopAction); diff --git a/src/vs/workbench/contrib/codeEditor/browser/diffEditorHelper.ts b/src/vs/workbench/contrib/codeEditor/browser/diffEditorHelper.ts index cfbcd71273a60..b47c9bc564291 100644 --- a/src/vs/workbench/contrib/codeEditor/browser/diffEditorHelper.ts +++ b/src/vs/workbench/contrib/codeEditor/browser/diffEditorHelper.ts @@ -8,9 +8,9 @@ import { autorunWithStore, observableFromEvent } from 'vs/base/common/observable import { IDiffEditor } from 'vs/editor/browser/editorBrowser'; import { registerDiffEditorContribution } from 'vs/editor/browser/editorExtensions'; import { ICodeEditorService } from 'vs/editor/browser/services/codeEditorService'; -import { AccessibleDiffViewerNext, AccessibleDiffViewerPrev } from 'vs/editor/browser/widget/diffEditor/diffEditor.contribution'; +import { AccessibleDiffViewerNext, AccessibleDiffViewerPrev } from 'vs/editor/browser/widget/diffEditor/commands'; import { DiffEditorWidget } from 'vs/editor/browser/widget/diffEditor/diffEditorWidget'; -import { EmbeddedDiffEditorWidget } from 'vs/editor/browser/widget/embeddedCodeEditorWidget'; +import { EmbeddedDiffEditorWidget } from 'vs/editor/browser/widget/diffEditor/embeddedDiffEditorWidget'; import { IDiffEditorContribution } from 'vs/editor/common/editorCommon'; import { localize } from 'vs/nls'; import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; @@ -91,9 +91,6 @@ function createScreenReaderHelp(): IDisposable { const keybindingService = accessor.get(IKeybindingService); const contextKeyService = accessor.get(IContextKeyService); - const next = keybindingService.lookupKeybinding(AccessibleDiffViewerNext.id)?.getAriaLabel(); - const previous = keybindingService.lookupKeybinding(AccessibleDiffViewerPrev.id)?.getAriaLabel(); - if (!(editorService.activeTextEditorControl instanceof DiffEditorWidget)) { return; } @@ -103,11 +100,25 @@ function createScreenReaderHelp(): IDisposable { return; } - const keys = ['audioCues.diffLineDeleted', 'audioCues.diffLineInserted', 'audioCues.diffLineModified']; + const next = keybindingService.lookupKeybinding(AccessibleDiffViewerNext.id)?.getAriaLabel(); + const previous = keybindingService.lookupKeybinding(AccessibleDiffViewerPrev.id)?.getAriaLabel(); + let switchSides; + const switchSidesKb = keybindingService.lookupKeybinding('diffEditor.switchSide')?.getAriaLabel(); + if (switchSidesKb) { + switchSides = localize('msg3', "Run the command Diff Editor: Switch Side ({0}) to toggle between the original and modified editors.", switchSidesKb); + } else { + switchSides = localize('switchSidesNoKb', "Run the command Diff Editor: Switch Side, which is currently not triggerable via keybinding, to toggle between the original and modified editors."); + } + + const diffEditorActiveAnnouncement = localize('msg5', "The setting, accessibility.verbosity.diffEditorActive, controls if a diff editor announcement is made when it becomes the active editor."); + + const keys = ['accessibility.signals.diffLineDeleted', 'accessibility.signals.diffLineInserted', 'accessibility.signals.diffLineModified']; const content = [ localize('msg1', "You are in a diff editor."), localize('msg2', "View the next ({0}) or previous ({1}) diff in diff review mode, which is optimized for screen readers.", next, previous), - localize('msg3', "To control which audio cues should be played, the following settings can be configured: {0}.", keys.join(', ')), + switchSides, + diffEditorActiveAnnouncement, + localize('msg4', "To control which accessibility signals should be played, the following settings can be configured: {0}.", keys.join(', ')), ]; const commentCommandInfo = getCommentCommandInfo(keybindingService, contextKeyService, codeEditor); if (commentCommandInfo) { diff --git a/src/vs/workbench/contrib/codeEditor/browser/emptyTextEditorHint/emptyTextEditorHint.ts b/src/vs/workbench/contrib/codeEditor/browser/emptyTextEditorHint/emptyTextEditorHint.ts index c103f95c23aed..fab89ecfe3c58 100644 --- a/src/vs/workbench/contrib/codeEditor/browser/emptyTextEditorHint/emptyTextEditorHint.ts +++ b/src/vs/workbench/contrib/codeEditor/browser/emptyTextEditorHint/emptyTextEditorHint.ts @@ -34,6 +34,8 @@ import { Registry } from 'vs/platform/registry/common/platform'; import { Extensions, IConfigurationMigrationRegistry } from 'vs/workbench/common/configuration'; import { LOG_MODE_ID, OUTPUT_MODE_ID } from 'vs/workbench/services/output/common/output'; import { SEARCH_RESULT_LANGUAGE_ID } from 'vs/workbench/services/search/common/search'; +import { getDefaultHoverDelegate } from 'vs/base/browser/ui/hover/hoverDelegateFactory'; +import { IHoverService } from 'vs/platform/hover/browser/hover'; const $ = dom.$; @@ -71,6 +73,7 @@ export class EmptyTextEditorHintContribution implements IEditorContribution { @IEditorGroupsService private readonly editorGroupsService: IEditorGroupsService, @ICommandService private readonly commandService: ICommandService, @IConfigurationService protected readonly configurationService: IConfigurationService, + @IHoverService protected readonly hoverService: IHoverService, @IKeybindingService private readonly keybindingService: IKeybindingService, @IInlineChatSessionService private readonly inlineChatSessionService: IInlineChatSessionService, @IInlineChatService protected readonly inlineChatService: IInlineChatService, @@ -83,6 +86,11 @@ export class EmptyTextEditorHintContribution implements IEditorContribution { this.toDispose.push(this.editor.onDidChangeModelContent(() => this.update())); this.toDispose.push(this.inlineChatService.onDidChangeProviders(() => this.update())); this.toDispose.push(this.editor.onDidChangeModelDecorations(() => this.update())); + this.toDispose.push(this.editor.onDidChangeConfiguration((e: ConfigurationChangedEvent) => { + if (e.hasChanged(EditorOption.readOnly)) { + this.update(); + } + })); this.toDispose.push(this.configurationService.onDidChangeConfiguration(e => { if (e.affectsConfiguration(emptyTextEditorHintSetting)) { this.update(); @@ -152,6 +160,7 @@ export class EmptyTextEditorHintContribution implements IEditorContribution { this.editorGroupsService, this.commandService, this.configurationService, + this.hoverService, this.keybindingService, this.inlineChatService, this.telemetryService, @@ -174,7 +183,7 @@ class EmptyTextEditorHintContentWidget implements IContentWidget { private static readonly ID = 'editor.widget.emptyHint'; private domNode: HTMLElement | undefined; - private toDispose: DisposableStore; + private readonly toDispose: DisposableStore; private isVisible = false; private ariaLabel: string = ''; @@ -184,6 +193,7 @@ class EmptyTextEditorHintContentWidget implements IContentWidget { private readonly editorGroupsService: IEditorGroupsService, private readonly commandService: ICommandService, private readonly configurationService: IConfigurationService, + private readonly hoverService: IHoverService, private readonly keybindingService: IKeybindingService, private readonly inlineChatService: IInlineChatService, private readonly telemetryService: ITelemetryService, @@ -219,7 +229,7 @@ class EmptyTextEditorHintContentWidget implements IContentWidget { id: 'inlineChat.hintAction', from: 'hint' }); - void this.commandService.executeCommand(inlineChatId, { from: 'hint' }); + this.commandService.executeCommand(inlineChatId, { from: 'hint' }); }; const hintHandler: IContentActionHandler = { @@ -247,7 +257,7 @@ class EmptyTextEditorHintContentWidget implements IContentWidget { const hintPart = $('a', undefined, fragment); hintPart.style.fontStyle = 'italic'; hintPart.style.cursor = 'pointer'; - hintPart.onclick = handleClick; + this.toDispose.add(dom.addDisposableListener(hintPart, dom.EventType.CLICK, handleClick)); return hintPart; } else { const hintPart = $('span', undefined, fragment); @@ -258,14 +268,14 @@ class EmptyTextEditorHintContentWidget implements IContentWidget { hintElement.appendChild(before); - const label = new KeybindingLabel(hintElement, OS); + const label = hintHandler.disposables.add(new KeybindingLabel(hintElement, OS)); label.set(keybindingHint); label.element.style.width = 'min-content'; label.element.style.display = 'inline'; if (this.options.clickable) { label.element.style.cursor = 'pointer'; - label.element.onclick = handleClick; + this.toDispose.add(dom.addDisposableListener(label.element, dom.EventType.CLICK, handleClick)); } hintElement.appendChild(after); @@ -377,7 +387,7 @@ class EmptyTextEditorHintContentWidget implements IContentWidget { anchor.style.cursor = 'pointer'; const id = keybindingsLookup.shift(); const title = id && this.keybindingService.lookupKeybinding(id)?.getLabel(); - anchor.title = title ?? ''; + hintHandler.disposables.add(this.hoverService.setupUpdatableHover(getDefaultHoverDelegate('mouse'), anchor, title ?? '')); } return { hintElement, ariaLabel }; diff --git a/src/vs/workbench/contrib/codeEditor/browser/find/simpleFindWidget.ts b/src/vs/workbench/contrib/codeEditor/browser/find/simpleFindWidget.ts index 5ba2522ed34ad..bfbdc1d3f50e9 100644 --- a/src/vs/workbench/contrib/codeEditor/browser/find/simpleFindWidget.ts +++ b/src/vs/workbench/contrib/codeEditor/browser/find/simpleFindWidget.ts @@ -25,6 +25,7 @@ import { status } from 'vs/base/browser/ui/aria/aria'; import { defaultInputBoxStyles, defaultToggleStyles } from 'vs/platform/theme/browser/defaultStyles'; import { ISashEvent, IVerticalSashLayoutProvider, Orientation, Sash } from 'vs/base/browser/ui/sash/sash'; import { registerColor } from 'vs/platform/theme/common/colorRegistry'; +import type { IHoverService } from 'vs/platform/hover/browser/hover'; const NLS_FIND_INPUT_LABEL = nls.localize('label.find', "Find"); const NLS_FIND_INPUT_PLACEHOLDER = nls.localize('placeholder.find', "Find"); @@ -73,7 +74,8 @@ export abstract class SimpleFindWidget extends Widget implements IVerticalSashLa options: IFindOptions, contextViewService: IContextViewService, contextKeyService: IContextKeyService, - private readonly _keybindingService: IKeybindingService + hoverService: IHoverService, + private readonly _keybindingService: IKeybindingService, ) { super(); @@ -143,7 +145,7 @@ export abstract class SimpleFindWidget extends Widget implements IVerticalSashLa onTrigger: () => { this.find(true); } - })); + }, hoverService)); this.nextBtn = this._register(new SimpleButton({ label: NLS_NEXT_MATCH_BTN_LABEL + (options.nextMatchActionId ? this._getKeybinding(options.nextMatchActionId) : ''), @@ -151,7 +153,7 @@ export abstract class SimpleFindWidget extends Widget implements IVerticalSashLa onTrigger: () => { this.find(false); } - })); + }, hoverService)); const closeBtn = this._register(new SimpleButton({ label: NLS_CLOSE_BTN_LABEL + (options.closeWidgetActionId ? this._getKeybinding(options.closeWidgetActionId) : ''), @@ -159,7 +161,7 @@ export abstract class SimpleFindWidget extends Widget implements IVerticalSashLa onTrigger: () => { this.hide(); } - })); + }, hoverService)); this._innerDomNode = document.createElement('div'); this._innerDomNode.classList.add('simple-find-part'); diff --git a/src/vs/workbench/contrib/codeEditor/browser/outline/documentSymbolsOutline.ts b/src/vs/workbench/contrib/codeEditor/browser/outline/documentSymbolsOutline.ts index 10c5c92bd461c..dc43b9557b7e5 100644 --- a/src/vs/workbench/contrib/codeEditor/browser/outline/documentSymbolsOutline.ts +++ b/src/vs/workbench/contrib/codeEditor/browser/outline/documentSymbolsOutline.ts @@ -144,7 +144,7 @@ class DocumentSymbolsOutline implements IOutline { this._breadcrumbsDataSource = new DocumentSymbolBreadcrumbsSource(_editor, textResourceConfigurationService); const delegate = new DocumentSymbolVirtualDelegate(); - const renderers = [new DocumentSymbolGroupRenderer(), instantiationService.createInstance(DocumentSymbolRenderer, true)]; + const renderers = [new DocumentSymbolGroupRenderer(), instantiationService.createInstance(DocumentSymbolRenderer, true, target)]; const treeDataSource: IDataSource = { getChildren: (parent) => { if (parent instanceof OutlineElement || parent instanceof OutlineGroup) { @@ -219,7 +219,7 @@ class DocumentSymbolsOutline implements IOutline { return this._outlineModel?.uri; } - async reveal(entry: DocumentSymbolItem, options: IEditorOptions, sideBySide: boolean): Promise { + async reveal(entry: DocumentSymbolItem, options: IEditorOptions, sideBySide: boolean, select: boolean): Promise { const model = OutlineModel.get(entry); if (!model || !(entry instanceof OutlineElement)) { return; @@ -228,7 +228,7 @@ class DocumentSymbolsOutline implements IOutline { resource: model.uri, options: { ...options, - selection: Range.collapseToStart(entry.symbol.selectionRange), + selection: select ? entry.symbol.range : Range.collapseToStart(entry.symbol.selectionRange), selectionRevealType: TextEditorSelectionRevealType.NearTopIfOutsideViewport, } }, this._editor, sideBySide); diff --git a/src/vs/workbench/contrib/codeEditor/browser/outline/documentSymbolsTree.ts b/src/vs/workbench/contrib/codeEditor/browser/outline/documentSymbolsTree.ts index 437a260752ee5..3d9af339abecf 100644 --- a/src/vs/workbench/contrib/codeEditor/browser/outline/documentSymbolsTree.ts +++ b/src/vs/workbench/contrib/codeEditor/browser/outline/documentSymbolsTree.ts @@ -21,7 +21,7 @@ import { IThemeService } from 'vs/platform/theme/common/themeService'; import { listErrorForeground, listWarningForeground } from 'vs/platform/theme/common/colorRegistry'; import { ITextResourceConfigurationService } from 'vs/editor/common/services/textResourceConfiguration'; import { IListAccessibilityProvider } from 'vs/base/browser/ui/list/listWidget'; -import { IOutlineComparator, OutlineConfigKeys } from 'vs/workbench/services/outline/browser/outline'; +import { IOutlineComparator, OutlineConfigKeys, OutlineTarget } from 'vs/workbench/services/outline/browser/outline'; import { ThemeIcon } from 'vs/base/common/themables'; import { mainWindow } from 'vs/base/browser/window'; @@ -66,6 +66,10 @@ class DocumentSymbolGroupTemplate { readonly labelContainer: HTMLElement, readonly label: HighlightedLabel, ) { } + + dispose() { + this.label.dispose(); + } } class DocumentSymbolTemplate { @@ -107,7 +111,7 @@ export class DocumentSymbolGroupRenderer implements ITreeRenderer('files.trimTrailingWhitespace', { overrideIdentifier: model.textEditorModel.getLanguageId(), resource: model.resource }); + const trimInRegexAndStrings = this.configurationService.getValue('files.trimTrailingWhitespaceInRegexAndStrings', { overrideIdentifier: model.textEditorModel.getLanguageId(), resource: model.resource }); + if (trimTrailingWhitespaceOption) { + this.doTrimTrailingWhitespace(model.textEditorModel, context.reason === SaveReason.AUTO, trimInRegexAndStrings); } } - private doTrimTrailingWhitespace(model: ITextModel, isAutoSaved: boolean): void { + private doTrimTrailingWhitespace(model: ITextModel, isAutoSaved: boolean, trimInRegexesAndStrings: boolean): void { let prevSelection: Selection[] = []; let cursors: Position[] = []; @@ -71,7 +74,7 @@ export class TrimWhitespaceParticipant implements ITextFileSaveParticipant { } } - const ops = trimTrailingWhitespace(model, cursors); + const ops = trimTrailingWhitespace(model, cursors, trimInRegexesAndStrings); if (!ops.length) { return; // Nothing to do } @@ -322,7 +325,7 @@ class CodeActionOnSaveParticipant implements ITextFileSaveParticipant { ? [] : Object.keys(setting) .filter(x => setting[x] === 'never' || false) - .map(x => new CodeActionKind(x)); + .map(x => new HierarchicalKind(x)); progress.report({ message: localize('codeaction', "Quick Fixes") }); @@ -331,8 +334,8 @@ class CodeActionOnSaveParticipant implements ITextFileSaveParticipant { await this.applyOnSaveActions(textEditorModel, filteredSaveList, excludedActions, progress, token); } - private createCodeActionsOnSave(settingItems: readonly string[]): CodeActionKind[] { - const kinds = settingItems.map(x => new CodeActionKind(x)); + private createCodeActionsOnSave(settingItems: readonly string[]): HierarchicalKind[] { + const kinds = settingItems.map(x => new HierarchicalKind(x)); // Remove subsets return kinds.filter(kind => { @@ -340,7 +343,7 @@ class CodeActionOnSaveParticipant implements ITextFileSaveParticipant { }); } - private async applyOnSaveActions(model: ITextModel, codeActionsOnSave: readonly CodeActionKind[], excludes: readonly CodeActionKind[], progress: IProgress, token: CancellationToken): Promise { + private async applyOnSaveActions(model: ITextModel, codeActionsOnSave: readonly HierarchicalKind[], excludes: readonly HierarchicalKind[], progress: IProgress, token: CancellationToken): Promise { const getActionProgress = new class implements IProgress { private _names = new Set(); @@ -385,7 +388,7 @@ class CodeActionOnSaveParticipant implements ITextFileSaveParticipant { } } - private getActionsToRun(model: ITextModel, codeActionKind: CodeActionKind, excludes: readonly CodeActionKind[], progress: IProgress, token: CancellationToken) { + private getActionsToRun(model: ITextModel, codeActionKind: HierarchicalKind, excludes: readonly HierarchicalKind[], progress: IProgress, token: CancellationToken) { return getCodeActions(this.languageFeaturesService.codeActionProvider, model, model.getFullModelRange(), { type: CodeActionTriggerType.Auto, triggerAction: CodeActionTriggerSource.OnSave, diff --git a/src/vs/workbench/contrib/codeEditor/browser/simpleEditorOptions.ts b/src/vs/workbench/contrib/codeEditor/browser/simpleEditorOptions.ts index 6acfd4495b78e..b4f6826502630 100644 --- a/src/vs/workbench/contrib/codeEditor/browser/simpleEditorOptions.ts +++ b/src/vs/workbench/contrib/codeEditor/browser/simpleEditorOptions.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { IEditorOptions } from 'vs/editor/common/config/editorOptions'; -import { ICodeEditorWidgetOptions } from 'vs/editor/browser/widget/codeEditorWidget'; +import { ICodeEditorWidgetOptions } from 'vs/editor/browser/widget/codeEditor/codeEditorWidget'; import { ContextMenuController } from 'vs/editor/contrib/contextmenu/browser/contextmenu'; import { SnippetController2 } from 'vs/editor/contrib/snippet/browser/snippetController2'; import { SuggestController } from 'vs/editor/contrib/suggest/browser/suggestController'; diff --git a/src/vs/workbench/contrib/codeEditor/browser/suggestEnabledInput/suggestEnabledInput.ts b/src/vs/workbench/contrib/codeEditor/browser/suggestEnabledInput/suggestEnabledInput.ts index 32ac5cb4dec8e..6fbc04ff214b9 100644 --- a/src/vs/workbench/contrib/codeEditor/browser/suggestEnabledInput/suggestEnabledInput.ts +++ b/src/vs/workbench/contrib/codeEditor/browser/suggestEnabledInput/suggestEnabledInput.ts @@ -11,7 +11,7 @@ import { KeyCode } from 'vs/base/common/keyCodes'; import { mixin } from 'vs/base/common/objects'; import { isMacintosh } from 'vs/base/common/platform'; import { URI as uri } from 'vs/base/common/uri'; -import { CodeEditorWidget } from 'vs/editor/browser/widget/codeEditorWidget'; +import { CodeEditorWidget } from 'vs/editor/browser/widget/codeEditor/codeEditorWidget'; import { IEditorOptions } from 'vs/editor/common/config/editorOptions'; import { EditOperation } from 'vs/editor/common/core/editOperation'; import { Position } from 'vs/editor/common/core/position'; @@ -30,7 +30,7 @@ import { MenuPreventer } from 'vs/workbench/contrib/codeEditor/browser/menuPreve import { getSimpleEditorOptions } from 'vs/workbench/contrib/codeEditor/browser/simpleEditorOptions'; import { SelectionClipboardContributionID } from 'vs/workbench/contrib/codeEditor/browser/selectionClipboard'; import { EditorExtensionsRegistry } from 'vs/editor/browser/editorExtensions'; -import { DEFAULT_FONT_FAMILY } from 'vs/workbench/browser/style'; +import { DEFAULT_FONT_FAMILY } from 'vs/base/browser/fonts'; import { HistoryNavigator } from 'vs/base/common/history'; import { registerAndCreateHistoryNavigationContext, IHistoryNavigationContext } from 'vs/platform/history/browser/contextScopedHistoryWidget'; import { IHistoryNavigationWidget } from 'vs/base/browser/history'; diff --git a/src/vs/workbench/contrib/codeEditor/browser/toggleWordWrap.ts b/src/vs/workbench/contrib/codeEditor/browser/toggleWordWrap.ts index c83ebc45f77dc..7cdd7d910ad4c 100644 --- a/src/vs/workbench/contrib/codeEditor/browser/toggleWordWrap.ts +++ b/src/vs/workbench/contrib/codeEditor/browser/toggleWordWrap.ts @@ -314,7 +314,7 @@ class EditorWordWrapContextKeyTracker extends Disposable implements IWorkbenchCo } } -registerWorkbenchContribution2(EditorWordWrapContextKeyTracker.ID, EditorWordWrapContextKeyTracker, WorkbenchPhase.BlockRestore); +registerWorkbenchContribution2(EditorWordWrapContextKeyTracker.ID, EditorWordWrapContextKeyTracker, WorkbenchPhase.AfterRestored); registerEditorContribution(ToggleWordWrapController.ID, ToggleWordWrapController, EditorContributionInstantiation.Eager); // eager because it needs to change the editor word wrap configuration registerDiffEditorContribution(DiffToggleWordWrapController.ID, DiffToggleWordWrapController); diff --git a/src/vs/workbench/contrib/codeEditor/browser/languageConfigurationExtensionPoint.ts b/src/vs/workbench/contrib/codeEditor/common/languageConfigurationExtensionPoint.ts similarity index 95% rename from src/vs/workbench/contrib/codeEditor/browser/languageConfigurationExtensionPoint.ts rename to src/vs/workbench/contrib/codeEditor/common/languageConfigurationExtensionPoint.ts index f602d051103a4..e3d1fcd1064cd 100644 --- a/src/vs/workbench/contrib/codeEditor/browser/languageConfigurationExtensionPoint.ts +++ b/src/vs/workbench/contrib/codeEditor/common/languageConfigurationExtensionPoint.ts @@ -47,7 +47,7 @@ interface IOnEnterRule { /** * Serialized form of a language configuration */ -interface ILanguageConfiguration { +export interface ILanguageConfiguration { comments?: CommentRule; brackets?: CharacterPair[]; autoClosingPairs?: Array; @@ -149,7 +149,7 @@ export class LanguageConfigurationFileHandler extends Disposable { } } - private _extractValidCommentRule(languageId: string, configuration: ILanguageConfiguration): CommentRule | undefined { + private static _extractValidCommentRule(languageId: string, configuration: ILanguageConfiguration): CommentRule | undefined { const source = configuration.comments; if (typeof source === 'undefined') { return undefined; @@ -179,7 +179,7 @@ export class LanguageConfigurationFileHandler extends Disposable { return result; } - private _extractValidBrackets(languageId: string, configuration: ILanguageConfiguration): CharacterPair[] | undefined { + private static _extractValidBrackets(languageId: string, configuration: ILanguageConfiguration): CharacterPair[] | undefined { const source = configuration.brackets; if (typeof source === 'undefined') { return undefined; @@ -203,7 +203,7 @@ export class LanguageConfigurationFileHandler extends Disposable { return result; } - private _extractValidAutoClosingPairs(languageId: string, configuration: ILanguageConfiguration): IAutoClosingPairConditional[] | undefined { + private static _extractValidAutoClosingPairs(languageId: string, configuration: ILanguageConfiguration): IAutoClosingPairConditional[] | undefined { const source = configuration.autoClosingPairs; if (typeof source === 'undefined') { return undefined; @@ -249,7 +249,7 @@ export class LanguageConfigurationFileHandler extends Disposable { return result; } - private _extractValidSurroundingPairs(languageId: string, configuration: ILanguageConfiguration): IAutoClosingPair[] | undefined { + private static _extractValidSurroundingPairs(languageId: string, configuration: ILanguageConfiguration): IAutoClosingPair[] | undefined { const source = configuration.surroundingPairs; if (typeof source === 'undefined') { return undefined; @@ -289,7 +289,7 @@ export class LanguageConfigurationFileHandler extends Disposable { return result; } - private _extractValidColorizedBracketPairs(languageId: string, configuration: ILanguageConfiguration): CharacterPair[] | undefined { + private static _extractValidColorizedBracketPairs(languageId: string, configuration: ILanguageConfiguration): CharacterPair[] | undefined { const source = configuration.colorizedBracketPairs; if (typeof source === 'undefined') { return undefined; @@ -312,7 +312,7 @@ export class LanguageConfigurationFileHandler extends Disposable { return result; } - private _extractValidOnEnterRules(languageId: string, configuration: ILanguageConfiguration): OnEnterRule[] | undefined { + private static _extractValidOnEnterRules(languageId: string, configuration: ILanguageConfiguration): OnEnterRule[] | undefined { const source = configuration.onEnterRules; if (typeof source === 'undefined') { return undefined; @@ -385,7 +385,7 @@ export class LanguageConfigurationFileHandler extends Disposable { return result; } - private _handleConfig(languageId: string, configuration: ILanguageConfiguration): void { + public static extractValidConfig(languageId: string, configuration: ILanguageConfiguration): ExplicitLanguageConfiguration { const comments = this._extractValidCommentRule(languageId, configuration); const brackets = this._extractValidBrackets(languageId, configuration); @@ -421,11 +421,15 @@ export class LanguageConfigurationFileHandler extends Disposable { folding, __electricCharacterSupport: undefined, }; + return richEditConfig; + } + private _handleConfig(languageId: string, configuration: ILanguageConfiguration): void { + const richEditConfig = LanguageConfigurationFileHandler.extractValidConfig(languageId, configuration); this._languageConfigurationService.register(languageId, richEditConfig, 50); } - private _parseRegex(languageId: string, confPath: string, value: string | IRegExp): RegExp | undefined { + private static _parseRegex(languageId: string, confPath: string, value: string | IRegExp): RegExp | undefined { if (typeof value === 'string') { try { return new RegExp(value, ''); @@ -454,7 +458,7 @@ export class LanguageConfigurationFileHandler extends Disposable { return undefined; } - private _mapIndentationRules(languageId: string, indentationRules: IIndentationRules): IndentationRule | undefined { + private static _mapIndentationRules(languageId: string, indentationRules: IIndentationRules): IndentationRule | undefined { const increaseIndentPattern = this._parseRegex(languageId, `indentationRules.increaseIndentPattern`, indentationRules.increaseIndentPattern); if (!increaseIndentPattern) { return undefined; diff --git a/src/vs/workbench/contrib/codeEditor/electron-sandbox/startDebugTextMate.ts b/src/vs/workbench/contrib/codeEditor/electron-sandbox/startDebugTextMate.ts index 653f28e4d15e6..65773d240257b 100644 --- a/src/vs/workbench/contrib/codeEditor/electron-sandbox/startDebugTextMate.ts +++ b/src/vs/workbench/contrib/codeEditor/electron-sandbox/startDebugTextMate.ts @@ -29,7 +29,7 @@ class StartDebugTextMate extends Action2 { constructor() { super({ id: 'editor.action.startDebugTextMate', - title: nls.localize2('startDebugTextMate', "Start Text Mate Syntax Grammar Logging"), + title: nls.localize2('startDebugTextMate', "Start TextMate Syntax Grammar Logging"), category: Categories.Developer, f1: true }); diff --git a/src/vs/workbench/contrib/codeEditor/test/node/autoindent.test.ts b/src/vs/workbench/contrib/codeEditor/test/node/autoindent.test.ts new file mode 100644 index 0000000000000..6d58f57ebbafe --- /dev/null +++ b/src/vs/workbench/contrib/codeEditor/test/node/autoindent.test.ts @@ -0,0 +1,326 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as fs from 'fs'; +import * as path from 'path'; +import * as assert from 'assert'; +import { DisposableStore } from 'vs/base/common/lifecycle'; +import { ensureNoDisposablesAreLeakedInTestSuite } from 'vs/base/test/common/utils'; +import { ILanguageConfigurationService } from 'vs/editor/common/languages/languageConfigurationRegistry'; +import { getReindentEditOperations } from 'vs/editor/contrib/indentation/common/indentation'; +import { IRelaxedTextModelCreationOptions, createModelServices, instantiateTextModel } from 'vs/editor/test/common/testTextModel'; +import { TestInstantiationService } from 'vs/platform/instantiation/test/common/instantiationServiceMock'; +import { ILanguageConfiguration, LanguageConfigurationFileHandler } from 'vs/workbench/contrib/codeEditor/common/languageConfigurationExtensionPoint'; +import { parse } from 'vs/base/common/json'; +import { IRange } from 'vs/editor/common/core/range'; + +function getIRange(range: IRange): IRange { + return { + startLineNumber: range.startLineNumber, + startColumn: range.startColumn, + endLineNumber: range.endLineNumber, + endColumn: range.endColumn + }; +} + +suite('Auto-Reindentation - TypeScript/JavaScript', () => { + + const languageId = 'ts-test'; + const options: IRelaxedTextModelCreationOptions = {}; + let disposables: DisposableStore; + let instantiationService: TestInstantiationService; + let languageConfigurationService: ILanguageConfigurationService; + + setup(() => { + disposables = new DisposableStore(); + instantiationService = createModelServices(disposables); + languageConfigurationService = instantiationService.get(ILanguageConfigurationService); + const configPath = path.join('extensions', 'typescript-basics', 'language-configuration.json'); + const configString = fs.readFileSync(configPath).toString(); + const config = parse(configString, []); + const configParsed = LanguageConfigurationFileHandler.extractValidConfig(languageId, config); + disposables.add(languageConfigurationService.register(languageId, configParsed)); + }); + + teardown(() => { + disposables.dispose(); + }); + + ensureNoDisposablesAreLeakedInTestSuite(); + + // Test which can be ran to find cases of incorrect indentation... + test.skip('Find Cases of Incorrect Indentation', () => { + + const filePath = path.join('..', 'TypeScript', 'src', 'server', 'utilities.ts'); + const fileContents = fs.readFileSync(filePath).toString(); + + const model = disposables.add(instantiateTextModel(instantiationService, fileContents, languageId, options)); + const editOperations = getReindentEditOperations(model, languageConfigurationService, 1, model.getLineCount()); + model.applyEdits(editOperations); + + // save the files to disk + const initialFile = path.join('..', 'autoindent', 'initial.ts'); + const finalFile = path.join('..', 'autoindent', 'final.ts'); + fs.writeFileSync(initialFile, fileContents); + fs.writeFileSync(finalFile, model.getValue()); + }); + + // Unit tests for increase and decrease indent patterns... + + /** + * First increase indent and decrease indent patterns: + * + * - decreaseIndentPattern: /^(.*\*\/)?\s*\}.*$/ + * - In (https://macromates.com/manual/en/appendix) + * Either we have white space before the closing bracket, or we have a multi line comment ending on that line followed by whitespaces + * This is followed by any character. + * Textmate decrease indent pattern is as follows: /^(.*\*\/)?\s*\}[;\s]*$/ + * Presumably allowing multi line comments ending on that line implies that } is itself not part of a multi line comment + * + * - increaseIndentPattern: /^.*\{[^}"']*$/ + * - In (https://macromates.com/manual/en/appendix) + * This regex means that we increase the indent when we have any characters followed by the opening brace, followed by characters + * except for closing brace }, double quotes " or single quote '. + * The } is checked in order to avoid the indentation in the following case `int arr[] = { 1, 2, 3 };` + * The double quote and single quote are checked in order to avoid the indentation in the following case: str = "foo {"; + */ + + test('Issue #25437', () => { + // issue: https://github.com/microsoft/vscode/issues/25437 + // fix: https://github.com/microsoft/vscode/commit/8c82a6c6158574e098561c28d470711f1b484fc8 + // explanation: var foo = `{`; should not increase indentation + + // increaseIndentPattern: /^.*\{[^}"']*$/ -> /^.*\{[^}"'`]*$/ + + const fileContents = [ + 'const foo = `{`;', + ' ', + ].join('\n'); + const model = disposables.add(instantiateTextModel(instantiationService, fileContents, languageId, options)); + const editOperations = getReindentEditOperations(model, languageConfigurationService, 1, model.getLineCount()); + assert.deepStrictEqual(editOperations.length, 1); + const operation = editOperations[0]; + assert.deepStrictEqual(getIRange(operation.range), { + "startLineNumber": 2, + "startColumn": 1, + "endLineNumber": 2, + "endColumn": 5, + }); + assert.deepStrictEqual(operation.text, ''); + }); + + test('Enriching the hover', () => { + // issue: - + // fix: https://github.com/microsoft/vscode/commit/19ae0932c45b1096443a8c1335cf1e02eb99e16d + // explanation: + // - decrease indent on ) and ] also + // - increase indent on ( and [ also + + // decreaseIndentPattern: /^(.*\*\/)?\s*\}.*$/ -> /^(.*\*\/)?\s*[\}\]\)].*$/ + // increaseIndentPattern: /^.*\{[^}"'`]*$/ -> /^.*(\{[^}"'`]*|\([^)"'`]*|\[[^\]"'`]*)$/ + + let fileContents = [ + 'function foo(', + ' bar: string', + ' ){}', + ].join('\n'); + let model = disposables.add(instantiateTextModel(instantiationService, fileContents, languageId, options)); + let editOperations = getReindentEditOperations(model, languageConfigurationService, 1, model.getLineCount()); + assert.deepStrictEqual(editOperations.length, 1); + let operation = editOperations[0]; + assert.deepStrictEqual(getIRange(operation.range), { + "startLineNumber": 3, + "startColumn": 1, + "endLineNumber": 3, + "endColumn": 5, + }); + assert.deepStrictEqual(operation.text, ''); + + fileContents = [ + 'function foo(', + 'bar: string', + '){}', + ].join('\n'); + model = disposables.add(instantiateTextModel(instantiationService, fileContents, languageId, options)); + editOperations = getReindentEditOperations(model, languageConfigurationService, 1, model.getLineCount()); + assert.deepStrictEqual(editOperations.length, 1); + operation = editOperations[0]; + assert.deepStrictEqual(getIRange(operation.range), { + "startLineNumber": 2, + "startColumn": 1, + "endLineNumber": 2, + "endColumn": 1, + }); + assert.deepStrictEqual(operation.text, ' '); + }); + + test('Issue #86176', () => { + // issue: https://github.com/microsoft/vscode/issues/86176 + // fix: https://github.com/microsoft/vscode/commit/d89e2e17a5d1ba37c99b1d3929eb6180a5bfc7a8 + // explanation: When quotation marks are present on the first line of an if statement or for loop, following line should not be indented + + // increaseIndentPattern: /^((?!\/\/).)*(\{[^}"'`]*|\([^)"'`]*|\[[^\]"'`]*)$/ -> /^((?!\/\/).)*(\{([^}"'`]*|(\t|[ ])*\/\/.*)|\([^)"'`]*|\[[^\]"'`]*)$/ + // explanation: after open brace, do not decrease indent if it is followed on the same line by " // " + // todo@aiday-mar: should also apply for when it follows ( and [ + + const fileContents = [ + `if () { // '`, + `x = 4`, + `}` + ].join('\n'); + const model = disposables.add(instantiateTextModel(instantiationService, fileContents, languageId, options)); + const editOperations = getReindentEditOperations(model, languageConfigurationService, 1, model.getLineCount()); + assert.deepStrictEqual(editOperations.length, 1); + const operation = editOperations[0]; + assert.deepStrictEqual(getIRange(operation.range), { + "startLineNumber": 2, + "startColumn": 1, + "endLineNumber": 2, + "endColumn": 1, + }); + assert.deepStrictEqual(operation.text, ' '); + }); + + test('Issue #141816', () => { + + // issue: https://github.com/microsoft/vscode/issues/141816 + // fix: https://github.com/microsoft/vscode/pull/141997/files + // explanation: if (, [, {, is followed by a forward slash then assume we are in a regex pattern, and do not indent + + // increaseIndentPattern: /^((?!\/\/).)*(\{([^}"'`]*|(\t|[ ])*\/\/.*)|\([^)"'`]*|\[[^\]"'`]*)$/ -> /^((?!\/\/).)*(\{([^}"'`/]*|(\t|[ ])*\/\/.*)|\([^)"'`/]*|\[[^\]"'`/]*)$/ + // -> Final current increase indent pattern at of writing + + const fileContents = [ + 'const r = /{/;', + ' ', + ].join('\n'); + const model = disposables.add(instantiateTextModel(instantiationService, fileContents, languageId, options)); + const editOperations = getReindentEditOperations(model, languageConfigurationService, 1, model.getLineCount()); + assert.deepStrictEqual(editOperations.length, 1); + const operation = editOperations[0]; + assert.deepStrictEqual(getIRange(operation.range), { + "startLineNumber": 2, + "startColumn": 1, + "endLineNumber": 2, + "endColumn": 4, + }); + assert.deepStrictEqual(operation.text, ''); + }); + + test('Issue #29886', () => { + // issue: https://github.com/microsoft/vscode/issues/29886 + // fix: https://github.com/microsoft/vscode/commit/7910b3d7bab8a721aae98dc05af0b5e1ea9d9782 + + // decreaseIndentPattern: /^(.*\*\/)?\s*[\}\]\)].*$/ -> /^((?!.*?\/\*).*\*\/)?\s*[\}\]\)].*$/ + // -> Final current decrease indent pattern at the time of writing + + // explanation: Positive lookahead: (?= «pattern») matches if pattern matches what comes after the current location in the input string. + // Negative lookahead: (?! «pattern») matches if pattern does not match what comes after the current location in the input string + // The change proposed is to not decrease the indent if there is a multi-line comment ending on the same line before the closing parentheses + + const fileContents = [ + 'function foo() {', + ' bar(/* */)', + '};', + ].join('\n'); + const model = disposables.add(instantiateTextModel(instantiationService, fileContents, languageId, options)); + const editOperations = getReindentEditOperations(model, languageConfigurationService, 1, model.getLineCount()); + assert.deepStrictEqual(editOperations.length, 0); + }); + + // Failing tests inferred from the current regexes... + + test.skip('Incorrect deindentation after `*/}` string', () => { + + // explanation: If */ was not before the }, the regex does not allow characters before the }, so there would not be an indent + // Here since there is */ before the }, the regex allows all the characters before, hence there is a deindent + + const fileContents = [ + `const obj = {`, + ` obj1: {`, + ` brace : '*/}'`, + ` }`, + `}`, + ].join('\n'); + const model = disposables.add(instantiateTextModel(instantiationService, fileContents, languageId, options)); + const editOperations = getReindentEditOperations(model, languageConfigurationService, 1, model.getLineCount()); + assert.deepStrictEqual(editOperations.length, 0); + }); + + // Failing tests from issues... + + test.skip('Issue #56275', () => { + + // issue: https://github.com/microsoft/vscode/issues/56275 + // explanation: If */ was not before the }, the regex does not allow characters before the }, so there would not be an indent + // Here since there is */ before the }, the regex allows all the characters before, hence there is a deindent + + let fileContents = [ + 'function foo() {', + ' var bar = (/b*/);', + '}', + ].join('\n'); + let model = disposables.add(instantiateTextModel(instantiationService, fileContents, languageId, options)); + let editOperations = getReindentEditOperations(model, languageConfigurationService, 1, model.getLineCount()); + assert.deepStrictEqual(editOperations.length, 0); + + fileContents = [ + 'function foo() {', + ' var bar = "/b*/)";', + '}', + ].join('\n'); + model = disposables.add(instantiateTextModel(instantiationService, fileContents, languageId, options)); + editOperations = getReindentEditOperations(model, languageConfigurationService, 1, model.getLineCount()); + assert.deepStrictEqual(editOperations.length, 0); + }); + + test.skip('Issue #116843', () => { + + // issue: https://github.com/microsoft/vscode/issues/116843 + // related: https://github.com/microsoft/vscode/issues/43244 + // explanation: When you have an arrow function, you don't have { or }, but you would expect indentation to still be done in that way + + // TODO: requires exploring indent/outdent pairs instead + + const fileContents = [ + 'const add1 = (n) =>', + ' n + 1;', + ].join('\n'); + const model = disposables.add(instantiateTextModel(instantiationService, fileContents, languageId, options)); + const editOperations = getReindentEditOperations(model, languageConfigurationService, 1, model.getLineCount()); + assert.deepStrictEqual(editOperations.length, 0); + }); + + test.skip('Issue #185252', () => { + + // issue: https://github.com/microsoft/vscode/issues/185252 + // explanation: Reindenting the comment correctly + + const fileContents = [ + '/*', + ' * This is a comment.', + ' */', + ].join('\n'); + const model = disposables.add(instantiateTextModel(instantiationService, fileContents, languageId, options)); + const editOperations = getReindentEditOperations(model, languageConfigurationService, 1, model.getLineCount()); + assert.deepStrictEqual(editOperations.length, 0); + }); + + test.skip('Issue 43244: incorrect indentation when signature of function call spans several lines', () => { + + // issue: https://github.com/microsoft/vscode/issues/43244 + + const fileContents = [ + 'function callSomeOtherFunction(one: number, two: number) { }', + 'function someFunction() {', + ' callSomeOtherFunction(4,', + ' 5)', + '}', + ].join('\n'); + const model = disposables.add(instantiateTextModel(instantiationService, fileContents, languageId, options)); + const editOperations = getReindentEditOperations(model, languageConfigurationService, 1, model.getLineCount()); + assert.deepStrictEqual(editOperations.length, 0); + }); +}); diff --git a/src/vs/workbench/contrib/comments/browser/commentNode.ts b/src/vs/workbench/contrib/comments/browser/commentNode.ts index 3f72b63dad9d6..47e5285466c03 100644 --- a/src/vs/workbench/contrib/comments/browser/commentNode.ts +++ b/src/vs/workbench/contrib/comments/browser/commentNode.ts @@ -8,11 +8,8 @@ import * as dom from 'vs/base/browser/dom'; import * as languages from 'vs/editor/common/languages'; import { ActionsOrientation, ActionBar } from 'vs/base/browser/ui/actionbar/actionbar'; import { Action, IActionRunner, IAction, Separator, ActionRunner } from 'vs/base/common/actions'; -import { Disposable, IDisposable } from 'vs/base/common/lifecycle'; -import { URI } from 'vs/base/common/uri'; -import { ITextModel } from 'vs/editor/common/model'; -import { IModelService } from 'vs/editor/common/services/model'; -import { ILanguageService } from 'vs/editor/common/languages/language'; +import { Disposable, IDisposable, IReference, dispose } from 'vs/base/common/lifecycle'; +import { URI, UriComponents } from 'vs/base/common/uri'; import { MarkdownRenderer } from 'vs/editor/browser/widget/markdownRenderer/browser/markdownRenderer'; import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; import { ICommentService } from 'vs/workbench/contrib/comments/browser/commentService'; @@ -30,7 +27,7 @@ import { MenuEntryActionViewItem, SubmenuEntryActionViewItem } from 'vs/platform import { IContextKeyService, IContextKey } from 'vs/platform/contextkey/common/contextkey'; import { CommentFormActions } from 'vs/workbench/contrib/comments/browser/commentFormActions'; import { MOUSE_CURSOR_TEXT_CSS_CLASS_NAME } from 'vs/base/browser/ui/mouseCursor/mouseCursor'; -import { ActionViewItem } from 'vs/base/browser/ui/actionbar/actionViewItems'; +import { ActionViewItem, IActionViewItemOptions } from 'vs/base/browser/ui/actionbar/actionViewItems'; import { DropdownMenuActionViewItem } from 'vs/base/browser/ui/dropdown/dropdownActionViewItem'; import { Codicon } from 'vs/base/common/codicons'; import { ThemeIcon } from 'vs/base/common/themables'; @@ -45,11 +42,14 @@ import { Scrollable, ScrollbarVisibility } from 'vs/base/common/scrollable'; import { SmoothScrollableElement } from 'vs/base/browser/ui/scrollbar/scrollableElement'; import { DomEmitter } from 'vs/base/browser/event'; import { CommentContextKeys } from 'vs/workbench/contrib/comments/common/commentContextKeys'; -import { FileAccess } from 'vs/base/common/network'; +import { FileAccess, Schemas } from 'vs/base/common/network'; import { COMMENTS_SECTION, ICommentsConfiguration } from 'vs/workbench/contrib/comments/common/commentsConfiguration'; import { StandardMouseEvent } from 'vs/base/browser/mouseEvent'; import { IAccessibilityService } from 'vs/platform/accessibility/common/accessibility'; import { IKeybindingService } from 'vs/platform/keybinding/common/keybinding'; +import { MarshalledCommentThread } from 'vs/workbench/common/comments'; +import { IHoverService } from 'vs/platform/hover/browser/hover'; +import { IResolvedTextEditorModel, ITextModelService } from 'vs/editor/common/services/resolverService'; class CommentsActionRunner extends ActionRunner { protected override async runAction(action: IAction, context: any[]): Promise { @@ -60,6 +60,7 @@ class CommentsActionRunner extends ActionRunner { export class CommentNode extends Disposable { private _domNode: HTMLElement; private _body: HTMLElement; + private _avatar: HTMLElement; private _md: HTMLElement | undefined; private _plainText: HTMLElement | undefined; private _clearTimeout: any; @@ -72,7 +73,7 @@ export class CommentNode extends Disposable { private _reactionActionsContainer?: HTMLElement; private _commentEditor: SimpleCommentEditor | null = null; private _commentEditorDisposables: IDisposable[] = []; - private _commentEditorModel: ITextModel | null = null; + private _commentEditorModel: IReference | null = null; private _editorHeight = MIN_EDITOR_HEIGHT; private _isPendingLabel!: HTMLElement; @@ -109,14 +110,14 @@ export class CommentNode extends Disposable { private markdownRenderer: MarkdownRenderer, @IInstantiationService private instantiationService: IInstantiationService, @ICommentService private commentService: ICommentService, - @IModelService private modelService: IModelService, - @ILanguageService private languageService: ILanguageService, @INotificationService private notificationService: INotificationService, @IContextMenuService private contextMenuService: IContextMenuService, @IContextKeyService contextKeyService: IContextKeyService, @IConfigurationService private configurationService: IConfigurationService, + @IHoverService private hoverService: IHoverService, @IAccessibilityService private accessibilityService: IAccessibilityService, - @IKeybindingService private keybindingService: IKeybindingService + @IKeybindingService private keybindingService: IKeybindingService, + @ITextModelService private readonly textModelService: ITextModelService, ) { super(); @@ -129,12 +130,9 @@ export class CommentNode extends Disposable { this._commentMenus = this.commentService.getCommentMenus(this.owner); this._domNode.tabIndex = -1; - const avatar = dom.append(this._domNode, dom.$('div.avatar-container')); - if (comment.userIconPath) { - const img = dom.append(avatar, dom.$('img.avatar')); - img.src = FileAccess.uriToBrowserUri(URI.revive(comment.userIconPath)).toString(true); - img.onerror = _ => img.remove(); - } + this._avatar = dom.append(this._domNode, dom.$('div.avatar-container')); + this.updateCommentUserIcon(this.comment.userIconPath); + this._commentDetailsContainer = dom.append(this._domNode, dom.$('.review-comment-contents')); this.createHeader(this._commentDetailsContainer); @@ -223,6 +221,15 @@ export class CommentNode extends Disposable { } } + private updateCommentUserIcon(userIconPath: UriComponents | undefined) { + this._avatar.textContent = ''; + if (userIconPath) { + const img = dom.append(this._avatar, dom.$('img.avatar')); + img.src = FileAccess.uriToBrowserUri(URI.revive(userIconPath)).toString(true); + img.onerror = _ => img.remove(); + } + } + public get onDidClick(): Event> { return this._onDidClick.event; } @@ -242,7 +249,7 @@ export class CommentNode extends Disposable { this._timestampWidget?.dispose(); } else { if (!this._timestampWidget) { - this._timestampWidget = new TimestampWidget(this.configurationService, this._timestamp, timestamp); + this._timestampWidget = new TimestampWidget(this.configurationService, this.hoverService, this._timestamp, timestamp); this._register(this._timestampWidget); } else { this._timestampWidget.setTimestamp(timestamp); @@ -286,7 +293,7 @@ export class CommentNode extends Disposable { return result; } - private get commentNodeContext() { + private get commentNodeContext(): [any, MarshalledCommentThread] { return [{ thread: this.commentThread, commentUniqueId: this.comment.uniqueIdInThread, @@ -301,21 +308,22 @@ export class CommentNode extends Disposable { private createToolbar() { this.toolbar = new ToolBar(this._actionsToolbarContainer, this.contextMenuService, { - actionViewItemProvider: action => { + actionViewItemProvider: (action, options) => { if (action.id === ToggleReactionsAction.ID) { return new DropdownMenuActionViewItem( action, (action).menuActions, this.contextMenuService, { - actionViewItemProvider: action => this.actionViewItemProvider(action as Action), + ...options, + actionViewItemProvider: (action, options) => this.actionViewItemProvider(action as Action, options), actionRunner: this.actionRunner, classNames: ['toolbar-toggle-pickReactions', ...ThemeIcon.asClassNameArray(Codicon.reactions)], anchorAlignmentProvider: () => AnchorAlignment.RIGHT } ); } - return this.actionViewItemProvider(action as Action); + return this.actionViewItemProvider(action as Action, options); }, orientation: ActionsOrientation.HORIZONTAL }); @@ -357,8 +365,7 @@ export class CommentNode extends Disposable { } } - actionViewItemProvider(action: Action) { - let options = {}; + actionViewItemProvider(action: Action, options: IActionViewItemOptions) { if (action.id === ToggleReactionsAction.ID) { options = { label: false, icon: true }; } else { @@ -369,9 +376,9 @@ export class CommentNode extends Disposable { const item = new ReactionActionViewItem(action); return item; } else if (action instanceof MenuItemAction) { - return this.instantiationService.createInstance(MenuEntryActionViewItem, action, undefined); + return this.instantiationService.createInstance(MenuEntryActionViewItem, action, { hoverDelegate: options.hoverDelegate }); } else if (action instanceof SubmenuItemAction) { - return this.instantiationService.createInstance(SubmenuEntryActionViewItem, action, undefined); + return this.instantiationService.createInstance(SubmenuEntryActionViewItem, action, options); } else { const item = new ActionViewItem({}, action, options); return item; @@ -413,11 +420,11 @@ export class CommentNode extends Disposable { (toggleReactionAction).menuActions, this.contextMenuService, { - actionViewItemProvider: action => { + actionViewItemProvider: (action, options) => { if (action.id === ToggleReactionsAction.ID) { return toggleReactionActionViewItem; } - return this.actionViewItemProvider(action as Action); + return this.actionViewItemProvider(action as Action, options); }, actionRunner: this.actionRunner, classNames: 'toolbar-toggle-pickReactions', @@ -431,21 +438,21 @@ export class CommentNode extends Disposable { private createReactionsContainer(commentDetailsContainer: HTMLElement): void { this._reactionActionsContainer = dom.append(commentDetailsContainer, dom.$('div.comment-reactions')); this._reactionsActionBar = new ActionBar(this._reactionActionsContainer, { - actionViewItemProvider: action => { + actionViewItemProvider: (action, options) => { if (action.id === ToggleReactionsAction.ID) { return new DropdownMenuActionViewItem( action, (action).menuActions, this.contextMenuService, { - actionViewItemProvider: action => this.actionViewItemProvider(action as Action), + actionViewItemProvider: (action, options) => this.actionViewItemProvider(action as Action, options), actionRunner: this.actionRunner, classNames: ['toolbar-toggle-pickReactions', ...ThemeIcon.asClassNameArray(Codicon.reactions)], anchorAlignmentProvider: () => AnchorAlignment.RIGHT } ); } - return this.actionViewItemProvider(action as Action); + return this.actionViewItemProvider(action as Action, options); } }); this._register(this._reactionsActionBar); @@ -484,13 +491,18 @@ export class CommentNode extends Disposable { return (typeof this.comment.body === 'string') ? this.comment.body : this.comment.body.value; } - private createCommentEditor(editContainer: HTMLElement): void { + private async createCommentEditor(editContainer: HTMLElement): Promise { const container = dom.append(editContainer, dom.$('.edit-textarea')); this._commentEditor = this.instantiationService.createInstance(SimpleCommentEditor, container, SimpleCommentEditor.getEditorOptions(this.configurationService), this._contextKeyService, this.parentThread); - const resource = URI.parse(`comment:commentinput-${this.comment.uniqueIdInThread}-${Date.now()}.md`); - this._commentEditorModel = this.modelService.createModel('', this.languageService.createByFilepathOrFirstLine(resource), resource, false); - this._commentEditor.setModel(this._commentEditorModel); + const resource = URI.from({ + scheme: Schemas.commentsInput, + path: `/commentinput-${this.comment.uniqueIdInThread}-${Date.now()}.md` + }); + const modelRef = await this.textModelService.createModelReference(resource); + this._commentEditorModel = modelRef; + + this._commentEditor.setModel(this._commentEditorModel.object.textEditorModel); this._commentEditor.setValue(this.pendingEdit ?? this.commentBodyValue); this.pendingEdit = undefined; this._commentEditor.layout({ width: container.clientWidth - 14, height: this._editorHeight }); @@ -501,8 +513,8 @@ export class CommentNode extends Disposable { this._commentEditor!.focus(); }); - const lastLine = this._commentEditorModel.getLineCount(); - const lastColumn = this._commentEditorModel.getLineLength(lastLine) + 1; + const lastLine = this._commentEditorModel.object.textEditorModel.getLineCount(); + const lastColumn = this._commentEditorModel.object.textEditorModel.getLineLength(lastLine) + 1; this._commentEditor.setSelection(new Selection(lastLine, lastColumn, lastLine, lastColumn)); const commentThread = this.commentThread; @@ -537,7 +549,7 @@ export class CommentNode extends Disposable { this.calculateEditorHeight(); - this._register((this._commentEditorModel.onDidChangeContent(() => { + this._register((this._commentEditorModel.object.textEditorModel.onDidChangeContent(() => { if (this._commentEditor && this.calculateEditorHeight()) { this._commentEditor.layout({ height: this._editorHeight, width: this._commentEditor.getLayoutInfo().width }); this._commentEditor.render(true); @@ -576,12 +588,10 @@ export class CommentNode extends Disposable { this._commentEditorModel?.dispose(); - this._commentEditorDisposables.forEach(dispose => dispose.dispose()); + dispose(this._commentEditorDisposables); this._commentEditorDisposables = []; - if (this._commentEditor) { - this._commentEditor.dispose(); - this._commentEditor = null; - } + this._commentEditor?.dispose(); + this._commentEditor = null; this._commentEditContainer!.remove(); } @@ -596,7 +606,7 @@ export class CommentNode extends Disposable { this._scrollableElement.setScrollDimensions({ width, scrollWidth, height, scrollHeight }); } - public switchToEditMode() { + public async switchToEditMode() { if (this.isEditing) { return; } @@ -604,7 +614,7 @@ export class CommentNode extends Disposable { this.isEditing = true; this._body.classList.add('hidden'); this._commentEditContainer = dom.append(this._commentDetailsContainer, dom.$('.edit-container')); - this.createCommentEditor(this._commentEditContainer); + await this.createCommentEditor(this._commentEditContainer); const formActions = dom.append(this._commentEditContainer, dom.$('.form-actions')); const otherActions = dom.append(formActions, dom.$('.other-actions')); @@ -697,19 +707,23 @@ export class CommentNode extends Disposable { })); } - update(newComment: languages.Comment) { + async update(newComment: languages.Comment) { if (newComment.body !== this.comment.body) { this.updateCommentBody(newComment.body); } + if (this.comment.userIconPath && newComment.userIconPath && (URI.from(this.comment.userIconPath).toString() !== URI.from(newComment.userIconPath).toString())) { + this.updateCommentUserIcon(newComment.userIconPath); + } + const isChangingMode: boolean = newComment.mode !== undefined && newComment.mode !== this.comment.mode; this.comment = newComment; if (isChangingMode) { if (newComment.mode === languages.CommentMode.Editing) { - this.switchToEditMode(); + await this.switchToEditMode(); } else { this.removeCommentEditor(); } @@ -766,6 +780,11 @@ export class CommentNode extends Disposable { }, 3000); } } + + override dispose(): void { + super.dispose(); + dispose(this._commentEditorDisposables); + } } function fillInActions(groups: [string, Array][], target: IAction[] | { primary: IAction[]; secondary: IAction[] }, useAlternativeActions: boolean, isPrimaryGroup: (group: string) => boolean = group => group === 'navigation'): void { diff --git a/src/vs/workbench/contrib/comments/browser/commentReply.ts b/src/vs/workbench/contrib/comments/browser/commentReply.ts index 4bf059c42677f..19c0752b10e9b 100644 --- a/src/vs/workbench/contrib/comments/browser/commentReply.ts +++ b/src/vs/workbench/contrib/comments/browser/commentReply.ts @@ -4,22 +4,24 @@ *--------------------------------------------------------------------------------------------*/ import * as dom from 'vs/base/browser/dom'; +import { getDefaultHoverDelegate } from 'vs/base/browser/ui/hover/hoverDelegateFactory'; import { MOUSE_CURSOR_TEXT_CSS_CLASS_NAME } from 'vs/base/browser/ui/mouseCursor/mouseCursor'; import { IAction } from 'vs/base/common/actions'; -import { Disposable, IDisposable } from 'vs/base/common/lifecycle'; +import { Disposable, IDisposable, dispose } from 'vs/base/common/lifecycle'; import { MarshalledId } from 'vs/base/common/marshallingIds'; +import { Schemas } from 'vs/base/common/network'; import { URI } from 'vs/base/common/uri'; import { generateUuid } from 'vs/base/common/uuid'; import { ICodeEditor } from 'vs/editor/browser/editorBrowser'; import { IRange } from 'vs/editor/common/core/range'; import * as languages from 'vs/editor/common/languages'; -import { ILanguageService } from 'vs/editor/common/languages/language'; import { ITextModel } from 'vs/editor/common/model'; -import { IModelService } from 'vs/editor/common/services/model'; +import { ITextModelService } from 'vs/editor/common/services/resolverService'; import * as nls from 'vs/nls'; import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; import { IContextKey, IContextKeyService } from 'vs/platform/contextkey/common/contextkey'; import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; +import { IKeybindingService } from 'vs/platform/keybinding/common/keybinding'; import { editorForeground, resolveColorValue } from 'vs/platform/theme/common/colorRegistry'; import { IThemeService } from 'vs/platform/theme/common/themeService'; import { CommentFormActions } from 'vs/workbench/contrib/comments/browser/commentFormActions'; @@ -29,9 +31,8 @@ import { CommentContextKeys } from 'vs/workbench/contrib/comments/common/comment import { ICommentThreadWidget } from 'vs/workbench/contrib/comments/common/commentThreadWidget'; import { ICellRange } from 'vs/workbench/contrib/notebook/common/notebookRange'; import { LayoutableEditor, MIN_EDITOR_HEIGHT, SimpleCommentEditor, calculateEditorHeight } from './simpleCommentEditor'; -import { IKeybindingService } from 'vs/platform/keybinding/common/keybinding'; +import { IHoverService } from 'vs/platform/hover/browser/hover'; -const COMMENT_SCHEME = 'comment'; let INMEM_MODEL_ID = 0; export const COMMENTEDITOR_DECORATION_KEY = 'commenteditordecoration'; @@ -40,8 +41,8 @@ export class CommentReply extends Disposable { form: HTMLElement; commentEditorIsEmpty: IContextKey; private _error!: HTMLElement; - private _formActions: HTMLElement | null; - private _editorActions: HTMLElement | null; + private _formActions!: HTMLElement; + private _editorActions!: HTMLElement; private _commentThreadDisposables: IDisposable[] = []; private _commentFormActions!: CommentFormActions; private _commentEditorActions!: CommentFormActions; @@ -61,11 +62,11 @@ export class CommentReply extends Disposable { private _parentThread: ICommentThreadWidget, private _actionRunDelegate: (() => void) | null, @ICommentService private commentService: ICommentService, - @ILanguageService private languageService: ILanguageService, - @IModelService private modelService: IModelService, @IThemeService private themeService: IThemeService, @IConfigurationService configurationService: IConfigurationService, - @IKeybindingService private keybindingService: IKeybindingService + @IKeybindingService private keybindingService: IKeybindingService, + @IHoverService private hoverService: IHoverService, + @ITextModelService private readonly textModelService: ITextModelService ) { super(); @@ -74,6 +75,10 @@ export class CommentReply extends Disposable { this.commentEditorIsEmpty = CommentContextKeys.commentIsEmpty.bindTo(this._contextKeyService); this.commentEditorIsEmpty.set(!this._pendingComment); + this.initialize(); + } + + async initialize() { const hasExistingComments = this._commentThread.comments && this._commentThread.comments.length > 0; const modeId = generateUuid() + '-' + (hasExistingComments ? this._commentThread.threadId : ++INMEM_MODEL_ID); const params = JSON.stringify({ @@ -81,25 +86,30 @@ export class CommentReply extends Disposable { commentThreadId: this._commentThread.threadId }); - let resource = URI.parse(`${COMMENT_SCHEME}://${this._commentThread.extensionId}/commentinput-${modeId}.md?${params}`); // TODO. Remove params once extensions adopt authority. - const commentController = this.commentService.getCommentController(owner); + let resource = URI.from({ + scheme: Schemas.commentsInput, + path: `/${this._commentThread.extensionId}/commentinput-${modeId}.md?${params}` // TODO. Remove params once extensions adopt authority. + }); + const commentController = this.commentService.getCommentController(this.owner); if (commentController) { resource = resource.with({ authority: commentController.id }); } - const model = this.modelService.createModel(this._pendingComment || '', this.languageService.createByFilepathOrFirstLine(resource), resource, false); + const model = await this.textModelService.createModelReference(resource); + model.object.textEditorModel.setValue(this._pendingComment || ''); + this._register(model); - this.commentEditor.setModel(model); + this.commentEditor.setModel(model.object.textEditorModel); this.calculateEditorHeight(); - this._register((model.onDidChangeContent(() => { + this._register(model.object.textEditorModel.onDidChangeContent(() => { this.setCommentEditorDecorations(); this.commentEditorIsEmpty?.set(!this.commentEditor.getValue()); if (this.calculateEditorHeight()) { this.commentEditor.layout({ height: this._editorHeight, width: this.commentEditor.getLayoutInfo().width }); this.commentEditor.render(true); } - }))); + })); this.createTextModelListener(this.commentEditor, this.form); @@ -114,9 +124,9 @@ export class CommentReply extends Disposable { this._error = dom.append(this.form, dom.$('.validation-error.hidden')); const formActions = dom.append(this.form, dom.$('.form-actions')); this._formActions = dom.append(formActions, dom.$('.other-actions')); - this.createCommentWidgetFormActions(this._formActions, model); + this.createCommentWidgetFormActions(this._formActions, model.object.textEditorModel); this._editorActions = dom.append(formActions, dom.$('.editor-actions')); - this.createCommentWidgetEditorActions(this._editorActions, model); + this.createCommentWidgetEditorActions(this._editorActions, model.object.textEditorModel); } private calculateEditorHeight(): boolean { @@ -355,7 +365,7 @@ export class CommentReply extends Disposable { private createReplyButton(commentEditor: ICodeEditor, commentForm: HTMLElement) { this._reviewThreadReplyButton = dom.append(commentForm, dom.$(`button.review-thread-reply-button.${MOUSE_CURSOR_TEXT_CSS_CLASS_NAME}`)); - this._reviewThreadReplyButton.title = this._commentOptions?.prompt || nls.localize('reply', "Reply..."); + this._register(this.hoverService.setupUpdatableHover(getDefaultHoverDelegate('mouse'), this._reviewThreadReplyButton, this._commentOptions?.prompt || nls.localize('reply', "Reply..."))); this._reviewThreadReplyButton.textContent = this._commentOptions?.prompt || nls.localize('reply', "Reply..."); // bind click/escape actions for reviewThreadReplyButton and textArea @@ -369,4 +379,8 @@ export class CommentReply extends Disposable { }); } + override dispose(): void { + super.dispose(); + dispose(this._commentThreadDisposables); + } } diff --git a/src/vs/workbench/contrib/comments/browser/commentService.ts b/src/vs/workbench/contrib/comments/browser/commentService.ts index 1072a60aedfc6..accc000bdce45 100644 --- a/src/vs/workbench/contrib/comments/browser/commentService.ts +++ b/src/vs/workbench/contrib/comments/browser/commentService.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { CommentThreadChangedEvent, CommentInfo, Comment, CommentReaction, CommentingRanges, CommentThread, CommentOptions, PendingCommentThread } from 'vs/editor/common/languages'; +import { CommentThreadChangedEvent, CommentInfo, Comment, CommentReaction, CommentingRanges, CommentThread, CommentOptions, PendingCommentThread, CommentingRangeResourceHint } from 'vs/editor/common/languages'; import { createDecorator, IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; import { Event, Emitter } from 'vs/base/common/event'; import { Disposable, DisposableStore, IDisposable } from 'vs/base/common/lifecycle'; @@ -21,6 +21,7 @@ import { IStorageService, StorageScope, StorageTarget } from 'vs/platform/storag import { CommentContextKeys } from 'vs/workbench/contrib/comments/common/commentContextKeys'; import { ILogService } from 'vs/platform/log/common/log'; import { CommentsModel, ICommentsModel } from 'vs/workbench/contrib/comments/browser/commentsModel'; +import { IModelService } from 'vs/editor/common/services/model'; export const ICommentService = createDecorator('commentService'); @@ -30,14 +31,14 @@ interface IResourceCommentThreadEvent { } export interface ICommentInfo extends CommentInfo { - owner: string; + uniqueOwner: string; label?: string; } export interface INotebookCommentInfo { extensionId?: string; threads: CommentThread[]; - owner: string; + uniqueOwner: string; label?: string; } @@ -48,7 +49,7 @@ export interface IWorkspaceCommentThreadsEvent { } export interface INotebookCommentThreadChangedEvent extends CommentThreadChangedEvent { - owner: string; + uniqueOwner: string; } export interface ICommentController { @@ -61,6 +62,7 @@ export interface ICommentController { }; options?: CommentOptions; contextValue?: string; + owner: string; createCommentThreadTemplate(resource: UriComponents, range: IRange | undefined): Promise; updateCommentThreadTemplate(threadHandle: number, range: IRange): Promise; deleteCommentThreadMain(commentThreadId: string): void; @@ -82,7 +84,7 @@ export interface ICommentService { readonly onDidUpdateNotebookCommentThreads: Event; readonly onDidChangeActiveEditingCommentThread: Event; readonly onDidChangeCurrentCommentThread: Event; - readonly onDidUpdateCommentingRanges: Event<{ owner: string }>; + readonly onDidUpdateCommentingRanges: Event<{ uniqueOwner: string }>; readonly onDidChangeActiveCommentingRange: Event<{ range: Range; commentingRangesInfo: CommentingRanges }>; readonly onDidSetDataProvider: Event; readonly onDidDeleteDataProvider: Event; @@ -90,28 +92,29 @@ export interface ICommentService { readonly isCommentingEnabled: boolean; readonly commentsModel: ICommentsModel; setDocumentComments(resource: URI, commentInfos: ICommentInfo[]): void; - setWorkspaceComments(owner: string, commentsByResource: CommentThread[]): void; - removeWorkspaceComments(owner: string): void; - registerCommentController(owner: string, commentControl: ICommentController): void; - unregisterCommentController(owner?: string): void; - getCommentController(owner: string): ICommentController | undefined; - createCommentThreadTemplate(owner: string, resource: URI, range: Range | undefined): Promise; - updateCommentThreadTemplate(owner: string, threadHandle: number, range: Range): Promise; - getCommentMenus(owner: string): CommentMenus; + setWorkspaceComments(uniqueOwner: string, commentsByResource: CommentThread[]): void; + removeWorkspaceComments(uniqueOwner: string): void; + registerCommentController(uniqueOwner: string, commentControl: ICommentController): void; + unregisterCommentController(uniqueOwner?: string): void; + getCommentController(uniqueOwner: string): ICommentController | undefined; + createCommentThreadTemplate(uniqueOwner: string, resource: URI, range: Range | undefined): Promise; + updateCommentThreadTemplate(uniqueOwner: string, threadHandle: number, range: Range): Promise; + getCommentMenus(uniqueOwner: string): CommentMenus; updateComments(ownerId: string, event: CommentThreadChangedEvent): void; updateNotebookComments(ownerId: string, event: CommentThreadChangedEvent): void; disposeCommentThread(ownerId: string, threadId: string): void; getDocumentComments(resource: URI): Promise<(ICommentInfo | null)[]>; getNotebookComments(resource: URI): Promise<(INotebookCommentInfo | null)[]>; - updateCommentingRanges(ownerId: string): void; - hasReactionHandler(owner: string): boolean; - toggleReaction(owner: string, resource: URI, thread: CommentThread, comment: Comment, reaction: CommentReaction): Promise; + updateCommentingRanges(ownerId: string, resourceHints?: CommentingRangeResourceHint): void; + hasReactionHandler(uniqueOwner: string): boolean; + toggleReaction(uniqueOwner: string, resource: URI, thread: CommentThread, comment: Comment, reaction: CommentReaction): Promise; setActiveEditingCommentThread(commentThread: CommentThread | null): void; setCurrentCommentThread(commentThread: CommentThread | undefined): void; - setActiveCommentAndThread(owner: string, commentInfo: { thread: CommentThread; comment?: Comment } | undefined): Promise; + setActiveCommentAndThread(uniqueOwner: string, commentInfo: { thread: CommentThread; comment?: Comment } | undefined): Promise; enableCommenting(enable: boolean): void; registerContinueOnCommentProvider(provider: IContinueOnCommentProvider): IDisposable; - removeContinueOnComment(pendingComment: { range: IRange | undefined; uri: URI; owner: string; isReply?: boolean }): PendingCommentThread | undefined; + removeContinueOnComment(pendingComment: { range: IRange | undefined; uri: URI; uniqueOwner: string; isReply?: boolean }): PendingCommentThread | undefined; + resourceHasCommentingRanges(resource: URI): boolean; } const CONTINUE_ON_COMMENTS = 'comments.continueOnComments'; @@ -137,8 +140,8 @@ export class CommentService extends Disposable implements ICommentService { private readonly _onDidUpdateNotebookCommentThreads: Emitter = this._register(new Emitter()); readonly onDidUpdateNotebookCommentThreads: Event = this._onDidUpdateNotebookCommentThreads.event; - private readonly _onDidUpdateCommentingRanges: Emitter<{ owner: string }> = this._register(new Emitter<{ owner: string }>()); - readonly onDidUpdateCommentingRanges: Event<{ owner: string }> = this._onDidUpdateCommentingRanges.event; + private readonly _onDidUpdateCommentingRanges: Emitter<{ uniqueOwner: string }> = this._register(new Emitter<{ uniqueOwner: string }>()); + readonly onDidUpdateCommentingRanges: Event<{ uniqueOwner: string }> = this._onDidUpdateCommentingRanges.event; private readonly _onDidChangeActiveEditingCommentThread = this._register(new Emitter()); readonly onDidChangeActiveEditingCommentThread = this._onDidChangeActiveEditingCommentThread.event; @@ -163,19 +166,23 @@ export class CommentService extends Disposable implements ICommentService { private _isCommentingEnabled: boolean = true; private _workspaceHasCommenting: IContextKey; - private _continueOnComments = new Map(); // owner -> PendingCommentThread[] + private _continueOnComments = new Map(); // uniqueOwner -> PendingCommentThread[] private _continueOnCommentProviders = new Set(); private readonly _commentsModel: CommentsModel = this._register(new CommentsModel()); public readonly commentsModel: ICommentsModel = this._commentsModel; + private _commentingRangeResources = new Set(); // URIs + private _commentingRangeResourceHintSchemes = new Set(); // schemes + constructor( @IInstantiationService protected readonly instantiationService: IInstantiationService, @IWorkbenchLayoutService private readonly layoutService: IWorkbenchLayoutService, @IConfigurationService private readonly configurationService: IConfigurationService, @IContextKeyService contextKeyService: IContextKeyService, @IStorageService private readonly storageService: IStorageService, - @ILogService private readonly logService: ILogService + @ILogService private readonly logService: ILogService, + @IModelService private readonly modelService: IModelService ) { super(); this._handleConfiguration(); @@ -194,15 +201,16 @@ export class CommentService extends Disposable implements ICommentService { } this.logService.debug(`Comments: URIs of continue on comments from storage ${commentsToRestore.map(thread => thread.uri.toString()).join(', ')}.`); const changedOwners = this._addContinueOnComments(commentsToRestore, this._continueOnComments); - for (const owner of changedOwners) { - const control = this._commentControls.get(owner); + for (const uniqueOwner of changedOwners) { + const control = this._commentControls.get(uniqueOwner); if (!control) { continue; } const evt: ICommentThreadChangedEvent = { - owner, + uniqueOwner: uniqueOwner, + owner: control.owner, ownerLabel: control.label, - pending: this._continueOnComments.get(owner) || [], + pending: this._continueOnComments.get(uniqueOwner) || [], added: [], removed: [], changed: [] @@ -218,6 +226,21 @@ export class CommentService extends Disposable implements ICommentService { } this._saveContinueOnComments(map); })); + + this._register(this.modelService.onModelAdded(model => { + // Allows comment providers to cause their commenting ranges to be prefetched by opening text documents in the background. + if (!this._commentingRangeResources.has(model.uri.toString())) { + this.getDocumentComments(model.uri); + } + })); + } + + private _updateResourcesWithCommentingRanges(resource: URI, commentInfos: (ICommentInfo | null)[]) { + for (const comments of commentInfos) { + if (comments && (comments.commentingRanges.ranges.length > 0 || comments.threads.length > 0)) { + this._commentingRangeResources.add(resource.toString()); + } + } } private _handleConfiguration() { @@ -273,8 +296,8 @@ export class CommentService extends Disposable implements ICommentService { } private _lastActiveCommentController: ICommentController | undefined; - async setActiveCommentAndThread(owner: string, commentInfo: { thread: CommentThread; comment?: Comment } | undefined) { - const commentController = this._commentControls.get(owner); + async setActiveCommentAndThread(uniqueOwner: string, commentInfo: { thread: CommentThread; comment?: Comment } | undefined) { + const commentController = this._commentControls.get(uniqueOwner); if (!commentController) { return; @@ -291,8 +314,8 @@ export class CommentService extends Disposable implements ICommentService { this._onDidSetResourceCommentInfos.fire({ resource, commentInfos }); } - private setModelThreads(ownerId: string, ownerLabel: string, commentThreads: CommentThread[]) { - this._commentsModel.setCommentThreads(ownerId, ownerLabel, commentThreads); + private setModelThreads(ownerId: string, owner: string, ownerLabel: string, commentThreads: CommentThread[]) { + this._commentsModel.setCommentThreads(ownerId, owner, ownerLabel, commentThreads); this._onDidSetAllCommentThreads.fire({ ownerId, ownerLabel, commentThreads }); } @@ -301,45 +324,45 @@ export class CommentService extends Disposable implements ICommentService { this._onDidUpdateCommentThreads.fire(event); } - setWorkspaceComments(owner: string, commentsByResource: CommentThread[]): void { + setWorkspaceComments(uniqueOwner: string, commentsByResource: CommentThread[]): void { if (commentsByResource.length) { this._workspaceHasCommenting.set(true); } - const control = this._commentControls.get(owner); + const control = this._commentControls.get(uniqueOwner); if (control) { - this.setModelThreads(owner, control.label, commentsByResource); + this.setModelThreads(uniqueOwner, control.owner, control.label, commentsByResource); } } - removeWorkspaceComments(owner: string): void { - const control = this._commentControls.get(owner); + removeWorkspaceComments(uniqueOwner: string): void { + const control = this._commentControls.get(uniqueOwner); if (control) { - this.setModelThreads(owner, control.label, []); + this.setModelThreads(uniqueOwner, control.owner, control.label, []); } } - registerCommentController(owner: string, commentControl: ICommentController): void { - this._commentControls.set(owner, commentControl); + registerCommentController(uniqueOwner: string, commentControl: ICommentController): void { + this._commentControls.set(uniqueOwner, commentControl); this._onDidSetDataProvider.fire(); } - unregisterCommentController(owner?: string): void { - if (owner) { - this._commentControls.delete(owner); + unregisterCommentController(uniqueOwner?: string): void { + if (uniqueOwner) { + this._commentControls.delete(uniqueOwner); } else { this._commentControls.clear(); } - this._commentsModel.deleteCommentsByOwner(owner); - this._onDidDeleteDataProvider.fire(owner); + this._commentsModel.deleteCommentsByOwner(uniqueOwner); + this._onDidDeleteDataProvider.fire(uniqueOwner); } - getCommentController(owner: string): ICommentController | undefined { - return this._commentControls.get(owner); + getCommentController(uniqueOwner: string): ICommentController | undefined { + return this._commentControls.get(uniqueOwner); } - async createCommentThreadTemplate(owner: string, resource: URI, range: Range | undefined): Promise { - const commentController = this._commentControls.get(owner); + async createCommentThreadTemplate(uniqueOwner: string, resource: URI, range: Range | undefined): Promise { + const commentController = this._commentControls.get(uniqueOwner); if (!commentController) { return; @@ -348,8 +371,8 @@ export class CommentService extends Disposable implements ICommentService { return commentController.createCommentThreadTemplate(resource, range); } - async updateCommentThreadTemplate(owner: string, threadHandle: number, range: Range) { - const commentController = this._commentControls.get(owner); + async updateCommentThreadTemplate(uniqueOwner: string, threadHandle: number, range: Range) { + const commentController = this._commentControls.get(uniqueOwner); if (!commentController) { return; @@ -358,41 +381,46 @@ export class CommentService extends Disposable implements ICommentService { await commentController.updateCommentThreadTemplate(threadHandle, range); } - disposeCommentThread(owner: string, threadId: string) { - const controller = this.getCommentController(owner); + disposeCommentThread(uniqueOwner: string, threadId: string) { + const controller = this.getCommentController(uniqueOwner); controller?.deleteCommentThreadMain(threadId); } - getCommentMenus(owner: string): CommentMenus { - if (this._commentMenus.get(owner)) { - return this._commentMenus.get(owner)!; + getCommentMenus(uniqueOwner: string): CommentMenus { + if (this._commentMenus.get(uniqueOwner)) { + return this._commentMenus.get(uniqueOwner)!; } const menu = this.instantiationService.createInstance(CommentMenus); - this._commentMenus.set(owner, menu); + this._commentMenus.set(uniqueOwner, menu); return menu; } updateComments(ownerId: string, event: CommentThreadChangedEvent): void { const control = this._commentControls.get(ownerId); if (control) { - const evt: ICommentThreadChangedEvent = Object.assign({}, event, { owner: ownerId, ownerLabel: control.label }); + const evt: ICommentThreadChangedEvent = Object.assign({}, event, { uniqueOwner: ownerId, ownerLabel: control.label, owner: control.owner }); this.updateModelThreads(evt); } } updateNotebookComments(ownerId: string, event: CommentThreadChangedEvent): void { - const evt: INotebookCommentThreadChangedEvent = Object.assign({}, event, { owner: ownerId }); + const evt: INotebookCommentThreadChangedEvent = Object.assign({}, event, { uniqueOwner: ownerId }); this._onDidUpdateNotebookCommentThreads.fire(evt); } - updateCommentingRanges(ownerId: string) { + updateCommentingRanges(ownerId: string, resourceHints?: CommentingRangeResourceHint) { + if (resourceHints?.schemes && resourceHints.schemes.length > 0) { + for (const scheme of resourceHints.schemes) { + this._commentingRangeResourceHintSchemes.add(scheme); + } + } this._workspaceHasCommenting.set(true); - this._onDidUpdateCommentingRanges.fire({ owner: ownerId }); + this._onDidUpdateCommentingRanges.fire({ uniqueOwner: ownerId }); } - async toggleReaction(owner: string, resource: URI, thread: CommentThread, comment: Comment, reaction: CommentReaction): Promise { - const commentController = this._commentControls.get(owner); + async toggleReaction(uniqueOwner: string, resource: URI, thread: CommentThread, comment: Comment, reaction: CommentReaction): Promise { + const commentController = this._commentControls.get(uniqueOwner); if (commentController) { return commentController.toggleReaction(resource, thread, comment, reaction, CancellationToken.None); @@ -401,8 +429,8 @@ export class CommentService extends Disposable implements ICommentService { } } - hasReactionHandler(owner: string): boolean { - const commentProvider = this._commentControls.get(owner); + hasReactionHandler(uniqueOwner: string): boolean { + const commentProvider = this._commentControls.get(uniqueOwner); if (commentProvider) { return !!commentProvider.features.reactionHandler; @@ -421,10 +449,10 @@ export class CommentService extends Disposable implements ICommentService { // This can happen because continue on comments are stored separately from local un-submitted comments. for (const documentCommentThread of documentComments.threads) { if (documentCommentThread.comments?.length === 0 && documentCommentThread.range) { - this.removeContinueOnComment({ range: documentCommentThread.range, uri: resource, owner: documentComments.owner }); + this.removeContinueOnComment({ range: documentCommentThread.range, uri: resource, uniqueOwner: documentComments.uniqueOwner }); } } - const pendingComments = this._continueOnComments.get(documentComments.owner); + const pendingComments = this._continueOnComments.get(documentComments.uniqueOwner); documentComments.pendingCommentThreads = pendingComments?.filter(pendingComment => pendingComment.uri.toString() === resource.toString()); return documentComments; }) @@ -433,7 +461,9 @@ export class CommentService extends Disposable implements ICommentService { })); } - return Promise.all(commentControlResult); + const commentInfos = await Promise.all(commentControlResult); + this._updateResourcesWithCommentingRanges(resource, commentInfos); + return commentInfos; } async getNotebookComments(resource: URI): Promise<(INotebookCommentInfo | null)[]> { @@ -467,8 +497,8 @@ export class CommentService extends Disposable implements ICommentService { this.storageService.store(CONTINUE_ON_COMMENTS, commentsToSave, StorageScope.WORKSPACE, StorageTarget.USER); } - removeContinueOnComment(pendingComment: { range: IRange; uri: URI; owner: string; isReply?: boolean }): PendingCommentThread | undefined { - const pendingComments = this._continueOnComments.get(pendingComment.owner); + removeContinueOnComment(pendingComment: { range: IRange; uri: URI; uniqueOwner: string; isReply?: boolean }): PendingCommentThread | undefined { + const pendingComments = this._continueOnComments.get(pendingComment.uniqueOwner); if (pendingComments) { const commentIndex = pendingComments.findIndex(comment => comment.uri.toString() === pendingComment.uri.toString() && Range.equalsRange(comment.range, pendingComment.range) && (pendingComment.isReply === undefined || comment.isReply === pendingComment.isReply)); if (commentIndex > -1) { @@ -481,17 +511,21 @@ export class CommentService extends Disposable implements ICommentService { private _addContinueOnComments(pendingComments: PendingCommentThread[], map: Map): Set { const changedOwners = new Set(); for (const pendingComment of pendingComments) { - if (!map.has(pendingComment.owner)) { - map.set(pendingComment.owner, [pendingComment]); - changedOwners.add(pendingComment.owner); + if (!map.has(pendingComment.uniqueOwner)) { + map.set(pendingComment.uniqueOwner, [pendingComment]); + changedOwners.add(pendingComment.uniqueOwner); } else { - const commentsForOwner = map.get(pendingComment.owner)!; + const commentsForOwner = map.get(pendingComment.uniqueOwner)!; if (commentsForOwner.every(comment => (comment.uri.toString() !== pendingComment.uri.toString()) || !Range.equalsRange(comment.range, pendingComment.range))) { commentsForOwner.push(pendingComment); - changedOwners.add(pendingComment.owner); + changedOwners.add(pendingComment.uniqueOwner); } } } return changedOwners; } + + resourceHasCommentingRanges(resource: URI): boolean { + return this._commentingRangeResourceHintSchemes.has(resource.scheme) || this._commentingRangeResources.has(resource.toString()); + } } diff --git a/src/vs/workbench/contrib/comments/browser/commentThreadBody.ts b/src/vs/workbench/contrib/comments/browser/commentThreadBody.ts index 62656e6f97ad3..14db2828ee2e3 100644 --- a/src/vs/workbench/contrib/comments/browser/commentThreadBody.ts +++ b/src/vs/workbench/contrib/comments/browser/commentThreadBody.ts @@ -70,7 +70,7 @@ export class CommentThreadBody extends D this._commentsElement.focus(); } - display() { + async display() { this._commentsElement = dom.append(this.container, dom.$('div.comments-container')); this._commentsElement.setAttribute('role', 'presentation'); this._commentsElement.tabIndex = 0; @@ -98,7 +98,7 @@ export class CommentThreadBody extends D this._commentElements.push(newCommentNode); this._commentsElement.appendChild(newCommentNode.domNode); if (comment.mode === languages.CommentMode.Editing) { - newCommentNode.switchToEditMode(); + await newCommentNode.switchToEditMode(); } } } @@ -156,7 +156,7 @@ export class CommentThreadBody extends D return; } - updateCommentThread(commentThread: languages.CommentThread, preserveFocus: boolean) { + async updateCommentThread(commentThread: languages.CommentThread, preserveFocus: boolean) { const oldCommentsLen = this._commentElements.length; const newCommentsLen = commentThread.comments ? commentThread.comments.length : 0; @@ -207,7 +207,7 @@ export class CommentThreadBody extends D } if (currentComment.mode === languages.CommentMode.Editing) { - newElement.switchToEditMode(); + await newElement.switchToEditMode(); newCommentsInEditMode.push(newElement); } } diff --git a/src/vs/workbench/contrib/comments/browser/commentThreadHeader.ts b/src/vs/workbench/contrib/comments/browser/commentThreadHeader.ts index b206d26b01101..9784625cd2f39 100644 --- a/src/vs/workbench/contrib/comments/browser/commentThreadHeader.ts +++ b/src/vs/workbench/contrib/comments/browser/commentThreadHeader.ts @@ -22,6 +22,7 @@ import { CommentMenus } from 'vs/workbench/contrib/comments/browser/commentMenus import { IContextMenuService } from 'vs/platform/contextview/browser/contextView'; import { MarshalledId } from 'vs/base/common/marshallingIds'; import { StandardMouseEvent } from 'vs/base/browser/mouseEvent'; +import { MarshalledCommentThread } from 'vs/workbench/common/comments'; const collapseIcon = registerIcon('review-comment-collapse', Codicon.chevronUp, nls.localize('collapseIcon', 'Icon to collapse a review comment.')); const COLLAPSE_ACTION_CLASS = 'expand-review-action ' + ThemeIcon.asClassName(collapseIcon); @@ -122,7 +123,7 @@ export class CommentThreadHeader extends Disposable { getAnchor: () => event, getActions: () => actions, actionRunner: new ActionRunner(), - getActionsContext: () => { + getActionsContext: (): MarshalledCommentThread => { return { commentControlHandle: this._commentThread.controllerHandle, commentThreadHandle: this._commentThread.commentThreadHandle, diff --git a/src/vs/workbench/contrib/comments/browser/commentThreadWidget.ts b/src/vs/workbench/contrib/comments/browser/commentThreadWidget.ts index 548157717f331..43b9b5d3ff42a 100644 --- a/src/vs/workbench/contrib/comments/browser/commentThreadWidget.ts +++ b/src/vs/workbench/contrib/comments/browser/commentThreadWidget.ts @@ -107,6 +107,7 @@ export class CommentThreadWidget extends const tracker = this._register(dom.trackFocus(bodyElement)); this._register(registerNavigableContainer({ + name: 'commentThreadWidget', focusNotifiers: [tracker], focusNextWidget: () => { if (!this._commentReply?.isCommentEditorFocused()) { @@ -204,7 +205,7 @@ export class CommentThreadWidget extends }, true)); } - updateCommentThread(commentThread: languages.CommentThread) { + async updateCommentThread(commentThread: languages.CommentThread) { const shouldCollapse = (this._commentThread.collapsibleState === languages.CommentThreadCollapsibleState.Expanded) && (this._commentThreadState === languages.CommentThreadState.Unresolved) && (commentThread.state === languages.CommentThreadState.Resolved); this._commentThreadState = commentThread.state; @@ -213,7 +214,7 @@ export class CommentThreadWidget extends this._commentThreadDisposables = []; this._bindCommentThreadListeners(); - this._body.updateCommentThread(commentThread, this._commentReply?.isCommentEditorFocused() ?? false); + await this._body.updateCommentThread(commentThread, this._commentReply?.isCommentEditorFocused() ?? false); this._threadIsEmpty.set(!this._body.length); this._header.updateCommentThread(commentThread); this._commentReply?.updateCommentThread(commentThread); @@ -229,11 +230,11 @@ export class CommentThreadWidget extends } } - display(lineHeight: number) { + async display(lineHeight: number) { const headHeight = Math.max(23, Math.ceil(lineHeight * 1.2)); // 23 is the value of `Math.ceil(lineHeight * 1.2)` with the default editor font size this._header.updateHeight(headHeight); - this._body.display(); + await this._body.display(); // create comment thread only when it supports reply if (this._commentThread.canReply) { @@ -261,6 +262,7 @@ export class CommentThreadWidget extends override dispose() { super.dispose(); + dispose(this._commentThreadDisposables); this.updateCurrentThread(false, false); } @@ -350,7 +352,7 @@ export class CommentThreadWidget extends } focusCommentEditor() { - this._commentReply?.focusCommentEditor(); + this._commentReply?.expandReplyAreaAndFocusCommentEditor(); } focus() { diff --git a/src/vs/workbench/contrib/comments/browser/commentThreadZoneWidget.ts b/src/vs/workbench/contrib/comments/browser/commentThreadZoneWidget.ts index e5ae9040d5096..6ce5527186e91 100644 --- a/src/vs/workbench/contrib/comments/browser/commentThreadZoneWidget.ts +++ b/src/vs/workbench/contrib/comments/browser/commentThreadZoneWidget.ts @@ -31,6 +31,12 @@ function getCommentThreadWidgetStateColor(thread: languages.CommentThreadState | return getCommentThreadStateBorderColor(thread, theme) ?? theme.getColor(peekViewBorder); } +export enum CommentWidgetFocus { + None = 0, + Widget = 1, + Editor = 2 +} + export function parseMouseDownInfoFromEvent(e: IEditorMouseEvent) { const range = e.target.range; @@ -105,8 +111,8 @@ export class ReviewZoneWidget extends ZoneWidget implements ICommentThreadWidget private _contextKeyService: IContextKeyService; private _scopedInstantiationService: IInstantiationService; - public get owner(): string { - return this._owner; + public get uniqueOwner(): string { + return this._uniqueOwner; } public get commentThread(): languages.CommentThread { return this._commentThread; @@ -120,7 +126,7 @@ export class ReviewZoneWidget extends ZoneWidget implements ICommentThreadWidget constructor( editor: ICodeEditor, - private _owner: string, + private _uniqueOwner: string, private _commentThread: languages.CommentThread, private _pendingComment: string | undefined, private _pendingEdits: { [key: number]: string } | undefined, @@ -137,7 +143,7 @@ export class ReviewZoneWidget extends ZoneWidget implements ICommentThreadWidget [IContextKeyService, this._contextKeyService] )); - const controller = this.commentService.getCommentController(this._owner); + const controller = this.commentService.getCommentController(this._uniqueOwner); if (controller) { this._commentOptions = controller.options; } @@ -181,7 +187,7 @@ export class ReviewZoneWidget extends ZoneWidget implements ICommentThreadWidget // we don't do anything here as we always do the reveal ourselves. } - public reveal(commentUniqueId?: number, focus: boolean = false) { + public reveal(commentUniqueId?: number, focus: CommentWidgetFocus = CommentWidgetFocus.None) { if (!this._isExpanded) { this.show(this.arrowPosition(this._commentThread.range), 2); } @@ -197,16 +203,23 @@ export class ReviewZoneWidget extends ZoneWidget implements ICommentThreadWidget scrollTop = this.editor.getTopForLineNumber(this._commentThread.range.startLineNumber) - height / 2 + commentCoords.top - commentThreadCoords.top; } this.editor.setScrollTop(scrollTop); - if (focus) { + if (focus === CommentWidgetFocus.Widget) { this._commentThreadWidget.focus(); + } else if (focus === CommentWidgetFocus.Editor) { + this._commentThreadWidget.focusCommentEditor(); } return; } } + const rangeToReveal = this._commentThread.range + ? new Range(this._commentThread.range.startLineNumber, this._commentThread.range.startColumn, this._commentThread.range.endLineNumber + 1, 1) + : new Range(1, 1, 1, 1); - this.editor.revealRangeInCenter(this._commentThread.range ?? new Range(1, 1, 1, 1)); - if (focus) { + this.editor.revealRangeInCenter(rangeToReveal); + if (focus === CommentWidgetFocus.Widget) { this._commentThreadWidget.focus(); + } else if (focus === CommentWidgetFocus.Editor) { + this._commentThreadWidget.focusCommentEditor(); } } @@ -229,7 +242,7 @@ export class ReviewZoneWidget extends ZoneWidget implements ICommentThreadWidget CommentThreadWidget, container, this.editor, - this._owner, + this._uniqueOwner, this.editor.getModel()!.uri, this._contextKeyService, this._scopedInstantiationService, @@ -258,7 +271,7 @@ export class ReviewZoneWidget extends ZoneWidget implements ICommentThreadWidget } else { range = new Range(originalRange.startLineNumber, originalRange.startColumn, originalRange.endLineNumber, originalRange.endColumn); } - await this.commentService.updateCommentThreadTemplate(this.owner, this._commentThread.commentThreadHandle, range); + await this.commentService.updateCommentThreadTemplate(this.uniqueOwner, this._commentThread.commentThreadHandle, range); } } }, @@ -281,7 +294,7 @@ export class ReviewZoneWidget extends ZoneWidget implements ICommentThreadWidget private deleteCommentThread(): void { this.dispose(); - this.commentService.disposeCommentThread(this.owner, this._commentThread.threadId); + this.commentService.disposeCommentThread(this.uniqueOwner, this._commentThread.threadId); } public collapse() { @@ -315,7 +328,7 @@ export class ReviewZoneWidget extends ZoneWidget implements ICommentThreadWidget this.bindCommentThreadListeners(); } - this._commentThreadWidget.updateCommentThread(commentThread); + await this._commentThreadWidget.updateCommentThread(commentThread); // Move comment glyph widget and show position if the line has changed. const lineNumber = this._commentThread.range?.endLineNumber ?? 1; @@ -343,13 +356,13 @@ export class ReviewZoneWidget extends ZoneWidget implements ICommentThreadWidget this._commentThreadWidget.layout(widthInPixel); } - display(range: IRange | undefined) { + async display(range: IRange | undefined) { if (range) { this._commentGlyph = new CommentGlyphWidget(this.editor, range?.endLineNumber ?? -1); this._commentGlyph.setThreadState(this._commentThread.state); } - this._commentThreadWidget.display(this.editor.getOption(EditorOption.lineHeight)); + await this._commentThreadWidget.display(this.editor.getOption(EditorOption.lineHeight)); this._disposables.add(this._commentThreadWidget.onDidResize(dimension => { this._refresh(dimension); })); diff --git a/src/vs/workbench/contrib/comments/browser/comments.contribution.ts b/src/vs/workbench/contrib/comments/browser/comments.contribution.ts index b47cdb2b883ee..e57e7c315e2bb 100644 --- a/src/vs/workbench/contrib/comments/browser/comments.contribution.ts +++ b/src/vs/workbench/contrib/comments/browser/comments.contribution.ts @@ -17,10 +17,14 @@ import { IActivityService, NumberBadge } from 'vs/workbench/services/activity/co import { COMMENTS_VIEW_ID } from 'vs/workbench/contrib/comments/browser/commentsTreeViewer'; import { CommentThreadState } from 'vs/editor/common/languages'; import { LifecyclePhase } from 'vs/workbench/services/lifecycle/common/lifecycle'; -import { MenuId, registerAction2 } from 'vs/platform/actions/common/actions'; +import { Action2, MenuId, registerAction2 } from 'vs/platform/actions/common/actions'; import { CONTEXT_KEY_HAS_COMMENTS, CONTEXT_KEY_SOME_COMMENTS_EXPANDED, CommentsPanel } from 'vs/workbench/contrib/comments/browser/commentsView'; import { ViewAction } from 'vs/workbench/browser/parts/views/viewPane'; import { Codicon } from 'vs/base/common/codicons'; +import { IEditorService } from 'vs/workbench/services/editor/common/editorService'; +import { IUriIdentityService } from 'vs/platform/uriIdentity/common/uriIdentity'; +import { revealCommentThread } from 'vs/workbench/contrib/comments/browser/commentsController'; +import { MarshalledCommentThreadInternal } from 'vs/workbench/common/comments'; registerAction2(class Collapse extends ViewAction { constructor() { @@ -64,6 +68,28 @@ registerAction2(class Expand extends ViewAction { } }); +registerAction2(class Reply extends Action2 { + constructor() { + super({ + id: 'comments.reply', + title: nls.localize('reply', "Reply"), + icon: Codicon.reply, + menu: { + id: MenuId.CommentsViewThreadActions, + order: 100, + when: ContextKeyExpr.equals('canReply', true) + }, + }); + } + + override run(accessor: ServicesAccessor, marshalledCommentThread: MarshalledCommentThreadInternal): void { + const commentService = accessor.get(ICommentService); + const editorService = accessor.get(IEditorService); + const uriIdentityService = accessor.get(IUriIdentityService); + revealCommentThread(commentService, editorService, uriIdentityService, marshalledCommentThread.thread, marshalledCommentThread.thread.comments![marshalledCommentThread.thread.comments!.length - 1], true); + } +}); + Registry.as(ConfigurationExtensions.Configuration).registerConfiguration({ id: 'comments', order: 20, diff --git a/src/vs/workbench/contrib/comments/browser/commentsController.ts b/src/vs/workbench/contrib/comments/browser/commentsController.ts index d2fa2c78e8a0a..4ad4707619acd 100644 --- a/src/vs/workbench/contrib/comments/browser/commentsController.ts +++ b/src/vs/workbench/contrib/comments/browser/commentsController.ts @@ -10,12 +10,12 @@ import { CancelablePromise, createCancelablePromise, Delayer } from 'vs/base/com import { onUnexpectedError } from 'vs/base/common/errors'; import { DisposableStore, dispose, IDisposable } from 'vs/base/common/lifecycle'; import 'vs/css!./media/review'; -import { ICodeEditor, IEditorMouseEvent } from 'vs/editor/browser/editorBrowser'; +import { ICodeEditor, IEditorMouseEvent, isCodeEditor, isDiffEditor } from 'vs/editor/browser/editorBrowser'; import { ICodeEditorService } from 'vs/editor/browser/services/codeEditorService'; import { IRange, Range } from 'vs/editor/common/core/range'; -import { EditorType, IDiffEditor, IEditorContribution } from 'vs/editor/common/editorCommon'; +import { EditorType, IDiffEditor, IEditor, IEditorContribution, IModelChangedEvent } from 'vs/editor/common/editorCommon'; import { IModelDecorationOptions, IModelDeltaDecoration } from 'vs/editor/common/model'; -import { ModelDecorationOptions } from 'vs/editor/common/model/textModel'; +import { ModelDecorationOptions, TextModel } from 'vs/editor/common/model/textModel'; import * as languages from 'vs/editor/common/languages'; import * as nls from 'vs/nls'; import { IContextMenuService } from 'vs/platform/contextview/browser/contextView'; @@ -23,9 +23,9 @@ import { IInstantiationService } from 'vs/platform/instantiation/common/instanti import { IQuickInputService, IQuickPickItem, QuickPickInput } from 'vs/platform/quickinput/common/quickInput'; import { CommentGlyphWidget } from 'vs/workbench/contrib/comments/browser/commentGlyphWidget'; import { ICommentInfo, ICommentService } from 'vs/workbench/contrib/comments/browser/commentService'; -import { isMouseUpEventDragFromMouseDown, parseMouseDownInfoFromEvent, ReviewZoneWidget } from 'vs/workbench/contrib/comments/browser/commentThreadZoneWidget'; -import { IEditorService } from 'vs/workbench/services/editor/common/editorService'; -import { EmbeddedCodeEditorWidget } from 'vs/editor/browser/widget/embeddedCodeEditorWidget'; +import { CommentWidgetFocus, isMouseUpEventDragFromMouseDown, parseMouseDownInfoFromEvent, ReviewZoneWidget } from 'vs/workbench/contrib/comments/browser/commentThreadZoneWidget'; +import { ACTIVE_GROUP, IEditorService, SIDE_GROUP } from 'vs/workbench/services/editor/common/editorService'; +import { EmbeddedCodeEditorWidget } from 'vs/editor/browser/widget/codeEditor/embeddedCodeEditorWidget'; import { EditorOption } from 'vs/editor/common/config/editorOptions'; import { IViewsService } from 'vs/workbench/services/views/common/viewsService'; import { COMMENTS_VIEW_ID } from 'vs/workbench/contrib/comments/browser/commentsTreeViewer'; @@ -45,6 +45,8 @@ import { AccessibilityCommandId } from 'vs/workbench/contrib/accessibility/commo import { IKeybindingService } from 'vs/platform/keybinding/common/keybinding'; import { IAccessibilityService } from 'vs/platform/accessibility/common/accessibility'; import { URI } from 'vs/base/common/uri'; +import { IUriIdentityService } from 'vs/platform/uriIdentity/common/uriIdentity'; +import { ITextResourceEditorInput } from 'vs/platform/editor/common/editor'; export const ID = 'editor.contrib.review'; @@ -203,10 +205,10 @@ class CommentingRangeDecorator { intersectingEmphasisRange = new Range(intersectingSelectionRange.endLineNumber, 1, intersectingSelectionRange.endLineNumber, 1); intersectingSelectionRange = new Range(intersectingSelectionRange.startLineNumber, 1, intersectingSelectionRange.endLineNumber - 1, 1); } - commentingRangeDecorations.push(new CommentingRangeDecoration(editor, info.owner, info.extensionId, info.label, intersectingSelectionRange, this.multilineDecorationOptions, info.commentingRanges, true)); + commentingRangeDecorations.push(new CommentingRangeDecoration(editor, info.uniqueOwner, info.extensionId, info.label, intersectingSelectionRange, this.multilineDecorationOptions, info.commentingRanges, true)); if (!this._lineHasThread(editor, intersectingEmphasisRange)) { - commentingRangeDecorations.push(new CommentingRangeDecoration(editor, info.owner, info.extensionId, info.label, intersectingEmphasisRange, this.hoverDecorationOptions, info.commentingRanges, true)); + commentingRangeDecorations.push(new CommentingRangeDecoration(editor, info.uniqueOwner, info.extensionId, info.label, intersectingEmphasisRange, this.hoverDecorationOptions, info.commentingRanges, true)); } const beforeRangeEndLine = Math.min(intersectingEmphasisRange.startLineNumber, intersectingSelectionRange.startLineNumber) - 1; @@ -215,27 +217,27 @@ class CommentingRangeDecorator { const hasAfterRange = rangeObject.endLineNumber >= afterRangeStartLine; if (hasBeforeRange) { const beforeRange = new Range(range.startLineNumber, 1, beforeRangeEndLine, 1); - commentingRangeDecorations.push(new CommentingRangeDecoration(editor, info.owner, info.extensionId, info.label, beforeRange, this.decorationOptions, info.commentingRanges, true)); + commentingRangeDecorations.push(new CommentingRangeDecoration(editor, info.uniqueOwner, info.extensionId, info.label, beforeRange, this.decorationOptions, info.commentingRanges, true)); } if (hasAfterRange) { const afterRange = new Range(afterRangeStartLine, 1, range.endLineNumber, 1); - commentingRangeDecorations.push(new CommentingRangeDecoration(editor, info.owner, info.extensionId, info.label, afterRange, this.decorationOptions, info.commentingRanges, true)); + commentingRangeDecorations.push(new CommentingRangeDecoration(editor, info.uniqueOwner, info.extensionId, info.label, afterRange, this.decorationOptions, info.commentingRanges, true)); } } else if ((rangeObject.startLineNumber <= emphasisLine) && (emphasisLine <= rangeObject.endLineNumber)) { if (rangeObject.startLineNumber < emphasisLine) { const beforeRange = new Range(range.startLineNumber, 1, emphasisLine - 1, 1); - commentingRangeDecorations.push(new CommentingRangeDecoration(editor, info.owner, info.extensionId, info.label, beforeRange, this.decorationOptions, info.commentingRanges, true)); + commentingRangeDecorations.push(new CommentingRangeDecoration(editor, info.uniqueOwner, info.extensionId, info.label, beforeRange, this.decorationOptions, info.commentingRanges, true)); } const emphasisRange = new Range(emphasisLine, 1, emphasisLine, 1); if (!this._lineHasThread(editor, emphasisRange)) { - commentingRangeDecorations.push(new CommentingRangeDecoration(editor, info.owner, info.extensionId, info.label, emphasisRange, this.hoverDecorationOptions, info.commentingRanges, true)); + commentingRangeDecorations.push(new CommentingRangeDecoration(editor, info.uniqueOwner, info.extensionId, info.label, emphasisRange, this.hoverDecorationOptions, info.commentingRanges, true)); } if (emphasisLine < rangeObject.endLineNumber) { const afterRange = new Range(emphasisLine + 1, 1, range.endLineNumber, 1); - commentingRangeDecorations.push(new CommentingRangeDecoration(editor, info.owner, info.extensionId, info.label, afterRange, this.decorationOptions, info.commentingRanges, true)); + commentingRangeDecorations.push(new CommentingRangeDecoration(editor, info.uniqueOwner, info.extensionId, info.label, afterRange, this.decorationOptions, info.commentingRanges, true)); } } else { - commentingRangeDecorations.push(new CommentingRangeDecoration(editor, info.owner, info.extensionId, info.label, range, this.decorationOptions, info.commentingRanges)); + commentingRangeDecorations.push(new CommentingRangeDecoration(editor, info.uniqueOwner, info.extensionId, info.label, range, this.decorationOptions, info.commentingRanges)); } }); } @@ -274,7 +276,7 @@ class CommentingRangeDecorator { return foundInfos.map(foundInfo => { return { action: { - ownerId: foundInfo.owner, + ownerId: foundInfo.uniqueOwner, extensionId: foundInfo.extensionId, label: foundInfo.label, commentingRangesInfo: foundInfo.commentingRanges @@ -290,7 +292,7 @@ class CommentingRangeDecorator { for (const decoration of this.commentingRangeDecorations) { const range = decoration.getActiveRange(); if (range && this.areRangesIntersectingOrTouchingByLine(range, commentRange)) { - // We can have several commenting ranges that match from the same owner because of how + // We can have several commenting ranges that match from the same uniqueOwner because of how // the line hover and selection decoration is done. // The ranges must be merged so that we can see if the new commentRange fits within them. const action = decoration.getCommentAction(); @@ -366,6 +368,57 @@ class CommentingRangeDecorator { } } +export function revealCommentThread(commentService: ICommentService, editorService: IEditorService, uriIdentityService: IUriIdentityService, + commentThread: languages.CommentThread, comment: languages.Comment | undefined, focusReply?: boolean, pinned?: boolean, preserveFocus?: boolean, sideBySide?: boolean): void { + if (!commentThread.resource) { + return; + } + if (!commentService.isCommentingEnabled) { + commentService.enableCommenting(true); + } + + const range = commentThread.range; + const focus = focusReply ? CommentWidgetFocus.Editor : (preserveFocus ? CommentWidgetFocus.None : CommentWidgetFocus.Widget); + + const activeEditor = editorService.activeTextEditorControl; + // If the active editor is a diff editor where one of the sides has the comment, + // then we try to reveal the comment in the diff editor. + const currentActiveResources: IEditor[] = isDiffEditor(activeEditor) ? [activeEditor.getOriginalEditor(), activeEditor.getModifiedEditor()] + : (activeEditor ? [activeEditor] : []); + const threadToReveal = commentThread.threadId; + const commentToReveal = comment?.uniqueIdInThread; + const resource = URI.parse(commentThread.resource); + + for (const editor of currentActiveResources) { + const model = editor.getModel(); + if ((model instanceof TextModel) && uriIdentityService.extUri.isEqual(resource, model.uri)) { + + if (threadToReveal && isCodeEditor(editor)) { + const controller = CommentController.get(editor); + controller?.revealCommentThread(threadToReveal, commentToReveal, true, focus); + } + return; + } + } + + editorService.openEditor({ + resource, + options: { + pinned: pinned, + preserveFocus: preserveFocus, + selection: range ?? new Range(1, 1, 1, 1) + } + } as ITextResourceEditorInput, sideBySide ? SIDE_GROUP : ACTIVE_GROUP).then(editor => { + if (editor) { + const control = editor.getControl(); + if (threadToReveal && isCodeEditor(control)) { + const controller = CommentController.get(control); + controller?.revealCommentThread(threadToReveal, commentToReveal, true, focus); + } + } + }); +} + export class CommentController implements IEditorContribution { private readonly globalToDispose = new DisposableStore(); private readonly localToDispose = new DisposableStore(); @@ -376,13 +429,14 @@ export class CommentController implements IEditorContribution { private _commentThreadRangeDecorator!: CommentThreadRangeDecorator; private mouseDownInfo: { lineNumber: number } | null = null; private _commentingRangeSpaceReserved = false; + private _commentingRangeAmountReserved = 0; private _computePromise: CancelablePromise> | null; private _addInProgress!: boolean; private _emptyThreadsToAddQueue: [Range | undefined, IEditorMouseEvent | undefined][] = []; private _computeCommentingRangePromise!: CancelablePromise | null; private _computeCommentingRangeScheduler!: Delayer> | null; private _pendingNewCommentCache: { [key: string]: { [key: string]: string } }; - private _pendingEditsCache: { [key: string]: { [key: string]: { [key: number]: string } } }; // owner -> threadId -> uniqueIdInThread -> pending comment + private _pendingEditsCache: { [key: string]: { [key: string]: { [key: number]: string } } }; // uniqueOwner -> threadId -> uniqueIdInThread -> pending comment private _inProcessContinueOnComments: Map = new Map(); private _editorDisposables: IDisposable[] = []; private _activeCursorHasCommentingRange: IContextKey; @@ -441,10 +495,10 @@ export class CommentController implements IEditorContribution { this.globalToDispose.add(this.commentService.onDidSetDataProvider(_ => this.beginComputeAndHandleEditorChange())); this.globalToDispose.add(this.commentService.onDidUpdateCommentingRanges(_ => this.beginComputeAndHandleEditorChange())); - this.globalToDispose.add(this.commentService.onDidSetResourceCommentInfos(e => { + this.globalToDispose.add(this.commentService.onDidSetResourceCommentInfos(async e => { const editorURI = this.editor && this.editor.hasModel() && this.editor.getModel().uri; if (editorURI && editorURI.toString() === e.resource.toString()) { - this.setComments(e.commentInfos.filter(commentInfo => commentInfo !== null)); + await this.setComments(e.commentInfos.filter(commentInfo => commentInfo !== null)); } })); @@ -462,6 +516,7 @@ export class CommentController implements IEditorContribution { } })); + this.globalToDispose.add(this.editor.onWillChangeModel(e => this.onWillChangeModel(e))); this.globalToDispose.add(this.editor.onDidChangeModel(_ => this.onModelChanged())); this.globalToDispose.add(this.configurationService.onDidChangeConfiguration(e => { if (e.affectsConfiguration('diffEditor.renderSideBySide')) { @@ -494,7 +549,7 @@ export class CommentController implements IEditorContribution { if (pendingNewComment !== lastCommentBody) { pendingComments.push({ - owner: zone.owner, + uniqueOwner: zone.uniqueOwner, uri: zone.editor.getModel()!.uri, range: zone.commentThread.range, body: pendingNewComment, @@ -591,8 +646,8 @@ export class CommentController implements IEditorContribution { return Promise.resolve([]); }); - return this._computePromise.then(commentInfos => { - this.setComments(coalesce(commentInfos)); + return this._computePromise.then(async commentInfos => { + await this.setComments(coalesce(commentInfos)); this._computePromise = null; }, error => console.log(error)); } @@ -628,7 +683,7 @@ export class CommentController implements IEditorContribution { return editor.getContribution(ID); } - public revealCommentThread(threadId: string, commentUniqueId: number, fetchOnceIfNotExist: boolean, focus: boolean): void { + public revealCommentThread(threadId: string, commentUniqueId: number | undefined, fetchOnceIfNotExist: boolean, focus: CommentWidgetFocus): void { const commentThreadWidget = this._commentWidgets.filter(widget => widget.commentThread.threadId === threadId); if (commentThreadWidget.length === 1) { commentThreadWidget[0].reveal(commentUniqueId, focus); @@ -732,7 +787,7 @@ export class CommentController implements IEditorContribution { nextWidget = sortedWidgets[idx]; } this.editor.setSelection(nextWidget.commentThread.range ?? new Range(1, 1, 1, 1)); - nextWidget.reveal(undefined, true); + nextWidget.reveal(undefined, CommentWidgetFocus.Widget); } public previousCommentThread(): void { @@ -778,8 +833,15 @@ export class CommentController implements IEditorContribution { this.editor = null!; // Strict null override - nulling out in dispose } + private onWillChangeModel(e: IModelChangedEvent): void { + if (e.newModelUrl) { + this.tryUpdateReservedSpace(e.newModelUrl); + } + } + public onModelChanged(): void { this.localToDispose.clear(); + this.tryUpdateReservedSpace(); this.removeCommentWidgetsAndStoreCache(); if (!this.editor) { @@ -815,7 +877,7 @@ export class CommentController implements IEditorContribution { await this._computePromise; } - const commentInfo = this._commentInfos.filter(info => info.owner === e.owner); + const commentInfo = this._commentInfos.filter(info => info.uniqueOwner === e.uniqueOwner); if (!commentInfo || !commentInfo.length) { return; } @@ -826,14 +888,14 @@ export class CommentController implements IEditorContribution { const pending = e.pending.filter(pending => pending.uri.toString() === editorURI.toString()); removed.forEach(thread => { - const matchedZones = this._commentWidgets.filter(zoneWidget => zoneWidget.owner === e.owner && zoneWidget.commentThread.threadId === thread.threadId && zoneWidget.commentThread.threadId !== ''); + const matchedZones = this._commentWidgets.filter(zoneWidget => zoneWidget.uniqueOwner === e.uniqueOwner && zoneWidget.commentThread.threadId === thread.threadId && zoneWidget.commentThread.threadId !== ''); if (matchedZones.length) { const matchedZone = matchedZones[0]; const index = this._commentWidgets.indexOf(matchedZone); this._commentWidgets.splice(index, 1); matchedZone.dispose(); } - const infosThreads = this._commentInfos.filter(info => info.owner === e.owner)[0].threads; + const infosThreads = this._commentInfos.filter(info => info.uniqueOwner === e.uniqueOwner)[0].threads; for (let i = 0; i < infosThreads.length; i++) { if (infosThreads[i] === thread) { infosThreads.splice(i, 1); @@ -843,7 +905,7 @@ export class CommentController implements IEditorContribution { }); changed.forEach(thread => { - const matchedZones = this._commentWidgets.filter(zoneWidget => zoneWidget.owner === e.owner && zoneWidget.commentThread.threadId === thread.threadId); + const matchedZones = this._commentWidgets.filter(zoneWidget => zoneWidget.uniqueOwner === e.uniqueOwner && zoneWidget.commentThread.threadId === thread.threadId); if (matchedZones.length) { const matchedZone = matchedZones[0]; matchedZone.update(thread); @@ -851,19 +913,19 @@ export class CommentController implements IEditorContribution { } }); for (const thread of added) { - const matchedZones = this._commentWidgets.filter(zoneWidget => zoneWidget.owner === e.owner && zoneWidget.commentThread.threadId === thread.threadId); + const matchedZones = this._commentWidgets.filter(zoneWidget => zoneWidget.uniqueOwner === e.uniqueOwner && zoneWidget.commentThread.threadId === thread.threadId); if (matchedZones.length) { return; } - const matchedNewCommentThreadZones = this._commentWidgets.filter(zoneWidget => zoneWidget.owner === e.owner && zoneWidget.commentThread.commentThreadHandle === -1 && Range.equalsRange(zoneWidget.commentThread.range, thread.range)); + const matchedNewCommentThreadZones = this._commentWidgets.filter(zoneWidget => zoneWidget.uniqueOwner === e.uniqueOwner && zoneWidget.commentThread.commentThreadHandle === -1 && Range.equalsRange(zoneWidget.commentThread.range, thread.range)); if (matchedNewCommentThreadZones.length) { matchedNewCommentThreadZones[0].update(thread); return; } - const continueOnCommentIndex = this._inProcessContinueOnComments.get(e.owner)?.findIndex(pending => { + const continueOnCommentIndex = this._inProcessContinueOnComments.get(e.uniqueOwner)?.findIndex(pending => { if (pending.range === undefined) { return thread.range === undefined; } else { @@ -872,14 +934,14 @@ export class CommentController implements IEditorContribution { }); let continueOnCommentText: string | undefined; if ((continueOnCommentIndex !== undefined) && continueOnCommentIndex >= 0) { - continueOnCommentText = this._inProcessContinueOnComments.get(e.owner)?.splice(continueOnCommentIndex, 1)[0].body; + continueOnCommentText = this._inProcessContinueOnComments.get(e.uniqueOwner)?.splice(continueOnCommentIndex, 1)[0].body; } - const pendingCommentText = (this._pendingNewCommentCache[e.owner] && this._pendingNewCommentCache[e.owner][thread.threadId]) + const pendingCommentText = (this._pendingNewCommentCache[e.uniqueOwner] && this._pendingNewCommentCache[e.uniqueOwner][thread.threadId]) ?? continueOnCommentText; - const pendingEdits = this._pendingEditsCache[e.owner] && this._pendingEditsCache[e.owner][thread.threadId]; - this.displayCommentThread(e.owner, thread, pendingCommentText, pendingEdits); - this._commentInfos.filter(info => info.owner === e.owner)[0].threads.push(thread); + const pendingEdits = this._pendingEditsCache[e.uniqueOwner] && this._pendingEditsCache[e.uniqueOwner][thread.threadId]; + await this.displayCommentThread(e.uniqueOwner, thread, pendingCommentText, pendingEdits); + this._commentInfos.filter(info => info.uniqueOwner === e.uniqueOwner)[0].threads.push(thread); this.tryUpdateReservedSpace(); } @@ -893,12 +955,12 @@ export class CommentController implements IEditorContribution { } private async resumePendingComment(editorURI: URI, thread: languages.PendingCommentThread) { - const matchedZones = this._commentWidgets.filter(zoneWidget => zoneWidget.owner === thread.owner && Range.lift(zoneWidget.commentThread.range)?.equalsRange(thread.range)); + const matchedZones = this._commentWidgets.filter(zoneWidget => zoneWidget.uniqueOwner === thread.uniqueOwner && Range.lift(zoneWidget.commentThread.range)?.equalsRange(thread.range)); if (thread.isReply && matchedZones.length) { - this.commentService.removeContinueOnComment({ owner: thread.owner, uri: editorURI, range: thread.range, isReply: true }); + this.commentService.removeContinueOnComment({ uniqueOwner: thread.uniqueOwner, uri: editorURI, range: thread.range, isReply: true }); matchedZones[0].setPendingComment(thread.body); } else if (matchedZones.length) { - this.commentService.removeContinueOnComment({ owner: thread.owner, uri: editorURI, range: thread.range, isReply: false }); + this.commentService.removeContinueOnComment({ uniqueOwner: thread.uniqueOwner, uri: editorURI, range: thread.range, isReply: false }); const existingPendingComment = matchedZones[0].getPendingComments().newComment; // We need to try to reconcile the existing pending comment with the incoming pending comment let pendingComment: string; @@ -911,15 +973,15 @@ export class CommentController implements IEditorContribution { } matchedZones[0].setPendingComment(pendingComment); } else if (!thread.isReply) { - const threadStillAvailable = this.commentService.removeContinueOnComment({ owner: thread.owner, uri: editorURI, range: thread.range, isReply: false }); + const threadStillAvailable = this.commentService.removeContinueOnComment({ uniqueOwner: thread.uniqueOwner, uri: editorURI, range: thread.range, isReply: false }); if (!threadStillAvailable) { return; } - if (!this._inProcessContinueOnComments.has(thread.owner)) { - this._inProcessContinueOnComments.set(thread.owner, []); + if (!this._inProcessContinueOnComments.has(thread.uniqueOwner)) { + this._inProcessContinueOnComments.set(thread.uniqueOwner, []); } - this._inProcessContinueOnComments.get(thread.owner)?.push(thread); - await this.commentService.createCommentThreadTemplate(thread.owner, thread.uri, thread.range ? Range.lift(thread.range) : undefined); + this._inProcessContinueOnComments.get(thread.uniqueOwner)?.push(thread); + await this.commentService.createCommentThreadTemplate(thread.uniqueOwner, thread.uri, thread.range ? Range.lift(thread.range) : undefined); } } @@ -959,7 +1021,7 @@ export class CommentController implements IEditorContribution { return undefined; } - private displayCommentThread(owner: string, thread: languages.CommentThread, pendingComment: string | undefined, pendingEdits: { [key: number]: string } | undefined): void { + private async displayCommentThread(uniqueOwner: string, thread: languages.CommentThread, pendingComment: string | undefined, pendingEdits: { [key: number]: string } | undefined): Promise { const editor = this.editor?.getModel(); if (!editor) { return; @@ -970,10 +1032,10 @@ export class CommentController implements IEditorContribution { let continueOnCommentReply: languages.PendingCommentThread | undefined; if (thread.range && !pendingComment) { - continueOnCommentReply = this.commentService.removeContinueOnComment({ owner, uri: editor.uri, range: thread.range, isReply: true }); + continueOnCommentReply = this.commentService.removeContinueOnComment({ uniqueOwner, uri: editor.uri, range: thread.range, isReply: true }); } - const zoneWidget = this.instantiationService.createInstance(ReviewZoneWidget, this.editor, owner, thread, pendingComment ?? continueOnCommentReply?.body, pendingEdits); - zoneWidget.display(thread.range); + const zoneWidget = this.instantiationService.createInstance(ReviewZoneWidget, this.editor, uniqueOwner, thread, pendingComment ?? continueOnCommentReply?.body, pendingEdits); + await zoneWidget.display(thread.range); this._commentWidgets.push(zoneWidget); this.openCommentsView(thread); } @@ -1171,15 +1233,20 @@ export class CommentController implements IEditorContribution { return { extraEditorClassName, lineDecorationsWidth }; } - private getWithCommentsEditorOptions(editor: ICodeEditor, extraEditorClassName: string[], startingLineDecorationsWidth: number) { + private getWithCommentsLineDecorationWidth(editor: ICodeEditor, startingLineDecorationsWidth: number) { let lineDecorationsWidth = startingLineDecorationsWidth; const options = editor.getOptions(); if (options.get(EditorOption.folding) && options.get(EditorOption.showFoldingControls) !== 'never') { lineDecorationsWidth -= 11; } lineDecorationsWidth += 24; + this._commentingRangeAmountReserved = lineDecorationsWidth; + return this._commentingRangeAmountReserved; + } + + private getWithCommentsEditorOptions(editor: ICodeEditor, extraEditorClassName: string[], startingLineDecorationsWidth: number) { extraEditorClassName.push('inline-comment'); - return { lineDecorationsWidth, extraEditorClassName }; + return { lineDecorationsWidth: this.getWithCommentsLineDecorationWidth(editor, startingLineDecorationsWidth), extraEditorClassName }; } private updateEditorLayoutOptions(editor: ICodeEditor, extraEditorClassName: string[], lineDecorationsWidth: number) { @@ -1189,21 +1256,38 @@ export class CommentController implements IEditorContribution { }); } - private tryUpdateReservedSpace() { + private ensureCommentingRangeReservedAmount(editor: ICodeEditor) { + const existing = this.getExistingCommentEditorOptions(editor); + if (existing.lineDecorationsWidth !== this._commentingRangeAmountReserved) { + editor.updateOptions({ + lineDecorationsWidth: this.getWithCommentsLineDecorationWidth(editor, existing.lineDecorationsWidth) + }); + } + } + + private tryUpdateReservedSpace(uri?: URI) { if (!this.editor) { return; } - const hasCommentsOrRanges = this._commentInfos.some(info => { + const hasCommentsOrRangesInInfo = this._commentInfos.some(info => { const hasRanges = Boolean(info.commentingRanges && (Array.isArray(info.commentingRanges) ? info.commentingRanges : info.commentingRanges.ranges).length); return hasRanges || (info.threads.length > 0); }); + uri = uri ?? this.editor.getModel()?.uri; + const resourceHasCommentingRanges = uri ? this.commentService.resourceHasCommentingRanges(uri) : false; - if (hasCommentsOrRanges && !this._commentingRangeSpaceReserved && this.commentService.isCommentingEnabled) { - this._commentingRangeSpaceReserved = true; - const { lineDecorationsWidth, extraEditorClassName } = this.getExistingCommentEditorOptions(this.editor); - const newOptions = this.getWithCommentsEditorOptions(this.editor, extraEditorClassName, lineDecorationsWidth); - this.updateEditorLayoutOptions(this.editor, newOptions.extraEditorClassName, newOptions.lineDecorationsWidth); + const hasCommentsOrRanges = hasCommentsOrRangesInInfo || resourceHasCommentingRanges; + + if (hasCommentsOrRanges && this.commentService.isCommentingEnabled) { + if (!this._commentingRangeSpaceReserved) { + this._commentingRangeSpaceReserved = true; + const { lineDecorationsWidth, extraEditorClassName } = this.getExistingCommentEditorOptions(this.editor); + const newOptions = this.getWithCommentsEditorOptions(this.editor, extraEditorClassName, lineDecorationsWidth); + this.updateEditorLayoutOptions(this.editor, newOptions.extraEditorClassName, newOptions.lineDecorationsWidth); + } else { + this.ensureCommentingRangeReservedAmount(this.editor); + } } else if ((!hasCommentsOrRanges || !this.commentService.isCommentingEnabled) && this._commentingRangeSpaceReserved) { this._commentingRangeSpaceReserved = false; const { lineDecorationsWidth, extraEditorClassName } = this.getExistingCommentEditorOptions(this.editor); @@ -1212,7 +1296,7 @@ export class CommentController implements IEditorContribution { } } - private setComments(commentInfos: ICommentInfo[]): void { + private async setComments(commentInfos: ICommentInfo[]): Promise { if (!this.editor || !this.commentService.isCommentingEnabled) { return; } @@ -1223,15 +1307,15 @@ export class CommentController implements IEditorContribution { this.removeCommentWidgetsAndStoreCache(); let hasCommentingRanges = false; - this._commentInfos.forEach(info => { + for (const info of this._commentInfos) { if (!hasCommentingRanges && (info.commentingRanges.ranges.length > 0 || info.commentingRanges.fileComments)) { hasCommentingRanges = true; } - const providerCacheStore = this._pendingNewCommentCache[info.owner]; - const providerEditsCacheStore = this._pendingEditsCache[info.owner]; + const providerCacheStore = this._pendingNewCommentCache[info.uniqueOwner]; + const providerEditsCacheStore = this._pendingEditsCache[info.uniqueOwner]; info.threads = info.threads.filter(thread => !thread.isDisposed); - info.threads.forEach(thread => { + for (const thread of info.threads) { let pendingComment: string | undefined = undefined; if (providerCacheStore) { pendingComment = providerCacheStore[thread.threadId]; @@ -1242,12 +1326,12 @@ export class CommentController implements IEditorContribution { pendingEdits = providerEditsCacheStore[thread.threadId]; } - this.displayCommentThread(info.owner, thread, pendingComment, pendingEdits); - }); + await this.displayCommentThread(info.uniqueOwner, thread, pendingComment, pendingEdits); + } for (const thread of info.pendingCommentThreads ?? []) { this.resumePendingComment(this.editor!.getModel()!.uri, thread); } - }); + } this._commentingRangeDecorator.update(this.editor, this._commentInfos); this._commentThreadRangeDecorator.update(this.editor, this._commentInfos); @@ -1272,7 +1356,7 @@ export class CommentController implements IEditorContribution { this._commentWidgets.forEach(zone => { const pendingComments = zone.getPendingComments(); const pendingNewComment = pendingComments.newComment; - const providerNewCommentCacheStore = this._pendingNewCommentCache[zone.owner]; + const providerNewCommentCacheStore = this._pendingNewCommentCache[zone.uniqueOwner]; let lastCommentBody; if (zone.commentThread.comments && zone.commentThread.comments.length) { @@ -1285,10 +1369,10 @@ export class CommentController implements IEditorContribution { } if (pendingNewComment && (pendingNewComment !== lastCommentBody)) { if (!providerNewCommentCacheStore) { - this._pendingNewCommentCache[zone.owner] = {}; + this._pendingNewCommentCache[zone.uniqueOwner] = {}; } - this._pendingNewCommentCache[zone.owner][zone.commentThread.threadId] = pendingNewComment; + this._pendingNewCommentCache[zone.uniqueOwner][zone.commentThread.threadId] = pendingNewComment; } else { if (providerNewCommentCacheStore) { delete providerNewCommentCacheStore[zone.commentThread.threadId]; @@ -1296,12 +1380,12 @@ export class CommentController implements IEditorContribution { } const pendingEdits = pendingComments.edits; - const providerEditsCacheStore = this._pendingEditsCache[zone.owner]; + const providerEditsCacheStore = this._pendingEditsCache[zone.uniqueOwner]; if (Object.keys(pendingEdits).length > 0) { if (!providerEditsCacheStore) { - this._pendingEditsCache[zone.owner] = {}; + this._pendingEditsCache[zone.uniqueOwner] = {}; } - this._pendingEditsCache[zone.owner][zone.commentThread.threadId] = pendingEdits; + this._pendingEditsCache[zone.uniqueOwner][zone.commentThread.threadId] = pendingEdits; } else if (providerEditsCacheStore) { delete providerEditsCacheStore[zone.commentThread.threadId]; } diff --git a/src/vs/workbench/contrib/comments/browser/commentsEditorContribution.ts b/src/vs/workbench/contrib/comments/browser/commentsEditorContribution.ts index 32d064d28006a..f419804c8d788 100644 --- a/src/vs/workbench/contrib/comments/browser/commentsEditorContribution.ts +++ b/src/vs/workbench/contrib/comments/browser/commentsEditorContribution.ts @@ -25,8 +25,11 @@ import { CONTEXT_ACCESSIBILITY_MODE_ENABLED } from 'vs/platform/accessibility/co import { ContextKeyExpr } from 'vs/platform/contextkey/common/contextkey'; import { accessibilityHelpIsShown, accessibleViewCurrentProviderId, AccessibleViewProviderId } from 'vs/workbench/contrib/accessibility/browser/accessibilityConfiguration'; import { CommentCommandId } from 'vs/workbench/contrib/comments/common/commentCommandIds'; +import { registerWorkbenchContribution2, WorkbenchPhase } from 'vs/workbench/common/contributions'; +import { CommentsInputContentProvider } from 'vs/workbench/contrib/comments/browser/commentsInputContentProvider'; registerEditorContribution(ID, CommentController, EditorContributionInstantiation.AfterFirstRender); +registerWorkbenchContribution2(CommentsInputContentProvider.ID, CommentsInputContentProvider, WorkbenchPhase.BlockRestore); KeybindingsRegistry.registerCommandAndKeybindingRule({ id: CommentCommandId.NextThread, diff --git a/src/vs/workbench/contrib/comments/browser/commentsInputContentProvider.ts b/src/vs/workbench/contrib/comments/browser/commentsInputContentProvider.ts new file mode 100644 index 0000000000000..200fd659b8790 --- /dev/null +++ b/src/vs/workbench/contrib/comments/browser/commentsInputContentProvider.ts @@ -0,0 +1,32 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable } from 'vs/base/common/lifecycle'; +import { Schemas } from 'vs/base/common/network'; +import { URI } from 'vs/base/common/uri'; +import { IEditorContribution } from 'vs/editor/common/editorCommon'; +import { ILanguageService } from 'vs/editor/common/languages/language'; +import { ITextModel } from 'vs/editor/common/model'; +import { IModelService } from 'vs/editor/common/services/model'; +import { ITextModelContentProvider, ITextModelService } from 'vs/editor/common/services/resolverService'; + +export class CommentsInputContentProvider extends Disposable implements ITextModelContentProvider, IEditorContribution { + + public static readonly ID = 'comments.input.contentProvider'; + + constructor( + @ITextModelService textModelService: ITextModelService, + @IModelService private readonly _modelService: IModelService, + @ILanguageService private readonly _languageService: ILanguageService, + ) { + super(); + this._register(textModelService.registerTextModelContentProvider(Schemas.commentsInput, this)); + } + + async provideTextContent(resource: URI): Promise { + const existing = this._modelService.getModel(resource); + return existing ?? this._modelService.createModel('', this._languageService.createById('markdown'), resource); + } +} diff --git a/src/vs/workbench/contrib/comments/browser/commentsModel.ts b/src/vs/workbench/contrib/comments/browser/commentsModel.ts index 6d345350e83db..d0701d5f344b7 100644 --- a/src/vs/workbench/contrib/comments/browser/commentsModel.ts +++ b/src/vs/workbench/contrib/comments/browser/commentsModel.ts @@ -43,15 +43,15 @@ export class CommentsModel extends Disposable implements ICommentsModel { }); } - public setCommentThreads(owner: string, ownerLabel: string, commentThreads: CommentThread[]): void { - this.commentThreadsMap.set(owner, { ownerLabel, resourceWithCommentThreads: this.groupByResource(owner, commentThreads) }); + public setCommentThreads(uniqueOwner: string, owner: string, ownerLabel: string, commentThreads: CommentThread[]): void { + this.commentThreadsMap.set(uniqueOwner, { ownerLabel, resourceWithCommentThreads: this.groupByResource(uniqueOwner, owner, commentThreads) }); this.updateResourceCommentThreads(); } - public deleteCommentsByOwner(owner?: string): void { - if (owner) { - const existingOwner = this.commentThreadsMap.get(owner); - this.commentThreadsMap.set(owner, { ownerLabel: existingOwner?.ownerLabel, resourceWithCommentThreads: [] }); + public deleteCommentsByOwner(uniqueOwner?: string): void { + if (uniqueOwner) { + const existingOwner = this.commentThreadsMap.get(uniqueOwner); + this.commentThreadsMap.set(uniqueOwner, { ownerLabel: existingOwner?.ownerLabel, resourceWithCommentThreads: [] }); } else { this.commentThreadsMap.clear(); } @@ -59,9 +59,9 @@ export class CommentsModel extends Disposable implements ICommentsModel { } public updateCommentThreads(event: ICommentThreadChangedEvent): boolean { - const { owner, ownerLabel, removed, changed, added } = event; + const { uniqueOwner, owner, ownerLabel, removed, changed, added } = event; - const threadsForOwner = this.commentThreadsMap.get(owner)?.resourceWithCommentThreads || []; + const threadsForOwner = this.commentThreadsMap.get(uniqueOwner)?.resourceWithCommentThreads || []; removed.forEach(thread => { // Find resource that has the comment thread @@ -91,9 +91,9 @@ export class CommentsModel extends Disposable implements ICommentsModel { // Find comment node on resource that is that thread and replace it const index = matchingResourceData.commentThreads.findIndex((commentThread) => commentThread.threadId === thread.threadId); if (index >= 0) { - matchingResourceData.commentThreads[index] = ResourceWithCommentThreads.createCommentNode(owner, URI.parse(matchingResourceData.id), thread); + matchingResourceData.commentThreads[index] = ResourceWithCommentThreads.createCommentNode(uniqueOwner, owner, URI.parse(matchingResourceData.id), thread); } else if (thread.comments && thread.comments.length) { - matchingResourceData.commentThreads.push(ResourceWithCommentThreads.createCommentNode(owner, URI.parse(matchingResourceData.id), thread)); + matchingResourceData.commentThreads.push(ResourceWithCommentThreads.createCommentNode(uniqueOwner, owner, URI.parse(matchingResourceData.id), thread)); } }); @@ -102,14 +102,14 @@ export class CommentsModel extends Disposable implements ICommentsModel { if (existingResource.length) { const resource = existingResource[0]; if (thread.comments && thread.comments.length) { - resource.commentThreads.push(ResourceWithCommentThreads.createCommentNode(owner, resource.resource, thread)); + resource.commentThreads.push(ResourceWithCommentThreads.createCommentNode(uniqueOwner, owner, resource.resource, thread)); } } else { - threadsForOwner.push(new ResourceWithCommentThreads(owner, URI.parse(thread.resource!), [thread])); + threadsForOwner.push(new ResourceWithCommentThreads(uniqueOwner, owner, URI.parse(thread.resource!), [thread])); } }); - this.commentThreadsMap.set(owner, { ownerLabel, resourceWithCommentThreads: threadsForOwner }); + this.commentThreadsMap.set(uniqueOwner, { ownerLabel, resourceWithCommentThreads: threadsForOwner }); this.updateResourceCommentThreads(); return removed.length > 0 || changed.length > 0 || added.length > 0; @@ -127,11 +127,11 @@ export class CommentsModel extends Disposable implements ICommentsModel { } } - private groupByResource(owner: string, commentThreads: CommentThread[]): ResourceWithCommentThreads[] { + private groupByResource(uniqueOwner: string, owner: string, commentThreads: CommentThread[]): ResourceWithCommentThreads[] { const resourceCommentThreads: ResourceWithCommentThreads[] = []; const commentThreadsByResource = new Map(); for (const group of groupBy(commentThreads, CommentsModel._compareURIs)) { - commentThreadsByResource.set(group[0].resource!, new ResourceWithCommentThreads(owner, URI.parse(group[0].resource!), group)); + commentThreadsByResource.set(group[0].resource!, new ResourceWithCommentThreads(uniqueOwner, owner, URI.parse(group[0].resource!), group)); } commentThreadsByResource.forEach((v, i, m) => { diff --git a/src/vs/workbench/contrib/comments/browser/commentsTreeViewer.ts b/src/vs/workbench/contrib/comments/browser/commentsTreeViewer.ts index 69ffc3d3f6d8a..e0f5f5f053228 100644 --- a/src/vs/workbench/contrib/comments/browser/commentsTreeViewer.ts +++ b/src/vs/workbench/contrib/comments/browser/commentsTreeViewer.ts @@ -10,7 +10,7 @@ import { IDisposable, DisposableStore } from 'vs/base/common/lifecycle'; import { IOpenerService } from 'vs/platform/opener/common/opener'; import { IResourceLabel, ResourceLabels } from 'vs/workbench/browser/labels'; import { CommentNode, ResourceWithCommentThreads } from 'vs/workbench/contrib/comments/common/commentModel'; -import { ITreeFilter, ITreeNode, TreeFilterResult, TreeVisibility } from 'vs/base/browser/ui/tree/tree'; +import { ITreeContextMenuEvent, ITreeFilter, ITreeNode, TreeFilterResult, TreeVisibility } from 'vs/base/browser/ui/tree/tree'; import { IListVirtualDelegate, IListRenderer } from 'vs/base/browser/ui/list/list'; import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; import { IContextKeyService } from 'vs/platform/contextkey/common/contextkey'; @@ -22,7 +22,7 @@ import { Codicon } from 'vs/base/common/codicons'; import { ThemeIcon } from 'vs/base/common/themables'; import { IMarkdownString } from 'vs/base/common/htmlContent'; import { commentViewThreadStateColorVar, getCommentThreadStateIconColor } from 'vs/workbench/contrib/comments/browser/commentColors'; -import { CommentThreadState } from 'vs/editor/common/languages'; +import { CommentThreadApplicability, CommentThreadState } from 'vs/editor/common/languages'; import { Color } from 'vs/base/common/color'; import { IMatch } from 'vs/base/common/filters'; import { FilterOptions } from 'vs/workbench/contrib/comments/browser/commentsFilterOptions'; @@ -32,6 +32,17 @@ import { IStyleOverride } from 'vs/platform/theme/browser/defaultStyles'; import { IListStyles } from 'vs/base/browser/ui/list/listWidget'; import { ILocalizedString } from 'vs/platform/action/common/action'; import { CommentsModel } from 'vs/workbench/contrib/comments/browser/commentsModel'; +import { getDefaultHoverDelegate } from 'vs/base/browser/ui/hover/hoverDelegateFactory'; +import { ActionBar, IActionViewItemProvider } from 'vs/base/browser/ui/actionbar/actionbar'; +import { createActionViewItem, createAndFillInContextMenuActions } from 'vs/platform/actions/browser/menuEntryActionViewItem'; +import { IMenu, IMenuService, MenuId } from 'vs/platform/actions/common/actions'; +import { IAction } from 'vs/base/common/actions'; +import { MarshalledId } from 'vs/base/common/marshallingIds'; +import { IContextMenuService } from 'vs/platform/contextview/browser/contextView'; +import { ActionViewItem } from 'vs/base/browser/ui/actionbar/actionViewItems'; +import { IKeybindingService } from 'vs/platform/keybinding/common/keybinding'; +import { MarshalledCommentThread, MarshalledCommentThreadInternal } from 'vs/workbench/common/comments'; +import { IHoverService } from 'vs/platform/hover/browser/hover'; export const COMMENTS_VIEW_ID = 'workbench.panel.comments'; export const COMMENTS_VIEW_STORAGE_ID = 'Comments'; @@ -45,6 +56,7 @@ interface IResourceTemplateData { interface ICommentThreadTemplateData { threadMetadata: { + relevance: HTMLElement; icon: HTMLElement; userNames: HTMLSpanElement; timestamp: TimestampWidget; @@ -60,6 +72,7 @@ interface ICommentThreadTemplateData { separator: HTMLElement; timestamp: TimestampWidget; }; + actionBar: ActionBar; disposables: IDisposable[]; } @@ -122,29 +135,86 @@ export class ResourceWithCommentsRenderer implements IListRenderer, ICommentThreadTemplateData> { templateId: string = 'comment-node'; constructor( + private actionViewItemProvider: IActionViewItemProvider, + private menus: CommentsMenus, @IOpenerService private readonly openerService: IOpenerService, @IConfigurationService private readonly configurationService: IConfigurationService, + @IHoverService private readonly hoverService: IHoverService, @IThemeService private themeService: IThemeService ) { } renderTemplate(container: HTMLElement) { - const threadContainer = dom.append(container, dom.$('.comment-thread-container')); const metadataContainer = dom.append(threadContainer, dom.$('.comment-metadata-container')); + const metadata = dom.append(metadataContainer, dom.$('.comment-metadata')); const threadMetadata = { - icon: dom.append(metadataContainer, dom.$('.icon')), - userNames: dom.append(metadataContainer, dom.$('.user')), - timestamp: new TimestampWidget(this.configurationService, dom.append(metadataContainer, dom.$('.timestamp-container'))), - separator: dom.append(metadataContainer, dom.$('.separator')), - commentPreview: dom.append(metadataContainer, dom.$('.text')), - range: dom.append(metadataContainer, dom.$('.range')) + icon: dom.append(metadata, dom.$('.icon')), + userNames: dom.append(metadata, dom.$('.user')), + timestamp: new TimestampWidget(this.configurationService, this.hoverService, dom.append(metadata, dom.$('.timestamp-container'))), + relevance: dom.append(metadata, dom.$('.relevance')), + separator: dom.append(metadata, dom.$('.separator')), + commentPreview: dom.append(metadata, dom.$('.text')), + range: dom.append(metadata, dom.$('.range')) }; threadMetadata.separator.innerText = '\u00b7'; + const actionsContainer = dom.append(metadataContainer, dom.$('.actions')); + const actionBar = new ActionBar(actionsContainer, { + actionViewItemProvider: this.actionViewItemProvider + }); + const snippetContainer = dom.append(threadContainer, dom.$('.comment-snippet-container')); const repliesMetadata = { container: snippetContainer, @@ -152,18 +222,20 @@ export class CommentNodeRenderer implements IListRenderer count: dom.append(snippetContainer, dom.$('.count')), lastReplyDetail: dom.append(snippetContainer, dom.$('.reply-detail')), separator: dom.append(snippetContainer, dom.$('.separator')), - timestamp: new TimestampWidget(this.configurationService, dom.append(snippetContainer, dom.$('.timestamp-container'))), + timestamp: new TimestampWidget(this.configurationService, this.hoverService, dom.append(snippetContainer, dom.$('.timestamp-container'))), }; repliesMetadata.separator.innerText = '\u00b7'; repliesMetadata.icon.classList.add(...ThemeIcon.asClassNameArray(Codicon.indent)); - const disposables = [threadMetadata.timestamp, repliesMetadata.timestamp]; - return { threadMetadata, repliesMetadata, disposables }; + const disposables = [threadMetadata.timestamp, repliesMetadata.timestamp]; + return { threadMetadata, repliesMetadata, actionBar, disposables }; } private getCountString(commentCount: number): string { - if (commentCount > 1) { - return nls.localize('commentsCount', "{0} comments", commentCount); + if (commentCount > 2) { + return nls.localize('commentsCountReplies', "{0} replies", commentCount - 1); + } else if (commentCount === 2) { + return nls.localize('commentsCountReply', "1 reply"); } else { return nls.localize('commentCount', "1 comment"); } @@ -196,7 +268,19 @@ export class CommentNodeRenderer implements IListRenderer } renderElement(node: ITreeNode, index: number, templateData: ICommentThreadTemplateData, height: number | undefined): void { + templateData.actionBar.clear(); + const commentCount = node.element.replies.length + 1; + if (node.element.threadRelevance === CommentThreadApplicability.Outdated) { + templateData.threadMetadata.relevance.style.display = ''; + templateData.threadMetadata.relevance.innerText = nls.localize('outdated', "Outdated"); + templateData.threadMetadata.separator.style.display = 'none'; + } else { + templateData.threadMetadata.relevance.innerText = ''; + templateData.threadMetadata.relevance.style.display = 'none'; + templateData.threadMetadata.separator.style.display = ''; + } + templateData.threadMetadata.icon.classList.remove(...Array.from(templateData.threadMetadata.icon.classList.values()) .filter(value => value.startsWith('codicon'))); templateData.threadMetadata.icon.classList.add(...ThemeIcon.asClassNameArray(this.getIcon(node.element.threadState))); @@ -219,7 +303,7 @@ export class CommentNodeRenderer implements IListRenderer const renderedComment = this.getRenderedComment(originalComment.comment.body, disposables); templateData.disposables.push(renderedComment); templateData.threadMetadata.commentPreview.appendChild(renderedComment.element.firstElementChild ?? renderedComment.element); - templateData.threadMetadata.commentPreview.title = renderedComment.element.textContent ?? ''; + templateData.disposables.push(this.hoverService.setupUpdatableHover(getDefaultHoverDelegate('mouse'), templateData.threadMetadata.commentPreview, renderedComment.element.textContent ?? '')); } if (node.element.range) { @@ -230,6 +314,14 @@ export class CommentNodeRenderer implements IListRenderer } } + const menuActions = this.menus.getResourceActions(node.element); + templateData.actionBar.push(menuActions.actions, { icon: true, label: false }); + templateData.actionBar.context = { + commentControlHandle: node.element.controllerHandle, + commentThreadHandle: node.element.threadHandle, + $mid: MarshalledId.CommentThread + } as MarshalledCommentThread; + if (!node.element.hasReply()) { templateData.repliesMetadata.container.style.display = 'none'; return; @@ -248,6 +340,7 @@ export class CommentNodeRenderer implements IListRenderer disposeTemplate(templateData: ICommentThreadTemplateData): void { templateData.disposables.forEach(disposeable => disposeable.dispose()); + templateData.actionBar.dispose(); } } @@ -345,6 +438,8 @@ export class Filter implements ITreeFilter { + private readonly menus: CommentsMenus; + constructor( labels: ResourceLabels, container: HTMLElement, @@ -353,12 +448,16 @@ export class CommentsList extends WorkbenchObjectTree this.commentsOnContextMenu(e))); + } + + private commentsOnContextMenu(treeEvent: ITreeContextMenuEvent): void { + const node: CommentsModel | ResourceWithCommentThreads | CommentNode | null = treeEvent.element; + if (!(node instanceof CommentNode)) { + return; + } + const event: UIEvent = treeEvent.browserEvent; + + event.preventDefault(); + event.stopPropagation(); + + this.setFocus([node]); + const actions = this.menus.getResourceContextActions(node); + if (!actions.length) { + return; + } + this.contextMenuService.showContextMenu({ + getAnchor: () => treeEvent.anchor, + getActions: () => actions, + getActionViewItem: (action) => { + const keybinding = this.keybindingService.lookupKeybinding(action.id); + if (keybinding) { + return new ActionViewItem(action, action, { label: true, keybinding: keybinding.getLabel() }); + } + return undefined; + }, + onHide: (wasCancelled?: boolean) => { + if (wasCancelled) { + this.domFocus(); + } + }, + getActionsContext: (): MarshalledCommentThreadInternal => ({ + commentControlHandle: node.controllerHandle, + commentThreadHandle: node.threadHandle, + $mid: MarshalledId.CommentThread, + thread: node.thread + }) + }); } filterComments(): void { diff --git a/src/vs/workbench/contrib/comments/browser/commentsView.ts b/src/vs/workbench/contrib/comments/browser/commentsView.ts index 385ac16e1dc88..d5d52c4c07ecb 100644 --- a/src/vs/workbench/contrib/comments/browser/commentsView.ts +++ b/src/vs/workbench/contrib/comments/browser/commentsView.ts @@ -7,13 +7,11 @@ import 'vs/css!./media/panel'; import * as nls from 'vs/nls'; import * as dom from 'vs/base/browser/dom'; import { basename } from 'vs/base/common/resources'; -import { isCodeEditor, isDiffEditor } from 'vs/editor/browser/editorBrowser'; import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; import { IThemeService } from 'vs/platform/theme/common/themeService'; import { CommentNode, ResourceWithCommentThreads, ICommentThreadChangedEvent } from 'vs/workbench/contrib/comments/common/commentModel'; import { IWorkspaceCommentThreadsEvent, ICommentService } from 'vs/workbench/contrib/comments/browser/commentService'; -import { IEditorService, ACTIVE_GROUP, SIDE_GROUP } from 'vs/workbench/services/editor/common/editorService'; -import { textLinkForeground, textLinkActiveForeground, focusBorder, textPreformatForeground } from 'vs/platform/theme/common/colorRegistry'; +import { IEditorService } from 'vs/workbench/services/editor/common/editorService'; import { ResourceLabels } from 'vs/workbench/browser/labels'; import { CommentsList, COMMENTS_VIEW_TITLE, Filter } from 'vs/workbench/contrib/comments/browser/commentsTreeViewer'; import { IViewPaneOptions, FilterViewPane } from 'vs/workbench/browser/parts/views/viewPane'; @@ -25,20 +23,18 @@ import { IKeybindingService } from 'vs/platform/keybinding/common/keybinding'; import { IOpenerService } from 'vs/platform/opener/common/opener'; import { ITelemetryService } from 'vs/platform/telemetry/common/telemetry'; import { IUriIdentityService } from 'vs/platform/uriIdentity/common/uriIdentity'; -import { IEditor } from 'vs/editor/common/editorCommon'; -import { TextModel } from 'vs/editor/common/model/textModel'; import { CommentsViewFilterFocusContextKey, ICommentsView } from 'vs/workbench/contrib/comments/browser/comments'; import { CommentsFilters, CommentsFiltersChangeEvent } from 'vs/workbench/contrib/comments/browser/commentsViewActions'; import { Memento, MementoObject } from 'vs/workbench/common/memento'; import { IStorageService, StorageScope, StorageTarget } from 'vs/platform/storage/common/storage'; import { FilterOptions } from 'vs/workbench/contrib/comments/browser/commentsFilterOptions'; -import { CommentThreadState } from 'vs/editor/common/languages'; +import { CommentThreadApplicability, CommentThreadState } from 'vs/editor/common/languages'; import { ITreeElement } from 'vs/base/browser/ui/tree/tree'; import { Iterable } from 'vs/base/common/iterator'; -import { CommentController } from 'vs/workbench/contrib/comments/browser/commentsController'; -import { Range } from 'vs/editor/common/core/range'; +import { revealCommentThread } from 'vs/workbench/contrib/comments/browser/commentsController'; import { registerNavigableContainer } from 'vs/workbench/browser/actions/widgetNavigationCommands'; import { CommentsModel, ICommentsModel } from 'vs/workbench/contrib/comments/browser/commentsModel'; +import { IHoverService } from 'vs/platform/hover/browser/hover'; export const CONTEXT_KEY_HAS_COMMENTS = new RawContextKey('commentsView.hasComments', false); export const CONTEXT_KEY_SOME_COMMENTS_EXPANDED = new RawContextKey('commentsView.someCommentsExpanded', false); @@ -85,6 +81,7 @@ export class CommentsPanel extends FilterViewPane implements ICommentsView { @IThemeService themeService: IThemeService, @ICommentService private readonly commentService: ICommentService, @ITelemetryService telemetryService: ITelemetryService, + @IHoverService hoverService: IHoverService, @IUriIdentityService private readonly uriIdentityService: IUriIdentityService, @IStorageService storageService: IStorageService ) { @@ -99,7 +96,7 @@ export class CommentsPanel extends FilterViewPane implements ICommentsView { text: viewState['filter'] || '', focusContextKey: CommentsViewFilterFocusContextKey.key } - }, keybindingService, contextMenuService, configurationService, contextKeyService, viewDescriptorService, instantiationService, openerService, themeService, telemetryService); + }, keybindingService, contextMenuService, configurationService, contextKeyService, viewDescriptorService, instantiationService, openerService, themeService, telemetryService, hoverService); this.hasCommentsContextKey = CONTEXT_KEY_HAS_COMMENTS.bindTo(contextKeyService); this.someCommentsExpandedContextKey = CONTEXT_KEY_SOME_COMMENTS_EXPANDED.bindTo(contextKeyService); this.stateMemento = stateMemento; @@ -131,6 +128,7 @@ export class CommentsPanel extends FilterViewPane implements ICommentsView { override render(): void { super.render(); this._register(registerNavigableContainer({ + name: 'commentsView', focusNotifiers: [this, this.filterWidget], focusNextWidget: () => { if (this.filterWidget.hasFocus()) { @@ -192,10 +190,6 @@ export class CommentsPanel extends FilterViewPane implements ICommentsView { this._register(this.commentService.onDidUpdateCommentThreads(this.onCommentsUpdated, this)); this._register(this.commentService.onDidDeleteDataProvider(this.onDataProviderDeleted, this)); - const styleElement = dom.createStyleSheet(container); - this.applyStyles(styleElement); - this._register(this.themeService.onDidColorThemeChange(_ => this.applyStyles(styleElement))); - this._register(this.onDidChangeBodyVisibility(visible => { if (visible) { this.refresh(); @@ -220,33 +214,6 @@ export class CommentsPanel extends FilterViewPane implements ICommentsView { } } - private applyStyles(styleElement: HTMLStyleElement) { - const content: string[] = []; - - const theme = this.themeService.getColorTheme(); - const linkColor = theme.getColor(textLinkForeground); - if (linkColor) { - content.push(`.comments-panel .comments-panel-container a { color: ${linkColor}; }`); - } - - const linkActiveColor = theme.getColor(textLinkActiveForeground); - if (linkActiveColor) { - content.push(`.comments-panel .comments-panel-container a:hover, a:active { color: ${linkActiveColor}; }`); - } - - const focusColor = theme.getColor(focusBorder); - if (focusColor) { - content.push(`.comments-panel .comments-panel-container a:focus { outline-color: ${focusColor}; }`); - } - - const codeTextForegroundColor = theme.getColor(textPreformatForeground); - if (codeTextForegroundColor) { - content.push(`.comments-panel .comments-panel-container .text code { color: ${codeTextForegroundColor}; }`); - } - - styleElement.textContent = content.join('\n'); - } - private async renderComments(): Promise { this.treeContainer.classList.toggle('hidden', !this.commentService.commentsModel.hasCommentThreads()); this.renderMessage(); @@ -296,10 +263,50 @@ export class CommentsPanel extends FilterViewPane implements ICommentsView { this.messageBoxContainer.classList.toggle('hidden', this.commentService.commentsModel.hasCommentThreads()); } + private getAriaForNode(element: CommentNode) { + if (element.range) { + if (element.threadRelevance === CommentThreadApplicability.Outdated) { + return nls.localize('resourceWithCommentLabelOutdated', + "Outdated from ${0} at line {1} column {2} in {3}, source: {4}", + element.comment.userName, + element.range.startLineNumber, + element.range.startColumn, + basename(element.resource), + (typeof element.comment.body === 'string') ? element.comment.body : element.comment.body.value + ); + } else { + return nls.localize('resourceWithCommentLabel', + "${0} at line {1} column {2} in {3}, source: {4}", + element.comment.userName, + element.range.startLineNumber, + element.range.startColumn, + basename(element.resource), + (typeof element.comment.body === 'string') ? element.comment.body : element.comment.body.value + ); + } + } else { + if (element.threadRelevance === CommentThreadApplicability.Outdated) { + return nls.localize('resourceWithCommentLabelFileOutdated', + "Outdated from {0} in {1}, source: {2}", + element.comment.userName, + basename(element.resource), + (typeof element.comment.body === 'string') ? element.comment.body : element.comment.body.value + ); + } else { + return nls.localize('resourceWithCommentLabelFile', + "{0} in {1}, source: {2}", + element.comment.userName, + basename(element.resource), + (typeof element.comment.body === 'string') ? element.comment.body : element.comment.body.value + ); + } + } + } + private createTree(): void { this.treeLabels = this._register(this.instantiationService.createInstance(ResourceLabels, this)); this.tree = this._register(this.instantiationService.createInstance(CommentsList, this.treeLabels, this.treeContainer, { - overrideStyles: { listBackground: this.getBackgroundColor() }, + overrideStyles: this.getLocationBasedColors().listOverrideStyles, selectionNavigation: true, filter: this.filter, keyboardNavigationLabelProvider: { @@ -308,7 +315,7 @@ export class CommentsPanel extends FilterViewPane implements ICommentsView { } }, accessibilityProvider: { - getAriaLabel(element: any): string { + getAriaLabel: (element: any): string => { if (element instanceof CommentsModel) { return nls.localize('rootCommentsLabel', "Comments for current workspace"); } @@ -316,23 +323,7 @@ export class CommentsPanel extends FilterViewPane implements ICommentsView { return nls.localize('resourceWithCommentThreadsLabel', "Comments in {0}, full path {1}", basename(element.resource), element.resource.fsPath); } if (element instanceof CommentNode) { - if (element.range) { - return nls.localize('resourceWithCommentLabel', - "${0} at line {1} column {2} in {3}, source: {4}", - element.comment.userName, - element.range.startLineNumber, - element.range.startColumn, - basename(element.resource), - (typeof element.comment.body === 'string') ? element.comment.body : element.comment.body.value - ); - } else { - return nls.localize('resourceWithCommentLabelFile', - "${0} in {1}, source: {2}", - element.comment.userName, - basename(element.resource), - (typeof element.comment.body === 'string') ? element.comment.body : element.comment.body.value - ); - } + return this.getAriaForNode(element); } return ''; }, @@ -355,62 +346,17 @@ export class CommentsPanel extends FilterViewPane implements ICommentsView { })); } - private openFile(element: any, pinned?: boolean, preserveFocus?: boolean, sideBySide?: boolean): boolean { + private openFile(element: any, pinned?: boolean, preserveFocus?: boolean, sideBySide?: boolean): void { if (!element) { - return false; + return; } if (!(element instanceof ResourceWithCommentThreads || element instanceof CommentNode)) { - return false; - } - - if (!this.commentService.isCommentingEnabled) { - this.commentService.enableCommenting(true); - } - - const range = element instanceof ResourceWithCommentThreads ? element.commentThreads[0].range : element.range; - - const activeEditor = this.editorService.activeTextEditorControl; - // If the active editor is a diff editor where one of the sides has the comment, - // then we try to reveal the comment in the diff editor. - const currentActiveResources: IEditor[] = isDiffEditor(activeEditor) ? [activeEditor.getOriginalEditor(), activeEditor.getModifiedEditor()] - : (activeEditor ? [activeEditor] : []); - - for (const editor of currentActiveResources) { - const model = editor.getModel(); - if ((model instanceof TextModel) && this.uriIdentityService.extUri.isEqual(element.resource, model.uri)) { - const threadToReveal = element instanceof ResourceWithCommentThreads ? element.commentThreads[0].threadId : element.threadId; - const commentToReveal = element instanceof ResourceWithCommentThreads ? element.commentThreads[0].comment.uniqueIdInThread : element.comment.uniqueIdInThread; - if (threadToReveal && isCodeEditor(editor)) { - const controller = CommentController.get(editor); - controller?.revealCommentThread(threadToReveal, commentToReveal, true, !preserveFocus); - } - - return true; - } + return; } - - const threadToReveal = element instanceof ResourceWithCommentThreads ? element.commentThreads[0].threadId : element.threadId; - const commentToReveal = element instanceof ResourceWithCommentThreads ? element.commentThreads[0].comment : element.comment; - - this.editorService.openEditor({ - resource: element.resource, - options: { - pinned: pinned, - preserveFocus: preserveFocus, - selection: range ?? new Range(1, 1, 1, 1) - } - }, sideBySide ? SIDE_GROUP : ACTIVE_GROUP).then(editor => { - if (editor) { - const control = editor.getControl(); - if (threadToReveal && isCodeEditor(control)) { - const controller = CommentController.get(control); - controller?.revealCommentThread(threadToReveal, commentToReveal.uniqueIdInThread, true, !preserveFocus); - } - } - }); - - return true; + const threadToReveal = element instanceof ResourceWithCommentThreads ? element.commentThreads[0].thread : element.thread; + const commentToReveal = element instanceof ResourceWithCommentThreads ? element.commentThreads[0].comment : undefined; + return revealCommentThread(this.commentService, this.editorService, this.uriIdentityService, threadToReveal, commentToReveal, false, pinned, preserveFocus, sideBySide); } private async refresh(): Promise { diff --git a/src/vs/workbench/contrib/comments/browser/commentsViewActions.ts b/src/vs/workbench/contrib/comments/browser/commentsViewActions.ts index e6fd43f4b918a..7a0f4d2153101 100644 --- a/src/vs/workbench/contrib/comments/browser/commentsViewActions.ts +++ b/src/vs/workbench/contrib/comments/browser/commentsViewActions.ts @@ -123,7 +123,7 @@ registerAction2(class extends ViewAction { constructor() { super({ id: `workbench.actions.${COMMENTS_VIEW_ID}.toggleUnResolvedComments`, - title: localize('toggle unresolved', "Toggle Unresolved Comments"), + title: localize('toggle unresolved', "Show Unresolved"), category: localize('comments', "Comments"), toggled: { condition: CONTEXT_KEY_SHOW_UNRESOLVED, @@ -148,7 +148,7 @@ registerAction2(class extends ViewAction { constructor() { super({ id: `workbench.actions.${COMMENTS_VIEW_ID}.toggleResolvedComments`, - title: localize('toggle resolved', "Toggle Resolved Comments"), + title: localize('toggle resolved', "Show Resolved"), category: localize('comments', "Comments"), toggled: { condition: CONTEXT_KEY_SHOW_RESOLVED, diff --git a/src/vs/workbench/contrib/comments/browser/media/panel.css b/src/vs/workbench/contrib/comments/browser/media/panel.css index a349ec5249098..938c658fd2dea 100644 --- a/src/vs/workbench/contrib/comments/browser/media/panel.css +++ b/src/vs/workbench/contrib/comments/browser/media/panel.css @@ -36,6 +36,11 @@ overflow: hidden; } +.comments-panel .comments-panel-container .tree-container .comment-thread-container .comment-metadata { + flex: 1; + display: flex; +} + .comments-panel .count, .comments-panel .user { padding-right: 5px; @@ -48,10 +53,23 @@ } .comments-panel .comments-panel-container .tree-container .comment-thread-container .comment-snippet-container .count, +.comments-panel .comments-panel-container .tree-container .comment-thread-container .comment-metadata-container .relevance, .comments-panel .comments-panel-container .tree-container .comment-thread-container .comment-metadata-container .user { min-width: fit-content; } +.comments-panel .comments-panel-container .tree-container .comment-thread-container .comment-metadata-container .relevance { + border-radius: 2px; + background-color: var(--vscode-badge-background); + color: var(--vscode-badge-foreground); + padding: 0px 4px 1px 4px; + font-size: 0.9em; + margin-right: 4px; + margin-top: 4px; + margin-bottom: 3px; + line-height: 14px; +} + .comments-panel .comments-panel-container .tree-container .comment-thread-container .comment-snippet-container .text { display: flex; flex: 1; @@ -117,3 +135,34 @@ .comments-panel .hide { display: none; } + +.comments-panel .comments-panel-container .text a { + color: var(--vscode-textLink-foreground); +} + +.comments-panel .comments-panel-container .text a:hover, +.comments-panel .comments-panel-container a:active { + color: var(--vscode-textLink-activeForeground); +} + +.comments-panel .comments-panel-container .text a:focus { + outline-color: var(--vscode-focusBorder); +} + +.comments-panel .comments-panel-container .text code { + color: var(--vscode-textPreformat-foreground); +} + +.comments-panel .comments-panel-container .actions { + display: none; +} + +.comments-panel .comments-panel-container .actions .action-label { + padding: 2px; +} + +.comments-panel .monaco-list .monaco-list-row:hover .comment-metadata-container .actions, +.comments-panel .monaco-list .monaco-list-row.selected .comment-metadata-container .actions, +.comments-panel .monaco-list .monaco-list-row.focused .comment-metadata-container .actions { + display: block; +} diff --git a/src/vs/workbench/contrib/comments/browser/media/review.css b/src/vs/workbench/contrib/comments/browser/media/review.css index cad293ca09e98..05c074921a3b9 100644 --- a/src/vs/workbench/contrib/comments/browser/media/review.css +++ b/src/vs/workbench/contrib/comments/browser/media/review.css @@ -386,7 +386,7 @@ margin-right: 12px; } -.review-widget .body .comment-form .monaco-text-button, +.review-widget .body .comment-form .form-actions .monaco-text-button, .review-widget .body .edit-container .monaco-text-button { width: auto; padding: 4px 10px; diff --git a/src/vs/workbench/contrib/comments/browser/simpleCommentEditor.ts b/src/vs/workbench/contrib/comments/browser/simpleCommentEditor.ts index cda25cbdfac99..1c603ca55bdab 100644 --- a/src/vs/workbench/contrib/comments/browser/simpleCommentEditor.ts +++ b/src/vs/workbench/contrib/comments/browser/simpleCommentEditor.ts @@ -6,13 +6,14 @@ import { EditorOption, IEditorOptions } from 'vs/editor/common/config/editorOptions'; import { EditorAction, EditorContributionInstantiation, EditorExtensionsRegistry, IEditorContributionDescription } from 'vs/editor/browser/editorExtensions'; import { ICodeEditorService } from 'vs/editor/browser/services/codeEditorService'; -import { CodeEditorWidget, ICodeEditorWidgetOptions } from 'vs/editor/browser/widget/codeEditorWidget'; +import { CodeEditorWidget, ICodeEditorWidgetOptions } from 'vs/editor/browser/widget/codeEditor/codeEditorWidget'; import { IContextKeyService, RawContextKey, IContextKey } from 'vs/platform/contextkey/common/contextkey'; import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; import { ICommandService } from 'vs/platform/commands/common/commands'; // Allowed Editor Contributions: import { MenuPreventer } from 'vs/workbench/contrib/codeEditor/browser/menuPreventer'; +import { EditorDictation } from 'vs/workbench/contrib/codeEditor/browser/dictation/editorDictation'; import { ContextMenuController } from 'vs/editor/contrib/contextmenu/browser/contextmenu'; import { SuggestController } from 'vs/editor/contrib/suggest/browser/suggestController'; import { SnippetController2 } from 'vs/editor/contrib/snippet/browser/snippetController2'; @@ -27,6 +28,14 @@ import { ILanguageFeaturesService } from 'vs/editor/common/services/languageFeat import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; import { ICodeEditor } from 'vs/editor/browser/editorBrowser'; import { clamp } from 'vs/base/common/numbers'; +import { CopyPasteController } from 'vs/editor/contrib/dropOrPasteInto/browser/copyPasteController'; +import { CodeActionController } from 'vs/editor/contrib/codeAction/browser/codeActionController'; +import { DropIntoEditorController } from 'vs/editor/contrib/dropOrPasteInto/browser/dropIntoEditorController'; +import { HoverController } from 'vs/editor/contrib/hover/browser/hover'; +import { InlineCompletionsController } from 'vs/editor/contrib/inlineCompletions/browser/inlineCompletionsController'; +import { LinkDetector } from 'vs/editor/contrib/links/browser/links'; +import { MessageController } from 'vs/editor/contrib/message/browser/messageController'; +import { SelectionClipboardContributionID } from 'vs/workbench/contrib/codeEditor/browser/selectionClipboard'; export const ctxCommentEditorFocused = new RawContextKey('commentEditorFocused', false); export const MIN_EDITOR_HEIGHT = 5 * 18; @@ -62,6 +71,17 @@ export class SimpleCommentEditor extends CodeEditorWidget { { id: SuggestController.ID, ctor: SuggestController, instantiation: EditorContributionInstantiation.Eager }, { id: SnippetController2.ID, ctor: SnippetController2, instantiation: EditorContributionInstantiation.Lazy }, { id: TabCompletionController.ID, ctor: TabCompletionController, instantiation: EditorContributionInstantiation.Eager }, // eager because it needs to define a context key + { id: EditorDictation.ID, ctor: EditorDictation, instantiation: EditorContributionInstantiation.Lazy }, + ...EditorExtensionsRegistry.getSomeEditorContributions([ + CopyPasteController.ID, + DropIntoEditorController.ID, + LinkDetector.ID, + MessageController.ID, + HoverController.ID, + SelectionClipboardContributionID, + InlineCompletionsController.ID, + CodeActionController.ID, + ]) ] }; @@ -111,6 +131,7 @@ export class SimpleCommentEditor extends CodeEditorWidget { minimap: { enabled: false }, + dropIntoEditor: { enabled: true }, autoClosingBrackets: configurationService.getValue('editor.autoClosingBrackets'), quickSuggestions: false, accessibilitySupport: configurationService.getValue<'auto' | 'off' | 'on'>('editor.accessibilitySupport'), @@ -121,7 +142,7 @@ export class SimpleCommentEditor extends CodeEditorWidget { export function calculateEditorHeight(parentEditor: LayoutableEditor, editor: ICodeEditor, currentHeight: number): number { const layoutInfo = editor.getLayoutInfo(); const lineHeight = editor.getOption(EditorOption.lineHeight); - const contentHeight = (editor.getModel()?.getLineCount()! * lineHeight) ?? editor.getContentHeight(); // Can't just call getContentHeight() because it returns an incorrect, large, value when the editor is first created. + const contentHeight = (editor._getViewModel()?.getLineCount()! * lineHeight) ?? editor.getContentHeight(); // Can't just call getContentHeight() because it returns an incorrect, large, value when the editor is first created. if ((contentHeight > layoutInfo.height) || (contentHeight < layoutInfo.height && currentHeight > MIN_EDITOR_HEIGHT)) { const linesToAdd = Math.ceil((contentHeight - layoutInfo.height) / lineHeight); diff --git a/src/vs/workbench/contrib/comments/browser/timestamp.ts b/src/vs/workbench/contrib/comments/browser/timestamp.ts index 2b9f79d4a8802..47faa02f1c9ca 100644 --- a/src/vs/workbench/contrib/comments/browser/timestamp.ts +++ b/src/vs/workbench/contrib/comments/browser/timestamp.ts @@ -4,10 +4,13 @@ *--------------------------------------------------------------------------------------------*/ import * as dom from 'vs/base/browser/dom'; +import type { IUpdatableHover } from 'vs/base/browser/ui/hover/hover'; +import { getDefaultHoverDelegate } from 'vs/base/browser/ui/hover/hoverDelegateFactory'; import { fromNow } from 'vs/base/common/date'; import { Disposable } from 'vs/base/common/lifecycle'; import { language } from 'vs/base/common/platform'; import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; +import type { IHoverService } from 'vs/platform/hover/browser/hover'; import { COMMENTS_SECTION, ICommentsConfiguration } from 'vs/workbench/contrib/comments/common/commentsConfiguration'; export class TimestampWidget extends Disposable { @@ -15,11 +18,19 @@ export class TimestampWidget extends Disposable { private _timestamp: Date | undefined; private _useRelativeTime: boolean; - constructor(private configurationService: IConfigurationService, container: HTMLElement, timeStamp?: Date) { + private hover: IUpdatableHover; + + constructor( + private configurationService: IConfigurationService, + hoverService: IHoverService, + container: HTMLElement, + timeStamp?: Date + ) { super(); this._date = dom.append(container, dom.$('span.timestamp')); this._date.style.display = 'none'; this._useRelativeTime = this.useRelativeTimeSetting; + this.hover = this._register(hoverService.setupUpdatableHover(getDefaultHoverDelegate('mouse'), this._date, '')); this.setTimestamp(timeStamp); } @@ -52,9 +63,7 @@ export class TimestampWidget extends Disposable { } this._date.textContent = textContent; - if (tooltip) { - this._date.title = tooltip; - } + this.hover.update(tooltip ?? ''); } } diff --git a/src/vs/workbench/contrib/comments/common/commentModel.ts b/src/vs/workbench/contrib/comments/common/commentModel.ts index 9a6d878637259..fbf25f6c06bf3 100644 --- a/src/vs/workbench/contrib/comments/common/commentModel.ts +++ b/src/vs/workbench/contrib/comments/common/commentModel.ts @@ -5,31 +5,38 @@ import { URI } from 'vs/base/common/uri'; import { IRange } from 'vs/editor/common/core/range'; -import { Comment, CommentThread, CommentThreadChangedEvent, CommentThreadState } from 'vs/editor/common/languages'; +import { Comment, CommentThread, CommentThreadChangedEvent, CommentThreadApplicability, CommentThreadState } from 'vs/editor/common/languages'; export interface ICommentThreadChangedEvent extends CommentThreadChangedEvent { + uniqueOwner: string; owner: string; ownerLabel: string; } export class CommentNode { - owner: string; - threadId: string; - range: IRange | undefined; - comment: Comment; + isRoot: boolean = false; replies: CommentNode[] = []; - resource: URI; - isRoot: boolean; - threadState?: CommentThreadState; + public readonly threadId: string; + public readonly range: IRange | undefined; + public readonly threadState: CommentThreadState | undefined; + public readonly threadRelevance: CommentThreadApplicability | undefined; + public readonly contextValue: string | undefined; + public readonly controllerHandle: number; + public readonly threadHandle: number; - constructor(owner: string, threadId: string, resource: URI, comment: Comment, range: IRange | undefined, threadState: CommentThreadState | undefined) { - this.owner = owner; - this.threadId = threadId; - this.comment = comment; - this.resource = resource; - this.range = range; - this.isRoot = false; - this.threadState = threadState; + constructor( + public readonly uniqueOwner: string, + public readonly owner: string, + public readonly resource: URI, + public readonly comment: Comment, + public readonly thread: CommentThread) { + this.threadId = thread.threadId; + this.range = thread.range; + this.threadState = thread.state; + this.threadRelevance = thread.applicability; + this.contextValue = thread.contextValue; + this.controllerHandle = thread.controllerHandle; + this.threadHandle = thread.commentThreadHandle; } hasReply(): boolean { @@ -39,21 +46,23 @@ export class CommentNode { export class ResourceWithCommentThreads { id: string; + uniqueOwner: string; owner: string; ownerLabel: string | undefined; commentThreads: CommentNode[]; // The top level comments on the file. Replys are nested under each node. resource: URI; - constructor(owner: string, resource: URI, commentThreads: CommentThread[]) { + constructor(uniqueOwner: string, owner: string, resource: URI, commentThreads: CommentThread[]) { + this.uniqueOwner = uniqueOwner; this.owner = owner; this.id = resource.toString(); this.resource = resource; - this.commentThreads = commentThreads.filter(thread => thread.comments && thread.comments.length).map(thread => ResourceWithCommentThreads.createCommentNode(owner, resource, thread)); + this.commentThreads = commentThreads.filter(thread => thread.comments && thread.comments.length).map(thread => ResourceWithCommentThreads.createCommentNode(uniqueOwner, owner, resource, thread)); } - public static createCommentNode(owner: string, resource: URI, commentThread: CommentThread): CommentNode { - const { threadId, comments, range } = commentThread; - const commentNodes: CommentNode[] = comments!.map(comment => new CommentNode(owner, threadId, resource, comment, range, commentThread.state)); + public static createCommentNode(uniqueOwner: string, owner: string, resource: URI, commentThread: CommentThread): CommentNode { + const { comments } = commentThread; + const commentNodes: CommentNode[] = comments!.map(comment => new CommentNode(uniqueOwner, owner, resource, comment, commentThread)); if (commentNodes.length > 1) { commentNodes[0].replies = commentNodes.slice(1, commentNodes.length); } diff --git a/src/vs/workbench/contrib/comments/test/browser/commentsView.test.ts b/src/vs/workbench/contrib/comments/test/browser/commentsView.test.ts index a3e171b9d40da..84e77afe298ca 100644 --- a/src/vs/workbench/contrib/comments/test/browser/commentsView.test.ts +++ b/src/vs/workbench/contrib/comments/test/browser/commentsView.test.ts @@ -19,6 +19,8 @@ import { DisposableStore } from 'vs/base/common/lifecycle'; import { ensureNoDisposablesAreLeakedInTestSuite } from 'vs/base/test/common/utils'; import { CancellationToken } from 'vs/base/common/cancellation'; import { URI, UriComponents } from 'vs/base/common/uri'; +import { IHoverService } from 'vs/platform/hover/browser/hover'; +import { NullHoverService } from 'vs/platform/hover/test/browser/nullHoverService'; class TestCommentThread implements CommentThread { isDocumentCommentThread(): this is CommentThread { @@ -49,6 +51,7 @@ class TestCommentThread implements CommentThread { class TestCommentController implements ICommentController { id: string = 'test'; label: string = 'Test Comments'; + owner: string = 'test'; features = {}; createCommentThreadTemplate(resource: UriComponents, range: IRange | undefined): Promise { throw new Error('Method not implemented.'); @@ -117,6 +120,7 @@ suite('Comments View', function () { disposables = new DisposableStore(); instantiationService = workbenchInstantiationService({}, disposables); instantiationService.stub(IConfigurationService, new TestConfigurationService()); + instantiationService.stub(IHoverService, NullHoverService); instantiationService.stub(IContextViewService, {}); instantiationService.stub(IViewDescriptorService, new TestViewDescriptorService()); commentService = instantiationService.createInstance(CommentService); diff --git a/src/vs/workbench/contrib/customEditor/browser/customEditorInput.ts b/src/vs/workbench/contrib/customEditor/browser/customEditorInput.ts index 4e677142d69a4..214f354088ebb 100644 --- a/src/vs/workbench/contrib/customEditor/browser/customEditorInput.ts +++ b/src/vs/workbench/contrib/customEditor/browser/customEditorInput.ts @@ -3,6 +3,9 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { getWindow } from 'vs/base/browser/dom'; +import { CodeWindow } from 'vs/base/browser/window'; +import { toAction } from 'vs/base/common/actions'; import { VSBuffer } from 'vs/base/common/buffer'; import { IMarkdownString } from 'vs/base/common/htmlContent'; import { IReference } from 'vs/base/common/lifecycle'; @@ -11,18 +14,23 @@ import { basename } from 'vs/base/common/path'; import { dirname, isEqual } from 'vs/base/common/resources'; import { assertIsDefined } from 'vs/base/common/types'; import { URI } from 'vs/base/common/uri'; +import { localize } from 'vs/nls'; +import { IContextKeyService } from 'vs/platform/contextkey/common/contextkey'; import { IFileDialogService } from 'vs/platform/dialogs/common/dialogs'; import { IResourceEditorInput } from 'vs/platform/editor/common/editor'; import { IFileService } from 'vs/platform/files/common/files'; import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; import { ILabelService } from 'vs/platform/label/common/label'; import { IUndoRedoService } from 'vs/platform/undoRedo/common/undoRedo'; -import { EditorInputCapabilities, GroupIdentifier, IMoveResult, IRevertOptions, ISaveOptions, IUntypedEditorInput, Verbosity } from 'vs/workbench/common/editor'; +import { EditorInputCapabilities, GroupIdentifier, IMoveResult, IRevertOptions, ISaveOptions, IUntypedEditorInput, Verbosity, createEditorOpenError } from 'vs/workbench/common/editor'; import { EditorInput } from 'vs/workbench/common/editor/editorInput'; +import { ICustomEditorLabelService } from 'vs/workbench/services/editor/common/customEditorLabelService'; import { ICustomEditorModel, ICustomEditorService } from 'vs/workbench/contrib/customEditor/common/customEditor'; import { IOverlayWebview, IWebviewService } from 'vs/workbench/contrib/webview/browser/webview'; import { IWebviewWorkbenchService, LazilyResolvedWebviewEditorInput } from 'vs/workbench/contrib/webviewPanel/browser/webviewWorkbenchService'; +import { IEditorGroupsService } from 'vs/workbench/services/editor/common/editorGroupsService'; import { IFilesConfigurationService } from 'vs/workbench/services/filesConfiguration/common/filesConfigurationService'; +import { IWorkbenchLayoutService } from 'vs/workbench/services/layout/browser/layoutService'; import { IUntitledTextEditorService } from 'vs/workbench/services/untitled/common/untitledTextEditorService'; interface CustomEditorInputInitInfo { @@ -83,7 +91,10 @@ export class CustomEditorInput extends LazilyResolvedWebviewEditorInput { @IFileDialogService private readonly fileDialogService: IFileDialogService, @IUndoRedoService private readonly undoRedoService: IUndoRedoService, @IFileService private readonly fileService: IFileService, - @IFilesConfigurationService private readonly filesConfigurationService: IFilesConfigurationService + @IFilesConfigurationService private readonly filesConfigurationService: IFilesConfigurationService, + @IEditorGroupsService private readonly editorGroupsService: IEditorGroupsService, + @IWorkbenchLayoutService private readonly layoutService: IWorkbenchLayoutService, + @ICustomEditorLabelService private readonly customEditorLabelService: ICustomEditorLabelService, ) { super({ providedId: init.viewType, viewType: init.viewType, name: '' }, webview, webviewWorkbenchService); this._editorResource = init.resource; @@ -101,6 +112,7 @@ export class CustomEditorInput extends LazilyResolvedWebviewEditorInput { this._register(this.labelService.onDidChangeFormatters(e => this.onLabelEvent(e.scheme))); this._register(this.fileService.onDidChangeFileSystemProviderRegistrations(e => this.onLabelEvent(e.scheme))); this._register(this.fileService.onDidChangeFileSystemProviderCapabilities(e => this.onLabelEvent(e.scheme))); + this._register(this.customEditorLabelService.onDidChange(() => this.updateLabel())); } private onLabelEvent(scheme: string): void { @@ -112,6 +124,7 @@ export class CustomEditorInput extends LazilyResolvedWebviewEditorInput { private updateLabel(): void { // Clear any cached labels from before + this._editorName = undefined; this._shortDescription = undefined; this._mediumDescription = undefined; this._longDescription = undefined; @@ -135,7 +148,6 @@ export class CustomEditorInput extends LazilyResolvedWebviewEditorInput { let capabilities = EditorInputCapabilities.None; capabilities |= EditorInputCapabilities.CanDropIntoEditor; - capabilities |= EditorInputCapabilities.AuxWindowUnsupported; if (!this.customEditorService.getCustomEditorCapabilities(this.viewType)?.supportsMultipleEditorsPerDocument) { capabilities |= EditorInputCapabilities.Singleton; @@ -158,8 +170,13 @@ export class CustomEditorInput extends LazilyResolvedWebviewEditorInput { return capabilities; } + private _editorName: string | undefined = undefined; override getName(): string { - return basename(this.labelService.getUriLabel(this.resource)); + if (typeof this._editorName !== 'string') { + this._editorName = this.customEditorLabelService.getName(this.resource) ?? basename(this.labelService.getUriLabel(this.resource)); + } + + return this._editorName; } override getDescription(verbosity = Verbosity.MEDIUM): string | undefined { @@ -389,4 +406,51 @@ export class CustomEditorInput extends LazilyResolvedWebviewEditorInput { } }; } + + public override claim(claimant: unknown, targetWindow: CodeWindow, scopedContextKeyService: IContextKeyService | undefined): void { + if (this.doCanMove(targetWindow.vscodeWindowId) !== true) { + throw createEditorOpenError(localize('editorUnsupportedInWindow', "Unable to open the editor in this window, it contains modifications that can only be saved in the original window."), [ + toAction({ + id: 'openInOriginalWindow', + label: localize('reopenInOriginalWindow', "Open in Original Window"), + run: async () => { + const originalPart = this.editorGroupsService.getPart(this.layoutService.getContainer(getWindow(this.webview.container).window)); + const currentPart = this.editorGroupsService.getPart(this.layoutService.getContainer(targetWindow.window)); + currentPart.activeGroup.moveEditor(this, originalPart.activeGroup); + } + }) + ], { forceMessage: true }); + } + return super.claim(claimant, targetWindow, scopedContextKeyService); + } + + public override canMove(sourceGroup: GroupIdentifier, targetGroup: GroupIdentifier): true | string { + const resolvedTargetGroup = this.editorGroupsService.getGroup(targetGroup); + if (resolvedTargetGroup) { + const canMove = this.doCanMove(resolvedTargetGroup.windowId); + if (typeof canMove === 'string') { + return canMove; + } + } + + return super.canMove(sourceGroup, targetGroup); + } + + private doCanMove(targetWindowId: number): true | string { + if (this.isModified() && this._modelRef?.object.canHotExit === false) { + const sourceWindowId = getWindow(this.webview.container).vscodeWindowId; + if (sourceWindowId !== targetWindowId) { + + // The custom editor is modified, not backed by a file and without a backup. + // We have to assume that the modified state is enclosed into the webview + // managed by an extension. As such, we cannot just move the webview + // into another window because that means, we potentally loose the modified + // state and thus trigger data loss. + + return localize('editorCannotMove', "Unable to move '{0}': The editor contains changes that can only be saved in its current window.", this.getName()); + } + } + + return true; + } } diff --git a/src/vs/workbench/contrib/customEditor/common/customEditor.ts b/src/vs/workbench/contrib/customEditor/common/customEditor.ts index 40c47ab8a80b9..28efa3bf90565 100644 --- a/src/vs/workbench/contrib/customEditor/common/customEditor.ts +++ b/src/vs/workbench/contrib/customEditor/common/customEditor.ts @@ -57,6 +57,7 @@ export interface ICustomEditorModel extends IDisposable { readonly viewType: string; readonly resource: URI; readonly backupId: string | undefined; + readonly canHotExit: boolean; isReadonly(): boolean | IMarkdownString; readonly onDidChangeReadonly: Event; diff --git a/src/vs/workbench/contrib/customEditor/common/customTextEditorModel.ts b/src/vs/workbench/contrib/customEditor/common/customTextEditorModel.ts index cb0defd952f82..e4aa463c98d1a 100644 --- a/src/vs/workbench/contrib/customEditor/common/customTextEditorModel.ts +++ b/src/vs/workbench/contrib/customEditor/common/customTextEditorModel.ts @@ -72,6 +72,10 @@ export class CustomTextEditorModel extends Disposable implements ICustomEditorMo return undefined; } + public get canHotExit() { + return true; // ensured via backups from text file models + } + public isDirty(): boolean { return this.textFileService.isDirty(this.resource); } diff --git a/src/vs/workbench/contrib/debug/browser/baseDebugView.ts b/src/vs/workbench/contrib/debug/browser/baseDebugView.ts index 4399464dbc05d..3546424023ce5 100644 --- a/src/vs/workbench/contrib/debug/browser/baseDebugView.ts +++ b/src/vs/workbench/contrib/debug/browser/baseDebugView.ts @@ -7,20 +7,25 @@ import * as dom from 'vs/base/browser/dom'; import { IKeyboardEvent } from 'vs/base/browser/keyboardEvent'; import { ActionBar } from 'vs/base/browser/ui/actionbar/actionbar'; import { HighlightedLabel, IHighlight } from 'vs/base/browser/ui/highlightedlabel/highlightedLabel'; +import { getDefaultHoverDelegate } from 'vs/base/browser/ui/hover/hoverDelegateFactory'; import { IInputValidationOptions, InputBox } from 'vs/base/browser/ui/inputbox/inputBox'; -import { ITreeNode, ITreeRenderer } from 'vs/base/browser/ui/tree/tree'; +import { IAsyncDataSource, ITreeNode, ITreeRenderer } from 'vs/base/browser/ui/tree/tree'; import { Codicon } from 'vs/base/common/codicons'; -import { ThemeIcon } from 'vs/base/common/themables'; -import { createMatches, FuzzyScore } from 'vs/base/common/filters'; +import { FuzzyScore, createMatches } from 'vs/base/common/filters'; import { createSingleCallFunction } from 'vs/base/common/functional'; import { KeyCode } from 'vs/base/common/keyCodes'; -import { DisposableStore, dispose, IDisposable, toDisposable } from 'vs/base/common/lifecycle'; +import { DisposableStore, IDisposable, dispose, toDisposable } from 'vs/base/common/lifecycle'; +import { ThemeIcon } from 'vs/base/common/themables'; import { localize } from 'vs/nls'; +import { CommandsRegistry, ICommandService } from 'vs/platform/commands/common/commands'; import { IContextViewService } from 'vs/platform/contextview/browser/contextView'; +import { IHoverService } from 'vs/platform/hover/browser/hover'; import { defaultInputBoxStyles } from 'vs/platform/theme/browser/defaultStyles'; +import { COPY_EVALUATE_PATH_ID, COPY_VALUE_ID } from 'vs/workbench/contrib/debug/browser/debugCommands'; import { LinkDetector } from 'vs/workbench/contrib/debug/browser/linkDetector'; import { IDebugService, IExpression, IExpressionValue } from 'vs/workbench/contrib/debug/common/debug'; import { Expression, ExpressionContainer, Variable } from 'vs/workbench/contrib/debug/common/debugModel'; +import { IDebugVisualizerService } from 'vs/workbench/contrib/debug/common/debugVisualizers'; import { ReplEvaluationResult } from 'vs/workbench/contrib/debug/common/replModel'; const MAX_VALUE_RENDER_LENGTH_IN_VIEWLET = 1024; @@ -31,7 +36,12 @@ const $ = dom.$; export interface IRenderValueOptions { showChanged?: boolean; maxValueLength?: number; - showHover?: boolean; + /** If set, a hover will be shown on the element. Requires a disposable store for usage. */ + hover?: DisposableStore | { + store: DisposableStore; + commands: { id: string; args: unknown[] }[]; + commandService: ICommandService; + }; colorize?: boolean; linkDetector?: LinkDetector; } @@ -51,7 +61,7 @@ export function renderViewTree(container: HTMLElement): HTMLElement { return treeContainer; } -export function renderExpressionValue(expressionOrValue: IExpressionValue | string, container: HTMLElement, options: IRenderValueOptions): void { +export function renderExpressionValue(expressionOrValue: IExpressionValue | string, container: HTMLElement, options: IRenderValueOptions, hoverService: IHoverService): void { let value = typeof expressionOrValue === 'string' ? expressionOrValue : expressionOrValue.value; // remove stale classes @@ -96,12 +106,31 @@ export function renderExpressionValue(expressionOrValue: IExpressionValue | stri } else { container.textContent = value; } - if (options.showHover) { - container.title = value || ''; + + if (options.hover) { + const { store, commands, commandService } = options.hover instanceof DisposableStore ? { store: options.hover, commands: [], commandService: undefined } : options.hover; + store.add(hoverService.setupUpdatableHover(getDefaultHoverDelegate('mouse'), container, () => { + const container = dom.$('div'); + const markdownHoverElement = dom.$('div.hover-row'); + const hoverContentsElement = dom.append(markdownHoverElement, dom.$('div.hover-contents')); + const hoverContentsPre = dom.append(hoverContentsElement, dom.$('pre.debug-var-hover-pre')); + hoverContentsPre.textContent = value; + container.appendChild(markdownHoverElement); + return container; + }, { + actions: commands.map(({ id, args }) => { + const description = CommandsRegistry.getCommand(id)?.metadata?.description; + return { + label: typeof description === 'string' ? description : description ? description.value : id, + commandId: id, + run: () => commandService!.executeCommand(id, ...args), + }; + }) + })); } } -export function renderVariable(variable: Variable, data: IVariableTemplateData, showChanged: boolean, highlights: IHighlight[], linkDetector?: LinkDetector): void { +export function renderVariable(store: DisposableStore, commandService: ICommandService, hoverService: IHoverService, variable: Variable, data: IVariableTemplateData, showChanged: boolean, highlights: IHighlight[], linkDetector?: LinkDetector): void { if (variable.available) { let text = variable.name; if (variable.value && typeof variable.name === 'string') { @@ -115,13 +144,20 @@ export function renderVariable(variable: Variable, data: IVariableTemplateData, } data.expression.classList.toggle('lazy', !!variable.presentationHint?.lazy); + const commands = [ + { id: COPY_VALUE_ID, args: [variable, [variable]] as unknown[] } + ]; + if (variable.evaluateName) { + commands.push({ id: COPY_EVALUATE_PATH_ID, args: [{ variable }] }); + } + renderExpressionValue(variable, data.value, { showChanged, maxValueLength: MAX_VALUE_RENDER_LENGTH_IN_VIEWLET, - showHover: true, + hover: { store, commands, commandService }, colorize: true, linkDetector - }); + }, hoverService); } export interface IInputBoxOptions { @@ -145,29 +181,60 @@ export interface IExpressionTemplateData { currentElement: IExpression | undefined; } +export abstract class AbstractExpressionDataSource implements IAsyncDataSource { + constructor( + @IDebugService protected debugService: IDebugService, + @IDebugVisualizerService protected debugVisualizer: IDebugVisualizerService, + ) { } + + public abstract hasChildren(element: Input | Element): boolean; + + public async getChildren(element: Input | Element): Promise { + const vm = this.debugService.getViewModel(); + const children = await this.doGetChildren(element); + return Promise.all(children.map(async r => { + const vizOrTree = vm.getVisualizedExpression(r as IExpression); + if (typeof vizOrTree === 'string') { + const viz = await this.debugVisualizer.getVisualizedNodeFor(vizOrTree, r); + if (viz) { + vm.setVisualizedExpression(r, viz); + return viz as IExpression as Element; + } + } else if (vizOrTree) { + return vizOrTree as Element; + } + + + return r; + })); + } + + protected abstract doGetChildren(element: Input | Element): Promise; +} + export abstract class AbstractExpressionsRenderer implements ITreeRenderer { constructor( @IDebugService protected debugService: IDebugService, @IContextViewService private readonly contextViewService: IContextViewService, + @IHoverService protected readonly hoverService: IHoverService, ) { } abstract get templateId(): string; renderTemplate(container: HTMLElement): IExpressionTemplateData { + const templateDisposable = new DisposableStore(); const expression = dom.append(container, $('.expression')); const name = dom.append(expression, $('span.name')); const lazyButton = dom.append(expression, $('span.lazy-button')); lazyButton.classList.add(...ThemeIcon.asClassNameArray(Codicon.eye)); - lazyButton.title = localize('debug.lazyButton.tooltip', "Click to expand"); + templateDisposable.add(this.hoverService.setupUpdatableHover(getDefaultHoverDelegate('mouse'), lazyButton, localize('debug.lazyButton.tooltip', "Click to expand"))); const value = dom.append(expression, $('span.value')); - const label = new HighlightedLabel(name); + const label = templateDisposable.add(new HighlightedLabel(name)); const inputBoxContainer = dom.append(expression, $('.inputBoxContainer')); - const templateDisposable = new DisposableStore(); - let actionBar: ActionBar | undefined; if (this.renderActionBar) { dom.append(expression, $('.span.actionbar-spacer')); diff --git a/src/vs/workbench/contrib/debug/browser/breakpointEditorContribution.ts b/src/vs/workbench/contrib/debug/browser/breakpointEditorContribution.ts index 1c36e83c55cdc..1b53d40047f18 100644 --- a/src/vs/workbench/contrib/debug/browser/breakpointEditorContribution.ts +++ b/src/vs/workbench/contrib/debug/browser/breakpointEditorContribution.ts @@ -54,7 +54,7 @@ const breakpointHelperDecoration: IModelDecorationOptions = { description: 'breakpoint-helper-decoration', glyphMarginClassName: ThemeIcon.asClassName(icons.debugBreakpointHint), glyphMargin: { position: GlyphMarginLane.Right }, - glyphMarginHoverMessage: new MarkdownString().appendText(nls.localize('breakpointHelper', "Click to add a breakpoint.")), + glyphMarginHoverMessage: new MarkdownString().appendText(nls.localize('breakpointHelper', "Click to add a breakpoint")), stickiness: TrackedRangeStickiness.NeverGrowsWhenTypingAtEdges }; @@ -327,7 +327,25 @@ export class BreakpointEditorContribution implements IBreakpointEditorContributi } } } else if (canSetBreakpoints) { - this.debugService.addBreakpoints(uri, [{ lineNumber }]); + if (e.event.middleButton) { + const action = this.configurationService.getValue('debug').gutterMiddleClickAction; + if (action !== 'none') { + let context: BreakpointWidgetContext; + switch (action) { + case 'logpoint': + context = BreakpointWidgetContext.LOG_MESSAGE; + break; + case 'conditionalBreakpoint': + context = BreakpointWidgetContext.CONDITION; + break; + case 'triggeredBreakpoint': + context = BreakpointWidgetContext.TRIGGER_POINT; + } + this.showBreakpointWidget(lineNumber, undefined, context); + } + } else { + this.debugService.addBreakpoints(uri, [{ lineNumber }]); + } } } })); diff --git a/src/vs/workbench/contrib/debug/browser/breakpointWidget.ts b/src/vs/workbench/contrib/debug/browser/breakpointWidget.ts index 877c0921a4c71..59a3a4dd4bfa1 100644 --- a/src/vs/workbench/contrib/debug/browser/breakpointWidget.ts +++ b/src/vs/workbench/contrib/debug/browser/breakpointWidget.ts @@ -16,7 +16,7 @@ import 'vs/css!./media/breakpointWidget'; import { IActiveCodeEditor, ICodeEditor } from 'vs/editor/browser/editorBrowser'; import { EditorCommand, ServicesAccessor, registerEditorCommand } from 'vs/editor/browser/editorExtensions'; import { ICodeEditorService } from 'vs/editor/browser/services/codeEditorService'; -import { CodeEditorWidget } from 'vs/editor/browser/widget/codeEditorWidget'; +import { CodeEditorWidget } from 'vs/editor/browser/widget/codeEditor/codeEditorWidget'; import { EditorOption, IEditorOptions } from 'vs/editor/common/config/editorOptions'; import { IPosition, Position } from 'vs/editor/common/core/position'; import { IRange, Range } from 'vs/editor/common/core/range'; @@ -85,10 +85,12 @@ export class BreakpointWidget extends ZoneWidget implements IPrivateBreakpointWi private selectBreakpointContainer!: HTMLElement; private input!: IActiveCodeEditor; private selectBreakpointBox!: SelectBox; + private selectModeBox?: SelectBox; private toDispose: lifecycle.IDisposable[]; private conditionInput = ''; private hitCountInput = ''; private logMessageInput = ''; + private modeInput?: DebugProtocol.BreakpointMode; private breakpoint: IBreakpoint | undefined; private context: Context; private heightInPx: number | undefined; @@ -216,6 +218,8 @@ export class BreakpointWidget extends ZoneWidget implements IPrivateBreakpointWi this.updateContextInput(); }); + this.createModesInput(container); + this.inputContainer = $('.inputContainer'); this.createBreakpointInput(dom.append(container, this.inputContainer)); @@ -232,32 +236,57 @@ export class BreakpointWidget extends ZoneWidget implements IPrivateBreakpointWi setTimeout(() => this.focusInput(), 150); } + private createModesInput(container: HTMLElement) { + const modes = this.debugService.getModel().getBreakpointModes('source'); + if (modes.length <= 1) { + return; + } + + const sb = this.selectModeBox = new SelectBox( + [ + { text: nls.localize('bpMode', 'Mode'), isDisabled: true }, + ...modes.map(mode => ({ text: mode.label, description: mode.description })), + ], + modes.findIndex(m => m.mode === this.breakpoint?.mode) + 1, + this.contextViewService, + defaultSelectBoxStyles, + ); + this.toDispose.push(sb); + this.toDispose.push(sb.onDidSelect(e => { + this.modeInput = modes[e.index - 1]; + })); + + const modeWrapper = $('.select-mode-container'); + const selectionWrapper = $('.select-box-container'); + dom.append(modeWrapper, selectionWrapper); + sb.render(selectionWrapper); + dom.append(container, modeWrapper); + } + private createTriggerBreakpointInput(container: HTMLElement) { const breakpoints = this.debugService.getModel().getBreakpoints().filter(bp => bp !== this.breakpoint); + const breakpointOptions: ISelectOptionItem[] = [ + { text: nls.localize('noTriggerByBreakpoint', 'None'), isDisabled: true }, + ...breakpoints.map(bp => ({ + text: `${this.labelService.getUriLabel(bp.uri, { relative: true })}: ${bp.lineNumber}`, + description: nls.localize('triggerByLoading', 'Loading...') + })), + ]; const index = breakpoints.findIndex((bp) => this.breakpoint?.triggeredBy === bp.getId()); - let select = 0; - if (index > -1) { - select = index + 1; - } - - Promise.all(breakpoints.map(async (bp): Promise => ({ - text: `${this.labelService.getUriLabel(bp.uri, { relative: true })}: ${bp.lineNumber}`, - description: await this.textModelService.createModelReference(bp.uri).then(ref => { + for (const [i, bp] of breakpoints.entries()) { + this.textModelService.createModelReference(bp.uri).then(ref => { try { - return ref.object.textEditorModel.getLineContent(bp.lineNumber).trim(); + breakpointOptions[i + 1].description = ref.object.textEditorModel.getLineContent(bp.lineNumber).trim(); } finally { ref.dispose(); } - }, () => undefined), - }))).then(breakpoints => { - selectBreakpointBox.setOptions([ - { text: nls.localize('noTriggerByBreakpoint', 'None') }, - ...breakpoints - ], select); - }); + }).catch(() => { + breakpointOptions[i + 1].description = nls.localize('noBpSource', 'Could not load source.'); + }); + } - const selectBreakpointBox = this.selectBreakpointBox = new SelectBox([{ text: nls.localize('triggerByLoading', 'Loading...'), isDisabled: true }], 0, this.contextViewService, defaultSelectBoxStyles, { ariaLabel: nls.localize('selectBreakpoint', 'Select breakpoint') }); + const selectBreakpointBox = this.selectBreakpointBox = new SelectBox(breakpointOptions, index + 1, this.contextViewService, defaultSelectBoxStyles, { ariaLabel: nls.localize('selectBreakpoint', 'Select breakpoint') }); selectBreakpointBox.onDidSelect(e => { if (e.index === 0) { this.triggeredByBreakpointInput = undefined; @@ -404,10 +433,12 @@ export class BreakpointWidget extends ZoneWidget implements IPrivateBreakpointWi if (success) { // if there is already a breakpoint on this location - remove it. - let condition = this.breakpoint && this.breakpoint.condition; - let hitCondition = this.breakpoint && this.breakpoint.hitCondition; - let logMessage = this.breakpoint && this.breakpoint.logMessage; - let triggeredBy = this.breakpoint && this.breakpoint.triggeredBy; + let condition = this.breakpoint?.condition; + let hitCondition = this.breakpoint?.hitCondition; + let logMessage = this.breakpoint?.logMessage; + let triggeredBy = this.breakpoint?.triggeredBy; + let mode = this.breakpoint?.mode; + let modeLabel = this.breakpoint?.modeLabel; this.rememberInput(); @@ -420,6 +451,10 @@ export class BreakpointWidget extends ZoneWidget implements IPrivateBreakpointWi if (this.logMessageInput || this.context === Context.LOG_MESSAGE) { logMessage = this.logMessageInput; } + if (this.selectModeBox) { + mode = this.modeInput?.mode; + modeLabel = this.modeInput?.label; + } if (this.context === Context.TRIGGER_POINT) { // currently, trigger points don't support additional conditions: condition = undefined; @@ -434,7 +469,9 @@ export class BreakpointWidget extends ZoneWidget implements IPrivateBreakpointWi condition, hitCondition, logMessage, - triggeredBy + triggeredBy, + mode, + modeLabel, }); this.debugService.updateBreakpoints(this.breakpoint.originalUri, data, false).then(undefined, onUnexpectedError); } else { @@ -447,7 +484,9 @@ export class BreakpointWidget extends ZoneWidget implements IPrivateBreakpointWi condition, hitCondition, logMessage, - triggeredBy + triggeredBy, + mode, + modeLabel, }]); } } diff --git a/src/vs/workbench/contrib/debug/browser/breakpointsView.ts b/src/vs/workbench/contrib/debug/browser/breakpointsView.ts index 5222b57d2e748..8b989fb46f485 100644 --- a/src/vs/workbench/contrib/debug/browser/breakpointsView.ts +++ b/src/vs/workbench/contrib/debug/browser/breakpointsView.ts @@ -8,6 +8,7 @@ import { IKeyboardEvent } from 'vs/base/browser/keyboardEvent'; import { Gesture } from 'vs/base/browser/touch'; import { ActionBar } from 'vs/base/browser/ui/actionbar/actionbar'; import { AriaRole } from 'vs/base/browser/ui/aria/aria'; +import { getDefaultHoverDelegate } from 'vs/base/browser/ui/hover/hoverDelegateFactory'; import { IconLabel } from 'vs/base/browser/ui/iconLabel/iconLabel'; import { InputBox } from 'vs/base/browser/ui/inputbox/inputBox'; import { IListContextMenuEvent, IListRenderer, IListVirtualDelegate } from 'vs/base/browser/ui/list/list'; @@ -39,6 +40,7 @@ import { IKeybindingService } from 'vs/platform/keybinding/common/keybinding'; import { ILabelService } from 'vs/platform/label/common/label'; import { WorkbenchList } from 'vs/platform/list/browser/listService'; import { IOpenerService } from 'vs/platform/opener/common/opener'; +import { IQuickInputService } from 'vs/platform/quickinput/common/quickInput'; import { ITelemetryService } from 'vs/platform/telemetry/common/telemetry'; import { defaultInputBoxStyles } from 'vs/platform/theme/browser/defaultStyles'; import { IThemeService } from 'vs/platform/theme/common/themeService'; @@ -48,10 +50,11 @@ import { IEditorPane } from 'vs/workbench/common/editor'; import { IViewDescriptorService } from 'vs/workbench/common/views'; import * as icons from 'vs/workbench/contrib/debug/browser/debugIcons'; import { DisassemblyView } from 'vs/workbench/contrib/debug/browser/disassemblyView'; -import { BREAKPOINTS_VIEW_ID, BREAKPOINT_EDITOR_CONTRIBUTION_ID, CONTEXT_BREAKPOINTS_EXIST, CONTEXT_BREAKPOINTS_FOCUSED, CONTEXT_BREAKPOINT_INPUT_FOCUSED, CONTEXT_BREAKPOINT_ITEM_TYPE, CONTEXT_BREAKPOINT_SUPPORTS_CONDITION, CONTEXT_DEBUGGERS_AVAILABLE, CONTEXT_IN_DEBUG_MODE, DEBUG_SCHEME, DebuggerString, IBaseBreakpoint, IBreakpoint, IBreakpointEditorContribution, IDataBreakpoint, IDebugModel, IDebugService, IEnablement, IExceptionBreakpoint, IFunctionBreakpoint, IInstructionBreakpoint, State } from 'vs/workbench/contrib/debug/common/debug'; +import { BREAKPOINTS_VIEW_ID, BREAKPOINT_EDITOR_CONTRIBUTION_ID, CONTEXT_BREAKPOINTS_EXIST, CONTEXT_BREAKPOINTS_FOCUSED, CONTEXT_BREAKPOINT_HAS_MODES, CONTEXT_BREAKPOINT_INPUT_FOCUSED, CONTEXT_BREAKPOINT_ITEM_IS_DATA_BYTES, CONTEXT_BREAKPOINT_ITEM_TYPE, CONTEXT_BREAKPOINT_SUPPORTS_CONDITION, CONTEXT_DEBUGGERS_AVAILABLE, CONTEXT_IN_DEBUG_MODE, CONTEXT_SET_DATA_BREAKPOINT_BYTES_SUPPORTED, DEBUG_SCHEME, DataBreakpointSetType, DataBreakpointSource, DebuggerString, IBaseBreakpoint, IBreakpoint, IBreakpointEditorContribution, IBreakpointUpdateData, IDataBreakpoint, IDataBreakpointInfoResponse, IDebugModel, IDebugService, IEnablement, IExceptionBreakpoint, IFunctionBreakpoint, IInstructionBreakpoint, State } from 'vs/workbench/contrib/debug/common/debug'; import { Breakpoint, DataBreakpoint, ExceptionBreakpoint, FunctionBreakpoint, InstructionBreakpoint } from 'vs/workbench/contrib/debug/common/debugModel'; import { DisassemblyViewInput } from 'vs/workbench/contrib/debug/common/disassemblyViewInput'; import { ACTIVE_GROUP, IEditorService, SIDE_GROUP } from 'vs/workbench/services/editor/common/editorService'; +import { INotificationService } from 'vs/platform/notification/common/notification'; const $ = dom.$; @@ -84,6 +87,8 @@ export class BreakpointsView extends ViewPane { private ignoreLayout = false; private menu: IMenu; private breakpointItemType: IContextKey; + private breakpointIsDataBytes: IContextKey; + private breakpointHasMultipleModes: IContextKey; private breakpointSupportsCondition: IContextKey; private _inputBoxData: InputBoxData | undefined; breakpointInputFocused: IContextKey; @@ -108,14 +113,16 @@ export class BreakpointsView extends ViewPane { @ITelemetryService telemetryService: ITelemetryService, @ILabelService private readonly labelService: ILabelService, @IMenuService menuService: IMenuService, - @IHoverService private readonly hoverService: IHoverService, + @IHoverService hoverService: IHoverService, @ILanguageService private readonly languageService: ILanguageService, ) { - super(options, keybindingService, contextMenuService, configurationService, contextKeyService, viewDescriptorService, instantiationService, openerService, themeService, telemetryService); + super(options, keybindingService, contextMenuService, configurationService, contextKeyService, viewDescriptorService, instantiationService, openerService, themeService, telemetryService, hoverService); this.menu = menuService.createMenu(MenuId.DebugBreakpointsContext, contextKeyService); this._register(this.menu); this.breakpointItemType = CONTEXT_BREAKPOINT_ITEM_TYPE.bindTo(contextKeyService); + this.breakpointIsDataBytes = CONTEXT_BREAKPOINT_ITEM_IS_DATA_BYTES.bindTo(contextKeyService); + this.breakpointHasMultipleModes = CONTEXT_BREAKPOINT_HAS_MODES.bindTo(contextKeyService); this.breakpointSupportsCondition = CONTEXT_BREAKPOINT_SUPPORTS_CONDITION.bindTo(contextKeyService); this.breakpointInputFocused = CONTEXT_BREAKPOINT_INPUT_FOCUSED.bindTo(contextKeyService); this._register(this.debugService.getModel().onDidChangeBreakpoints(() => this.onBreakpointsChange())); @@ -132,22 +139,20 @@ export class BreakpointsView extends ViewPane { const delegate = new BreakpointsDelegate(this); this.list = this.instantiationService.createInstance(WorkbenchList, 'Breakpoints', container, delegate, [ - this.instantiationService.createInstance(BreakpointsRenderer, this.menu, this.breakpointSupportsCondition, this.breakpointItemType), - new ExceptionBreakpointsRenderer(this.menu, this.breakpointSupportsCondition, this.breakpointItemType, this.debugService), + this.instantiationService.createInstance(BreakpointsRenderer, this.menu, this.breakpointHasMultipleModes, this.breakpointSupportsCondition, this.breakpointItemType), + new ExceptionBreakpointsRenderer(this.menu, this.breakpointHasMultipleModes, this.breakpointSupportsCondition, this.breakpointItemType, this.debugService, this.hoverService), new ExceptionBreakpointInputRenderer(this, this.debugService, this.contextViewService), this.instantiationService.createInstance(FunctionBreakpointsRenderer, this.menu, this.breakpointSupportsCondition, this.breakpointItemType), - new FunctionBreakpointInputRenderer(this, this.debugService, this.contextViewService, this.labelService), - this.instantiationService.createInstance(DataBreakpointsRenderer, this.menu, this.breakpointSupportsCondition, this.breakpointItemType), - new DataBreakpointInputRenderer(this, this.debugService, this.contextViewService, this.labelService), + new FunctionBreakpointInputRenderer(this, this.debugService, this.contextViewService, this.hoverService, this.labelService), + this.instantiationService.createInstance(DataBreakpointsRenderer, this.menu, this.breakpointHasMultipleModes, this.breakpointSupportsCondition, this.breakpointItemType, this.breakpointIsDataBytes), + new DataBreakpointInputRenderer(this, this.debugService, this.contextViewService, this.hoverService, this.labelService), this.instantiationService.createInstance(InstructionBreakpointsRenderer), ], { identityProvider: { getId: (element: IEnablement) => element.getId() }, multipleSelectionSupport: false, keyboardNavigationLabelProvider: { getKeyboardNavigationLabel: (e: IEnablement) => e }, accessibilityProvider: new BreakpointsAccessibilityProvider(this.debugService, this.labelService), - overrideStyles: { - listBackground: this.getBackgroundColor() - } + overrideStyles: this.getLocationBasedColors().listOverrideStyles }) as WorkbenchList; CONTEXT_BREAKPOINTS_FOCUSED.bindTo(this.list.contextKeyService); @@ -261,6 +266,7 @@ export class BreakpointsView extends ViewPane { const session = this.debugService.getViewModel().focusedSession; const conditionSupported = element instanceof ExceptionBreakpoint ? element.supportsCondition : (!session || !!session.capabilities.supportsConditionalBreakpoints); this.breakpointSupportsCondition.set(conditionSupported); + this.breakpointIsDataBytes.set(element instanceof DataBreakpoint && element.src.type === DataBreakpointSetType.Address); const secondary: IAction[] = []; createAndFillInContextMenuActions(this.menu, { arg: e.element, shouldForwardArgs: false }, { primary: [], secondary }, 'inline'); @@ -426,6 +432,7 @@ interface IBaseBreakpointTemplateData { context: BreakpointItem; actionBar: ActionBar; toDispose: IDisposable[]; + badge: HTMLElement; } interface IBaseBreakpointWithIconTemplateData extends IBaseBreakpointTemplateData { @@ -433,7 +440,6 @@ interface IBaseBreakpointWithIconTemplateData extends IBaseBreakpointTemplateDat } interface IBreakpointTemplateData extends IBaseBreakpointWithIconTemplateData { - lineNumber: HTMLElement; filePath: HTMLElement; } @@ -486,9 +492,11 @@ class BreakpointsRenderer implements IListRenderer, private breakpointSupportsCondition: IContextKey, private breakpointItemType: IContextKey, @IDebugService private readonly debugService: IDebugService, + @IHoverService private readonly hoverService: IHoverService, @ILabelService private readonly labelService: ILabelService ) { // noop @@ -521,8 +529,8 @@ class BreakpointsRenderer implements IListRenderer 1); createAndFillInActionBarActions(this.menu, { arg: breakpoint, shouldForwardArgs: true }, { primary, secondary: [] }, 'inline'); data.actionBar.clear(); data.actionBar.push(primary, { icon: true, label: false }); @@ -567,9 +580,11 @@ class ExceptionBreakpointsRenderer implements IListRenderer, private breakpointSupportsCondition: IContextKey, private breakpointItemType: IContextKey, - private debugService: IDebugService + private debugService: IDebugService, + private readonly hoverService: IHoverService, ) { // noop } @@ -598,21 +613,33 @@ class ExceptionBreakpointsRenderer implements IListRenderer 1); createAndFillInActionBarActions(this.menu, { arg: exceptionBreakpoint, shouldForwardArgs: true }, { primary, secondary: [] }, 'inline'); data.actionBar.clear(); data.actionBar.push(primary, { icon: true, label: false }); @@ -631,6 +658,7 @@ class FunctionBreakpointsRenderer implements IListRenderer, private breakpointItemType: IContextKey, @IDebugService private readonly debugService: IDebugService, + @IHoverService private readonly hoverService: IHoverService, @ILabelService private readonly labelService: ILabelService ) { // noop @@ -661,6 +689,8 @@ class FunctionBreakpointsRenderer implements IListRenderer, private breakpointSupportsCondition: IContextKey, private breakpointItemType: IContextKey, + private breakpointIsDataBytes: IContextKey, @IDebugService private readonly debugService: IDebugService, + @IHoverService private readonly hoverService: IHoverService, @ILabelService private readonly labelService: ILabelService ) { // noop @@ -738,6 +778,8 @@ class DataBreakpointsRenderer implements IListRenderer 1); this.breakpointItemType.set('dataBreakpoint'); + this.breakpointIsDataBytes.set(dataBreakpoint.src.type === DataBreakpointSetType.Address); createAndFillInActionBarActions(this.menu, { arg: dataBreakpoint, shouldForwardArgs: true }, { primary, secondary: [] }, 'inline'); data.actionBar.clear(); data.actionBar.push(primary, { icon: true, label: false }); breakpointIdToActionBarDomeNode.set(dataBreakpoint.getId(), data.actionBar.domNode); + this.breakpointIsDataBytes.reset(); } disposeTemplate(templateData: IBaseBreakpointWithIconTemplateData): void { @@ -787,6 +839,7 @@ class InstructionBreakpointsRenderer implements IListRenderer { + const debugService = accessor.get(IDebugService); + const session = debugService.getViewModel().focusedSession; + if (!session) { + return; + } + + let defaultValue = undefined; + if (existingBreakpoint && existingBreakpoint.src.type === DataBreakpointSetType.Address) { + defaultValue = `${existingBreakpoint.src.address} + ${existingBreakpoint.src.bytes}`; + } + + const quickInput = accessor.get(IQuickInputService); + const notifications = accessor.get(INotificationService); + const range = await this.getRange(quickInput, defaultValue); + if (!range) { + return; + } + + let info: IDataBreakpointInfoResponse | undefined; + try { + info = await session.dataBytesBreakpointInfo(range.address, range.bytes); + } catch (e) { + notifications.error(localize('dataBreakpointError', "Failed to set data breakpoint at {0}: {1}", range.address, e.message)); + } + + if (!info?.dataId) { + return; + } + + let accessType: DebugProtocol.DataBreakpointAccessType = 'write'; + if (info.accessTypes && info.accessTypes?.length > 1) { + const accessTypes = info.accessTypes.map(type => ({ label: type })); + const selectedAccessType = await quickInput.pick(accessTypes, { placeHolder: localize('dataBreakpointAccessType', "Select the access type to monitor") }); + if (!selectedAccessType) { + return; + } + + accessType = selectedAccessType.label; + } + + const src: DataBreakpointSource = { type: DataBreakpointSetType.Address, ...range }; + if (existingBreakpoint) { + await debugService.removeDataBreakpoints(existingBreakpoint.getId()); + } + + await debugService.addDataBreakpoint({ + description: info.description, + src, + canPersist: true, + accessTypes: info.accessTypes, + accessType: accessType, + initialSessionData: { session, dataId: info.dataId } + }); + } + + private getRange(quickInput: IQuickInputService, defaultValue?: string) { + return new Promise<{ address: string; bytes: number } | undefined>(resolve => { + const input = quickInput.createInputBox(); + input.prompt = localize('dataBreakpointMemoryRangePrompt', "Enter a memory range in which to break"); + input.placeholder = localize('dataBreakpointMemoryRangePlaceholder', 'Absolute range (0x1234 - 0x1300) or range of bytes after an address (0x1234 + 0xff)'); + if (defaultValue) { + input.value = defaultValue; + input.valueSelection = [0, defaultValue.length]; + } + input.onDidChangeValue(e => { + const err = this.parseAddress(e, false); + input.validationMessage = err?.error; + }); + input.onDidAccept(() => { + const r = this.parseAddress(input.value, true); + if ('error' in r) { + input.validationMessage = r.error; + } else { + resolve(r); + } + input.dispose(); + }); + input.onDidHide(() => { + resolve(undefined); + input.dispose(); + }); + input.ignoreFocusOut = true; + input.show(); + }); + } + + private parseAddress(range: string, isFinal: false): { error: string } | undefined; + private parseAddress(range: string, isFinal: true): { error: string } | { address: string; bytes: number }; + private parseAddress(range: string, isFinal: boolean): { error: string } | { address: string; bytes: number } | undefined { + const parts = /^(\S+)\s*(?:([+-])\s*(\S+))?/.exec(range); + if (!parts) { + return { error: localize('dataBreakpointAddrFormat', 'Address should be a range of numbers the form "[Start] - [End]" or "[Start] + [Bytes]"') }; + } + + const isNum = (e: string) => isFinal ? /^0x[0-9a-f]*|[0-9]*$/i.test(e) : /^0x[0-9a-f]+|[0-9]+$/i.test(e); + const [, startStr, sign = '+', endStr = '1'] = parts; + + for (const n of [startStr, endStr]) { + if (!isNum(n)) { + return { error: localize('dataBreakpointAddrStartEnd', 'Number must be a decimal integer or hex value starting with \"0x\", got {0}', n) }; + } + } + + if (!isFinal) { + return; + } + + const start = BigInt(startStr); + const end = BigInt(endStr); + const address = `0x${start.toString(16)}`; + if (sign === '-') { + return { address, bytes: Number(start - end) }; + } + + return { address, bytes: Number(end) }; + } +} + +registerAction2(class extends MemoryBreakpointAction { + constructor() { + super({ + id: 'workbench.debug.viewlet.action.addDataBreakpointOnAddress', + title: { + ...localize2('addDataBreakpointOnAddress', "Add Data Breakpoint at Address"), + mnemonicTitle: localize({ key: 'miDataBreakpoint', comment: ['&& denotes a mnemonic'] }, "&&Data Breakpoint..."), + }, + f1: true, + icon: icons.watchExpressionsAddDataBreakpoint, + menu: [{ + id: MenuId.ViewTitle, + group: 'navigation', + order: 11, + when: ContextKeyExpr.and(CONTEXT_SET_DATA_BREAKPOINT_BYTES_SUPPORTED, ContextKeyExpr.equals('view', BREAKPOINTS_VIEW_ID)) + }, { + id: MenuId.MenubarNewBreakpointMenu, + group: '1_breakpoints', + order: 4, + when: CONTEXT_SET_DATA_BREAKPOINT_BYTES_SUPPORTED + }] + }); + } +}); + +registerAction2(class extends MemoryBreakpointAction { + constructor() { + super({ + id: 'workbench.debug.viewlet.action.editDataBreakpointOnAddress', + title: localize2('editDataBreakpointOnAddress', "Edit Address..."), + menu: [{ + id: MenuId.DebugBreakpointsContext, + when: ContextKeyExpr.and(CONTEXT_SET_DATA_BREAKPOINT_BYTES_SUPPORTED, CONTEXT_BREAKPOINT_ITEM_IS_DATA_BYTES), + group: 'navigation', + order: 15, + }] + }); + } +}); + registerAction2(class extends Action2 { constructor() { super({ @@ -1636,3 +1860,49 @@ registerAction2(class extends ViewAction { view.renderInputBox({ breakpoint, type: 'hitCount' }); } }); + +registerAction2(class extends ViewAction { + constructor() { + super({ + id: 'debug.editBreakpointMode', + viewId: BREAKPOINTS_VIEW_ID, + title: localize('editMode', "Edit Mode..."), + menu: [{ + id: MenuId.DebugBreakpointsContext, + group: 'navigation', + order: 20, + when: ContextKeyExpr.and( + CONTEXT_BREAKPOINT_HAS_MODES, + ContextKeyExpr.or(CONTEXT_BREAKPOINT_ITEM_TYPE.isEqualTo('breakpoint'), CONTEXT_BREAKPOINT_ITEM_TYPE.isEqualTo('exceptionBreakpoint'), CONTEXT_BREAKPOINT_ITEM_TYPE.isEqualTo('instructionBreakpoint')) + ) + }] + }); + } + + async runInView(accessor: ServicesAccessor, view: BreakpointsView, breakpoint: IBreakpoint) { + const kind = breakpoint instanceof Breakpoint ? 'source' : breakpoint instanceof InstructionBreakpoint ? 'instruction' : 'exception'; + const debugService = accessor.get(IDebugService); + const modes = debugService.getModel().getBreakpointModes(kind); + const picked = await accessor.get(IQuickInputService).pick( + modes.map(mode => ({ label: mode.label, description: mode.description, mode: mode.mode })), + { placeHolder: localize('selectBreakpointMode', "Select Breakpoint Mode") } + ); + + if (!picked) { + return; + } + + if (kind === 'source') { + const data = new Map(); + data.set(breakpoint.getId(), { mode: picked.mode, modeLabel: picked.label }); + debugService.updateBreakpoints(breakpoint.originalUri, data, false); + } else if (breakpoint instanceof InstructionBreakpoint) { + debugService.removeInstructionBreakpoints(breakpoint.instructionReference, breakpoint.offset); + debugService.addInstructionBreakpoint({ ...breakpoint.toJSON(), mode: picked.mode, modeLabel: picked.label }); + } else if (breakpoint instanceof ExceptionBreakpoint) { + breakpoint.mode = picked.mode; + breakpoint.modeLabel = picked.label; + debugService.setExceptionBreakpointCondition(breakpoint, breakpoint.condition); // no-op to trigger a re-send + } + } +}); diff --git a/src/vs/workbench/contrib/debug/browser/callStackView.ts b/src/vs/workbench/contrib/debug/browser/callStackView.ts index c930fbdf0e29d..80bdc5ffbd85e 100644 --- a/src/vs/workbench/contrib/debug/browser/callStackView.ts +++ b/src/vs/workbench/contrib/debug/browser/callStackView.ts @@ -45,9 +45,12 @@ import { renderViewTree } from 'vs/workbench/contrib/debug/browser/baseDebugView import { CONTINUE_ID, CONTINUE_LABEL, DISCONNECT_ID, DISCONNECT_LABEL, PAUSE_ID, PAUSE_LABEL, RESTART_LABEL, RESTART_SESSION_ID, STEP_INTO_ID, STEP_INTO_LABEL, STEP_OUT_ID, STEP_OUT_LABEL, STEP_OVER_ID, STEP_OVER_LABEL, STOP_ID, STOP_LABEL } from 'vs/workbench/contrib/debug/browser/debugCommands'; import * as icons from 'vs/workbench/contrib/debug/browser/debugIcons'; import { createDisconnectMenuItemAction } from 'vs/workbench/contrib/debug/browser/debugToolBar'; -import { CALLSTACK_VIEW_ID, CONTEXT_CALLSTACK_ITEM_STOPPED, CONTEXT_CALLSTACK_ITEM_TYPE, CONTEXT_CALLSTACK_SESSION_HAS_ONE_THREAD, CONTEXT_CALLSTACK_SESSION_IS_ATTACH, CONTEXT_DEBUG_STATE, CONTEXT_FOCUSED_SESSION_IS_NO_DEBUG, CONTEXT_STACK_FRAME_SUPPORTS_RESTART, getStateLabel, IDebugModel, IDebugService, IDebugSession, IRawStoppedDetails, IStackFrame, IThread, State } from 'vs/workbench/contrib/debug/common/debug'; +import { CALLSTACK_VIEW_ID, CONTEXT_CALLSTACK_ITEM_STOPPED, CONTEXT_CALLSTACK_ITEM_TYPE, CONTEXT_CALLSTACK_SESSION_HAS_ONE_THREAD, CONTEXT_CALLSTACK_SESSION_IS_ATTACH, CONTEXT_DEBUG_STATE, CONTEXT_FOCUSED_SESSION_IS_NO_DEBUG, CONTEXT_STACK_FRAME_SUPPORTS_RESTART, getStateLabel, IDebugModel, IDebugService, IDebugSession, IRawStoppedDetails, isFrameDeemphasized, IStackFrame, IThread, State } from 'vs/workbench/contrib/debug/common/debug'; import { StackFrame, Thread, ThreadAndSessionIds } from 'vs/workbench/contrib/debug/common/debugModel'; import { isSessionAttach } from 'vs/workbench/contrib/debug/common/debugUtils'; +import { getDefaultHoverDelegate } from 'vs/base/browser/ui/hover/hoverDelegateFactory'; +import type { IUpdatableHover } from 'vs/base/browser/ui/hover/hover'; +import { IHoverService } from 'vs/platform/hover/browser/hover'; const $ = dom.$; @@ -133,6 +136,7 @@ async function expandTo(session: IDebugSession, tree: WorkbenchCompressibleAsync export class CallStackView extends ViewPane { private stateMessage!: HTMLSpanElement; private stateMessageLabel!: HTMLSpanElement; + private stateMessageLabelHover!: IUpdatableHover; private onCallStackChangeScheduler: RunOnceScheduler; private needsRefresh = false; private ignoreSelectionChangedEvent = false; @@ -155,9 +159,10 @@ export class CallStackView extends ViewPane { @IOpenerService openerService: IOpenerService, @IThemeService themeService: IThemeService, @ITelemetryService telemetryService: ITelemetryService, + @IHoverService hoverService: IHoverService, @IMenuService private readonly menuService: IMenuService, ) { - super(options, keybindingService, contextMenuService, configurationService, contextKeyService, viewDescriptorService, instantiationService, openerService, themeService, telemetryService); + super(options, keybindingService, contextMenuService, configurationService, contextKeyService, viewDescriptorService, instantiationService, openerService, themeService, telemetryService, hoverService); // Create scheduler to prevent unnecessary flashing of tree when reacting to changes this.onCallStackChangeScheduler = this._register(new RunOnceScheduler(async () => { @@ -172,12 +177,12 @@ export class CallStackView extends ViewPane { const stoppedDetails = sessions.length === 1 ? sessions[0].getStoppedDetails() : undefined; if (stoppedDetails && (thread || typeof stoppedDetails.threadId !== 'number')) { this.stateMessageLabel.textContent = stoppedDescription(stoppedDetails); - this.stateMessageLabel.title = stoppedText(stoppedDetails); + this.stateMessageLabelHover.update(stoppedText(stoppedDetails)); this.stateMessageLabel.classList.toggle('exception', stoppedDetails.reason === 'exception'); this.stateMessage.hidden = false; } else if (sessions.length === 1 && sessions[0].state === State.Running) { this.stateMessageLabel.textContent = localize({ key: 'running', comment: ['indicates state'] }, "Running"); - this.stateMessageLabel.title = sessions[0].getLabel(); + this.stateMessageLabelHover.update(sessions[0].getLabel()); this.stateMessageLabel.classList.remove('exception'); this.stateMessage.hidden = false; } else { @@ -216,6 +221,7 @@ export class CallStackView extends ViewPane { this.stateMessage = dom.append(container, $('span.call-stack-state-message')); this.stateMessage.hidden = true; this.stateMessageLabel = dom.append(this.stateMessage, $('span.label')); + this.stateMessageLabelHover = this._register(this.hoverService.setupUpdatableHover(getDefaultHoverDelegate('mouse'), this.stateMessage, '')); } protected override renderBody(container: HTMLElement): void { @@ -229,7 +235,7 @@ export class CallStackView extends ViewPane { this.instantiationService.createInstance(SessionsRenderer), this.instantiationService.createInstance(ThreadsRenderer), this.instantiationService.createInstance(StackFramesRenderer), - new ErrorsRenderer(), + this.instantiationService.createInstance(ErrorsRenderer), new LoadMoreRenderer(), new ShowMoreRenderer() ], this.dataSource, { @@ -274,9 +280,7 @@ export class CallStackView extends ViewPane { } }, expandOnlyOnTwistieClick: true, - overrideStyles: { - listBackground: this.getBackgroundColor() - } + overrideStyles: this.getLocationBasedColors().listOverrideStyles }); this.tree.setInput(this.debugService.getModel()); @@ -344,6 +348,7 @@ export class CallStackView extends ViewPane { } if (!this.isBodyVisible()) { this.needsRefresh = true; + this.selectionNeedsUpdate = true; return; } if (this.onCallStackChangeScheduler.isScheduled()) { @@ -493,6 +498,7 @@ interface ISessionTemplateData { interface IErrorTemplateData { label: HTMLElement; + templateDisposable: DisposableStore; } interface ILabelTemplateData { @@ -506,7 +512,7 @@ interface IStackFrameTemplateData { lineNumber: HTMLElement; label: HighlightedLabel; actionBar: ActionBar; - templateDisposable: IDisposable; + templateDisposable: DisposableStore; } function getSessionContextOverlay(session: IDebugSession): [string, any][] { @@ -524,6 +530,7 @@ class SessionsRenderer implements ICompressibleTreeRenderer { + actionViewItemProvider: (action, options) => { if ((action.id === STOP_ID || action.id === DISCONNECT_ID) && action instanceof MenuItemAction) { stopActionViewItemDisposables.clear(); - const item = this.instantiationService.invokeFunction(accessor => createDisconnectMenuItemAction(action as MenuItemAction, stopActionViewItemDisposables, accessor)); + const item = this.instantiationService.invokeFunction(accessor => createDisconnectMenuItemAction(action as MenuItemAction, stopActionViewItemDisposables, accessor, { ...options, menuAsChild: false })); if (item) { return item; } } if (action instanceof MenuItemAction) { - return this.instantiationService.createInstance(MenuEntryActionViewItem, action, undefined); + return this.instantiationService.createInstance(MenuEntryActionViewItem, action, { hoverDelegate: options.hoverDelegate }); } else if (action instanceof SubmenuItemAction) { - return this.instantiationService.createInstance(SubmenuEntryActionViewItem, action, undefined); + return this.instantiationService.createInstance(SubmenuEntryActionViewItem, action, { hoverDelegate: options.hoverDelegate }); } return undefined; @@ -575,7 +582,7 @@ class SessionsRenderer implements ICompressibleTreeRenderer t.stopped); @@ -603,11 +610,11 @@ class SessionsRenderer implements ICompressibleTreeRenderer, _index: number, data: IThreadTemplateData): void { const thread = element.element; - data.thread.title = thread.name; + data.elementDisposable.add(this.hoverService.setupUpdatableHover(getDefaultHoverDelegate('mouse'), data.thread, thread.name)); data.label.set(thread.name, createMatches(element.filterData)); data.stateLabel.textContent = thread.stateLabel; data.stateLabel.classList.toggle('exception', thread.stoppedDetails?.reason === 'exception'); @@ -712,6 +720,7 @@ class StackFramesRenderer implements ICompressibleTreeRenderer, index: number, data: IStackFrameTemplateData): void { const stackFrame = element.element; - data.stackFrame.classList.toggle('disabled', !stackFrame.source || !stackFrame.source.available || isDeemphasized(stackFrame)); + data.stackFrame.classList.toggle('disabled', !stackFrame.source || !stackFrame.source.available || isFrameDeemphasized(stackFrame)); data.stackFrame.classList.toggle('label', stackFrame.presentationHint === 'label'); - data.stackFrame.classList.toggle('subtle', stackFrame.presentationHint === 'subtle'); const hasActions = !!stackFrame.thread.session.capabilities.supportsRestartFrame && stackFrame.presentationHint !== 'label' && stackFrame.presentationHint !== 'subtle' && stackFrame.canRestart; data.stackFrame.classList.toggle('has-actions', hasActions); - data.file.title = stackFrame.source.inMemory ? stackFrame.source.uri.path : this.labelService.getUriLabel(stackFrame.source.uri); + let title = stackFrame.source.inMemory ? stackFrame.source.uri.path : this.labelService.getUriLabel(stackFrame.source.uri); if (stackFrame.source.raw.origin) { - data.file.title += `\n${stackFrame.source.raw.origin}`; + title += `\n${stackFrame.source.raw.origin}`; } + data.templateDisposable.add(this.hoverService.setupUpdatableHover(getDefaultHoverDelegate('mouse'), data.file, title)); + data.label.set(stackFrame.name, createMatches(element.filterData), stackFrame.name); data.fileName.textContent = getSpecificSourceName(stackFrame); if (stackFrame.range.startLineNumber !== undefined) { @@ -788,16 +799,21 @@ class ErrorsRenderer implements ICompressibleTreeRenderer, index: number, data: IErrorTemplateData): void { const error = element.element; data.label.textContent = error; - data.label.title = error; + data.templateDisposable.add(this.hoverService.setupUpdatableHover(getDefaultHoverDelegate('mouse'), data.label, error)); } renderCompressedElements(node: ITreeNode, FuzzyScore>, index: number, templateData: IErrorTemplateData, height: number | undefined): void { @@ -924,10 +940,6 @@ function isDebugSession(obj: any): obj is IDebugSession { return obj && typeof obj.getAllThreads === 'function'; } -function isDeemphasized(frame: IStackFrame): boolean { - return frame.source.presentationHint === 'deemphasize' || frame.presentationHint === 'deemphasize'; -} - class CallStackDataSource implements IAsyncDataSource { deemphasizedStackFramesToShow: IStackFrame[] = []; @@ -975,7 +987,7 @@ class CallStackDataSource implements IAsyncDataSource + + ${setting.key} + `; + } + + private getSettingMessage(setting: ISetting, newValue: boolean | string | number): string | undefined { + if (setting.type === 'boolean') { + return this.booleanSettingMessage(setting, newValue as boolean); + } else if (setting.type === 'string') { + return this.stringSettingMessage(setting, newValue as string); + } else if (setting.type === 'number') { + return this.numberSettingMessage(setting, newValue as number); + } + return undefined; + } + + async restoreSetting(settingId: string): Promise { + const userOriginalSettingValue = this._updatedSettings.get(settingId); + this._updatedSettings.delete(settingId); + return this._configurationService.updateValue(settingId, userOriginalSettingValue, ConfigurationTarget.USER); + } + + async setSetting(settingId: string, currentSettingValue: any, newSettingValue: any): Promise { + this._updatedSettings.set(settingId, currentSettingValue); + return this._configurationService.updateValue(settingId, newSettingValue, ConfigurationTarget.USER); + } + + getActions(uri: URI) { + if (uri.scheme !== Schemas.codeSetting) { + return; + } + + const actions: IAction[] = []; + + const settingId = uri.authority; + const newSettingValue = this.parseValue(uri.authority, uri.path.substring(1)); + const currentSettingValue = this._configurationService.inspect(settingId).userValue; + + if ((newSettingValue !== undefined) && newSettingValue === currentSettingValue && this._updatedSettings.has(settingId)) { + const restoreMessage = this.restorePreviousSettingMessage(settingId); + actions.push({ + class: undefined, + id: 'restoreSetting', + enabled: true, + tooltip: restoreMessage, + label: restoreMessage, + run: () => { + return this.restoreSetting(settingId); + } + }); + } else if (newSettingValue !== undefined) { + const setting = this.getSetting(settingId); + const trySettingMessage = setting ? this.getSettingMessage(setting, newSettingValue) : undefined; + + if (setting && trySettingMessage) { + actions.push({ + class: undefined, + id: 'trySetting', + enabled: currentSettingValue !== newSettingValue, + tooltip: trySettingMessage, + label: trySettingMessage, + run: () => { + this.setSetting(settingId, currentSettingValue, newSettingValue); + } + }); + } + } + + const viewInSettingsMessage = this.viewInSettingsMessage(settingId, actions.length > 0); + actions.push({ + class: undefined, + enabled: true, + id: 'viewInSettings', + tooltip: viewInSettingsMessage, + label: viewInSettingsMessage, + run: () => { + return this._preferencesService.openApplicationSettings({ query: `@id:${settingId}` }); + } + }); + + actions.push({ + class: undefined, + enabled: true, + id: 'copySettingId', + tooltip: nls.localize('copySettingId', "Copy Setting ID"), + label: nls.localize('copySettingId', "Copy Setting ID"), + run: () => { + this._clipboardService.writeText(settingId); + } + }); + + return actions; + } + + private showContextMenu(uri: URI, x: number, y: number) { + const actions = this.getActions(uri); + if (!actions) { + return; + } + + this._contextMenuService.showContextMenu({ + getAnchor: () => ({ x, y }), + getActions: () => actions, + getActionViewItem: (action) => { + return new ActionViewItem(action, action, { label: true }); + }, + }); + } + + async updateSetting(uri: URI, x: number, y: number) { + if (uri.scheme === Schemas.codeSetting) { + type ReleaseNotesSettingUsedClassification = { + owner: 'alexr00'; + comment: 'Used to understand if the the action to update settings from the release notes is used.'; + settingId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The id of the setting that was clicked on in the release notes' }; + }; + type ReleaseNotesSettingUsed = { + settingId: string; + }; + this._telemetryService.publicLog2('releaseNotesSettingAction', { + settingId: uri.authority + }); + return this.showContextMenu(uri, x, y); + } + } +} diff --git a/src/vs/workbench/contrib/markdown/test/browser/markdownSettingRenderer.test.ts b/src/vs/workbench/contrib/markdown/test/browser/markdownSettingRenderer.test.ts new file mode 100644 index 0000000000000..55e38f3d018d4 --- /dev/null +++ b/src/vs/workbench/contrib/markdown/test/browser/markdownSettingRenderer.test.ts @@ -0,0 +1,118 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +import * as assert from 'assert'; +import { IAction } from 'vs/base/common/actions'; +import { URI } from 'vs/base/common/uri'; +import { ensureNoDisposablesAreLeakedInTestSuite } from 'vs/base/test/common/utils'; +import { ConfigurationScope, Extensions, IConfigurationNode, IConfigurationRegistry } from 'vs/platform/configuration/common/configurationRegistry'; +import { TestConfigurationService } from 'vs/platform/configuration/test/common/testConfigurationService'; +import { IContextMenuService } from 'vs/platform/contextview/browser/contextView'; +import { Registry } from 'vs/platform/registry/common/platform'; +import { SimpleSettingRenderer } from 'vs/workbench/contrib/markdown/browser/markdownSettingRenderer'; +import { IPreferencesService } from 'vs/workbench/services/preferences/common/preferences'; + +const configuration: IConfigurationNode = { + 'id': 'examples', + 'title': 'Examples', + 'type': 'object', + 'properties': { + 'example.booleanSetting': { + 'type': 'boolean', + 'default': false, + 'scope': ConfigurationScope.APPLICATION + }, + 'example.booleanSetting2': { + 'type': 'boolean', + 'default': true, + 'scope': ConfigurationScope.APPLICATION + }, + 'example.stringSetting': { + 'type': 'string', + 'default': 'one', + 'scope': ConfigurationScope.APPLICATION + }, + 'example.numberSetting': { + 'type': 'number', + 'default': 3, + 'scope': ConfigurationScope.APPLICATION + } + } +}; + +class MarkdownConfigurationService extends TestConfigurationService { + override async updateValue(key: string, value: any): Promise { + const [section, setting] = key.split('.'); + return this.setUserConfiguration(section, { [setting]: value }); + } +} + +suite('Markdown Setting Renderer Test', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + let configurationService: TestConfigurationService; + let preferencesService: IPreferencesService; + let contextMenuService: IContextMenuService; + let settingRenderer: SimpleSettingRenderer; + + suiteSetup(() => { + configurationService = new MarkdownConfigurationService(); + preferencesService = {}; + contextMenuService = {}; + Registry.as(Extensions.Configuration).registerConfiguration(configuration); + settingRenderer = new SimpleSettingRenderer(configurationService, contextMenuService, preferencesService, { publicLog2: () => { } } as any, { writeText: async () => { } } as any); + }); + + suiteTeardown(() => { + Registry.as(Extensions.Configuration).deregisterConfigurations([configuration]); + }); + + test('render code setting button with value', () => { + const htmlRenderer = settingRenderer.getHtmlRenderer(); + const htmlNoValue = ''; + const renderedHtmlNoValue = htmlRenderer(htmlNoValue); + assert.strictEqual(renderedHtmlNoValue, + ` + + example.booleanSetting + `); + }); + + test('actions with no value', () => { + const uri = URI.parse(settingRenderer.settingToUriString('example.booleanSetting')); + const actions = settingRenderer.getActions(uri); + assert.strictEqual(actions?.length, 2); + assert.strictEqual(actions[0].label, 'View "Example: Boolean Setting" in Settings'); + }); + + test('actions with value + updating and restoring', async () => { + await configurationService.setUserConfiguration('example', { stringSetting: 'two' }); + const uri = URI.parse(settingRenderer.settingToUriString('example.stringSetting', 'three')); + + const verifyOriginalState = (actions: IAction[] | undefined): actions is IAction[] => { + assert.strictEqual(actions?.length, 3); + assert.strictEqual(actions[0].label, 'Set "Example: String Setting" to "three"'); + assert.strictEqual(actions[1].label, 'View in Settings'); + assert.strictEqual(configurationService.getValue('example.stringSetting'), 'two'); + return true; + }; + + const actions = settingRenderer.getActions(uri); + if (verifyOriginalState(actions)) { + // Update the value + await actions[0].run(); + assert.strictEqual(configurationService.getValue('example.stringSetting'), 'three'); + const actionsUpdated = settingRenderer.getActions(uri); + assert.strictEqual(actionsUpdated?.length, 3); + assert.strictEqual(actionsUpdated[0].label, 'Restore value of "Example: String Setting"'); + assert.strictEqual(actions[1].label, 'View in Settings'); + assert.strictEqual(actions[2].label, 'Copy Setting ID'); + assert.strictEqual(configurationService.getValue('example.stringSetting'), 'three'); + + // Restore the value + await actionsUpdated[0].run(); + verifyOriginalState(settingRenderer.getActions(uri)); + } + }); +}); diff --git a/src/vs/workbench/contrib/markers/browser/markersTable.ts b/src/vs/workbench/contrib/markers/browser/markersTable.ts index d466b58a6b2d6..93465f6d3d58b 100644 --- a/src/vs/workbench/contrib/markers/browser/markersTable.ts +++ b/src/vs/workbench/contrib/markers/browser/markersTable.ts @@ -7,7 +7,7 @@ import { localize } from 'vs/nls'; import * as DOM from 'vs/base/browser/dom'; import { Event } from 'vs/base/common/event'; import { ITableContextMenuEvent, ITableEvent, ITableRenderer, ITableVirtualDelegate } from 'vs/base/browser/ui/table/table'; -import { Disposable } from 'vs/base/common/lifecycle'; +import { Disposable, DisposableStore } from 'vs/base/common/lifecycle'; import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; import { IOpenEvent, IWorkbenchTableOptions, WorkbenchTable } from 'vs/platform/list/browser/listService'; import { HighlightedLabel } from 'vs/base/browser/ui/highlightedlabel/highlightedLabel'; @@ -30,6 +30,7 @@ import { IContextKeyService } from 'vs/platform/contextkey/common/contextkey'; import { Range } from 'vs/editor/common/core/range'; import { unsupportedSchemas } from 'vs/platform/markers/common/markerService'; import Severity from 'vs/base/common/severity'; +import { IHoverService } from 'vs/platform/hover/browser/hover'; const $ = DOM.$; @@ -43,6 +44,7 @@ interface IMarkerCodeColumnTemplateData { readonly sourceLabel: HighlightedLabel; readonly codeLabel: HighlightedLabel; readonly codeLink: Link; + readonly templateDisposable: DisposableStore; } interface IMarkerFileColumnTemplateData { @@ -74,8 +76,7 @@ class MarkerSeverityColumnRenderer implements ITableRenderer action.id === QuickFixAction.ID ? this.instantiationService.createInstance(QuickFixActionViewItem, action) : undefined, - animated: false + actionViewItemProvider: (action: IAction, options) => action.id === QuickFixAction.ID ? this.instantiationService.createInstance(QuickFixActionViewItem, action, options) : undefined }); return { actionBar, icon }; @@ -118,21 +119,23 @@ class MarkerCodeColumnRenderer implements ITableRenderer { @@ -186,7 +191,9 @@ class MarkerMessageColumnRenderer implements ITableRenderer { @@ -217,7 +224,10 @@ class MarkerFileColumnRenderer implements ITableRenderer { @@ -237,7 +247,9 @@ class MarkerOwnerColumnRenderer implements ITableRenderer { diff --git a/src/vs/workbench/contrib/markers/browser/markersTreeViewer.ts b/src/vs/workbench/contrib/markers/browser/markersTreeViewer.ts index 4b7a07b3b178b..0ed55eb912764 100644 --- a/src/vs/workbench/contrib/markers/browser/markersTreeViewer.ts +++ b/src/vs/workbench/contrib/markers/browser/markersTreeViewer.ts @@ -51,6 +51,9 @@ import { MarkersContextKeys, MarkersViewMode } from 'vs/workbench/contrib/marker import { unsupportedSchemas } from 'vs/platform/markers/common/markerService'; import { defaultCountBadgeStyles } from 'vs/platform/theme/browser/defaultStyles'; import Severity from 'vs/base/common/severity'; +import { getDefaultHoverDelegate } from 'vs/base/browser/ui/hover/hoverDelegateFactory'; +import type { IUpdatableHover } from 'vs/base/browser/ui/hover/hover'; +import { IHoverService } from 'vs/platform/hover/browser/hover'; interface IResourceMarkersTemplateData { readonly resourceLabel: IResourceLabel; @@ -234,6 +237,7 @@ export class MarkerRenderer implements ITreeRenderer action.id === QuickFixAction.ID ? _instantiationService.createInstance(QuickFixActionViewItem, action) : undefined + actionViewItemProvider: (action: IAction, options) => action.id === QuickFixAction.ID ? _instantiationService.createInstance(QuickFixActionViewItem, action, options) : undefined })); // wrap the icon in a container that get the icon color as foreground color. That way, if the @@ -304,6 +310,7 @@ class MarkerWidget extends Disposable { this.iconContainer = dom.append(parent, dom.$('')); this.icon = dom.append(this.iconContainer, dom.$('')); this.messageAndDetailsContainer = dom.append(parent, dom.$('.marker-message-details-container')); + this.messageAndDetailsContainerHover = this._register(this._hoverService.setupUpdatableHover(getDefaultHoverDelegate('mouse'), this.messageAndDetailsContainer, '')); } render(element: Marker, filterData: MarkerFilterData | undefined): void { @@ -342,9 +349,9 @@ class MarkerWidget extends Disposable { private renderMultilineActionbar(marker: Marker, parent: HTMLElement): void { const multilineActionbar = this.disposables.add(new ActionBar(dom.append(parent, dom.$('.multiline-actions')), { - actionViewItemProvider: (action) => { + actionViewItemProvider: (action, options) => { if (action.id === toggleMultilineAction) { - return new ToggleMultilineActionViewItem(undefined, action, { icon: true }); + return new ToggleMultilineActionViewItem(undefined, action, { ...options, icon: true }); } return undefined; } @@ -366,13 +373,13 @@ class MarkerWidget extends Disposable { const viewState = this.markersViewModel.getViewModel(element); const multiline = !viewState || viewState.multiline; const lineMatches = filterData && filterData.lineMatches || []; - this.messageAndDetailsContainer.title = element.marker.message; + this.messageAndDetailsContainerHover.update(element.marker.message); const lineElements: HTMLElement[] = []; for (let index = 0; index < (multiline ? lines.length : 1); index++) { const lineElement = dom.append(this.messageAndDetailsContainer, dom.$('.marker-message-line')); const messageElement = dom.append(lineElement, dom.$('.marker-message')); - const highlightedLabel = new HighlightedLabel(messageElement); + const highlightedLabel = this.disposables.add(new HighlightedLabel(messageElement)); highlightedLabel.set(lines[index].length > 1000 ? `${lines[index].substring(0, 1000)}...` : lines[index], lineMatches[index]); if (lines[index] === '') { lineElement.style.height = `${VirtualDelegate.LINE_HEIGHT}px`; @@ -387,20 +394,20 @@ class MarkerWidget extends Disposable { parent.classList.add('details-container'); if (marker.source || marker.code) { - const source = new HighlightedLabel(dom.append(parent, dom.$('.marker-source'))); + const source = this.disposables.add(new HighlightedLabel(dom.append(parent, dom.$('.marker-source')))); const sourceMatches = filterData && filterData.sourceMatches || []; source.set(marker.source, sourceMatches); if (marker.code) { if (typeof marker.code === 'string') { - const code = new HighlightedLabel(dom.append(parent, dom.$('.marker-code'))); + const code = this.disposables.add(new HighlightedLabel(dom.append(parent, dom.$('.marker-code')))); const codeMatches = filterData && filterData.codeMatches || []; code.set(marker.code, codeMatches); } else { const container = dom.$('.marker-code'); - const code = new HighlightedLabel(container); + const code = this.disposables.add(new HighlightedLabel(container)); const link = marker.code.target.toString(true); - this.disposables.add(new Link(parent, { href: link, label: container, title: link }, undefined, this._openerService)); + this.disposables.add(new Link(parent, { href: link, label: container, title: link }, undefined, this._hoverService, this._openerService)); const codeMatches = filterData && filterData.codeMatches || []; code.set(marker.code.value, codeMatches); } @@ -443,15 +450,15 @@ export class RelatedInformationRenderer implements ITreeRenderer> { return Iterable.map(resourceMarkers.markers, m => { @@ -102,7 +103,7 @@ export class MarkersView extends FilterViewPane implements IMarkersView { private readonly onVisibleDisposables = this._register(new DisposableStore()); private widget!: IProblemsWidget; - private widgetDisposables = this._register(new DisposableStore()); + private readonly widgetDisposables = this._register(new DisposableStore()); private widgetContainer!: HTMLElement; private widgetIdentityProvider: IIdentityProvider; private widgetAccessibilityProvider: MarkersWidgetAccessibilityProvider; @@ -138,6 +139,7 @@ export class MarkersView extends FilterViewPane implements IMarkersView { @IStorageService storageService: IStorageService, @IOpenerService openerService: IOpenerService, @IThemeService themeService: IThemeService, + @IHoverService hoverService: IHoverService, ) { const memento = new Memento(Markers.MARKERS_VIEW_STORAGE_ID, storageService); const panelState = memento.getMemento(StorageScope.WORKSPACE, StorageTarget.MACHINE); @@ -150,7 +152,7 @@ export class MarkersView extends FilterViewPane implements IMarkersView { text: panelState['filter'] || '', history: panelState['filterHistory'] || [] } - }, keybindingService, contextMenuService, configurationService, contextKeyService, viewDescriptorService, instantiationService, openerService, themeService, telemetryService); + }, keybindingService, contextMenuService, configurationService, contextKeyService, viewDescriptorService, instantiationService, openerService, themeService, telemetryService, hoverService); this.memento = memento; this.panelState = panelState; @@ -187,6 +189,7 @@ export class MarkersView extends FilterViewPane implements IMarkersView { override render(): void { super.render(); this._register(registerNavigableContainer({ + name: 'markersView', focusNotifiers: [this, this.filterWidget], focusNextWidget: () => { if (this.filterWidget.hasFocus()) { @@ -498,9 +501,7 @@ export class MarkersView extends FilterViewPane implements IMarkersView { return null; }), expandOnlyOnTwistieClick: (e: MarkerElement) => e instanceof Marker && e.relatedInformation.length > 0, - overrideStyles: { - listBackground: this.getBackgroundColor() - }, + overrideStyles: this.getLocationBasedColors().listOverrideStyles, selectionNavigation: true, multipleSelectionSupport: true, }, diff --git a/src/vs/workbench/contrib/markers/browser/markersViewActions.ts b/src/vs/workbench/contrib/markers/browser/markersViewActions.ts index 818f2f56fe629..74fa00a81ae6a 100644 --- a/src/vs/workbench/contrib/markers/browser/markersViewActions.ts +++ b/src/vs/workbench/contrib/markers/browser/markersViewActions.ts @@ -13,7 +13,7 @@ import { IContextKeyService } from 'vs/platform/contextkey/common/contextkey'; import { Event, Emitter } from 'vs/base/common/event'; import { Codicon } from 'vs/base/common/codicons'; import { ThemeIcon } from 'vs/base/common/themables'; -import { ActionViewItem } from 'vs/base/browser/ui/actionbar/actionViewItems'; +import { ActionViewItem, IActionViewItemOptions } from 'vs/base/browser/ui/actionbar/actionViewItems'; import { MarkersContextKeys } from 'vs/workbench/contrib/markers/common/markers'; import 'vs/css!./markersViewActions'; @@ -145,10 +145,12 @@ export class QuickFixAction extends Action { export class QuickFixActionViewItem extends ActionViewItem { - constructor(action: QuickFixAction, + constructor( + action: QuickFixAction, + options: IActionViewItemOptions, @IContextMenuService private readonly contextMenuService: IContextMenuService, ) { - super(null, action, { icon: true, label: false }); + super(null, action, { ...options, icon: true, label: false }); } public override onClick(event: DOM.EventLike): void { diff --git a/src/vs/workbench/contrib/mergeEditor/browser/mergeEditorInput.ts b/src/vs/workbench/contrib/mergeEditor/browser/mergeEditorInput.ts index 3304f0b90b746..e345bfd2b00d5 100644 --- a/src/vs/workbench/contrib/mergeEditor/browser/mergeEditorInput.ts +++ b/src/vs/workbench/contrib/mergeEditor/browser/mergeEditorInput.ts @@ -16,6 +16,7 @@ import { IInstantiationService } from 'vs/platform/instantiation/common/instanti import { ILabelService } from 'vs/platform/label/common/label'; import { DEFAULT_EDITOR_ASSOCIATION, EditorInputCapabilities, IResourceMergeEditorInput, IRevertOptions, isResourceMergeEditorInput, IUntypedEditorInput } from 'vs/workbench/common/editor'; import { EditorInput, IEditorCloseHandler } from 'vs/workbench/common/editor/editorInput'; +import { ICustomEditorLabelService } from 'vs/workbench/services/editor/common/customEditorLabelService'; import { AbstractTextResourceEditorInput } from 'vs/workbench/common/editor/textResourceEditorInput'; import { IMergeEditorInputModel, TempFileMergeEditorModeFactory, WorkspaceMergeEditorModeFactory } from 'vs/workbench/contrib/mergeEditor/browser/mergeEditorInputModel'; import { MergeEditorTelemetry } from 'vs/workbench/contrib/mergeEditor/browser/telemetry'; @@ -62,9 +63,10 @@ export class MergeEditorInput extends AbstractTextResourceEditorInput implements @IFileService fileService: IFileService, @IConfigurationService private readonly configurationService: IConfigurationService, @IFilesConfigurationService filesConfigurationService: IFilesConfigurationService, - @ITextResourceConfigurationService textResourceConfigurationService: ITextResourceConfigurationService + @ITextResourceConfigurationService textResourceConfigurationService: ITextResourceConfigurationService, + @ICustomEditorLabelService customEditorLabelService: ICustomEditorLabelService, ) { - super(result, undefined, editorService, textFileService, labelService, fileService, filesConfigurationService, textResourceConfigurationService); + super(result, undefined, editorService, textFileService, labelService, fileService, filesConfigurationService, textResourceConfigurationService, customEditorLabelService); } override dispose(): void { diff --git a/src/vs/workbench/contrib/mergeEditor/browser/model/rangeUtils.ts b/src/vs/workbench/contrib/mergeEditor/browser/model/rangeUtils.ts index 92f242a25971f..dd224530145d3 100644 --- a/src/vs/workbench/contrib/mergeEditor/browser/model/rangeUtils.ts +++ b/src/vs/workbench/contrib/mergeEditor/browser/model/rangeUtils.ts @@ -5,7 +5,7 @@ import { Position } from 'vs/editor/common/core/position'; import { Range } from 'vs/editor/common/core/range'; -import { LengthObj } from 'vs/editor/common/model/bracketPairsTextModelPart/bracketPairsTree/length'; +import { TextLength } from 'vs/editor/common/core/textLength'; export function rangeContainsPosition(range: Range, position: Position): boolean { if (position.lineNumber < range.startLineNumber || position.lineNumber > range.endLineNumber) { @@ -20,23 +20,23 @@ export function rangeContainsPosition(range: Range, position: Position): boolean return true; } -export function lengthOfRange(range: Range): LengthObj { +export function lengthOfRange(range: Range): TextLength { if (range.startLineNumber === range.endLineNumber) { - return new LengthObj(0, range.endColumn - range.startColumn); + return new TextLength(0, range.endColumn - range.startColumn); } else { - return new LengthObj(range.endLineNumber - range.startLineNumber, range.endColumn - 1); + return new TextLength(range.endLineNumber - range.startLineNumber, range.endColumn - 1); } } -export function lengthBetweenPositions(position1: Position, position2: Position): LengthObj { +export function lengthBetweenPositions(position1: Position, position2: Position): TextLength { if (position1.lineNumber === position2.lineNumber) { - return new LengthObj(0, position2.column - position1.column); + return new TextLength(0, position2.column - position1.column); } else { - return new LengthObj(position2.lineNumber - position1.lineNumber, position2.column - 1); + return new TextLength(position2.lineNumber - position1.lineNumber, position2.column - 1); } } -export function addLength(position: Position, length: LengthObj): Position { +export function addLength(position: Position, length: TextLength): Position { if (length.lineCount === 0) { return new Position(position.lineNumber, position.column + length.columnCount); } else { diff --git a/src/vs/workbench/contrib/mergeEditor/browser/model/textModelDiffs.ts b/src/vs/workbench/contrib/mergeEditor/browser/model/textModelDiffs.ts index 2affffb8df3ed..9f888b4cec50b 100644 --- a/src/vs/workbench/contrib/mergeEditor/browser/model/textModelDiffs.ts +++ b/src/vs/workbench/contrib/mergeEditor/browser/model/textModelDiffs.ts @@ -10,7 +10,7 @@ import { ITextModel } from 'vs/editor/common/model'; import { DetailedLineRangeMapping } from 'vs/workbench/contrib/mergeEditor/browser/model/mapping'; import { LineRangeEdit } from 'vs/workbench/contrib/mergeEditor/browser/model/editing'; import { LineRange } from 'vs/workbench/contrib/mergeEditor/browser/model/lineRange'; -import { ReentrancyBarrier } from 'vs/workbench/contrib/mergeEditor/browser/utils'; +import { ReentrancyBarrier } from '../../../../../base/common/controlFlow'; import { IMergeDiffComputer } from './diffComputer'; import { autorun, IObservable, IReader, ITransaction, observableSignal, observableValue, transaction } from 'vs/base/common/observable'; import { UndoRedoGroup } from 'vs/platform/undoRedo/common/undoRedo'; @@ -24,7 +24,7 @@ export class TextModelDiffs extends Disposable { private _isDisposed = false; public get isApplyingChange() { - return this._barrier.isActive; + return this._barrier.isOccupied; } constructor( @@ -44,14 +44,14 @@ export class TextModelDiffs extends Disposable { this._register( baseTextModel.onDidChangeContent( - this._barrier.makeExclusive(() => { + this._barrier.makeExclusiveOrSkip(() => { recomputeSignal.trigger(undefined); }) ) ); this._register( textModel.onDidChangeContent( - this._barrier.makeExclusive(() => { + this._barrier.makeExclusiveOrSkip(() => { recomputeSignal.trigger(undefined); }) ) diff --git a/src/vs/workbench/contrib/mergeEditor/browser/telemetry.ts b/src/vs/workbench/contrib/mergeEditor/browser/telemetry.ts index 9ae305ac5da56..ba91b9a88fe07 100644 --- a/src/vs/workbench/contrib/mergeEditor/browser/telemetry.ts +++ b/src/vs/workbench/contrib/mergeEditor/browser/telemetry.ts @@ -28,8 +28,8 @@ export class MergeEditorTelemetry { }, { owner: 'hediet'; - conflictCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'To understand how many conflicts typically occur' }; - combinableConflictCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'To evaluate how useful the smart-merge feature is' }; + conflictCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'To understand how many conflicts typically occur' }; + combinableConflictCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'To evaluate how useful the smart-merge feature is' }; baseVisible: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'To understand how many users use the base view to solve a conflict' }; isColumnView: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'To gain insight which layout should be default' }; @@ -120,28 +120,28 @@ export class MergeEditorTelemetry { }, { owner: 'hediet'; - conflictCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'To understand how many conflicts typically occur' }; - combinableConflictCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'To evaluate how useful the smart-merge feature is' }; + conflictCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'To understand how many conflicts typically occur' }; + combinableConflictCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'To evaluate how useful the smart-merge feature is' }; - durationOpenedSecs: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Indicates how long the merge editor was open before it was closed. This can be compared with the inline experience to investigate time savings.' }; - remainingConflictCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Indicates how many conflicts were skipped. Should be zero for a successful merge.' }; + durationOpenedSecs: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Indicates how long the merge editor was open before it was closed. This can be compared with the inline experience to investigate time savings.' }; + remainingConflictCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Indicates how many conflicts were skipped. Should be zero for a successful merge.' }; accepted: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Indicates if the user completed the merge successfully or just closed the editor' }; - conflictsResolvedWithBase: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'To understand how many conflicts are resolved with base' }; - conflictsResolvedWithInput1: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'To understand how many conflicts are resolved with input1' }; - conflictsResolvedWithInput2: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'To understand how many conflicts are resolved with input2' }; - conflictsResolvedWithSmartCombination: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'To understand how many conflicts are resolved with smart combination' }; - - manuallySolvedConflictCountThatEqualNone: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Indicates how many conflicts were solved manually that are not recognized by the merge editor.' }; - manuallySolvedConflictCountThatEqualSmartCombine: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Indicates how many conflicts were solved manually that equal the smart combination of the inputs.' }; - manuallySolvedConflictCountThatEqualInput1: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Indicates how many conflicts were solved manually that equal just input 1' }; - manuallySolvedConflictCountThatEqualInput2: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Indicates how many conflicts were solved manually that equal just input 2' }; - - manuallySolvedConflictCountThatEqualNoneAndStartedWithBase: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Indicates how many manually solved conflicts that are not recognized started with base' }; - manuallySolvedConflictCountThatEqualNoneAndStartedWithInput1: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Indicates how many manually solved conflicts that are not recognized started with input1' }; - manuallySolvedConflictCountThatEqualNoneAndStartedWithInput2: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Indicates how many manually solved conflicts that are not recognized started with input2' }; - manuallySolvedConflictCountThatEqualNoneAndStartedWithBothNonSmart: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Indicates how many manually solved conflicts that are not recognized started with both (non-smart combination)' }; - manuallySolvedConflictCountThatEqualNoneAndStartedWithBothSmart: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Indicates how many manually solved conflicts that are not recognized started with both (smart-combination)' }; + conflictsResolvedWithBase: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'To understand how many conflicts are resolved with base' }; + conflictsResolvedWithInput1: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'To understand how many conflicts are resolved with input1' }; + conflictsResolvedWithInput2: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'To understand how many conflicts are resolved with input2' }; + conflictsResolvedWithSmartCombination: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'To understand how many conflicts are resolved with smart combination' }; + + manuallySolvedConflictCountThatEqualNone: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Indicates how many conflicts were solved manually that are not recognized by the merge editor.' }; + manuallySolvedConflictCountThatEqualSmartCombine: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Indicates how many conflicts were solved manually that equal the smart combination of the inputs.' }; + manuallySolvedConflictCountThatEqualInput1: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Indicates how many conflicts were solved manually that equal just input 1' }; + manuallySolvedConflictCountThatEqualInput2: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Indicates how many conflicts were solved manually that equal just input 2' }; + + manuallySolvedConflictCountThatEqualNoneAndStartedWithBase: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Indicates how many manually solved conflicts that are not recognized started with base' }; + manuallySolvedConflictCountThatEqualNoneAndStartedWithInput1: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Indicates how many manually solved conflicts that are not recognized started with input1' }; + manuallySolvedConflictCountThatEqualNoneAndStartedWithInput2: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Indicates how many manually solved conflicts that are not recognized started with input2' }; + manuallySolvedConflictCountThatEqualNoneAndStartedWithBothNonSmart: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Indicates how many manually solved conflicts that are not recognized started with both (non-smart combination)' }; + manuallySolvedConflictCountThatEqualNoneAndStartedWithBothSmart: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Indicates how many manually solved conflicts that are not recognized started with both (smart-combination)' }; comment: 'This event tracks when a user closes a merge editor. It also tracks how the user solved the merge conflicts. This data can be used to improve the UX of the merge editor. This event will be fired rarely (less than 200k per week)'; }>('mergeEditor.closed', { diff --git a/src/vs/workbench/contrib/mergeEditor/browser/utils.ts b/src/vs/workbench/contrib/mergeEditor/browser/utils.ts index d2853645051ab..c085272472c95 100644 --- a/src/vs/workbench/contrib/mergeEditor/browser/utils.ts +++ b/src/vs/workbench/contrib/mergeEditor/browser/utils.ts @@ -4,60 +4,14 @@ *--------------------------------------------------------------------------------------------*/ import { ArrayQueue, CompareResult } from 'vs/base/common/arrays'; -import { BugIndicatingError, onUnexpectedError } from 'vs/base/common/errors'; +import { onUnexpectedError } from 'vs/base/common/errors'; import { DisposableStore, IDisposable, toDisposable } from 'vs/base/common/lifecycle'; import { IObservable, autorunOpts, observableFromEvent } from 'vs/base/common/observable'; -import { CodeEditorWidget } from 'vs/editor/browser/widget/codeEditorWidget'; +import { CodeEditorWidget } from 'vs/editor/browser/widget/codeEditor/codeEditorWidget'; import { IModelDeltaDecoration } from 'vs/editor/common/model'; import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; import { IStorageService, StorageScope, StorageTarget } from 'vs/platform/storage/common/storage'; -export class ReentrancyBarrier { - private _isActive = false; - - public get isActive() { - return this._isActive; - } - - public makeExclusive(fn: TFunction): TFunction { - return ((...args: any[]) => { - if (this._isActive) { - return; - } - this._isActive = true; - try { - return fn(...args); - } finally { - this._isActive = false; - } - }) as any; - } - - public runExclusively(fn: () => void): void { - if (this._isActive) { - return; - } - this._isActive = true; - try { - fn(); - } finally { - this._isActive = false; - } - } - - public runExclusivelyOrThrow(fn: () => void): void { - if (this._isActive) { - throw new BugIndicatingError(); - } - this._isActive = true; - try { - fn(); - } finally { - this._isActive = false; - } - } -} - export function setStyle( element: HTMLElement, style: { diff --git a/src/vs/workbench/contrib/mergeEditor/browser/view/editorGutter.ts b/src/vs/workbench/contrib/mergeEditor/browser/view/editorGutter.ts index 9d349b594ec72..b751b6bc0ade2 100644 --- a/src/vs/workbench/contrib/mergeEditor/browser/view/editorGutter.ts +++ b/src/vs/workbench/contrib/mergeEditor/browser/view/editorGutter.ts @@ -6,7 +6,7 @@ import { h, reset } from 'vs/base/browser/dom'; import { Disposable, IDisposable, toDisposable } from 'vs/base/common/lifecycle'; import { autorun, IReader, observableFromEvent, observableSignal, observableSignalFromEvent, transaction } from 'vs/base/common/observable'; -import { CodeEditorWidget } from 'vs/editor/browser/widget/codeEditorWidget'; +import { CodeEditorWidget } from 'vs/editor/browser/widget/codeEditor/codeEditorWidget'; import { LineRange } from 'vs/workbench/contrib/mergeEditor/browser/model/lineRange'; export class EditorGutter extends Disposable { diff --git a/src/vs/workbench/contrib/mergeEditor/browser/view/editors/codeEditorView.ts b/src/vs/workbench/contrib/mergeEditor/browser/view/editors/codeEditorView.ts index 38266ed06008b..8cd243e377726 100644 --- a/src/vs/workbench/contrib/mergeEditor/browser/view/editors/codeEditorView.ts +++ b/src/vs/workbench/contrib/mergeEditor/browser/view/editors/codeEditorView.ts @@ -9,7 +9,7 @@ import { Emitter, Event } from 'vs/base/common/event'; import { Disposable, IDisposable } from 'vs/base/common/lifecycle'; import { IObservable, autorun, derived, observableFromEvent } from 'vs/base/common/observable'; import { EditorExtensionsRegistry, IEditorContributionDescription } from 'vs/editor/browser/editorExtensions'; -import { CodeEditorWidget } from 'vs/editor/browser/widget/codeEditorWidget'; +import { CodeEditorWidget } from 'vs/editor/browser/widget/codeEditor/codeEditorWidget'; import { IEditorOptions } from 'vs/editor/common/config/editorOptions'; import { Range } from 'vs/editor/common/core/range'; import { Selection } from 'vs/editor/common/core/selection'; diff --git a/src/vs/workbench/contrib/mergeEditor/browser/view/lineAlignment.ts b/src/vs/workbench/contrib/mergeEditor/browser/view/lineAlignment.ts index f6ccc0cf7a35a..c6d9664b4beef 100644 --- a/src/vs/workbench/contrib/mergeEditor/browser/view/lineAlignment.ts +++ b/src/vs/workbench/contrib/mergeEditor/browser/view/lineAlignment.ts @@ -8,7 +8,7 @@ import { assertFn, checkAdjacentItems } from 'vs/base/common/assert'; import { isDefined } from 'vs/base/common/types'; import { Position } from 'vs/editor/common/core/position'; import { Range } from 'vs/editor/common/core/range'; -import { LengthObj } from 'vs/editor/common/model/bracketPairsTextModelPart/bracketPairsTree/length'; +import { TextLength } from 'vs/editor/common/core/textLength'; import { RangeMapping } from 'vs/workbench/contrib/mergeEditor/browser/model/mapping'; import { ModifiedBaseRange } from 'vs/workbench/contrib/mergeEditor/browser/model/modifiedBaseRange'; import { addLength, lengthBetweenPositions, lengthOfRange } from 'vs/workbench/contrib/mergeEditor/browser/model/rangeUtils'; @@ -49,7 +49,7 @@ export function getAlignments(m: ModifiedBaseRange): LineAlignment[] { if (shouldAdd) { result.push(lineAlignment); } else { - if (m.length.isGreaterThan(new LengthObj(1, 0))) { + if (m.length.isGreaterThan(new TextLength(1, 0))) { result.push([ m.output1Pos ? m.output1Pos.lineNumber + 1 : undefined, m.inputPos.lineNumber + 1, @@ -75,7 +75,7 @@ interface CommonRangeMapping { output1Pos: Position | undefined; output2Pos: Position | undefined; inputPos: Position; - length: LengthObj; + length: TextLength; } function toEqualRangeMappings(diffs: RangeMapping[], inputRange: Range, outputRange: Range): RangeMapping[] { diff --git a/src/vs/workbench/contrib/mergeEditor/browser/view/mergeEditor.ts b/src/vs/workbench/contrib/mergeEditor/browser/view/mergeEditor.ts index 45af28b94f0fc..3609b0046fa30 100644 --- a/src/vs/workbench/contrib/mergeEditor/browser/view/mergeEditor.ts +++ b/src/vs/workbench/contrib/mergeEditor/browser/view/mergeEditor.ts @@ -108,6 +108,7 @@ export class MergeEditor extends AbstractTextEditor { private readonly scrollSynchronizer = this._register(new ScrollSynchronizer(this._viewModel, this.input1View, this.input2View, this.baseView, this.inputResultView, this._layoutModeObs)); constructor( + group: IEditorGroup, @IInstantiationService instantiation: IInstantiationService, @IContextKeyService private readonly contextKeyService: IContextKeyService, @ITelemetryService telemetryService: ITelemetryService, @@ -121,7 +122,7 @@ export class MergeEditor extends AbstractTextEditor { @ICodeEditorService private readonly _codeEditorService: ICodeEditorService, @IConfigurationService private readonly configurationService: IConfigurationService ) { - super(MergeEditor.ID, telemetryService, instantiation, storageService, textResourceConfigurationService, themeService, editorService, editorGroupService, fileService); + super(MergeEditor.ID, group, telemetryService, instantiation, storageService, textResourceConfigurationService, themeService, editorService, editorGroupService, fileService); } override dispose(): void { @@ -354,7 +355,7 @@ export class MergeEditor extends AbstractTextEditor { // all empty -> replace this editor with a normal editor for result that.editorService.replaceEditors( [{ editor: input, replacement: { resource: input.result, options: { preserveFocus: true } }, forceReplaceDirty: true }], - that.group ?? that.editorGroupService.activeGroup + that.group ); } }); @@ -467,8 +468,8 @@ export class MergeEditor extends AbstractTextEditor { return super.hasFocus(); } - protected override setEditorVisible(visible: boolean, group: IEditorGroup | undefined): void { - super.setEditorVisible(visible, group); + protected override setEditorVisible(visible: boolean): void { + super.setEditorVisible(visible); for (const { editor } of [this.input1View, this.input2View, this.inputResultView]) { if (visible) { diff --git a/src/vs/workbench/contrib/mergeEditor/browser/view/scrollSynchronizer.ts b/src/vs/workbench/contrib/mergeEditor/browser/view/scrollSynchronizer.ts index bbbe978f30293..79e0e76939824 100644 --- a/src/vs/workbench/contrib/mergeEditor/browser/view/scrollSynchronizer.ts +++ b/src/vs/workbench/contrib/mergeEditor/browser/view/scrollSynchronizer.ts @@ -5,10 +5,10 @@ import { Disposable } from 'vs/base/common/lifecycle'; import { autorunWithStore, IObservable } from 'vs/base/common/observable'; -import { CodeEditorWidget } from 'vs/editor/browser/widget/codeEditorWidget'; +import { CodeEditorWidget } from 'vs/editor/browser/widget/codeEditor/codeEditorWidget'; import { ScrollType } from 'vs/editor/common/editorCommon'; import { DocumentLineRangeMap } from 'vs/workbench/contrib/mergeEditor/browser/model/mapping'; -import { ReentrancyBarrier } from 'vs/workbench/contrib/mergeEditor/browser/utils'; +import { ReentrancyBarrier } from '../../../../../base/common/controlFlow'; import { BaseCodeEditorView } from 'vs/workbench/contrib/mergeEditor/browser/view/editors/baseCodeEditorView'; import { IMergeEditorLayout } from 'vs/workbench/contrib/mergeEditor/browser/view/mergeEditor'; import { MergeEditorViewModel } from 'vs/workbench/contrib/mergeEditor/browser/view/viewModel'; @@ -62,7 +62,7 @@ export class ScrollSynchronizer extends Disposable { this._store.add( this.input1View.editor.onDidScrollChange( - this.reentrancyBarrier.makeExclusive((c) => { + this.reentrancyBarrier.makeExclusiveOrSkip((c) => { if (c.scrollTopChanged) { handleInput1OnScroll(); } @@ -77,7 +77,7 @@ export class ScrollSynchronizer extends Disposable { this._store.add( this.input2View.editor.onDidScrollChange( - this.reentrancyBarrier.makeExclusive((c) => { + this.reentrancyBarrier.makeExclusiveOrSkip((c) => { if (!this.model) { return; } @@ -112,7 +112,7 @@ export class ScrollSynchronizer extends Disposable { ); this._store.add( this.inputResultView.editor.onDidScrollChange( - this.reentrancyBarrier.makeExclusive((c) => { + this.reentrancyBarrier.makeExclusiveOrSkip((c) => { if (c.scrollTopChanged) { if (this.shouldAlignResult) { this.input1View.editor.setScrollTop(c.scrollTop, ScrollType.Immediate); @@ -146,7 +146,7 @@ export class ScrollSynchronizer extends Disposable { const baseView = this.baseView.read(reader); if (baseView) { store.add(baseView.editor.onDidScrollChange( - this.reentrancyBarrier.makeExclusive((c) => { + this.reentrancyBarrier.makeExclusiveOrSkip((c) => { if (c.scrollTopChanged) { if (!this.model) { return; diff --git a/src/vs/workbench/contrib/mergeEditor/test/browser/mapping.test.ts b/src/vs/workbench/contrib/mergeEditor/test/browser/mapping.test.ts index ae714c1076690..85c475e3c2fe9 100644 --- a/src/vs/workbench/contrib/mergeEditor/test/browser/mapping.test.ts +++ b/src/vs/workbench/contrib/mergeEditor/test/browser/mapping.test.ts @@ -7,7 +7,7 @@ import * as assert from 'assert'; import { ensureNoDisposablesAreLeakedInTestSuite } from 'vs/base/test/common/utils'; import { Position } from 'vs/editor/common/core/position'; import { Range } from 'vs/editor/common/core/range'; -import { LengthObj } from 'vs/editor/common/model/bracketPairsTextModelPart/bracketPairsTree/length'; +import { TextLength } from 'vs/editor/common/core/textLength'; import { DocumentRangeMap, RangeMapping } from 'vs/workbench/contrib/mergeEditor/browser/model/mapping'; suite('merge editor mapping', () => { @@ -53,19 +53,19 @@ function parsePos(str: string): Position { return new Position(parseInt(lineCount, 10), parseInt(columnCount, 10)); } -function parseLengthObj(str: string): LengthObj { +function parseLengthObj(str: string): TextLength { const [lineCount, columnCount] = str.split(':'); - return new LengthObj(parseInt(lineCount, 10), parseInt(columnCount, 10)); + return new TextLength(parseInt(lineCount, 10), parseInt(columnCount, 10)); } -function toPosition(length: LengthObj): Position { +function toPosition(length: TextLength): Position { return new Position(length.lineCount + 1, length.columnCount + 1); } function createDocumentRangeMap(items: ([string, string] | string)[]) { const mappings: RangeMapping[] = []; - let lastLen1 = new LengthObj(0, 0); - let lastLen2 = new LengthObj(0, 0); + let lastLen1 = new TextLength(0, 0); + let lastLen2 = new TextLength(0, 0); for (const item of items) { if (typeof item === 'string') { const len = parseLengthObj(item); diff --git a/src/vs/workbench/contrib/multiDiffEditor/browser/multiDiffEditor.ts b/src/vs/workbench/contrib/multiDiffEditor/browser/multiDiffEditor.ts index 263668667598c..ffe7a8738f523 100644 --- a/src/vs/workbench/contrib/multiDiffEditor/browser/multiDiffEditor.ts +++ b/src/vs/workbench/contrib/multiDiffEditor/browser/multiDiffEditor.ts @@ -5,10 +5,9 @@ import * as DOM from 'vs/base/browser/dom'; import { CancellationToken } from 'vs/base/common/cancellation'; -import { MultiDiffEditorWidget } from 'vs/editor/browser/widget/multiDiffEditorWidget/multiDiffEditorWidget'; -import { IResourceLabel, IWorkbenchUIElementFactory } from 'vs/editor/browser/widget/multiDiffEditorWidget/workbenchUIElementFactory'; +import { MultiDiffEditorWidget } from 'vs/editor/browser/widget/multiDiffEditor/multiDiffEditorWidget'; +import { IResourceLabel, IWorkbenchUIElementFactory } from 'vs/editor/browser/widget/multiDiffEditor/workbenchUIElementFactory'; import { ITextResourceConfigurationService } from 'vs/editor/common/services/textResourceConfiguration'; -import { IEditorOptions } from 'vs/platform/editor/common/editor'; import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; import { InstantiationService } from 'vs/platform/instantiation/common/instantiationService'; import { IStorageService } from 'vs/platform/storage/common/storage'; @@ -20,13 +19,14 @@ import { ICompositeControl } from 'vs/workbench/common/composite'; import { IEditorOpenContext } from 'vs/workbench/common/editor'; import { EditorInput } from 'vs/workbench/common/editor/editorInput'; import { MultiDiffEditorInput } from 'vs/workbench/contrib/multiDiffEditor/browser/multiDiffEditorInput'; -import { IEditorGroupsService } from 'vs/workbench/services/editor/common/editorGroupsService'; +import { IEditorGroup, IEditorGroupsService } from 'vs/workbench/services/editor/common/editorGroupsService'; import { IEditorService } from 'vs/workbench/services/editor/common/editorService'; import { URI } from 'vs/base/common/uri'; -import { MultiDiffEditorViewModel } from 'vs/editor/browser/widget/multiDiffEditorWidget/multiDiffEditorViewModel'; -import { IMultiDiffEditorViewState } from 'vs/editor/browser/widget/multiDiffEditorWidget/multiDiffEditorWidgetImpl'; +import { MultiDiffEditorViewModel } from 'vs/editor/browser/widget/multiDiffEditor/multiDiffEditorViewModel'; +import { IMultiDiffEditorOptions, IMultiDiffEditorViewState } from 'vs/editor/browser/widget/multiDiffEditor/multiDiffEditorWidgetImpl'; import { ICodeEditor } from 'vs/editor/browser/editorBrowser'; import { IDiffEditor } from 'vs/editor/common/editorCommon'; +import { Range } from 'vs/editor/common/core/range'; export class MultiDiffEditor extends AbstractEditorWithViewState { static readonly ID = 'multiDiffEditor'; @@ -39,6 +39,7 @@ export class MultiDiffEditor extends AbstractEditorWithViewState { + override async setInput(input: MultiDiffEditorInput, options: IMultiDiffEditorOptions | undefined, context: IEditorOpenContext, token: CancellationToken): Promise { await super.setInput(input, options, context, token); this._viewModel = await input.getViewModel(); this._multiDiffEditorWidget!.setViewModel(this._viewModel); @@ -81,6 +83,22 @@ export class MultiDiffEditor extends AbstractEditorWithViewState { @@ -96,6 +114,16 @@ export class MultiDiffEditor extends AbstractEditorWithViewState new MultiDiffEditorItem( resource.originalUri ? URI.parse(resource.originalUri) : undefined, resource.modifiedUri ? URI.parse(resource.modifiedUri) : undefined, - )) + )), + false ); } @@ -81,6 +82,7 @@ export class MultiDiffEditorInput extends EditorInput implements ILanguageSuppor public readonly multiDiffSource: URI, public readonly label: string | undefined, public readonly initialResources: readonly MultiDiffEditorItem[] | undefined, + public readonly isTransient: boolean = false, @ITextModelService private readonly _textModelService: ITextModelService, @ITextResourceConfigurationService private readonly _textResourceConfigurationService: ITextResourceConfigurationService, @IInstantiationService private readonly _instantiationService: IInstantiationService, @@ -93,7 +95,10 @@ export class MultiDiffEditorInput extends EditorInput implements ILanguageSuppor /** @description Updates name */ const resources = this._resources.read(reader) ?? []; const label = this.label ?? localize('name', "Multi Diff Editor"); - this._name = label + localize('files', " ({0} files)", resources?.length ?? 0); + this._name = label + localize({ + key: 'files', + comment: ['the number of files being shown'] + }, " ({0} files)", resources?.length ?? 0); this._onDidChangeLabel.fire(); })); } @@ -132,7 +137,7 @@ export class MultiDiffEditorInput extends EditorInput implements ILanguageSuppor }); private async _createModel(): Promise { - const source = await this._resolvedSource.getValue(); + const source = await this._resolvedSource.getPromise(); const textResourceConfigurationService = this._textResourceConfigurationService; // Enables delayed disposing @@ -208,7 +213,7 @@ export class MultiDiffEditorInput extends EditorInput implements ILanguageSuppor }; } - private readonly _resolvedSource = new ObservableLazyStatefulPromise(async () => { + private readonly _resolvedSource = new ObservableLazyPromise(async () => { const source: IResolvedMultiDiffSource | undefined = this.initialResources ? new ConstResolvedMultiDiffSource(this.initialResources) : await this._multiDiffSourceResolverService.resolve(this.multiDiffSource); @@ -230,7 +235,7 @@ export class MultiDiffEditorInput extends EditorInput implements ILanguageSuppor return false; } - private readonly _resources = derived(this, reader => this._resolvedSource.cachedValue.read(reader)?.value?.resources.read(reader)); + private readonly _resources = derived(this, reader => this._resolvedSource.cachedPromiseResult.read(reader)?.data?.resources.read(reader)); private readonly _isDirtyObservables = mapObservableArrayCached(this, this._resources.map(r => r || []), res => { const isModifiedDirty = res.modified ? isUriDirty(this._textFileService, res.modified) : constObservable(false); const isOriginalDirty = res.original ? isUriDirty(this._textFileService, res.original) : constObservable(false); @@ -338,9 +343,9 @@ export class MultiDiffEditorResolverContribution extends Disposable { }, {}, { - createMultiDiffEditorInput: (diffListEditor: IResourceMultiDiffEditorInput): EditorInputWithOptions => { + createMultiDiffEditorInput: (multiDiffEditor: IResourceMultiDiffEditorInput): EditorInputWithOptions => { return { - editor: MultiDiffEditorInput.fromResourceMultiDiffEditorInput(diffListEditor, instantiationService), + editor: MultiDiffEditorInput.fromResourceMultiDiffEditorInput(multiDiffEditor, instantiationService), }; }, } @@ -358,11 +363,16 @@ interface ISerializedMultiDiffEditorInput { } export class MultiDiffEditorSerializer implements IEditorSerializer { - canSerialize(editor: EditorInput): boolean { - return editor instanceof MultiDiffEditorInput; + + canSerialize(editor: EditorInput): editor is MultiDiffEditorInput { + return editor instanceof MultiDiffEditorInput && !editor.isTransient; } serialize(editor: MultiDiffEditorInput): string | undefined { + if (!this.canSerialize(editor)) { + return undefined; + } + return JSON.stringify(editor.serialize()); } diff --git a/src/vs/workbench/contrib/multiDiffEditor/browser/scmMultiDiffSourceResolver.ts b/src/vs/workbench/contrib/multiDiffEditor/browser/scmMultiDiffSourceResolver.ts index 318566cc48654..845a58ab46cb5 100644 --- a/src/vs/workbench/contrib/multiDiffEditor/browser/scmMultiDiffSourceResolver.ts +++ b/src/vs/workbench/contrib/multiDiffEditor/browser/scmMultiDiffSourceResolver.ts @@ -31,7 +31,7 @@ export class ScmMultiDiffSourceResolver implements IMultiDiffSourceResolver { return undefined; } - let query: any; + let query: UriFields; try { query = JSON.parse(uri.query) as UriFields; } catch (e) { diff --git a/src/vs/workbench/contrib/multiDiffEditor/browser/utils.ts b/src/vs/workbench/contrib/multiDiffEditor/browser/utils.ts deleted file mode 100644 index 26383b333ea07..0000000000000 --- a/src/vs/workbench/contrib/multiDiffEditor/browser/utils.ts +++ /dev/null @@ -1,99 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { IObservable, derived, observableValue } from 'vs/base/common/observable'; - -export class ObservableLazy { - private readonly _value = observableValue(this, undefined); - - /** - * The cached value. - * Does not force a computation of the value. - */ - public get cachedValue(): IObservable { return this._value; } - - constructor(private readonly _computeValue: () => T) { - } - - /** - * Returns the cached value. - * Computes the value if the value has not been cached yet. - */ - public getValue() { - let v = this._value.get(); - if (!v) { - v = this._computeValue(); - this._value.set(v, undefined); - } - return v; - } -} - -/** - * A promise whose state is observable. - */ -export class ObservablePromise { - private readonly _value = observableValue | undefined>(this, undefined); - - public readonly promise: Promise; - public readonly value: IObservable | undefined> = this._value; - - constructor(promise: Promise) { - this.promise = promise.then(value => { - this._value.set(new PromiseResult(value, undefined), undefined); - return value; - }, error => { - this._value.set(new PromiseResult(undefined, error), undefined); - throw error; - }); - } -} - -export class PromiseResult { - constructor( - /** - * The value of the resolved promise. - * Undefined if the promise rejected. - */ - public readonly value: T | undefined, - - /** - * The error in case of a rejected promise. - * Undefined if the promise resolved. - */ - public readonly error: unknown | undefined, - ) { - } - - /** - * Returns the value if the promise resolved, otherwise throws the error. - */ - public getValue(): T { - if (this.error) { - throw this.error; - } - return this.value!; - } -} - -/** - * A lazy promise whose state is observable. - */ -export class ObservableLazyStatefulPromise { - private readonly _lazyValue = new ObservableLazy(() => new ObservablePromise(this._computeValue())); - - /** - * Does not enforce evaluation of the promise compute function. - * Is undefined if the promise has not been computed yet. - */ - public readonly cachedValue = derived(this, reader => this._lazyValue.cachedValue.read(reader)?.value.read(reader)); - - constructor(private readonly _computeValue: () => Promise) { - } - - public getValue(): Promise { - return this._lazyValue.getValue().promise; - } -} diff --git a/src/vs/workbench/contrib/notebook/browser/contrib/cellCommands/cellCommands.ts b/src/vs/workbench/contrib/notebook/browser/contrib/cellCommands/cellCommands.ts index 77a91525426ff..de90619669fa0 100644 --- a/src/vs/workbench/contrib/notebook/browser/contrib/cellCommands/cellCommands.ts +++ b/src/vs/workbench/contrib/notebook/browser/contrib/cellCommands/cellCommands.ts @@ -14,14 +14,18 @@ import { ServicesAccessor } from 'vs/platform/instantiation/common/instantiation import { KeybindingWeight } from 'vs/platform/keybinding/common/keybindingsRegistry'; import { ResourceNotebookCellEdit } from 'vs/workbench/contrib/bulkEdit/browser/bulkCellEdits'; import { changeCellToKind, computeCellLinesContents, copyCellRange, joinCellsWithSurrounds, joinSelectedCells, moveCellRange } from 'vs/workbench/contrib/notebook/browser/controller/cellOperations'; -import { cellExecutionArgs, CellOverflowToolbarGroups, CellToolbarOrder, CELL_TITLE_CELL_GROUP_ID, INotebookCellActionContext, INotebookCellToolbarActionContext, INotebookCommandContext, NotebookCellAction, NotebookMultiCellAction, parseMultiCellExecutionArgs } from 'vs/workbench/contrib/notebook/browser/controller/coreActions'; +import { cellExecutionArgs, CellOverflowToolbarGroups, CellToolbarOrder, CELL_TITLE_CELL_GROUP_ID, INotebookCellActionContext, INotebookCellToolbarActionContext, INotebookCommandContext, NotebookCellAction, NotebookMultiCellAction, parseMultiCellExecutionArgs, findTargetCellEditor } from 'vs/workbench/contrib/notebook/browser/controller/coreActions'; import { CellFocusMode, EXPAND_CELL_INPUT_COMMAND_ID, EXPAND_CELL_OUTPUT_COMMAND_ID, ICellOutputViewModel, ICellViewModel, INotebookEditor } from 'vs/workbench/contrib/notebook/browser/notebookBrowser'; -import { NOTEBOOK_CELL_EDITABLE, NOTEBOOK_CELL_HAS_OUTPUTS, NOTEBOOK_CELL_INPUT_COLLAPSED, NOTEBOOK_CELL_LIST_FOCUSED, NOTEBOOK_CELL_OUTPUT_COLLAPSED, NOTEBOOK_CELL_TYPE, NOTEBOOK_EDITOR_EDITABLE, NOTEBOOK_EDITOR_FOCUSED, NOTEBOOK_IS_ACTIVE_EDITOR, NOTEBOOK_OUTPUT_FOCUSED } from 'vs/workbench/contrib/notebook/common/notebookContextKeys'; +import { NOTEBOOK_CELL_EDITABLE, NOTEBOOK_CELL_EDITOR_FOCUSED, NOTEBOOK_CELL_FOCUSED, NOTEBOOK_CELL_HAS_ERROR_DIAGNOSTICS, NOTEBOOK_CELL_HAS_OUTPUTS, NOTEBOOK_CELL_INPUT_COLLAPSED, NOTEBOOK_CELL_LIST_FOCUSED, NOTEBOOK_CELL_OUTPUT_COLLAPSED, NOTEBOOK_CELL_TYPE, NOTEBOOK_EDITOR_EDITABLE, NOTEBOOK_EDITOR_FOCUSED, NOTEBOOK_IS_ACTIVE_EDITOR, NOTEBOOK_OUTPUT_FOCUSED } from 'vs/workbench/contrib/notebook/common/notebookContextKeys'; import * as icons from 'vs/workbench/contrib/notebook/browser/notebookIcons'; import { CellEditType, CellKind, NotebookSetting } from 'vs/workbench/contrib/notebook/common/notebookCommon'; import { INotificationService } from 'vs/platform/notification/common/notification'; import { EditorContextKeys } from 'vs/editor/common/editorContextKeys'; import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; +import { CodeCellViewModel } from 'vs/workbench/contrib/notebook/browser/viewModel/codeCellViewModel'; +import { Range } from 'vs/editor/common/core/range'; +import { CodeActionController } from 'vs/editor/contrib/codeAction/browser/codeActionController'; +import { CodeActionKind, CodeActionTriggerSource } from 'vs/editor/contrib/codeAction/common/types'; //#region Move/Copy cells const MOVE_CELL_UP_COMMAND_ID = 'notebook.cell.moveUp'; @@ -353,6 +357,7 @@ const COLLAPSE_ALL_CELL_OUTPUTS_COMMAND_ID = 'notebook.cell.collapseAllCellOutpu const EXPAND_ALL_CELL_OUTPUTS_COMMAND_ID = 'notebook.cell.expandAllCellOutputs'; const TOGGLE_CELL_OUTPUTS_COMMAND_ID = 'notebook.cell.toggleOutputs'; const TOGGLE_CELL_OUTPUT_SCROLLING = 'notebook.cell.toggleOutputScrolling'; +export const OPEN_CELL_FAILURE_ACTIONS_COMMAND_ID = 'notebook.cell.openFailureActions'; registerAction2(class CollapseCellInputAction extends NotebookMultiCellAction { constructor() { @@ -579,6 +584,45 @@ registerAction2(class ToggleCellOutputScrolling extends NotebookMultiCellAction } }); +registerAction2(class ExpandAllCellOutputsAction extends NotebookCellAction { + constructor() { + super({ + id: OPEN_CELL_FAILURE_ACTIONS_COMMAND_ID, + title: localize2('notebookActions.cellFailureActions', "Show Cell Failure Actions"), + precondition: ContextKeyExpr.and(NOTEBOOK_CELL_FOCUSED, NOTEBOOK_CELL_HAS_ERROR_DIAGNOSTICS, NOTEBOOK_CELL_EDITOR_FOCUSED.toNegated()), + f1: true, + keybinding: { + when: ContextKeyExpr.and(NOTEBOOK_CELL_FOCUSED, NOTEBOOK_CELL_HAS_ERROR_DIAGNOSTICS, NOTEBOOK_CELL_EDITOR_FOCUSED.toNegated()), + primary: KeyMod.CtrlCmd | KeyCode.Period, + weight: KeybindingWeight.WorkbenchContrib + } + }); + } + + async runWithContext(accessor: ServicesAccessor, context: INotebookCellActionContext): Promise { + if (context.cell instanceof CodeCellViewModel) { + const error = context.cell.cellDiagnostics.ErrorDetails; + if (error?.location) { + const location = Range.lift({ + startLineNumber: error.location.startLineNumber + 1, + startColumn: error.location.startColumn + 1, + endLineNumber: error.location.endLineNumber + 1, + endColumn: error.location.endColumn + 1 + }); + context.notebookEditor.setCellEditorSelection(context.cell, Range.lift(location)); + const editor = findTargetCellEditor(context, context.cell); + if (editor) { + const controller = CodeActionController.get(editor); + controller?.manualTriggerAtCurrentPosition( + localize('cellCommands.quickFix.noneMessage', "No code actions available"), + CodeActionTriggerSource.Default, + { include: CodeActionKind.QuickFix }); + } + } + } + } +}); + //#endregion function forEachCell(editor: INotebookEditor, callback: (cell: ICellViewModel, index: number) => void) { diff --git a/src/vs/workbench/contrib/notebook/browser/contrib/cellDiagnostics/cellDiagnostics.ts b/src/vs/workbench/contrib/notebook/browser/contrib/cellDiagnostics/cellDiagnostics.ts new file mode 100644 index 0000000000000..d04037b939fce --- /dev/null +++ b/src/vs/workbench/contrib/notebook/browser/contrib/cellDiagnostics/cellDiagnostics.ts @@ -0,0 +1,113 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable } from 'vs/base/common/lifecycle'; +import { IMarkerData, IMarkerService } from 'vs/platform/markers/common/markers'; +import { IRange } from 'vs/editor/common/core/range'; +import { ICellExecutionError, ICellExecutionStateChangedEvent, IExecutionStateChangedEvent, INotebookExecutionStateService, NotebookExecutionType } from 'vs/workbench/contrib/notebook/common/notebookExecutionStateService'; +import { IInlineChatService } from 'vs/workbench/contrib/inlineChat/common/inlineChat'; +import { Iterable } from 'vs/base/common/iterator'; +import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; +import { NotebookSetting } from 'vs/workbench/contrib/notebook/common/notebookCommon'; +import { CodeCellViewModel } from 'vs/workbench/contrib/notebook/browser/viewModel/codeCellViewModel'; +import { Emitter, Event } from 'vs/base/common/event'; + + +export class CellDiagnostics extends Disposable { + + private readonly _onDidDiagnosticsChange = new Emitter(); + readonly onDidDiagnosticsChange: Event = this._onDidDiagnosticsChange.event; + + static ID: string = 'workbench.notebook.cellDiagnostics'; + + private enabled = false; + private listening = false; + private errorDetails: ICellExecutionError | undefined = undefined; + public get ErrorDetails() { + return this.errorDetails; + } + + constructor( + private readonly cell: CodeCellViewModel, + @INotebookExecutionStateService private readonly notebookExecutionStateService: INotebookExecutionStateService, + @IMarkerService private readonly markerService: IMarkerService, + @IInlineChatService private readonly inlineChatService: IInlineChatService, + @IConfigurationService private readonly configurationService: IConfigurationService + ) { + super(); + + if (cell.viewType !== 'interactive') { + this.updateEnabled(); + + this._register(inlineChatService.onDidChangeProviders(() => this.updateEnabled())); + this._register(configurationService.onDidChangeConfiguration((e) => { + if (e.affectsConfiguration(NotebookSetting.cellFailureDiagnostics)) { + this.updateEnabled(); + } + })); + } + } + + private updateEnabled() { + const settingEnabled = this.configurationService.getValue(NotebookSetting.cellFailureDiagnostics); + if (this.enabled && (!settingEnabled || Iterable.isEmpty(this.inlineChatService.getAllProvider()))) { + this.enabled = false; + this.clear(); + } else if (!this.enabled && settingEnabled && !Iterable.isEmpty(this.inlineChatService.getAllProvider())) { + this.enabled = true; + if (!this.listening) { + this.listening = true; + this._register(this.notebookExecutionStateService.onDidChangeExecution((e) => this.handleChangeExecutionState(e))); + } + } + } + + private handleChangeExecutionState(e: ICellExecutionStateChangedEvent | IExecutionStateChangedEvent) { + if (this.enabled && e.type === NotebookExecutionType.cell && e.affectsCell(this.cell.uri)) { + if (!!e.changed) { + // cell is running + this.clear(); + } else { + this.setDiagnostics(); + } + } + } + + public clear() { + if (this.ErrorDetails) { + this.markerService.changeOne(CellDiagnostics.ID, this.cell.uri, []); + this.errorDetails = undefined; + this._onDidDiagnosticsChange.fire(); + } + } + + private setDiagnostics() { + const metadata = this.cell.model.internalMetadata; + if (!metadata.lastRunSuccess && metadata?.error?.location) { + const marker = this.createMarkerData(metadata.error.message, metadata.error.location); + this.markerService.changeOne(CellDiagnostics.ID, this.cell.uri, [marker]); + this.errorDetails = metadata.error; + this._onDidDiagnosticsChange.fire(); + } + } + + private createMarkerData(message: string, location: IRange): IMarkerData { + return { + severity: 8, + message: message, + startLineNumber: location.startLineNumber + 1, + startColumn: location.startColumn + 1, + endLineNumber: location.endLineNumber + 1, + endColumn: location.endColumn + 1, + source: 'Cell Execution Error' + }; + } + + override dispose() { + super.dispose(); + this.clear(); + } + +} diff --git a/src/vs/workbench/contrib/notebook/browser/contrib/cellStatusBar/executionStatusBarItemController.ts b/src/vs/workbench/contrib/notebook/browser/contrib/cellStatusBar/executionStatusBarItemController.ts index 34a10466dd1cb..59fdc90a8c5f8 100644 --- a/src/vs/workbench/contrib/notebook/browser/contrib/cellStatusBar/executionStatusBarItemController.ts +++ b/src/vs/workbench/contrib/notebook/browser/contrib/cellStatusBar/executionStatusBarItemController.ts @@ -19,6 +19,9 @@ import { CellStatusbarAlignment, INotebookCellStatusBarItem, NotebookCellExecuti import { INotebookCellExecution, INotebookExecutionStateService, NotebookExecutionType } from 'vs/workbench/contrib/notebook/common/notebookExecutionStateService'; import { INotebookService } from 'vs/workbench/contrib/notebook/common/notebookService'; import { IMarkdownString } from 'vs/base/common/htmlContent'; +import { CodeCellViewModel } from 'vs/workbench/contrib/notebook/browser/viewModel/codeCellViewModel'; +import { OPEN_CELL_FAILURE_ACTIONS_COMMAND_ID } from 'vs/workbench/contrib/notebook/browser/contrib/cellCommands/cellCommands'; +import { IKeybindingService } from 'vs/platform/keybinding/common/keybinding'; export function formatCellDuration(duration: number, showMilliseconds: boolean = true): string { if (showMilliseconds && duration < 1000) { @@ -102,7 +105,7 @@ class ExecutionStateCellStatusBarItem extends Disposable { private _currentItemIds: string[] = []; private _showedExecutingStateTime: number | undefined; - private _clearExecutingStateTimer = this._register(new MutableDisposable()); + private readonly _clearExecutingStateTimer = this._register(new MutableDisposable()); constructor( private readonly _notebookViewModel: INotebookViewModel, @@ -333,3 +336,60 @@ class TimerCellStatusBarItem extends Disposable { this._notebookViewModel.deltaCellStatusBarItems(this._currentItemIds, [{ handle: this._cell.handle, items: [] }]); } } + +export class DiagnosticCellStatusBarContrib extends Disposable implements INotebookEditorContribution { + static id: string = 'workbench.notebook.statusBar.diagtnostic'; + + constructor( + notebookEditor: INotebookEditor, + @IInstantiationService instantiationService: IInstantiationService + ) { + super(); + this._register(new NotebookStatusBarController(notebookEditor, (vm, cell) => + cell instanceof CodeCellViewModel ? + instantiationService.createInstance(DiagnosticCellStatusBarItem, vm, cell) : + Disposable.None + )); + } +} +registerNotebookContribution(DiagnosticCellStatusBarContrib.id, DiagnosticCellStatusBarContrib); + + +class DiagnosticCellStatusBarItem extends Disposable { + private _currentItemIds: string[] = []; + + constructor( + private readonly _notebookViewModel: INotebookViewModel, + private readonly cell: CodeCellViewModel, + @IKeybindingService private readonly keybindingService: IKeybindingService + ) { + super(); + this._update(); + this._register(this.cell.cellDiagnostics.onDidDiagnosticsChange(() => this._update())); + } + + private async _update() { + let item: INotebookCellStatusBarItem | undefined; + + if (!!this.cell.cellDiagnostics.ErrorDetails) { + const keybinding = this.keybindingService.lookupKeybinding(OPEN_CELL_FAILURE_ACTIONS_COMMAND_ID)?.getLabel(); + const tooltip = localize('notebook.cell.status.diagnostic', "Quick Actions {0}", `(${keybinding})`); + + item = { + text: `$(sparkle)`, + tooltip, + alignment: CellStatusbarAlignment.Left, + command: OPEN_CELL_FAILURE_ACTIONS_COMMAND_ID, + priority: Number.MAX_SAFE_INTEGER - 1 + }; + } + + const items = item ? [item] : []; + this._currentItemIds = this._notebookViewModel.deltaCellStatusBarItems(this._currentItemIds, [{ handle: this.cell.handle, items }]); + } + + override dispose() { + super.dispose(); + this._notebookViewModel.deltaCellStatusBarItems(this._currentItemIds, [{ handle: this.cell.handle, items: [] }]); + } +} diff --git a/src/vs/workbench/contrib/notebook/browser/contrib/clipboard/notebookClipboard.ts b/src/vs/workbench/contrib/notebook/browser/contrib/clipboard/notebookClipboard.ts index 73f0201827bb6..2c4a1d4343675 100644 --- a/src/vs/workbench/contrib/notebook/browser/contrib/clipboard/notebookClipboard.ts +++ b/src/vs/workbench/contrib/notebook/browser/contrib/clipboard/notebookClipboard.ts @@ -7,7 +7,7 @@ import { localize, localize2 } from 'vs/nls'; import { Disposable } from 'vs/base/common/lifecycle'; import { WorkbenchPhase, registerWorkbenchContribution2 } from 'vs/workbench/common/contributions'; import { IEditorService } from 'vs/workbench/services/editor/common/editorService'; -import { NOTEBOOK_CELL_EDITABLE, NOTEBOOK_EDITOR_EDITABLE, NOTEBOOK_EDITOR_FOCUSED } from 'vs/workbench/contrib/notebook/common/notebookContextKeys'; +import { NOTEBOOK_CELL_EDITABLE, NOTEBOOK_EDITOR_EDITABLE, NOTEBOOK_EDITOR_FOCUSED, NOTEBOOK_OUTPUT_FOCUSED } from 'vs/workbench/contrib/notebook/common/notebookContextKeys'; import { cellRangeToViewCells, expandCellRangesWithHiddenCells, getNotebookEditorFromEditorPane, ICellViewModel, INotebookEditor } from 'vs/workbench/contrib/notebook/browser/notebookBrowser'; import { CopyAction, CutAction, PasteAction } from 'vs/editor/contrib/clipboard/browser/clipboard'; import { IClipboardService } from 'vs/platform/clipboard/common/clipboardService'; @@ -16,7 +16,7 @@ import { CellEditType, ICellEditOperation, ISelectionState, SelectionStateType } import { INotebookService } from 'vs/workbench/contrib/notebook/common/notebookService'; import * as platform from 'vs/base/common/platform'; import { Action2, MenuId, registerAction2 } from 'vs/platform/actions/common/actions'; -import { CellOverflowToolbarGroups, INotebookActionContext, INotebookCellActionContext, NotebookAction, NotebookCellAction, NOTEBOOK_EDITOR_WIDGET_ACTION_WEIGHT } from 'vs/workbench/contrib/notebook/browser/controller/coreActions'; +import { CellOverflowToolbarGroups, INotebookActionContext, INotebookCellActionContext, NotebookAction, NotebookCellAction, NOTEBOOK_EDITOR_WIDGET_ACTION_WEIGHT, NOTEBOOK_OUTPUT_WEBVIEW_ACTION_WEIGHT } from 'vs/workbench/contrib/notebook/browser/controller/coreActions'; import { KeyCode, KeyMod } from 'vs/base/common/keyCodes'; import { ContextKeyExpr } from 'vs/platform/contextkey/common/contextkey'; import { InputFocusedContextKey } from 'vs/platform/contextkey/common/contextkeys'; @@ -41,7 +41,7 @@ function _log(loggerService: ILogService, str: string) { } } -function getFocusedWebviewDelegate(accessor: ServicesAccessor): IWebview | undefined { +function getFocusedEditor(accessor: ServicesAccessor) { const loggerService = accessor.get(ILogService); const editorService = accessor.get(IEditorService); const editor = getNotebookEditorFromEditorPane(editorService.activeEditorPane); @@ -59,9 +59,21 @@ function getFocusedWebviewDelegate(accessor: ServicesAccessor): IWebview | undef _log(loggerService, '[Revive Webview] Notebook editor backlayer webview is not focused, bypass'); return; } + // If none of the outputs have focus, then webview is not focused + const view = editor.getViewModel(); + if (view && view.viewCells.every(cell => !cell.outputIsFocused && !cell.outputIsHovered)) { + return; + } - const webview = editor.getInnerWebview(); - _log(loggerService, '[Revive Webview] Notebook editor backlayer webview is focused'); + return { editor, loggerService }; +} +function getFocusedWebviewDelegate(accessor: ServicesAccessor): IWebview | undefined { + const result = getFocusedEditor(accessor); + if (!result) { + return; + } + const webview = result.editor.getInnerWebview(); + _log(result.loggerService, '[Revive Webview] Notebook editor backlayer webview is focused'); return webview; } @@ -74,6 +86,11 @@ function withWebview(accessor: ServicesAccessor, f: (webviewe: IWebview) => void return false; } +function withEditor(accessor: ServicesAccessor, f: (editor: INotebookEditor) => boolean) { + const result = getFocusedEditor(accessor); + return result ? f(result.editor) : false; +} + const PRIORITY = 105; UndoCommand.addImplementation(PRIORITY, 'notebook-webview', accessor => { @@ -96,7 +113,6 @@ CutAction?.addImplementation(PRIORITY, 'notebook-webview', accessor => { return withWebview(accessor, webview => webview.cut()); }); - export function runPasteCells(editor: INotebookEditor, activeCell: ICellViewModel | undefined, pasteCells: { items: NotebookCellTextModel[]; isCopy: boolean; @@ -422,6 +438,7 @@ registerAction2(class extends NotebookCellAction { id: MenuId.NotebookCellTitle, when: NOTEBOOK_EDITOR_FOCUSED, group: CellOverflowToolbarGroups.Copy, + order: 2, }, keybinding: platform.isNative ? undefined : { primary: KeyMod.CtrlCmd | KeyCode.KeyC, @@ -447,6 +464,7 @@ registerAction2(class extends NotebookCellAction { id: MenuId.NotebookCellTitle, when: ContextKeyExpr.and(NOTEBOOK_EDITOR_FOCUSED, NOTEBOOK_EDITOR_EDITABLE, NOTEBOOK_CELL_EDITABLE), group: CellOverflowToolbarGroups.Copy, + order: 1, }, keybinding: platform.isNative ? undefined : { when: ContextKeyExpr.and(NOTEBOOK_EDITOR_FOCUSED, ContextKeyExpr.not(InputFocusedContextKey)), @@ -472,6 +490,7 @@ registerAction2(class extends NotebookAction { id: MenuId.NotebookCellTitle, when: ContextKeyExpr.and(NOTEBOOK_EDITOR_FOCUSED, NOTEBOOK_EDITOR_EDITABLE), group: CellOverflowToolbarGroups.Copy, + order: 3, }, keybinding: platform.isNative ? undefined : { when: ContextKeyExpr.and(NOTEBOOK_EDITOR_FOCUSED, ContextKeyExpr.not(InputFocusedContextKey)), @@ -568,3 +587,41 @@ registerAction2(class extends Action2 { } } }); + + +registerAction2(class extends NotebookCellAction { + constructor() { + super( + { + id: 'notebook.cell.output.selectAll', + title: localize('notebook.cell.output.selectAll', "Select All"), + keybinding: { + primary: KeyMod.CtrlCmd | KeyCode.KeyA, + when: ContextKeyExpr.and(NOTEBOOK_EDITOR_FOCUSED, NOTEBOOK_OUTPUT_FOCUSED), + weight: NOTEBOOK_OUTPUT_WEBVIEW_ACTION_WEIGHT + } + }); + } + + async runWithContext(accessor: ServicesAccessor, _context: INotebookCellActionContext) { + withEditor(accessor, editor => { + if (!editor.hasEditorFocus()) { + return false; + } + if (editor.hasEditorFocus() && !editor.hasWebviewFocus()) { + return true; + } + const cell = editor.getActiveCell(); + if (!cell || !cell.outputIsFocused || !editor.hasWebviewFocus()) { + return true; + } + if (cell.inputInOutputIsFocused) { + editor.selectInputContents(cell); + } else { + editor.selectOutputContent(cell); + } + return true; + }); + + } +}); diff --git a/src/vs/workbench/contrib/notebook/browser/contrib/editorHint/emptyCellEditorHint.ts b/src/vs/workbench/contrib/notebook/browser/contrib/editorHint/emptyCellEditorHint.ts index 8bbbe4c775314..2ee5da0b02b7c 100644 --- a/src/vs/workbench/contrib/notebook/browser/contrib/editorHint/emptyCellEditorHint.ts +++ b/src/vs/workbench/contrib/notebook/browser/contrib/editorHint/emptyCellEditorHint.ts @@ -8,6 +8,7 @@ import { ICodeEditor } from 'vs/editor/browser/editorBrowser'; import { EditorContributionInstantiation, registerEditorContribution } from 'vs/editor/browser/editorExtensions'; import { ICommandService } from 'vs/platform/commands/common/commands'; import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; +import { IHoverService } from 'vs/platform/hover/browser/hover'; import { IKeybindingService } from 'vs/platform/keybinding/common/keybinding'; import { IProductService } from 'vs/platform/product/common/productService'; import { ITelemetryService } from 'vs/platform/telemetry/common/telemetry'; @@ -26,6 +27,7 @@ export class EmptyCellEditorHintContribution extends EmptyTextEditorHintContribu @IEditorGroupsService editorGroupsService: IEditorGroupsService, @ICommandService commandService: ICommandService, @IConfigurationService configurationService: IConfigurationService, + @IHoverService hoverService: IHoverService, @IKeybindingService keybindingService: IKeybindingService, @IInlineChatSessionService inlineChatSessionService: IInlineChatSessionService, @IInlineChatService inlineChatService: IInlineChatService, @@ -37,6 +39,7 @@ export class EmptyCellEditorHintContribution extends EmptyTextEditorHintContribu editorGroupsService, commandService, configurationService, + hoverService, keybindingService, inlineChatSessionService, inlineChatService, diff --git a/src/vs/workbench/contrib/notebook/browser/contrib/editorStatusBar/editorStatusBar.ts b/src/vs/workbench/contrib/notebook/browser/contrib/editorStatusBar/editorStatusBar.ts index 9dd8e75711773..5ad7c67426883 100644 --- a/src/vs/workbench/contrib/notebook/browser/contrib/editorStatusBar/editorStatusBar.ts +++ b/src/vs/workbench/contrib/notebook/browser/contrib/editorStatusBar/editorStatusBar.ts @@ -3,17 +3,19 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import * as nls from 'vs/nls'; import { Disposable, DisposableStore, IDisposable, MutableDisposable } from 'vs/base/common/lifecycle'; import { Schemas } from 'vs/base/common/network'; import { ILanguageFeaturesService } from 'vs/editor/common/services/languageFeatures'; -import * as nls from 'vs/nls'; +import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; import { ILogService } from 'vs/platform/log/common/log'; import { Registry } from 'vs/platform/registry/common/platform'; -import { Extensions as WorkbenchExtensions, IWorkbenchContribution, IWorkbenchContributionsRegistry } from 'vs/workbench/common/contributions'; +import { IWorkbenchContribution, IWorkbenchContributionsRegistry, Extensions as WorkbenchExtensions, WorkbenchPhase, registerWorkbenchContribution2 } from 'vs/workbench/common/contributions'; import { CENTER_ACTIVE_CELL } from 'vs/workbench/contrib/notebook/browser/contrib/navigation/arrow'; import { SELECT_KERNEL_ID } from 'vs/workbench/contrib/notebook/browser/controller/coreActions'; -import { getNotebookEditorFromEditorPane, INotebookEditor } from 'vs/workbench/contrib/notebook/browser/notebookBrowser'; +import { SELECT_NOTEBOOK_INDENTATION_ID } from 'vs/workbench/contrib/notebook/browser/controller/editActions'; +import { INotebookEditor, getNotebookEditorFromEditorPane } from 'vs/workbench/contrib/notebook/browser/notebookBrowser'; import { NotebookTextModel } from 'vs/workbench/contrib/notebook/common/model/notebookTextModel'; import { NotebookCellsChangeType } from 'vs/workbench/contrib/notebook/common/notebookCommon'; import { INotebookKernel, INotebookKernelService } from 'vs/workbench/contrib/notebook/common/notebookKernelService'; @@ -254,3 +256,87 @@ export class ActiveCellStatus extends Disposable implements IWorkbenchContributi } Registry.as(WorkbenchExtensions.Workbench).registerWorkbenchContribution(ActiveCellStatus, LifecyclePhase.Restored); + +export class NotebookIndentationStatus extends Disposable implements IWorkbenchContribution { + + private readonly _itemDisposables = this._register(new DisposableStore()); + private readonly _accessor = this._register(new MutableDisposable()); + + static readonly ID = 'selectNotebookIndentation'; + + constructor( + @IEditorService private readonly _editorService: IEditorService, + @IStatusbarService private readonly _statusbarService: IStatusbarService, + @IConfigurationService private readonly _configurationService: IConfigurationService, + ) { + super(); + this._register(this._editorService.onDidActiveEditorChange(() => this._update())); + this._register(this._configurationService.onDidChangeConfiguration(e => { + if (e.affectsConfiguration('editor') || e.affectsConfiguration('notebook')) { + this._update(); + } + })); + } + + private _update() { + this._itemDisposables.clear(); + const activeEditor = getNotebookEditorFromEditorPane(this._editorService.activeEditorPane); + if (activeEditor) { + this._show(activeEditor); + this._itemDisposables.add(activeEditor.onDidChangeSelection(() => { + this._accessor.clear(); + this._show(activeEditor); + })); + } else { + this._accessor.clear(); + } + } + + private _show(editor: INotebookEditor) { + if (!editor.hasModel()) { + this._accessor.clear(); + return; + } + + const cellOptions = editor.getActiveCell()?.textModel?.getOptions(); + if (!cellOptions) { + this._accessor.clear(); + return; + } + + const cellEditorOverridesRaw = editor.notebookOptions.getDisplayOptions().editorOptionsCustomizations; + const indentSize = cellEditorOverridesRaw?.['editor.indentSize'] ?? cellOptions?.indentSize; + const insertSpaces = cellEditorOverridesRaw?.['editor.insertSpaces'] ?? cellOptions?.insertSpaces; + const tabSize = cellEditorOverridesRaw?.['editor.tabSize'] ?? cellOptions?.tabSize; + + const width = typeof indentSize === 'number' ? indentSize : tabSize; + + const message = insertSpaces ? `Spaces: ${width}` : `Tab Size: ${width}`; + const newText = message; + if (!newText) { + this._accessor.clear(); + return; + } + + const entry: IStatusbarEntry = { + name: nls.localize('notebook.indentation', "Notebook Indentation"), + text: newText, + ariaLabel: newText, + tooltip: nls.localize('selectNotebookIndentation', "Select Indentation"), + command: SELECT_NOTEBOOK_INDENTATION_ID + }; + + if (!this._accessor.value) { + this._accessor.value = this._statusbarService.addEntry( + entry, + 'notebook.status.indentation', + StatusbarAlignment.RIGHT, + 100.4 + ); + } else { + this._accessor.value.update(entry); + } + } +} + +registerWorkbenchContribution2(NotebookIndentationStatus.ID, NotebookIndentationStatus, WorkbenchPhase.AfterRestored); // TODO@Yoyokrazy -- unsure on the phase diff --git a/src/vs/workbench/contrib/notebook/browser/contrib/find/notebookFindReplaceWidget.ts b/src/vs/workbench/contrib/notebook/browser/contrib/find/notebookFindReplaceWidget.ts index 480033afb33d9..d616161b30996 100644 --- a/src/vs/workbench/contrib/notebook/browser/contrib/find/notebookFindReplaceWidget.ts +++ b/src/vs/workbench/contrib/notebook/browser/contrib/find/notebookFindReplaceWidget.ts @@ -40,6 +40,8 @@ import { defaultInputBoxStyles, defaultProgressBarStyles, defaultToggleStyles } import { IToggleStyles } from 'vs/base/browser/ui/toggle/toggle'; import { Disposable } from 'vs/base/common/lifecycle'; import { NotebookSetting } from 'vs/workbench/contrib/notebook/common/notebookCommon'; +import { IActionViewItemOptions } from 'vs/base/browser/ui/actionbar/actionViewItems'; +import { IHoverService } from 'vs/platform/hover/browser/hover'; const NLS_FIND_INPUT_LABEL = nls.localize('label.find', "Find"); const NLS_FIND_INPUT_PLACEHOLDER = nls.localize('placeholder.find', "Find"); @@ -63,11 +65,12 @@ const NOTEBOOK_FIND_IN_CODE_OUTPUT = nls.localize('notebook.find.filter.findInCo const NOTEBOOK_FIND_WIDGET_INITIAL_WIDTH = 318; const NOTEBOOK_FIND_WIDGET_INITIAL_HORIZONTAL_PADDING = 4; class NotebookFindFilterActionViewItem extends DropdownMenuActionViewItem { - constructor(readonly filters: NotebookFindFilters, action: IAction, actionRunner: IActionRunner, @IContextMenuService contextMenuService: IContextMenuService) { + constructor(readonly filters: NotebookFindFilters, action: IAction, options: IActionViewItemOptions, actionRunner: IActionRunner, @IContextMenuService contextMenuService: IContextMenuService) { super(action, { getActions: () => this.getActions() }, contextMenuService, { + ...options, actionRunner, classNames: action.class, anchorAlignmentProvider: () => AnchorAlignment.RIGHT @@ -180,10 +183,26 @@ export class NotebookFindInputFilterButton extends Disposable { return this._filterButtonContainer; } - get width() { + width() { return 2 /*margin left*/ + 2 /*border*/ + 2 /*padding*/ + 16 /* icon width */; } + enable(): void { + this.container.setAttribute('aria-disabled', String(false)); + } + + disable(): void { + this.container.setAttribute('aria-disabled', String(true)); + } + + set visible(visible: boolean) { + this._filterButtonContainer.style.display = visible ? '' : 'none'; + } + + get visible() { + return this._filterButtonContainer.style.display !== 'none'; + } + applyStyles(filterChecked: boolean): void { const toggleStyles = this._toggleStyles; @@ -196,9 +215,9 @@ export class NotebookFindInputFilterButton extends Disposable { private createFilters(container: HTMLElement): void { this._actionbar = this._register(new ActionBar(container, { - actionViewItemProvider: action => { + actionViewItemProvider: (action, options) => { if (action.id === this._filtersAction.id) { - return this.instantiationService.createInstance(NotebookFindFilterActionViewItem, this.filters, action, new ActionRunner()); + return this.instantiationService.createInstance(NotebookFindFilterActionViewItem, this.filters, action, options, new ActionRunner()); } return undefined; } @@ -225,7 +244,7 @@ export class NotebookFindInput extends FindInput { this._register(registerAndCreateHistoryNavigationContext(contextKeyService, this.inputBox)); this._findFilter = this._register(new NotebookFindInputFilterButton(filters, contextMenuService, instantiationService, options)); - this.inputBox.paddingRight = (this.caseSensitive?.width() ?? 0) + (this.wholeWords?.width() ?? 0) + (this.regex?.width() ?? 0) + this._findFilter.width; + this.inputBox.paddingRight = (this.caseSensitive?.width() ?? 0) + (this.wholeWords?.width() ?? 0) + (this.regex?.width() ?? 0) + this._findFilter.width(); this.controls.appendChild(this._findFilter.container); } @@ -301,6 +320,7 @@ export abstract class SimpleFindReplaceWidget extends Widget { @IConfigurationService protected readonly _configurationService: IConfigurationService, @IContextMenuService private readonly contextMenuService: IContextMenuService, @IInstantiationService private readonly instantiationService: IInstantiationService, + @IHoverService hoverService: IHoverService, protected readonly _state: FindReplaceState = new FindReplaceState(), protected readonly _notebookEditor: INotebookEditor, ) { @@ -340,7 +360,7 @@ export abstract class SimpleFindReplaceWidget extends Widget { this._state.change({ isReplaceRevealed: this._isReplaceVisible }, false); this._updateReplaceViewDisplay(); } - })); + }, hoverService)); this._toggleReplaceBtn.setEnabled(!isInteractiveWindow); this._toggleReplaceBtn.setExpanded(this._isReplaceVisible); this._domNode.appendChild(this._toggleReplaceBtn.domNode); @@ -423,7 +443,7 @@ export abstract class SimpleFindReplaceWidget extends Widget { onTrigger: () => { this.find(true); } - })); + }, hoverService)); this.nextBtn = this._register(new SimpleButton({ label: NLS_NEXT_MATCH_BTN_LABEL, @@ -431,7 +451,7 @@ export abstract class SimpleFindReplaceWidget extends Widget { onTrigger: () => { this.find(false); } - })); + }, hoverService)); const closeBtn = this._register(new SimpleButton({ label: NLS_CLOSE_BTN_LABEL, @@ -439,7 +459,7 @@ export abstract class SimpleFindReplaceWidget extends Widget { onTrigger: () => { this.hide(); } - })); + }, hoverService)); this._innerFindDomNode.appendChild(this._findInput.domNode); this._innerFindDomNode.appendChild(this._matchesCount); @@ -500,7 +520,7 @@ export abstract class SimpleFindReplaceWidget extends Widget { onTrigger: () => { this.replaceOne(); } - })); + }, hoverService)); // Replace all button this._replaceAllBtn = this._register(new SimpleButton({ @@ -509,7 +529,7 @@ export abstract class SimpleFindReplaceWidget extends Widget { onTrigger: () => { this.replaceAll(); } - })); + }, hoverService)); this._innerReplaceDomNode.appendChild(this._replaceBtn.domNode); this._innerReplaceDomNode.appendChild(this._replaceAllBtn.domNode); diff --git a/src/vs/workbench/contrib/notebook/browser/contrib/find/notebookFindWidget.ts b/src/vs/workbench/contrib/notebook/browser/contrib/find/notebookFindWidget.ts index ea51ae067cafa..7c92a6b057669 100644 --- a/src/vs/workbench/contrib/notebook/browser/contrib/find/notebookFindWidget.ts +++ b/src/vs/workbench/contrib/notebook/browser/contrib/find/notebookFindWidget.ts @@ -16,10 +16,10 @@ import { MATCHES_LIMIT } from 'vs/editor/contrib/find/browser/findModel'; import { FindReplaceState } from 'vs/editor/contrib/find/browser/findState'; import { NLS_MATCHES_LOCATION, NLS_NO_RESULTS } from 'vs/editor/contrib/find/browser/findWidget'; import { localize } from 'vs/nls'; -import { IMenuService } from 'vs/platform/actions/common/actions'; import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; import { IContextKey, IContextKeyService } from 'vs/platform/contextkey/common/contextkey'; import { IContextMenuService, IContextViewService } from 'vs/platform/contextview/browser/contextView'; +import { IHoverService } from 'vs/platform/hover/browser/hover'; import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; import { NotebookFindFilters } from 'vs/workbench/contrib/notebook/browser/contrib/find/findFilters'; import { FindModel } from 'vs/workbench/contrib/notebook/browser/contrib/find/findModel'; @@ -82,10 +82,10 @@ class NotebookFindWidget extends SimpleFindReplaceWidget implements INotebookEdi @IContextKeyService contextKeyService: IContextKeyService, @IConfigurationService configurationService: IConfigurationService, @IContextMenuService contextMenuService: IContextMenuService, - @IMenuService menuService: IMenuService, + @IHoverService hoverService: IHoverService, @IInstantiationService instantiationService: IInstantiationService, ) { - super(contextViewService, contextKeyService, configurationService, contextMenuService, instantiationService, new FindReplaceState(), _notebookEditor); + super(contextViewService, contextKeyService, configurationService, contextMenuService, instantiationService, hoverService, new FindReplaceState(), _notebookEditor); this._findModel = new FindModel(this._notebookEditor, this._state, this._configurationService); DOM.append(this._notebookEditor.getDomNode(), this.getDomNode()); diff --git a/src/vs/workbench/contrib/notebook/browser/contrib/kernelDetection/notebookKernelDetection.ts b/src/vs/workbench/contrib/notebook/browser/contrib/kernelDetection/notebookKernelDetection.ts index dead468af769a..85bc8020fe75b 100644 --- a/src/vs/workbench/contrib/notebook/browser/contrib/kernelDetection/notebookKernelDetection.ts +++ b/src/vs/workbench/contrib/notebook/browser/contrib/kernelDetection/notebookKernelDetection.ts @@ -14,7 +14,7 @@ import { LifecyclePhase } from 'vs/workbench/services/lifecycle/common/lifecycle class NotebookKernelDetection extends Disposable implements IWorkbenchContribution { private _detectionMap = new Map(); - private _localDisposableStore = this._register(new DisposableStore()); + private readonly _localDisposableStore = this._register(new DisposableStore()); constructor( @INotebookKernelService private readonly _notebookKernelService: INotebookKernelService, diff --git a/src/vs/workbench/contrib/notebook/browser/contrib/navigation/arrow.ts b/src/vs/workbench/contrib/notebook/browser/contrib/navigation/arrow.ts index 2436576bbce5e..cb9e490f91d7a 100644 --- a/src/vs/workbench/contrib/notebook/browser/contrib/navigation/arrow.ts +++ b/src/vs/workbench/contrib/notebook/browser/contrib/navigation/arrow.ts @@ -18,6 +18,7 @@ import { ServicesAccessor } from 'vs/platform/instantiation/common/instantiation import { KeybindingWeight } from 'vs/platform/keybinding/common/keybindingsRegistry'; import { Registry } from 'vs/platform/registry/common/platform'; import { InlineChatController } from 'vs/workbench/contrib/inlineChat/browser/inlineChatController'; +import { CTX_NOTEBOOK_CHAT_OUTER_FOCUS_POSITION } from 'vs/workbench/contrib/notebook/browser/controller/chat/notebookChatContext'; import { INotebookActionContext, INotebookCellActionContext, NotebookAction, NotebookCellAction, NOTEBOOK_EDITOR_WIDGET_ACTION_WEIGHT, findTargetCellEditor } from 'vs/workbench/contrib/notebook/browser/controller/coreActions'; import { CellEditState } from 'vs/workbench/contrib/notebook/browser/notebookBrowser'; import { CellKind, NOTEBOOK_EDITOR_CURSOR_BOUNDARY } from 'vs/workbench/contrib/notebook/common/notebookCommon'; @@ -196,12 +197,18 @@ registerAction2(class extends NotebookAction { super({ id: NOTEBOOK_FOCUS_TOP, title: localize('focusFirstCell', 'Focus First Cell'), - keybinding: { - when: ContextKeyExpr.and(NOTEBOOK_EDITOR_FOCUSED, ContextKeyExpr.not(InputFocusedContextKey)), - primary: KeyMod.CtrlCmd | KeyCode.Home, - mac: { primary: KeyMod.CtrlCmd | KeyCode.UpArrow }, - weight: KeybindingWeight.WorkbenchContrib - }, + keybinding: [ + { + when: ContextKeyExpr.and(NOTEBOOK_EDITOR_FOCUSED, ContextKeyExpr.not(InputFocusedContextKey)), + primary: KeyMod.CtrlCmd | KeyCode.Home, + weight: KeybindingWeight.WorkbenchContrib + }, + { + when: ContextKeyExpr.and(NOTEBOOK_EDITOR_FOCUSED, ContextKeyExpr.not(InputFocusedContextKey), CTX_NOTEBOOK_CHAT_OUTER_FOCUS_POSITION.isEqualTo('')), + mac: { primary: KeyMod.CtrlCmd | KeyCode.UpArrow }, + weight: KeybindingWeight.WorkbenchContrib + } + ], }); } @@ -221,12 +228,19 @@ registerAction2(class extends NotebookAction { super({ id: NOTEBOOK_FOCUS_BOTTOM, title: localize('focusLastCell', 'Focus Last Cell'), - keybinding: { - when: ContextKeyExpr.and(NOTEBOOK_EDITOR_FOCUSED, ContextKeyExpr.not(InputFocusedContextKey)), - primary: KeyMod.CtrlCmd | KeyCode.End, - mac: { primary: KeyMod.CtrlCmd | KeyCode.DownArrow }, - weight: KeybindingWeight.WorkbenchContrib - }, + keybinding: [ + { + when: ContextKeyExpr.and(NOTEBOOK_EDITOR_FOCUSED, ContextKeyExpr.not(InputFocusedContextKey)), + primary: KeyMod.CtrlCmd | KeyCode.End, + mac: undefined, + weight: KeybindingWeight.WorkbenchContrib + }, + { + when: ContextKeyExpr.and(NOTEBOOK_EDITOR_FOCUSED, ContextKeyExpr.not(InputFocusedContextKey), CTX_NOTEBOOK_CHAT_OUTER_FOCUS_POSITION.isEqualTo('')), + mac: { primary: KeyMod.CtrlCmd | KeyCode.DownArrow }, + weight: KeybindingWeight.WorkbenchContrib + } + ], }); } @@ -344,6 +358,7 @@ registerAction2(class extends NotebookCellAction { NOTEBOOK_EDITOR_FOCUSED, ContextKeyExpr.has(InputFocusedContextKey), EditorContextKeys.editorTextFocus, + NOTEBOOK_OUTPUT_FOCUSED.negate(), // Webview handles Shift+PageUp for selection of output contents ), primary: KeyMod.Shift | KeyCode.PageUp, weight: NOTEBOOK_EDITOR_WIDGET_ACTION_WEIGHT @@ -392,6 +407,7 @@ registerAction2(class extends NotebookCellAction { NOTEBOOK_EDITOR_FOCUSED, ContextKeyExpr.has(InputFocusedContextKey), EditorContextKeys.editorTextFocus, + NOTEBOOK_OUTPUT_FOCUSED.negate(), // Webview handles Shift+PageDown for selection of output contents ), primary: KeyMod.Shift | KeyCode.PageDown, weight: NOTEBOOK_EDITOR_WIDGET_ACTION_WEIGHT diff --git a/src/vs/workbench/contrib/notebook/browser/contrib/notebookVariables/notebookVariableCommands.ts b/src/vs/workbench/contrib/notebook/browser/contrib/notebookVariables/notebookVariableCommands.ts index 1e955f90bb276..03db0e250cd92 100644 --- a/src/vs/workbench/contrib/notebook/browser/contrib/notebookVariables/notebookVariableCommands.ts +++ b/src/vs/workbench/contrib/notebook/browser/contrib/notebookVariables/notebookVariableCommands.ts @@ -3,12 +3,15 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { CancellationToken } from 'vs/base/common/cancellation'; +import { URI, UriComponents } from 'vs/base/common/uri'; import { localize } from 'vs/nls'; import { Action2, registerAction2 } from 'vs/platform/actions/common/actions'; import { IClipboardService } from 'vs/platform/clipboard/common/clipboardService'; -import { ContextKeyExpr } from 'vs/platform/contextkey/common/contextkey'; import { ServicesAccessor } from 'vs/platform/instantiation/common/instantiation'; import { contextMenuArg } from 'vs/workbench/contrib/notebook/browser/contrib/notebookVariables/notebookVariablesView'; +import { INotebookKernelService, VariablesResult } from 'vs/workbench/contrib/notebook/common/notebookKernelService'; +import { INotebookService } from 'vs/workbench/contrib/notebook/common/notebookService'; export const COPY_NOTEBOOK_VARIABLE_VALUE_ID = 'workbench.debug.viewlet.action.copyWorkspaceVariableValue'; export const COPY_NOTEBOOK_VARIABLE_VALUE_LABEL = localize('copyWorkspaceVariableValue', "Copy Value"); @@ -18,7 +21,6 @@ registerAction2(class extends Action2 { id: COPY_NOTEBOOK_VARIABLE_VALUE_ID, title: COPY_NOTEBOOK_VARIABLE_VALUE_LABEL, f1: false, - precondition: ContextKeyExpr.has('value') }); } @@ -30,3 +32,39 @@ registerAction2(class extends Action2 { } } }); + + +registerAction2(class extends Action2 { + constructor() { + super({ + id: '_executeNotebookVariableProvider', + title: localize('executeNotebookVariableProvider', "Execute Notebook Variable Provider"), + f1: false, + }); + } + + async run(accessor: ServicesAccessor, resource: UriComponents | undefined): Promise { + if (!resource) { + return []; + } + + const uri = URI.revive(resource); + const notebookKernelService = accessor.get(INotebookKernelService); + const notebookService = accessor.get(INotebookService); + const notebookTextModel = notebookService.getNotebookTextModel(uri); + + if (!notebookTextModel) { + return []; + } + + const selectedKernel = notebookKernelService.getMatchingKernel(notebookTextModel).selected; + if (selectedKernel && selectedKernel.hasVariableProvider) { + const variables = selectedKernel.provideVariables(notebookTextModel.uri, undefined, 'named', 0, CancellationToken.None); + return await variables + .map(variable => { return variable; }) + .toPromise(); + } + + return []; + } +}); diff --git a/src/vs/workbench/contrib/chat/browser/chatSlashCommandContentWidget.css b/src/vs/workbench/contrib/notebook/browser/contrib/notebookVariables/notebookVariableContextKeys.ts similarity index 65% rename from src/vs/workbench/contrib/chat/browser/chatSlashCommandContentWidget.css rename to src/vs/workbench/contrib/notebook/browser/contrib/notebookVariables/notebookVariableContextKeys.ts index d2568dbbfed72..b90769ef43cf5 100644 --- a/src/vs/workbench/contrib/chat/browser/chatSlashCommandContentWidget.css +++ b/src/vs/workbench/contrib/notebook/browser/contrib/notebookVariables/notebookVariableContextKeys.ts @@ -3,9 +3,6 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -.chat-slash-command-content-widget { - background-color: var(--vscode-chat-slashCommandBackground); - color: var(--vscode-chat-slashCommandForeground); - border-radius: 3px; - padding: 1px; -} +import { RawContextKey } from 'vs/platform/contextkey/common/contextkey'; + +export const NOTEBOOK_VARIABLE_VIEW_ENABLED = new RawContextKey('notebookVariableViewEnabled', false); diff --git a/src/vs/workbench/contrib/notebook/browser/contrib/notebookVariables/notebookVariables.ts b/src/vs/workbench/contrib/notebook/browser/contrib/notebookVariables/notebookVariables.ts index 962f144cc7061..933c67cea811c 100644 --- a/src/vs/workbench/contrib/notebook/browser/contrib/notebookVariables/notebookVariables.ts +++ b/src/vs/workbench/contrib/notebook/browser/contrib/notebookVariables/notebookVariables.ts @@ -3,46 +3,82 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import * as nls from 'vs/nls'; import { Disposable, IDisposable } from 'vs/base/common/lifecycle'; -import { IWorkbenchContribution } from 'vs/workbench/common/contributions'; +import { URI } from 'vs/base/common/uri'; +import * as nls from 'vs/nls'; +import { IConfigurationChangeEvent, IConfigurationService } from 'vs/platform/configuration/common/configuration'; +import { IContextKey, IContextKeyService } from 'vs/platform/contextkey/common/contextkey'; +import { SyncDescriptor } from 'vs/platform/instantiation/common/descriptors'; import { Registry } from 'vs/platform/registry/common/platform'; +import { IWorkbenchContribution } from 'vs/workbench/common/contributions'; import { Extensions, IViewContainersRegistry, IViewsRegistry } from 'vs/workbench/common/views'; import { VIEWLET_ID as debugContainerId } from 'vs/workbench/contrib/debug/common/debug'; -import { ContextKeyExpr } from 'vs/platform/contextkey/common/contextkey'; -import { SyncDescriptor } from 'vs/platform/instantiation/common/descriptors'; +import { NOTEBOOK_VARIABLE_VIEW_ENABLED } from 'vs/workbench/contrib/notebook/browser/contrib/notebookVariables/notebookVariableContextKeys'; import { NotebookVariablesView } from 'vs/workbench/contrib/notebook/browser/contrib/notebookVariables/notebookVariablesView'; -import { NOTEBOOK_KERNEL } from 'vs/workbench/contrib/notebook/common/notebookContextKeys'; +import { getNotebookEditorFromEditorPane } from 'vs/workbench/contrib/notebook/browser/notebookBrowser'; import { variablesViewIcon } from 'vs/workbench/contrib/notebook/browser/notebookIcons'; -import { IEditorService } from 'vs/workbench/services/editor/common/editorService'; -import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; -import { INotebookExecutionStateService } from 'vs/workbench/contrib/notebook/common/notebookExecutionStateService'; import { NotebookSetting } from 'vs/workbench/contrib/notebook/common/notebookCommon'; - +import { INotebookExecutionStateService } from 'vs/workbench/contrib/notebook/common/notebookExecutionStateService'; +import { INotebookKernelService } from 'vs/workbench/contrib/notebook/common/notebookKernelService'; +import { INotebookService } from 'vs/workbench/contrib/notebook/common/notebookService'; +import { IEditorService } from 'vs/workbench/services/editor/common/editorService'; export class NotebookVariables extends Disposable implements IWorkbenchContribution { private listeners: IDisposable[] = []; + private configListener: IDisposable; + private initialized = false; + + private viewEnabled: IContextKey; constructor( + @IContextKeyService contextKeyService: IContextKeyService, + @IConfigurationService private readonly configurationService: IConfigurationService, @IEditorService private readonly editorService: IEditorService, - @IConfigurationService configurationService: IConfigurationService, - @INotebookExecutionStateService private readonly notebookExecutionStateService: INotebookExecutionStateService + @INotebookExecutionStateService private readonly notebookExecutionStateService: INotebookExecutionStateService, + @INotebookKernelService private readonly notebookKernelService: INotebookKernelService, + @INotebookService private readonly notebookDocumentService: INotebookService ) { super(); - this.listeners.push(this.editorService.onDidEditorsChange(() => this.handleInitEvent(configurationService))); - this.listeners.push(this.notebookExecutionStateService.onDidChangeExecution(() => this.handleInitEvent(configurationService))); + this.viewEnabled = NOTEBOOK_VARIABLE_VIEW_ENABLED.bindTo(contextKeyService); + + this.listeners.push(this.editorService.onDidActiveEditorChange(() => this.handleInitEvent())); + this.listeners.push(this.notebookExecutionStateService.onDidChangeExecution((e) => this.handleInitEvent(e.notebook))); + + this.configListener = configurationService.onDidChangeConfiguration((e) => this.handleConfigChange(e)); + } + + private handleConfigChange(e: IConfigurationChangeEvent) { + if (e.affectsConfiguration(NotebookSetting.notebookVariablesView)) { + if (!this.configurationService.getValue(NotebookSetting.notebookVariablesView)) { + this.viewEnabled.set(false); + } else if (this.initialized) { + this.viewEnabled.set(true); + } else { + this.handleInitEvent(); + } + } } - private handleInitEvent(configurationService: IConfigurationService) { - if (configurationService.getValue(NotebookSetting.notebookVariablesView) - && this.editorService.activeEditorPane?.getId() === 'workbench.editor.notebook') { - if (this.initializeView()) { + private handleInitEvent(notebook?: URI) { + if (this.configurationService.getValue(NotebookSetting.notebookVariablesView) + && (!!notebook || this.editorService.activeEditorPane?.getId() === 'workbench.editor.notebook')) { + + if (this.hasVariableProvider(notebook) && !this.initialized && this.initializeView()) { + this.viewEnabled.set(true); + this.initialized = true; this.listeners.forEach(listener => listener.dispose()); } } } + private hasVariableProvider(notebookUri?: URI) { + const notebook = notebookUri ? + this.notebookDocumentService.getNotebookTextModel(notebookUri) : + getNotebookEditorFromEditorPane(this.editorService.activeEditorPane)?.getViewModel()?.notebookDocument; + return notebook && this.notebookKernelService.getMatchingKernel(notebook).selected?.hasVariableProvider; + } + private initializeView() { const debugViewContainer = Registry.as('workbench.registry.view.containers').get(debugContainerId); @@ -51,7 +87,7 @@ export class NotebookVariables extends Disposable implements IWorkbenchContribut const viewDescriptor = { id: 'NOTEBOOK_VARIABLES', name: nls.localize2('notebookVariables', "Notebook Variables"), containerIcon: variablesViewIcon, ctorDescriptor: new SyncDescriptor(NotebookVariablesView), - order: 50, weight: 5, canToggleVisibility: true, canMoveView: true, collapsed: true, when: ContextKeyExpr.notEquals(NOTEBOOK_KERNEL.key, ''), + order: 50, weight: 5, canToggleVisibility: true, canMoveView: true, collapsed: true, when: NOTEBOOK_VARIABLE_VIEW_ENABLED, }; viewsRegistry.registerViews([viewDescriptor], debugViewContainer); @@ -61,4 +97,10 @@ export class NotebookVariables extends Disposable implements IWorkbenchContribut return false; } + override dispose(): void { + super.dispose(); + this.listeners.forEach(listener => listener.dispose()); + this.configListener.dispose(); + } + } diff --git a/src/vs/workbench/contrib/notebook/browser/contrib/notebookVariables/notebookVariablesDataSource.ts b/src/vs/workbench/contrib/notebook/browser/contrib/notebookVariables/notebookVariablesDataSource.ts index 035e5b0ee7f9e..13bc42a678d9b 100644 --- a/src/vs/workbench/contrib/notebook/browser/contrib/notebookVariables/notebookVariablesDataSource.ts +++ b/src/vs/workbench/contrib/notebook/browser/contrib/notebookVariables/notebookVariablesDataSource.ts @@ -21,10 +21,14 @@ export interface INotebookVariableElement { readonly name: string; readonly value: string; readonly type?: string; + readonly interfaces?: string[]; + readonly expression?: string; + readonly language?: string; readonly indexedChildrenCount: number; readonly indexStart?: number; readonly hasNamedChildren: boolean; readonly notebook: NotebookTextModel; + readonly extensionId?: string; } export class NotebookVariableDataSource implements IAsyncDataSource { @@ -53,7 +57,7 @@ export class NotebookVariableDataSource implements IAsyncDataSource { + private async getVariables(parent: INotebookVariableElement): Promise { const selectedKernel = this.notebookKernelService.getMatchingKernel(parent.notebook).selected; if (selectedKernel && selectedKernel.hasVariableProvider) { @@ -75,17 +79,20 @@ export class NotebookVariableDataSource implements IAsyncDataSource variablePageSize) { - // TODO: improve handling of large number of children - const indexedChildCountLimit = 100000; - const limit = Math.min(parent.indexedChildrenCount, indexedChildCountLimit); - for (let start = 0; start < limit; start += variablePageSize) { - let end = start + variablePageSize; - if (end > limit) { - end = limit; + + const nestedPageSize = Math.floor(Math.max(parent.indexedChildrenCount / variablePageSize, 100)); + + const indexedChildCountLimit = 1_000_000; + let start = parent.indexStart ?? 0; + const last = start + Math.min(parent.indexedChildrenCount, indexedChildCountLimit); + for (; start < last; start += nestedPageSize) { + let end = start + nestedPageSize; + if (end > last) { + end = last; } childNodes.push({ @@ -105,7 +112,7 @@ export class NotebookVariableDataSource implements IAsyncDataSource { + private async getRootVariables(notebook: NotebookTextModel): Promise { const selectedKernel = this.notebookKernelService.getMatchingKernel(notebook).selected; if (selectedKernel && selectedKernel.hasVariableProvider) { const variables = selectedKernel.provideVariables(notebook.uri, undefined, 'named', 0, this.cancellationTokenSource.token); diff --git a/src/vs/workbench/contrib/notebook/browser/contrib/notebookVariables/notebookVariablesTree.ts b/src/vs/workbench/contrib/notebook/browser/contrib/notebookVariables/notebookVariablesTree.ts index 5ffe8b8c96f92..01c0e2b2d4be6 100644 --- a/src/vs/workbench/contrib/notebook/browser/contrib/notebookVariables/notebookVariablesTree.ts +++ b/src/vs/workbench/contrib/notebook/browser/contrib/notebookVariables/notebookVariablesTree.ts @@ -8,7 +8,9 @@ import { IListVirtualDelegate } from 'vs/base/browser/ui/list/list'; import { IListAccessibilityProvider } from 'vs/base/browser/ui/list/listWidget'; import { ITreeNode, ITreeRenderer } from 'vs/base/browser/ui/tree/tree'; import { FuzzyScore } from 'vs/base/common/filters'; +import { DisposableStore } from 'vs/base/common/lifecycle'; import { localize } from 'vs/nls'; +import { IHoverService } from 'vs/platform/hover/browser/hover'; import { WorkbenchObjectTree } from 'vs/platform/list/browser/listService'; import { renderExpressionValue } from 'vs/workbench/contrib/debug/browser/baseDebugView'; import { INotebookVariableElement } from 'vs/workbench/contrib/notebook/browser/contrib/notebookVariables/notebookVariablesDataSource'; @@ -33,6 +35,7 @@ export interface IVariableTemplateData { expression: HTMLElement; name: HTMLSpanElement; value: HTMLSpanElement; + elementDisposables: DisposableStore; } export class NotebookVariableRenderer implements ITreeRenderer { @@ -43,12 +46,17 @@ export class NotebookVariableRenderer implements ITreeRenderer, index: number, templateData: IVariableTemplateData, height: number | undefined): void { + templateData.elementDisposables.clear(); + } + + + disposeTemplate(templateData: IVariableTemplateData): void { + templateData.elementDisposables.dispose(); } } diff --git a/src/vs/workbench/contrib/notebook/browser/contrib/notebookVariables/notebookVariablesView.ts b/src/vs/workbench/contrib/notebook/browser/contrib/notebookVariables/notebookVariablesView.ts index d7c5246450c44..c05cdaae7f6f8 100644 --- a/src/vs/workbench/contrib/notebook/browser/contrib/notebookVariables/notebookVariablesView.ts +++ b/src/vs/workbench/contrib/notebook/browser/contrib/notebookVariables/notebookVariablesView.ts @@ -10,11 +10,12 @@ import { URI } from 'vs/base/common/uri'; import * as nls from 'vs/nls'; import { ILocalizedString } from 'vs/platform/action/common/action'; import { createAndFillInContextMenuActions } from 'vs/platform/actions/browser/menuEntryActionViewItem'; -import { IMenu, IMenuService, MenuId } from 'vs/platform/actions/common/actions'; +import { IMenuService, MenuId } from 'vs/platform/actions/common/actions'; import { ICommandService } from 'vs/platform/commands/common/commands'; import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; import { IContextKeyService } from 'vs/platform/contextkey/common/contextkey'; import { IContextMenuService } from 'vs/platform/contextview/browser/contextView'; +import { IHoverService } from 'vs/platform/hover/browser/hover'; import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; import { IKeybindingService } from 'vs/platform/keybinding/common/keybinding'; @@ -25,6 +26,7 @@ import { ITelemetryService } from 'vs/platform/telemetry/common/telemetry'; import { IThemeService } from 'vs/platform/theme/common/themeService'; import { IViewPaneOptions, ViewPane } from 'vs/workbench/browser/parts/views/viewPane'; import { IViewDescriptorService } from 'vs/workbench/common/views'; +import { CONTEXT_VARIABLE_EXTENSIONID, CONTEXT_VARIABLE_INTERFACES, CONTEXT_VARIABLE_LANGUAGE, CONTEXT_VARIABLE_NAME, CONTEXT_VARIABLE_TYPE, CONTEXT_VARIABLE_VALUE } from 'vs/workbench/contrib/debug/common/debug'; import { INotebookScope, INotebookVariableElement, NotebookVariableDataSource } from 'vs/workbench/contrib/notebook/browser/contrib/notebookVariables/notebookVariablesDataSource'; import { NotebookVariableAccessibilityProvider, NotebookVariableRenderer, NotebookVariablesDelegate } from 'vs/workbench/contrib/notebook/browser/contrib/notebookVariables/notebookVariablesTree'; import { getNotebookEditorFromEditorPane } from 'vs/workbench/contrib/notebook/browser/notebookBrowser'; @@ -33,7 +35,7 @@ import { ICellExecutionStateChangedEvent, IExecutionStateChangedEvent, INotebook import { INotebookKernelService } from 'vs/workbench/contrib/notebook/common/notebookKernelService'; import { IEditorService } from 'vs/workbench/services/editor/common/editorService'; -export type contextMenuArg = { source?: string; type?: string; value?: string }; +export type contextMenuArg = { source: string; name: string; type?: string; value?: string; expression?: string; language?: string; extensionId?: string }; export class NotebookVariablesView extends ViewPane { @@ -42,7 +44,6 @@ export class NotebookVariablesView extends ViewPane { private tree: WorkbenchAsyncDataTree | undefined; private activeNotebook: NotebookTextModel | undefined; - private readonly menu: IMenu; private readonly dataSource: NotebookVariableDataSource; private updateScheduler: RunOnceScheduler; @@ -63,9 +64,10 @@ export class NotebookVariablesView extends ViewPane { @ICommandService protected commandService: ICommandService, @IThemeService themeService: IThemeService, @ITelemetryService telemetryService: ITelemetryService, - @IMenuService menuService: IMenuService + @IHoverService hoverService: IHoverService, + @IMenuService private readonly menuService: IMenuService ) { - super(options, keybindingService, contextMenuService, configurationService, contextKeyService, viewDescriptorService, instantiationService, openerService, themeService, telemetryService); + super(options, keybindingService, contextMenuService, configurationService, contextKeyService, viewDescriptorService, instantiationService, openerService, themeService, telemetryService, hoverService); this._register(this.editorService.onDidActiveEditorChange(this.handleActiveEditorChange.bind(this))); this._register(this.notebookKernelService.onDidNotebookVariablesUpdate(this.handleVariablesChanged.bind(this))); @@ -73,20 +75,20 @@ export class NotebookVariablesView extends ViewPane { this.setActiveNotebook(); - this.menu = menuService.createMenu(MenuId.NotebookVariablesContext, contextKeyService); this.dataSource = new NotebookVariableDataSource(this.notebookKernelService); this.updateScheduler = new RunOnceScheduler(() => this.tree?.updateChildren(), 100); } protected override renderBody(container: HTMLElement): void { super.renderBody(container); + this.element.classList.add('debug-pane'); this.tree = >this.instantiationService.createInstance( WorkbenchAsyncDataTree, 'notebookVariablesTree', container, new NotebookVariablesDelegate(), - [new NotebookVariableRenderer()], + [new NotebookVariableRenderer(this.hoverService)], this.dataSource, { accessibilityProvider: new NotebookVariableAccessibilityProvider(), @@ -102,22 +104,36 @@ export class NotebookVariablesView extends ViewPane { } private onContextMenu(e: ITreeContextMenuEvent): any { + if (!e.element) { + return; + } const element = e.element; - const context = { - type: element?.type - }; const arg: contextMenuArg = { - source: element?.notebook.uri.toString(), - value: element?.value, - ...context + source: element.notebook.uri.toString(), + name: element.name, + value: element.value, + type: element.type, + expression: element.expression, + language: element.language, + extensionId: element.extensionId }; const actions: IAction[] = []; - createAndFillInContextMenuActions(this.menu, { arg, shouldForwardArgs: true }, actions); + + const overlayedContext = this.contextKeyService.createOverlay([ + [CONTEXT_VARIABLE_NAME.key, element.name], + [CONTEXT_VARIABLE_VALUE.key, element.value], + [CONTEXT_VARIABLE_TYPE.key, element.type], + [CONTEXT_VARIABLE_INTERFACES.key, element.interfaces], + [CONTEXT_VARIABLE_LANGUAGE.key, element.language], + [CONTEXT_VARIABLE_EXTENSIONID.key, element.extensionId] + ]); + const menu = this.menuService.createMenu(MenuId.NotebookVariablesContext, overlayedContext); + createAndFillInContextMenuActions(menu, { arg, shouldForwardArgs: true }, actions); + menu.dispose(); this.contextMenuService.showContextMenu({ getAnchor: () => e.anchor, - getActions: () => actions, - getActionsContext: () => context, + getActions: () => actions }); } @@ -129,7 +145,7 @@ export class NotebookVariablesView extends ViewPane { private setActiveNotebook() { const current = this.activeNotebook; const activeEditorPane = this.editorService.activeEditorPane; - if (activeEditorPane && activeEditorPane.getId() === 'workbench.editor.notebook') { + if (activeEditorPane?.getId() === 'workbench.editor.notebook' || activeEditorPane?.getId() === 'workbench.editor.interactive') { const notebookDocument = getNotebookEditorFromEditorPane(activeEditorPane)?.getViewModel()?.notebookDocument; this.activeNotebook = notebookDocument; } diff --git a/src/vs/workbench/contrib/notebook/browser/contrib/outline/notebookOutline.ts b/src/vs/workbench/contrib/notebook/browser/contrib/outline/notebookOutline.ts index 67ac4131d0d63..4e7b875a30ce8 100644 --- a/src/vs/workbench/contrib/notebook/browser/contrib/outline/notebookOutline.ts +++ b/src/vs/workbench/contrib/notebook/browser/contrib/outline/notebookOutline.ts @@ -4,6 +4,8 @@ *--------------------------------------------------------------------------------------------*/ import { localize } from 'vs/nls'; +import * as DOM from 'vs/base/browser/dom'; +import { ToolBar } from 'vs/base/browser/ui/toolbar/toolbar'; import { IIconLabelValueOptions, IconLabel } from 'vs/base/browser/ui/iconLabel/iconLabel'; import { IKeyboardNavigationLabelProvider, IListVirtualDelegate } from 'vs/base/browser/ui/list/list'; import { IListAccessibilityProvider } from 'vs/base/browser/ui/list/listWidget'; @@ -17,7 +19,7 @@ import { getIconClassesForLanguageId } from 'vs/editor/common/services/getIconCl import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; import { Extensions as ConfigurationExtensions, IConfigurationRegistry } from 'vs/platform/configuration/common/configurationRegistry'; import { IEditorOptions } from 'vs/platform/editor/common/editor'; -import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; +import { IInstantiationService, ServicesAccessor } from 'vs/platform/instantiation/common/instantiation'; import { IWorkbenchDataTreeOptions } from 'vs/platform/list/browser/listService'; import { MarkerSeverity } from 'vs/platform/markers/common/markers'; import { Registry } from 'vs/platform/registry/common/platform'; @@ -25,7 +27,7 @@ import { listErrorForeground, listWarningForeground } from 'vs/platform/theme/co import { IThemeService } from 'vs/platform/theme/common/themeService'; import { IWorkbenchContributionsRegistry, Extensions as WorkbenchExtensions } from 'vs/workbench/common/contributions'; import { IEditorPane } from 'vs/workbench/common/editor'; -import { CellRevealType, ICellModelDecorations, ICellModelDeltaDecorations, INotebookEditorOptions, INotebookEditorPane } from 'vs/workbench/contrib/notebook/browser/notebookBrowser'; +import { CellFoldingState, CellRevealType, ICellModelDecorations, ICellModelDeltaDecorations, ICellViewModel, INotebookEditor, INotebookEditorOptions, INotebookEditorPane, INotebookViewModel } from 'vs/workbench/contrib/notebook/browser/notebookBrowser'; import { NotebookEditor } from 'vs/workbench/contrib/notebook/browser/notebookEditor'; import { NotebookCellOutlineProvider } from 'vs/workbench/contrib/notebook/browser/viewModel/notebookOutlineProvider'; import { CellKind, NotebookSetting } from 'vs/workbench/contrib/notebook/common/notebookCommon'; @@ -37,7 +39,17 @@ import { CancellationToken } from 'vs/base/common/cancellation'; import { IModelDeltaDecoration } from 'vs/editor/common/model'; import { Range } from 'vs/editor/common/core/range'; import { mainWindow } from 'vs/base/browser/window'; -import { WindowIdleValue } from 'vs/base/browser/dom'; +import { IContextMenuService } from 'vs/platform/contextview/browser/contextView'; +import { Action2, IMenu, IMenuService, MenuId, MenuItemAction, MenuRegistry, registerAction2 } from 'vs/platform/actions/common/actions'; +import { ContextKeyExpr, IContextKeyService, RawContextKey } from 'vs/platform/contextkey/common/contextkey'; +import { MenuEntryActionViewItem, createAndFillInActionBarActions } from 'vs/platform/actions/browser/menuEntryActionViewItem'; +import { IAction } from 'vs/base/common/actions'; +import { NotebookSectionArgs } from 'vs/workbench/contrib/notebook/browser/controller/sectionActions'; +import { MarkupCellViewModel } from 'vs/workbench/contrib/notebook/browser/viewModel/markupCellViewModel'; +import { disposableTimeout } from 'vs/base/common/async'; +import { IOutlinePane } from 'vs/workbench/contrib/outline/browser/outline'; +import { Codicon } from 'vs/base/common/codicons'; +import { NOTEBOOK_IS_ACTIVE_EDITOR } from 'vs/workbench/contrib/notebook/common/notebookContextKeys'; class NotebookOutlineTemplate { @@ -47,7 +59,9 @@ class NotebookOutlineTemplate { readonly container: HTMLElement, readonly iconClass: HTMLElement, readonly iconLabel: IconLabel, - readonly decoration: HTMLElement + readonly decoration: HTMLElement, + readonly actionMenu: HTMLElement, + readonly elementDisposables: DisposableStore, ) { } } @@ -56,11 +70,19 @@ class NotebookOutlineRenderer implements ITreeRenderer, _index: number, template: NotebookOutlineTemplate, _height: number | undefined): void { @@ -79,14 +105,17 @@ class NotebookOutlineRenderer implements ITreeRenderer= 8) { // symbol + template.iconClass.className = 'element-icon ' + ThemeIcon.asClassNameArray(node.element.icon).join(' '); + } else if (isCodeCell && this._themeService.getFileIconTheme().hasFileIcons && !node.element.isExecuting) { template.iconClass.className = ''; extraClasses.push(...getIconClassesForLanguageId(node.element.cell.language ?? '')); } else { template.iconClass.className = 'element-icon ' + ThemeIcon.asClassNameArray(node.element.icon).join(' '); } - template.iconLabel.setLabel(node.element.label, undefined, options); + template.iconLabel.setLabel(' ' + node.element.label, undefined, options); const { markerInfo } = node.element; @@ -118,11 +147,113 @@ class NotebookOutlineRenderer implements ITreeRenderer 0); + NotebookOutlineContext.CellHasHeader.bindTo(scopedContextKeyService).set(node.element.level !== 7); + NotebookOutlineContext.OutlineElementTarget.bindTo(scopedContextKeyService).set(this._target); + this.setupFolding(isCodeCell, nbViewModel, scopedContextKeyService, template, nbCell); + + const outlineEntryToolbar = template.elementDisposables.add(new ToolBar(template.actionMenu, this._contextMenuService, { + actionViewItemProvider: action => { + if (action instanceof MenuItemAction) { + return this._instantiationService.createInstance(MenuEntryActionViewItem, action, undefined); + } + return undefined; + }, + })); + + const menu = template.elementDisposables.add(this._menuService.createMenu(MenuId.NotebookOutlineActionMenu, scopedContextKeyService)); + const actions = getOutlineToolbarActions(menu, { notebookEditor: this._editor, outlineEntry: node.element }); + outlineEntryToolbar.setActions(actions.primary, actions.secondary); + + this.setupToolbarListeners(outlineEntryToolbar, menu, actions, node.element, template); + template.actionMenu.style.padding = '0 0.8em 0 0.4em'; + } } disposeTemplate(templateData: NotebookOutlineTemplate): void { templateData.iconLabel.dispose(); + templateData.elementDisposables.clear(); } + + disposeElement(element: ITreeNode, index: number, templateData: NotebookOutlineTemplate, height: number | undefined): void { + templateData.elementDisposables.clear(); + DOM.clearNode(templateData.actionMenu); + } + + private setupFolding(isCodeCell: boolean, nbViewModel: INotebookViewModel, scopedContextKeyService: IContextKeyService, template: NotebookOutlineTemplate, nbCell: ICellViewModel) { + const foldingState = isCodeCell ? CellFoldingState.None : ((nbCell as MarkupCellViewModel).foldingState); + const foldingStateCtx = NotebookOutlineContext.CellFoldingState.bindTo(scopedContextKeyService); + foldingStateCtx.set(foldingState); + + if (!isCodeCell) { + template.elementDisposables.add(nbViewModel.onDidFoldingStateChanged(() => { + const foldingState = (nbCell as MarkupCellViewModel).foldingState; + NotebookOutlineContext.CellFoldingState.bindTo(scopedContextKeyService).set(foldingState); + foldingStateCtx.set(foldingState); + })); + } + } + + private setupToolbarListeners(toolbar: ToolBar, menu: IMenu, initActions: { primary: IAction[]; secondary: IAction[] }, entry: OutlineEntry, templateData: NotebookOutlineTemplate): void { + // same fix as in cellToolbars setupListeners re #103926 + let dropdownIsVisible = false; + let deferredUpdate: (() => void) | undefined; + + toolbar.setActions(initActions.primary, initActions.secondary); + templateData.elementDisposables.add(menu.onDidChange(() => { + if (dropdownIsVisible) { + const actions = getOutlineToolbarActions(menu, { notebookEditor: this._editor, outlineEntry: entry }); + deferredUpdate = () => toolbar.setActions(actions.primary, actions.secondary); + + return; + } + + const actions = getOutlineToolbarActions(menu, { notebookEditor: this._editor, outlineEntry: entry }); + toolbar.setActions(actions.primary, actions.secondary); + })); + + templateData.container.classList.remove('notebook-outline-toolbar-dropdown-active'); + templateData.elementDisposables.add(toolbar.onDidChangeDropdownVisibility(visible => { + dropdownIsVisible = visible; + if (visible) { + templateData.container.classList.add('notebook-outline-toolbar-dropdown-active'); + } else { + templateData.container.classList.remove('notebook-outline-toolbar-dropdown-active'); + } + + if (deferredUpdate && !visible) { + disposableTimeout(() => { + deferredUpdate?.(); + }, 0, templateData.elementDisposables); + + deferredUpdate = undefined; + } + })); + + } +} + +function getOutlineToolbarActions(menu: IMenu, args?: NotebookSectionArgs): { primary: IAction[]; secondary: IAction[] } { + const primary: IAction[] = []; + const secondary: IAction[] = []; + const result = { primary, secondary }; + + // TODO: @Yoyokrazy bring the "inline" back when there's an appropriate run in section icon + createAndFillInActionBarActions(menu, { shouldForwardArgs: true, arg: args }, result); //, g => /^inline/.test(g)); + + return result; } class NotebookOutlineAccessibility implements IListAccessibilityProvider { @@ -183,7 +314,7 @@ class NotebookQuickPickProvider implements IQuickPickDataSource { class NotebookComparator implements IOutlineComparator { - private readonly _collator = new WindowIdleValue(mainWindow, () => new Intl.Collator(undefined, { numeric: true })); + private readonly _collator = new DOM.WindowIdleValue(mainWindow, () => new Intl.Collator(undefined, { numeric: true })); compareByPosition(a: OutlineEntry, b: OutlineEntry): number { return a.index - b.index; @@ -249,9 +380,13 @@ export class NotebookCellOutline implements IOutline { })); installSelectionListener(); - const treeDataSource: IDataSource = { getChildren: parent => parent instanceof NotebookCellOutline ? (this._outlineProvider?.entries ?? []) : parent.children }; + const treeDataSource: IDataSource = { + getChildren: parent => { + return this.getChildren(parent, _configurationService); + } + }; const delegate = new NotebookOutlineVirtualDelegate(); - const renderers = [instantiationService.createInstance(NotebookOutlineRenderer)]; + const renderers = [instantiationService.createInstance(NotebookOutlineRenderer, this._editor.getControl(), _target)]; const comparator = new NotebookComparator(); const options: IWorkbenchDataTreeOptions = { @@ -284,6 +419,29 @@ export class NotebookCellOutline implements IOutline { }; } + *getChildren(parent: OutlineEntry | NotebookCellOutline, configurationService: IConfigurationService): Iterable { + const showCodeCells = configurationService.getValue(NotebookSetting.outlineShowCodeCells); + const showCodeCellSymbols = configurationService.getValue(NotebookSetting.outlineShowCodeCellSymbols); + const showMarkdownHeadersOnly = configurationService.getValue(NotebookSetting.outlineShowMarkdownHeadersOnly); + + for (const entry of parent instanceof NotebookCellOutline ? (this._outlineProvider?.entries ?? []) : parent.children) { + if (entry.cell.cellKind === CellKind.Markup) { + if (!showMarkdownHeadersOnly) { + yield entry; + } else if (entry.level < 7) { + yield entry; + } + + } else if (showCodeCells && entry.cell.cellKind === CellKind.Code) { + if (showCodeCellSymbols) { + yield entry; + } else if (entry.level === 7) { + yield entry; + } + } + } + } + async setFullSymbols(cancelToken: CancellationToken) { await this._outlineProvider?.setFullSymbols(cancelToken); } @@ -396,36 +554,138 @@ export class NotebookOutlineCreator implements IOutlineCreator | undefined> { const outline = this._instantiationService.createInstance(NotebookCellOutline, editor, target); - const showAllSymbols = this._configurationService.getValue(NotebookSetting.gotoSymbolsAllSymbols); - if (target === OutlineTarget.QuickPick && showAllSymbols) { + const showAllGotoSymbols = this._configurationService.getValue(NotebookSetting.gotoSymbolsAllSymbols); + const showAllOutlineSymbols = this._configurationService.getValue(NotebookSetting.outlineShowCodeCellSymbols); + if (target === OutlineTarget.QuickPick && showAllGotoSymbols) { + await outline.setFullSymbols(cancelToken); + } else if (target === OutlineTarget.OutlinePane && showAllOutlineSymbols) { await outline.setFullSymbols(cancelToken); } + return outline; } } -Registry.as(WorkbenchExtensions.Workbench).registerWorkbenchContribution(NotebookOutlineCreator, LifecyclePhase.Eventually); +export const NotebookOutlineContext = { + CellKind: new RawContextKey('notebookCellKind', undefined), + CellHasChildren: new RawContextKey('notebookCellHasChildren', false), + CellHasHeader: new RawContextKey('notebookCellHasHeader', false), + CellFoldingState: new RawContextKey('notebookCellFoldingState', CellFoldingState.None), + OutlineElementTarget: new RawContextKey('notebookOutlineElementTarget', undefined), +}; +Registry.as(WorkbenchExtensions.Workbench).registerWorkbenchContribution(NotebookOutlineCreator, LifecyclePhase.Eventually); Registry.as(ConfigurationExtensions.Configuration).registerConfiguration({ id: 'notebook', order: 100, type: 'object', 'properties': { - 'notebook.outline.showCodeCells': { + [NotebookSetting.outlineShowMarkdownHeadersOnly]: { + type: 'boolean', + default: true, + markdownDescription: localize('outline.showMarkdownHeadersOnly', "When enabled, notebook outline will show only markdown cells containing a header.") + }, + [NotebookSetting.outlineShowCodeCells]: { type: 'boolean', default: false, - markdownDescription: localize('outline.showCodeCells', "When enabled notebook outline shows code cells.") + markdownDescription: localize('outline.showCodeCells', "When enabled, notebook outline shows code cells.") }, - 'notebook.breadcrumbs.showCodeCells': { + [NotebookSetting.outlineShowCodeCellSymbols]: { type: 'boolean', default: true, - markdownDescription: localize('breadcrumbs.showCodeCells', "When enabled notebook breadcrumbs contain code cells.") + markdownDescription: localize('outline.showCodeCellSymbols', "When enabled, notebook outline shows code cell symbols. Relies on `notebook.outline.showCodeCells` being enabled.") + }, + [NotebookSetting.breadcrumbsShowCodeCells]: { + type: 'boolean', + default: true, + markdownDescription: localize('breadcrumbs.showCodeCells', "When enabled, notebook breadcrumbs contain code cells.") }, [NotebookSetting.gotoSymbolsAllSymbols]: { type: 'boolean', default: true, - markdownDescription: localize('notebook.gotoSymbols.showAllSymbols', "When enabled the Go to Symbol Quick Pick will display full code symbols from the notebook, as well as Markdown headers.") + markdownDescription: localize('notebook.gotoSymbols.showAllSymbols', "When enabled, the Go to Symbol Quick Pick will display full code symbols from the notebook, as well as Markdown headers.") }, } }); + +MenuRegistry.appendMenuItem(MenuId.ViewTitle, { + submenu: MenuId.NotebookOutlineFilter, + title: localize('filter', "Filter Entries"), + icon: Codicon.filter, + group: 'navigation', + order: -1, + when: ContextKeyExpr.and(ContextKeyExpr.equals('view', IOutlinePane.Id), NOTEBOOK_IS_ACTIVE_EDITOR), +}); + +registerAction2(class ToggleShowMarkdownHeadersOnly extends Action2 { + constructor() { + super({ + id: 'notebook.outline.toggleShowMarkdownHeadersOnly', + title: localize('toggleShowMarkdownHeadersOnly', "Markdown Headers Only"), + f1: false, + toggled: { + condition: ContextKeyExpr.equals('config.notebook.outline.showMarkdownHeadersOnly', true) + }, + menu: { + id: MenuId.NotebookOutlineFilter, + group: '0_markdown_cells', + } + }); + } + + run(accessor: ServicesAccessor, ...args: any[]) { + const configurationService = accessor.get(IConfigurationService); + const showMarkdownHeadersOnly = configurationService.getValue(NotebookSetting.outlineShowMarkdownHeadersOnly); + configurationService.updateValue(NotebookSetting.outlineShowMarkdownHeadersOnly, !showMarkdownHeadersOnly); + } +}); + + +registerAction2(class ToggleCodeCellEntries extends Action2 { + constructor() { + super({ + id: 'notebook.outline.toggleCodeCells', + title: localize('toggleCodeCells', "Code Cells"), + f1: false, + toggled: { + condition: ContextKeyExpr.equals('config.notebook.outline.showCodeCells', true) + }, + menu: { + id: MenuId.NotebookOutlineFilter, + order: 1, + group: '1_code_cells', + } + }); + } + + run(accessor: ServicesAccessor, ...args: any[]) { + const configurationService = accessor.get(IConfigurationService); + const showCodeCells = configurationService.getValue(NotebookSetting.outlineShowCodeCells); + configurationService.updateValue(NotebookSetting.outlineShowCodeCells, !showCodeCells); + } +}); + +registerAction2(class ToggleCodeCellSymbolEntries extends Action2 { + constructor() { + super({ + id: 'notebook.outline.toggleCodeCellSymbols', + title: localize('toggleCodeCellSymbols', "Code Cell Symbols"), + f1: false, + toggled: { + condition: ContextKeyExpr.equals('config.notebook.outline.showCodeCellSymbols', true) + }, + menu: { + id: MenuId.NotebookOutlineFilter, + order: 2, + group: '1_code_cells', + } + }); + } + + run(accessor: ServicesAccessor, ...args: any[]) { + const configurationService = accessor.get(IConfigurationService); + const showCodeCellSymbols = configurationService.getValue(NotebookSetting.outlineShowCodeCellSymbols); + configurationService.updateValue(NotebookSetting.outlineShowCodeCellSymbols, !showCodeCellSymbols); + } +}); diff --git a/src/vs/workbench/contrib/notebook/browser/contrib/profile/notebookProfile.ts b/src/vs/workbench/contrib/notebook/browser/contrib/profile/notebookProfile.ts index ae330d22aacc3..ec03486e587ca 100644 --- a/src/vs/workbench/contrib/notebook/browser/contrib/profile/notebookProfile.ts +++ b/src/vs/workbench/contrib/notebook/browser/contrib/profile/notebookProfile.ts @@ -3,14 +3,11 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { Disposable } from 'vs/base/common/lifecycle'; import { ServicesAccessor } from 'vs/editor/browser/editorExtensions'; import { localize } from 'vs/nls'; import { Action2, registerAction2 } from 'vs/platform/actions/common/actions'; import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; import { NotebookSetting } from 'vs/workbench/contrib/notebook/common/notebookCommon'; -import { IWorkbenchAssignmentService } from 'vs/workbench/services/assignment/common/assignmentService'; -import { WorkbenchPhase, registerWorkbenchContribution2 } from 'vs/workbench/common/contributions'; export enum NotebookProfileType { default = 'default', @@ -89,40 +86,40 @@ function isSetProfileArgs(args: unknown): args is ISetProfileArgs { setProfileArgs.profile === NotebookProfileType.jupyter; } -export class NotebookProfileContribution extends Disposable { +// export class NotebookProfileContribution extends Disposable { - static readonly ID = 'workbench.contrib.notebookProfile'; +// static readonly ID = 'workbench.contrib.notebookProfile'; - constructor(@IConfigurationService configService: IConfigurationService, @IWorkbenchAssignmentService private readonly experimentService: IWorkbenchAssignmentService) { - super(); +// constructor(@IConfigurationService configService: IConfigurationService, @IWorkbenchAssignmentService private readonly experimentService: IWorkbenchAssignmentService) { +// super(); - if (this.experimentService) { - this.experimentService.getTreatment('notebookprofile').then(treatment => { - if (treatment === undefined) { - return; - } else { - // check if settings are already modified - const focusIndicator = configService.getValue(NotebookSetting.focusIndicator); - const insertToolbarPosition = configService.getValue(NotebookSetting.insertToolbarLocation); - const globalToolbar = configService.getValue(NotebookSetting.globalToolbar); - // const cellToolbarLocation = configService.getValue(NotebookSetting.cellToolbarLocation); - const compactView = configService.getValue(NotebookSetting.compactView); - const showCellStatusBar = configService.getValue(NotebookSetting.showCellStatusBar); - const consolidatedRunButton = configService.getValue(NotebookSetting.consolidatedRunButton); - if (focusIndicator === 'border' - && insertToolbarPosition === 'both' - && globalToolbar === false - // && cellToolbarLocation === undefined - && compactView === true - && showCellStatusBar === 'visible' - && consolidatedRunButton === true - ) { - applyProfile(configService, profiles[treatment] ?? profiles[NotebookProfileType.default]); - } - } - }); - } - } -} +// if (this.experimentService) { +// this.experimentService.getTreatment('notebookprofile').then(treatment => { +// if (treatment === undefined) { +// return; +// } else { +// // check if settings are already modified +// const focusIndicator = configService.getValue(NotebookSetting.focusIndicator); +// const insertToolbarPosition = configService.getValue(NotebookSetting.insertToolbarLocation); +// const globalToolbar = configService.getValue(NotebookSetting.globalToolbar); +// // const cellToolbarLocation = configService.getValue(NotebookSetting.cellToolbarLocation); +// const compactView = configService.getValue(NotebookSetting.compactView); +// const showCellStatusBar = configService.getValue(NotebookSetting.showCellStatusBar); +// const consolidatedRunButton = configService.getValue(NotebookSetting.consolidatedRunButton); +// if (focusIndicator === 'border' +// && insertToolbarPosition === 'both' +// && globalToolbar === false +// // && cellToolbarLocation === undefined +// && compactView === true +// && showCellStatusBar === 'visible' +// && consolidatedRunButton === true +// ) { +// applyProfile(configService, profiles[treatment] ?? profiles[NotebookProfileType.default]); +// } +// } +// }); +// } +// } +// } -registerWorkbenchContribution2(NotebookProfileContribution.ID, NotebookProfileContribution, WorkbenchPhase.BlockRestore); +// registerWorkbenchContribution2(NotebookProfileContribution.ID, NotebookProfileContribution, WorkbenchPhase.BlockRestore); diff --git a/src/vs/workbench/contrib/notebook/browser/contrib/saveParticipants/saveParticipants.ts b/src/vs/workbench/contrib/notebook/browser/contrib/saveParticipants/saveParticipants.ts index 0ffd61f705a28..31c62508ca9e9 100644 --- a/src/vs/workbench/contrib/notebook/browser/contrib/saveParticipants/saveParticipants.ts +++ b/src/vs/workbench/contrib/notebook/browser/contrib/saveParticipants/saveParticipants.ts @@ -3,8 +3,8 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { localize } from 'vs/nls'; import { CancellationToken } from 'vs/base/common/cancellation'; +import { HierarchicalKind } from 'vs/base/common/hierarchicalKind'; import { Disposable, DisposableStore } from 'vs/base/common/lifecycle'; import { isEqual } from 'vs/base/common/resources'; import { ICodeEditor } from 'vs/editor/browser/editorBrowser'; @@ -22,6 +22,7 @@ import { ApplyCodeActionReason, applyCodeAction, getCodeActions } from 'vs/edito import { CodeActionKind, CodeActionTriggerSource } from 'vs/editor/contrib/codeAction/common/types'; import { getDocumentFormattingEditsUntilResult } from 'vs/editor/contrib/format/browser/format'; import { SnippetController2 } from 'vs/editor/contrib/snippet/browser/snippetController2'; +import { localize } from 'vs/nls'; import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; import { ILogService } from 'vs/platform/log/common/log'; @@ -109,12 +110,14 @@ class TrimWhitespaceParticipant implements IStoredFileWorkingCopySaveParticipant ) { } async participate(workingCopy: IStoredFileWorkingCopy, context: IStoredFileWorkingCopySaveParticipantContext, progress: IProgress, _token: CancellationToken): Promise { - if (this.configurationService.getValue('files.trimTrailingWhitespace')) { - await this.doTrimTrailingWhitespace(workingCopy, context.reason === SaveReason.AUTO, progress); + const trimTrailingWhitespaceOption = this.configurationService.getValue('files.trimTrailingWhitespace'); + const trimInRegexAndStrings = this.configurationService.getValue('files.trimTrailingWhitespaceInRegexAndStrings'); + if (trimTrailingWhitespaceOption) { + await this.doTrimTrailingWhitespace(workingCopy, context.reason === SaveReason.AUTO, trimInRegexAndStrings, progress); } } - private async doTrimTrailingWhitespace(workingCopy: IStoredFileWorkingCopy, isAutoSaved: boolean, progress: IProgress) { + private async doTrimTrailingWhitespace(workingCopy: IStoredFileWorkingCopy, isAutoSaved: boolean, trimInRegexesAndStrings: boolean, progress: IProgress) { if (!workingCopy.model || !(workingCopy.model instanceof NotebookFileWorkingCopyModel)) { return; } @@ -149,7 +152,7 @@ class TrimWhitespaceParticipant implements IStoredFileWorkingCopySaveParticipant } } - const ops = trimTrailingWhitespace(model, cursors); + const ops = trimTrailingWhitespace(model, cursors, trimInRegexesAndStrings); if (!ops.length) { return []; // Nothing to do } @@ -224,8 +227,11 @@ class TrimFinalNewLinesParticipant implements IStoredFileWorkingCopySaveParticip const textBuffer = cell.textBuffer; const lastNonEmptyLine = this.findLastNonEmptyLine(textBuffer); const deleteFromLineNumber = Math.max(lastNonEmptyLine + 1, cannotTouchLineNumber + 1); - const deletionRange = new Range(deleteFromLineNumber, 1, textBuffer.getLineCount(), textBuffer.getLineLastNonWhitespaceColumn(textBuffer.getLineCount())); + if (deleteFromLineNumber > textBuffer.getLineCount()) { + return; + } + const deletionRange = new Range(deleteFromLineNumber, 1, textBuffer.getLineCount(), textBuffer.getLineLastNonWhitespaceColumn(textBuffer.getLineCount())); if (deletionRange.isEmpty()) { return; } @@ -244,7 +250,7 @@ class TrimFinalNewLinesParticipant implements IStoredFileWorkingCopySaveParticip } } -class FinalNewLineParticipant implements IStoredFileWorkingCopySaveParticipant { +class InsertFinalNewLineParticipant implements IStoredFileWorkingCopySaveParticipant { constructor( @IConfigurationService private readonly configurationService: IConfigurationService, @@ -419,8 +425,8 @@ class CodeActionOnSaveParticipant implements IStoredFileWorkingCopySaveParticipa } } - private createCodeActionsOnSave(settingItems: readonly string[]): CodeActionKind[] { - const kinds = settingItems.map(x => new CodeActionKind(x)); + private createCodeActionsOnSave(settingItems: readonly string[]): HierarchicalKind[] { + const kinds = settingItems.map(x => new HierarchicalKind(x)); // Remove subsets return kinds.filter(kind => { @@ -428,7 +434,7 @@ class CodeActionOnSaveParticipant implements IStoredFileWorkingCopySaveParticipa }); } - private async applyOnSaveActions(model: ITextModel, codeActionsOnSave: readonly CodeActionKind[], excludes: readonly CodeActionKind[], progress: IProgress, token: CancellationToken): Promise { + private async applyOnSaveActions(model: ITextModel, codeActionsOnSave: readonly HierarchicalKind[], excludes: readonly HierarchicalKind[], progress: IProgress, token: CancellationToken): Promise { const getActionProgress = new class implements IProgress { private _names = new Set(); @@ -491,7 +497,7 @@ class CodeActionOnSaveParticipant implements IStoredFileWorkingCopySaveParticipa } } - private getActionsToRun(model: ITextModel, codeActionKind: CodeActionKind, excludes: readonly CodeActionKind[], progress: IProgress, token: CancellationToken) { + private getActionsToRun(model: ITextModel, codeActionKind: HierarchicalKind, excludes: readonly HierarchicalKind[], progress: IProgress, token: CancellationToken) { return getCodeActions(this.languageFeaturesService.codeActionProvider, model, model.getFullModelRange(), { type: CodeActionTriggerType.Invoke, triggerAction: CodeActionTriggerSource.OnSave, @@ -520,7 +526,7 @@ export class SaveParticipantsContribution extends Disposable implements IWorkben this._register(this.workingCopyFileService.addSaveParticipant(this.instantiationService.createInstance(TrimWhitespaceParticipant))); this._register(this.workingCopyFileService.addSaveParticipant(this.instantiationService.createInstance(CodeActionOnSaveParticipant))); this._register(this.workingCopyFileService.addSaveParticipant(this.instantiationService.createInstance(FormatOnSaveParticipant))); - this._register(this.workingCopyFileService.addSaveParticipant(this.instantiationService.createInstance(FinalNewLineParticipant))); + this._register(this.workingCopyFileService.addSaveParticipant(this.instantiationService.createInstance(InsertFinalNewLineParticipant))); this._register(this.workingCopyFileService.addSaveParticipant(this.instantiationService.createInstance(TrimFinalNewLinesParticipant))); } } diff --git a/src/vs/workbench/contrib/notebook/browser/view/cellParts/chat/cellChatActions.ts b/src/vs/workbench/contrib/notebook/browser/controller/chat/cellChatActions.ts similarity index 54% rename from src/vs/workbench/contrib/notebook/browser/view/cellParts/chat/cellChatActions.ts rename to src/vs/workbench/contrib/notebook/browser/controller/chat/cellChatActions.ts index 575a5ddedeb35..701264ac4527c 100644 --- a/src/vs/workbench/contrib/notebook/browser/view/cellParts/chat/cellChatActions.ts +++ b/src/vs/workbench/contrib/notebook/browser/controller/chat/cellChatActions.ts @@ -4,9 +4,8 @@ *--------------------------------------------------------------------------------------------*/ import { Codicon } from 'vs/base/common/codicons'; -import { KeyCode, KeyMod } from 'vs/base/common/keyCodes'; +import { KeyChord, KeyCode, KeyMod } from 'vs/base/common/keyCodes'; import { EditorContextKeys } from 'vs/editor/common/editorContextKeys'; -import { ILanguageService } from 'vs/editor/common/languages/language'; import { localize, localize2 } from 'vs/nls'; import { CONTEXT_ACCESSIBILITY_MODE_ENABLED } from 'vs/platform/accessibility/common/accessibility'; import { MenuId, MenuRegistry, registerAction2 } from 'vs/platform/actions/common/actions'; @@ -15,16 +14,15 @@ import { InputFocusedContextKey } from 'vs/platform/contextkey/common/contextkey import { ServicesAccessor } from 'vs/platform/instantiation/common/instantiation'; import { KeybindingWeight } from 'vs/platform/keybinding/common/keybindingsRegistry'; import { CTX_INLINE_CHAT_FOCUSED, CTX_INLINE_CHAT_HAS_PROVIDER, CTX_INLINE_CHAT_INNER_CURSOR_FIRST, CTX_INLINE_CHAT_INNER_CURSOR_LAST, CTX_INLINE_CHAT_LAST_RESPONSE_TYPE, CTX_INLINE_CHAT_RESPONSE_TYPES, InlineChatResponseFeedbackKind, InlineChatResponseTypes } from 'vs/workbench/contrib/inlineChat/common/inlineChat'; -import { insertCell } from 'vs/workbench/contrib/notebook/browser/controller/cellOperations'; -import { INotebookActionContext, INotebookCellActionContext, NotebookAction, NotebookCellAction, getEditorFromArgsOrActivePane } from 'vs/workbench/contrib/notebook/browser/controller/coreActions'; -import { insertNewCell } from 'vs/workbench/contrib/notebook/browser/controller/insertCellActions'; -import { CellEditState, ICellViewModel } from 'vs/workbench/contrib/notebook/browser/notebookBrowser'; -import { CTX_NOTEBOOK_CELL_CHAT_FOCUSED, CTX_NOTEBOOK_CHAT_HAS_ACTIVE_REQUEST, MENU_CELL_CHAT_INPUT, MENU_CELL_CHAT_WIDGET, MENU_CELL_CHAT_WIDGET_FEEDBACK, MENU_CELL_CHAT_WIDGET_STATUS, NotebookCellChatController } from 'vs/workbench/contrib/notebook/browser/view/cellParts/chat/cellChatController'; +import { CTX_NOTEBOOK_CELL_CHAT_FOCUSED, CTX_NOTEBOOK_CHAT_HAS_ACTIVE_REQUEST, CTX_NOTEBOOK_CHAT_OUTER_FOCUS_POSITION, CTX_NOTEBOOK_CHAT_USER_DID_EDIT, MENU_CELL_CHAT_INPUT, MENU_CELL_CHAT_WIDGET, MENU_CELL_CHAT_WIDGET_FEEDBACK, MENU_CELL_CHAT_WIDGET_STATUS } from 'vs/workbench/contrib/notebook/browser/controller/chat/notebookChatContext'; +import { NotebookChatController } from 'vs/workbench/contrib/notebook/browser/controller/chat/notebookChatController'; +import { CELL_TITLE_CELL_GROUP_ID, INotebookActionContext, INotebookCellActionContext, NotebookAction, NotebookCellAction, getEditorFromArgsOrActivePane } from 'vs/workbench/contrib/notebook/browser/controller/coreActions'; +import { CellEditState } from 'vs/workbench/contrib/notebook/browser/notebookBrowser'; import { CellKind, NOTEBOOK_EDITOR_CURSOR_BOUNDARY, NotebookSetting } from 'vs/workbench/contrib/notebook/common/notebookCommon'; -import { NOTEBOOK_CELL_LIST_FOCUSED, NOTEBOOK_EDITOR_EDITABLE, NOTEBOOK_EDITOR_FOCUSED } from 'vs/workbench/contrib/notebook/common/notebookContextKeys'; +import { NOTEBOOK_CELL_EDITOR_FOCUSED, NOTEBOOK_CELL_GENERATED_BY_CHAT, NOTEBOOK_EDITOR_EDITABLE, NOTEBOOK_EDITOR_FOCUSED } from 'vs/workbench/contrib/notebook/common/notebookContextKeys'; -registerAction2(class extends NotebookCellAction { +registerAction2(class extends NotebookAction { constructor() { super( { @@ -32,26 +30,22 @@ registerAction2(class extends NotebookCellAction { title: localize2('notebook.cell.chat.accept', "Make Request"), icon: Codicon.send, keybinding: { - when: ContextKeyExpr.and(CTX_NOTEBOOK_CELL_CHAT_FOCUSED, CTX_INLINE_CHAT_FOCUSED), - weight: KeybindingWeight.EditorCore + 7, + when: ContextKeyExpr.and(CTX_NOTEBOOK_CELL_CHAT_FOCUSED, CTX_INLINE_CHAT_FOCUSED, NOTEBOOK_CELL_EDITOR_FOCUSED.negate()), + weight: KeybindingWeight.WorkbenchContrib, primary: KeyCode.Enter }, menu: { id: MENU_CELL_CHAT_INPUT, - group: 'main', + group: 'navigation', order: 1, when: CTX_NOTEBOOK_CHAT_HAS_ACTIVE_REQUEST.negate() - } + }, + f1: false }); } - async runWithContext(accessor: ServicesAccessor, context: INotebookCellActionContext) { - const ctrl = NotebookCellChatController.get(context.cell); - if (!ctrl) { - return; - } - - ctrl.acceptInput(); + async runWithContext(accessor: ServicesAccessor, context: INotebookActionContext) { + NotebookChatController.get(context.notebookEditor)?.acceptInput(); } }); @@ -66,11 +60,13 @@ registerAction2(class extends NotebookCellAction { CTX_NOTEBOOK_CELL_CHAT_FOCUSED, CTX_INLINE_CHAT_FOCUSED, CTX_INLINE_CHAT_INNER_CURSOR_FIRST, + NOTEBOOK_CELL_EDITOR_FOCUSED.negate(), CONTEXT_ACCESSIBILITY_MODE_ENABLED.negate() ), weight: KeybindingWeight.EditorCore + 7, primary: KeyMod.CtrlCmd | KeyCode.UpArrow - } + }, + f1: false }); } @@ -95,7 +91,7 @@ registerAction2(class extends NotebookCellAction { } }); -registerAction2(class extends NotebookCellAction { +registerAction2(class extends NotebookAction { constructor() { super( { @@ -106,18 +102,18 @@ registerAction2(class extends NotebookCellAction { CTX_NOTEBOOK_CELL_CHAT_FOCUSED, CTX_INLINE_CHAT_FOCUSED, CTX_INLINE_CHAT_INNER_CURSOR_LAST, + NOTEBOOK_CELL_EDITOR_FOCUSED.negate(), CONTEXT_ACCESSIBILITY_MODE_ENABLED.negate() ), weight: KeybindingWeight.EditorCore + 7, primary: KeyMod.CtrlCmd | KeyCode.DownArrow - } + }, + f1: false }); } - async runWithContext(accessor: ServicesAccessor, context: INotebookCellActionContext) { - const editor = context.notebookEditor; - const activeCell = context.cell; - await editor.focusNotebookCell(activeCell, 'editor'); + async runWithContext(accessor: ServicesAccessor, context: INotebookActionContext) { + await NotebookChatController.get(context.notebookEditor)?.focusNext(); } }); @@ -141,19 +137,14 @@ registerAction2(class extends NotebookCellAction { ), weight: KeybindingWeight.EditorCore + 7, primary: KeyMod.CtrlCmd | KeyCode.UpArrow - } + }, + f1: false }); } async runWithContext(accessor: ServicesAccessor, context: INotebookCellActionContext) { - const activeCell = context.cell; - // Navigate to cell chat widget if it exists - const controller = NotebookCellChatController.get(activeCell); - if (controller && controller.isWidgetVisible()) { - controller.focusWidget(); - return; - } - + const index = context.notebookEditor.getCellIndex(context.cell); + await NotebookChatController.get(context.notebookEditor)?.focusNearestWidget(index, 'above'); } }); @@ -177,38 +168,18 @@ registerAction2(class extends NotebookCellAction { ), weight: KeybindingWeight.EditorCore + 7, primary: KeyMod.CtrlCmd | KeyCode.DownArrow - } + }, + f1: false }); } async runWithContext(accessor: ServicesAccessor, context: INotebookCellActionContext) { - const editor = context.notebookEditor; - const activeCell = context.cell; - - const idx = editor.getCellIndex(activeCell); - if (typeof idx !== 'number') { - return; - } - - if (idx >= editor.getLength() - 1) { - // last one - return; - } - - const targetCell = editor.cellAt(idx + 1); - - if (targetCell) { - // Navigate to cell chat widget if it exists - const controller = NotebookCellChatController.get(targetCell); - if (controller && controller.isWidgetVisible()) { - controller.focusWidget(); - return; - } - } + const index = context.notebookEditor.getCellIndex(context.cell); + await NotebookChatController.get(context.notebookEditor)?.focusNearestWidget(index, 'below'); } }); -registerAction2(class extends NotebookCellAction { +registerAction2(class extends NotebookAction { constructor() { super( { @@ -217,24 +188,20 @@ registerAction2(class extends NotebookCellAction { icon: Codicon.debugStop, menu: { id: MENU_CELL_CHAT_INPUT, - group: 'main', + group: 'navigation', order: 1, when: CTX_NOTEBOOK_CHAT_HAS_ACTIVE_REQUEST - } + }, + f1: false }); } - async runWithContext(accessor: ServicesAccessor, context: INotebookCellActionContext) { - const ctrl = NotebookCellChatController.get(context.cell); - if (!ctrl) { - return; - } - - ctrl.cancelCurrentRequest(false); + async runWithContext(accessor: ServicesAccessor, context: INotebookActionContext) { + NotebookChatController.get(context.notebookEditor)?.cancelCurrentRequest(false); } }); -registerAction2(class extends NotebookCellAction { +registerAction2(class extends NotebookAction { constructor() { super( { @@ -243,19 +210,15 @@ registerAction2(class extends NotebookCellAction { icon: Codicon.close, menu: { id: MENU_CELL_CHAT_WIDGET, - group: 'main', + group: 'navigation', order: 2 - } + }, + f1: false }); } - async runWithContext(accessor: ServicesAccessor, context: INotebookCellActionContext) { - const ctrl = NotebookCellChatController.get(context.cell); - if (!ctrl) { - return; - } - - ctrl.dismiss(false); + async runWithContext(accessor: ServicesAccessor, context: INotebookActionContext) { + NotebookChatController.get(context.notebookEditor)?.dismiss(false); } }); @@ -268,11 +231,28 @@ registerAction2(class extends NotebookAction { shortTitle: localize('apply2', 'Accept'), icon: Codicon.check, tooltip: localize('apply3', 'Accept Changes'), - keybinding: { - when: ContextKeyExpr.and(CTX_NOTEBOOK_CELL_CHAT_FOCUSED, CTX_INLINE_CHAT_FOCUSED), - weight: KeybindingWeight.EditorContrib + 10, - primary: KeyMod.CtrlCmd | KeyCode.Enter, - }, + keybinding: [ + { + when: ContextKeyExpr.and(CTX_NOTEBOOK_CELL_CHAT_FOCUSED, CTX_INLINE_CHAT_FOCUSED, NOTEBOOK_CELL_EDITOR_FOCUSED.negate()), + weight: KeybindingWeight.EditorContrib + 10, + primary: KeyMod.CtrlCmd | KeyCode.Enter, + }, + { + when: ContextKeyExpr.and(CTX_NOTEBOOK_CELL_CHAT_FOCUSED, CTX_INLINE_CHAT_FOCUSED, CTX_NOTEBOOK_CHAT_USER_DID_EDIT, NOTEBOOK_CELL_EDITOR_FOCUSED.negate()), + weight: KeybindingWeight.EditorCore + 10, + primary: KeyCode.Escape + }, + { + when: ContextKeyExpr.and( + NOTEBOOK_EDITOR_FOCUSED, + ContextKeyExpr.not(InputFocusedContextKey), + NOTEBOOK_CELL_EDITOR_FOCUSED.negate(), + CTX_NOTEBOOK_CHAT_OUTER_FOCUS_POSITION.isEqualTo('below') + ), + primary: KeyMod.CtrlCmd | KeyCode.Enter, + weight: KeybindingWeight.WorkbenchContrib + } + ], menu: [ { id: MENU_CELL_CHAT_WIDGET_STATUS, @@ -280,21 +260,17 @@ registerAction2(class extends NotebookAction { order: 0, when: CTX_INLINE_CHAT_RESPONSE_TYPES.notEqualsTo(InlineChatResponseTypes.OnlyMessages), } - ] + ], + f1: false }); } - async runWithContext(accessor: ServicesAccessor, context: INotebookCellActionContext) { - const ctrl = NotebookCellChatController.get(context.cell); - if (!ctrl) { - return; - } - - ctrl.acceptSession(); + async runWithContext(accessor: ServicesAccessor, context: INotebookActionContext) { + NotebookChatController.get(context.notebookEditor)?.acceptSession(); } }); -registerAction2(class extends NotebookCellAction { +registerAction2(class extends NotebookAction { constructor() { super( { @@ -302,7 +278,7 @@ registerAction2(class extends NotebookCellAction { title: localize('discard', 'Discard'), icon: Codicon.discard, keybinding: { - when: ContextKeyExpr.and(CTX_NOTEBOOK_CELL_CHAT_FOCUSED, CTX_INLINE_CHAT_FOCUSED, NOTEBOOK_CELL_LIST_FOCUSED), + when: ContextKeyExpr.and(CTX_NOTEBOOK_CELL_CHAT_FOCUSED, CTX_INLINE_CHAT_FOCUSED, CTX_NOTEBOOK_CHAT_USER_DID_EDIT.negate(), NOTEBOOK_CELL_EDITOR_FOCUSED.negate()), weight: KeybindingWeight.EditorContrib, primary: KeyCode.Escape }, @@ -310,24 +286,17 @@ registerAction2(class extends NotebookCellAction { id: MENU_CELL_CHAT_WIDGET_STATUS, group: 'main', order: 1 - } + }, + f1: false }); } - async runWithContext(accessor: ServicesAccessor, context: INotebookCellActionContext) { - const ctrl = NotebookCellChatController.get(context.cell); - if (!ctrl) { - return; - } - - // todo discard - ctrl.dismiss(true); - // focus on the cell editor container - context.notebookEditor.focusNotebookCell(context.cell, 'container'); + async runWithContext(accessor: ServicesAccessor, context: INotebookActionContext) { + NotebookChatController.get(context.notebookEditor)?.discard(); } }); -registerAction2(class extends NotebookCellAction { +registerAction2(class extends NotebookAction { constructor() { super({ id: 'notebook.cell.feedbackHelpful', @@ -338,21 +307,17 @@ registerAction2(class extends NotebookCellAction { group: 'inline', order: 1, when: CTX_INLINE_CHAT_LAST_RESPONSE_TYPE.notEqualsTo(undefined), - } + }, + f1: false }); } - async runWithContext(accessor: ServicesAccessor, context: INotebookCellActionContext) { - const ctrl = NotebookCellChatController.get(context.cell); - if (!ctrl) { - return; - } - - ctrl.feedbackLast(InlineChatResponseFeedbackKind.Helpful); + async runWithContext(accessor: ServicesAccessor, context: INotebookActionContext) { + NotebookChatController.get(context.notebookEditor)?.feedbackLast(InlineChatResponseFeedbackKind.Helpful); } }); -registerAction2(class extends NotebookCellAction { +registerAction2(class extends NotebookAction { constructor() { super({ id: 'notebook.cell.feedbackUnhelpful', @@ -363,21 +328,17 @@ registerAction2(class extends NotebookCellAction { group: 'inline', order: 2, when: CTX_INLINE_CHAT_LAST_RESPONSE_TYPE.notEqualsTo(undefined), - } + }, + f1: false }); } - async runWithContext(accessor: ServicesAccessor, context: INotebookCellActionContext) { - const ctrl = NotebookCellChatController.get(context.cell); - if (!ctrl) { - return; - } - - ctrl.feedbackLast(InlineChatResponseFeedbackKind.Unhelpful); + async runWithContext(accessor: ServicesAccessor, context: INotebookActionContext) { + NotebookChatController.get(context.notebookEditor)?.feedbackLast(InlineChatResponseFeedbackKind.Unhelpful); } }); -registerAction2(class extends NotebookCellAction { +registerAction2(class extends NotebookAction { constructor() { super({ id: 'notebook.cell.reportIssueForBug', @@ -388,17 +349,13 @@ registerAction2(class extends NotebookCellAction { group: 'inline', order: 3, when: CTX_INLINE_CHAT_LAST_RESPONSE_TYPE.notEqualsTo(undefined), - } + }, + f1: false }); } - async runWithContext(accessor: ServicesAccessor, context: INotebookCellActionContext) { - const ctrl = NotebookCellChatController.get(context.cell); - if (!ctrl) { - return; - } - - ctrl.feedbackLast(InlineChatResponseFeedbackKind.Bug); + async runWithContext(accessor: ServicesAccessor, context: INotebookActionContext) { + NotebookChatController.get(context.notebookEditor)?.feedbackLast(InlineChatResponseFeedbackKind.Bug); } }); @@ -411,14 +368,14 @@ registerAction2(class extends NotebookAction { constructor() { super( { - id: 'notebook.cell.insertCodeCellWithChat', + id: 'notebook.cell.chat.start', title: { value: '$(sparkle) ' + localize('notebookActions.menu.insertCodeCellWithChat', "Generate"), original: '$(sparkle) Generate', }, - tooltip: localize('notebookActions.menu.insertCodeCellWithChat.tooltip', "Generate Code Cell with Chat"), + tooltip: localize('notebookActions.menu.insertCodeCellWithChat.tooltip', "Start Chat to Generate Code"), metadata: { - description: localize('notebookActions.menu.insertCodeCellWithChat.tooltip', "Generate Code Cell with Chat"), + description: localize('notebookActions.menu.insertCodeCellWithChat.tooltip', "Start Chat to Generate Code"), args: [ { name: 'args', @@ -440,6 +397,19 @@ registerAction2(class extends NotebookAction { } ] }, + f1: false, + keybinding: { + when: ContextKeyExpr.and( + NOTEBOOK_EDITOR_FOCUSED, + NOTEBOOK_EDITOR_EDITABLE.isEqualTo(true), + ContextKeyExpr.not(InputFocusedContextKey), + CTX_INLINE_CHAT_HAS_PROVIDER, + ContextKeyExpr.equals(`config.${NotebookSetting.cellChat}`, true) + ), + weight: KeybindingWeight.WorkbenchContrib, + primary: KeyMod.CtrlCmd | KeyCode.KeyI, + secondary: [KeyChord(KeyMod.CtrlCmd | KeyCode.KeyK, KeyCode.KeyI)], + }, menu: [ { id: MenuId.NotebookCellBetween, @@ -458,7 +428,22 @@ registerAction2(class extends NotebookAction { override getEditorContextFromArgsOrActive(accessor: ServicesAccessor, ...args: any[]): IInsertCellWithChatArgs | undefined { const [firstArg] = args; if (!firstArg) { - return undefined; + const notebookEditor = getEditorFromArgsOrActivePane(accessor); + if (!notebookEditor) { + return undefined; + } + + const activeCell = notebookEditor.getActiveCell(); + if (!activeCell) { + return undefined; + } + + return { + cell: activeCell, + notebookEditor, + input: undefined, + autoSend: undefined + }; } if (typeof firstArg !== 'object' || typeof firstArg.index !== 'number') { @@ -481,46 +466,23 @@ registerAction2(class extends NotebookAction { } async runWithContext(accessor: ServicesAccessor, context: IInsertCellWithChatArgs) { - let newCell: ICellViewModel | null = null; - if (!context.cell) { - // insert at the top - const languageService = accessor.get(ILanguageService); - newCell = insertCell(languageService, context.notebookEditor, 0, CellKind.Code, 'above', undefined, true); - } else { - newCell = insertNewCell(accessor, context, CellKind.Code, 'below', true); - } - - if (!newCell) { - return; - } - - await context.notebookEditor.focusNotebookCell(newCell, 'container'); - const ctrl = NotebookCellChatController.get(newCell); - if (!ctrl) { - return; - } - - context.notebookEditor.getCellsInRange().forEach(cell => { - const cellCtrl = NotebookCellChatController.get(cell); - if (cellCtrl) { - cellCtrl.dismiss(false); - } - }); - - ctrl.show(context.input, context.autoSend); + const index = Math.max(0, context.cell ? context.notebookEditor.getCellIndex(context.cell) + 1 : 0); + context.notebookEditor.focusContainer(); + NotebookChatController.get(context.notebookEditor)?.run(index, context.input, context.autoSend); } }); -registerAction2(class extends NotebookCellAction { +registerAction2(class extends NotebookAction { constructor() { super( { - id: 'notebook.cell.insertCodeCellWithChatAtTop', + id: 'notebook.cell.chat.startAtTop', title: { value: '$(sparkle) ' + localize('notebookActions.menu.insertCodeCellWithChat', "Generate"), original: '$(sparkle) Generate', }, - tooltip: localize('notebookActions.menu.insertCodeCellWithChat.tooltip', "Generate Code Cell with Chat"), + tooltip: localize('notebookActions.menu.insertCodeCellWithChat.tooltip', "Start Chat to Generate Code"), + f1: false, menu: [ { id: MenuId.NotebookCellListTop, @@ -536,36 +498,18 @@ registerAction2(class extends NotebookCellAction { }); } - async runWithContext(accessor: ServicesAccessor, context: INotebookCellActionContext) { - const languageService = accessor.get(ILanguageService); - const newCell = insertCell(languageService, context.notebookEditor, 0, CellKind.Code, 'above', undefined, true); - - if (!newCell) { - return; - } - await context.notebookEditor.focusNotebookCell(newCell, 'container'); - const ctrl = NotebookCellChatController.get(newCell); - if (!ctrl) { - return; - } - - context.notebookEditor.getCellsInRange().forEach(cell => { - const cellCtrl = NotebookCellChatController.get(cell); - if (cellCtrl) { - cellCtrl.dismiss(false); - } - }); - - ctrl.show(); + async runWithContext(accessor: ServicesAccessor, context: INotebookActionContext) { + context.notebookEditor.focusContainer(); + NotebookChatController.get(context.notebookEditor)?.run(0, '', false); } }); MenuRegistry.appendMenuItem(MenuId.NotebookToolbar, { command: { - id: 'notebook.cell.insertCodeCellWithChat', + id: 'notebook.cell.chat.start', icon: Codicon.sparkle, title: localize('notebookActions.menu.insertCode.ontoolbar', "Generate"), - tooltip: localize('notebookActions.menu.insertCode.tooltip', "Generate Code Cell with Chat") + tooltip: localize('notebookActions.menu.insertCode.tooltip', "Start Chat to Generate Code") }, order: -10, group: 'navigation/add', @@ -577,3 +521,171 @@ MenuRegistry.appendMenuItem(MenuId.NotebookToolbar, { ContextKeyExpr.equals(`config.${NotebookSetting.cellChat}`, true) ) }); + +registerAction2(class extends NotebookAction { + constructor() { + super({ + id: 'notebook.cell.chat.focus', + title: localize('focusNotebookChat', 'Focus Chat'), + keybinding: [ + { + when: ContextKeyExpr.and( + NOTEBOOK_EDITOR_FOCUSED, + ContextKeyExpr.not(InputFocusedContextKey), + CTX_NOTEBOOK_CHAT_OUTER_FOCUS_POSITION.isEqualTo('above') + ), + primary: KeyMod.CtrlCmd | KeyCode.DownArrow, + weight: KeybindingWeight.WorkbenchContrib + }, + { + when: ContextKeyExpr.and( + NOTEBOOK_EDITOR_FOCUSED, + ContextKeyExpr.not(InputFocusedContextKey), + CTX_NOTEBOOK_CHAT_OUTER_FOCUS_POSITION.isEqualTo('below') + ), + primary: KeyMod.CtrlCmd | KeyCode.UpArrow, + weight: KeybindingWeight.WorkbenchContrib + } + ], + f1: false + }); + } + + async runWithContext(accessor: ServicesAccessor, context: INotebookActionContext): Promise { + NotebookChatController.get(context.notebookEditor)?.focus(); + } +}); + +registerAction2(class extends NotebookAction { + constructor() { + super({ + id: 'notebook.cell.chat.focusNextCell', + title: localize('focusNextCell', 'Focus Next Cell'), + keybinding: [ + { + when: ContextKeyExpr.and( + CTX_NOTEBOOK_CELL_CHAT_FOCUSED, + CTX_INLINE_CHAT_FOCUSED, + ), + primary: KeyMod.CtrlCmd | KeyCode.DownArrow, + weight: KeybindingWeight.WorkbenchContrib + } + ], + f1: false + }); + } + + async runWithContext(accessor: ServicesAccessor, context: INotebookActionContext): Promise { + NotebookChatController.get(context.notebookEditor)?.focusNext(); + } +}); + +registerAction2(class extends NotebookAction { + constructor() { + super({ + id: 'notebook.cell.chat.focusPreviousCell', + title: localize('focusPreviousCell', 'Focus Previous Cell'), + keybinding: [ + { + when: ContextKeyExpr.and( + CTX_NOTEBOOK_CELL_CHAT_FOCUSED, + CTX_INLINE_CHAT_FOCUSED, + ), + primary: KeyMod.CtrlCmd | KeyCode.UpArrow, + weight: KeybindingWeight.WorkbenchContrib + } + ], + f1: false + }); + } + + async runWithContext(accessor: ServicesAccessor, context: INotebookActionContext): Promise { + NotebookChatController.get(context.notebookEditor)?.focusAbove(); + } +}); + +registerAction2(class extends NotebookAction { + constructor() { + super( + { + id: 'notebook.cell.chat.previousFromHistory', + title: localize2('notebook.cell.chat.previousFromHistory', "Previous From History"), + precondition: ContextKeyExpr.and(CTX_NOTEBOOK_CELL_CHAT_FOCUSED, CTX_INLINE_CHAT_FOCUSED), + keybinding: { + when: ContextKeyExpr.and(CTX_NOTEBOOK_CELL_CHAT_FOCUSED, CTX_INLINE_CHAT_FOCUSED), + weight: KeybindingWeight.EditorCore + 10, + primary: KeyCode.UpArrow, + }, + f1: false + }); + } + + async runWithContext(accessor: ServicesAccessor, context: INotebookActionContext) { + NotebookChatController.get(context.notebookEditor)?.populateHistory(true); + } +}); + +registerAction2(class extends NotebookAction { + constructor() { + super( + { + id: 'notebook.cell.chat.nextFromHistory', + title: localize2('notebook.cell.chat.nextFromHistory', "Next From History"), + precondition: ContextKeyExpr.and(CTX_NOTEBOOK_CELL_CHAT_FOCUSED, CTX_INLINE_CHAT_FOCUSED), + keybinding: { + when: ContextKeyExpr.and(CTX_NOTEBOOK_CELL_CHAT_FOCUSED, CTX_INLINE_CHAT_FOCUSED), + weight: KeybindingWeight.EditorCore + 10, + primary: KeyCode.DownArrow + }, + f1: false + }); + } + + async runWithContext(accessor: ServicesAccessor, context: INotebookActionContext) { + NotebookChatController.get(context.notebookEditor)?.populateHistory(false); + } +}); + +registerAction2(class extends NotebookCellAction { + constructor() { + super( + { + id: 'notebook.cell.chat.restore', + title: localize2('notebookActions.restoreCellprompt', "Generate"), + icon: Codicon.sparkle, + menu: { + id: MenuId.NotebookCellTitle, + group: CELL_TITLE_CELL_GROUP_ID, + order: 0, + when: ContextKeyExpr.and( + NOTEBOOK_EDITOR_EDITABLE.isEqualTo(true), + CTX_INLINE_CHAT_HAS_PROVIDER, + NOTEBOOK_CELL_GENERATED_BY_CHAT, + ContextKeyExpr.equals(`config.${NotebookSetting.cellChat}`, true) + ) + }, + f1: false + }); + } + + async runWithContext(accessor: ServicesAccessor, context: INotebookCellActionContext) { + const cell = context.cell; + + if (!cell) { + return; + } + + const notebookEditor = context.notebookEditor; + const controller = NotebookChatController.get(notebookEditor); + + if (!controller) { + return; + } + + const prompt = controller.getPromptFromCache(cell); + + if (prompt) { + controller.restore(cell, prompt); + } + } +}); diff --git a/src/vs/workbench/contrib/notebook/browser/controller/chat/notebook.chat.contribution.ts b/src/vs/workbench/contrib/notebook/browser/controller/chat/notebook.chat.contribution.ts new file mode 100644 index 0000000000000..995340fb19188 --- /dev/null +++ b/src/vs/workbench/contrib/notebook/browser/controller/chat/notebook.chat.contribution.ts @@ -0,0 +1,6 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import 'vs/workbench/contrib/notebook/browser/controller/chat/cellChatActions'; diff --git a/src/vs/workbench/contrib/notebook/browser/controller/chat/notebookChatContext.ts b/src/vs/workbench/contrib/notebook/browser/controller/chat/notebookChatContext.ts new file mode 100644 index 0000000000000..4c8a6aa024d0c --- /dev/null +++ b/src/vs/workbench/contrib/notebook/browser/controller/chat/notebookChatContext.ts @@ -0,0 +1,19 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { localize } from 'vs/nls'; +import { MenuId } from 'vs/platform/actions/common/actions'; +import { RawContextKey } from 'vs/platform/contextkey/common/contextkey'; + +export const CTX_NOTEBOOK_CELL_CHAT_FOCUSED = new RawContextKey('notebookCellChatFocused', false, localize('notebookCellChatFocused', "Whether the cell chat editor is focused")); +export const CTX_NOTEBOOK_CHAT_HAS_ACTIVE_REQUEST = new RawContextKey('notebookChatHasActiveRequest', false, localize('notebookChatHasActiveRequest', "Whether the cell chat editor has an active request")); +export const CTX_NOTEBOOK_CHAT_USER_DID_EDIT = new RawContextKey('notebookChatUserDidEdit', false, localize('notebookChatUserDidEdit', "Whether the user did changes ontop of the notebook cell chat")); +export const CTX_NOTEBOOK_CHAT_OUTER_FOCUS_POSITION = new RawContextKey<'above' | 'below' | ''>('notebookChatOuterFocusPosition', '', localize('notebookChatOuterFocusPosition', "Whether the focus of the notebook editor is above or below the cell chat")); + +export const MENU_CELL_CHAT_INPUT = MenuId.for('cellChatInput'); +export const MENU_CELL_CHAT_WIDGET = MenuId.for('cellChatWidget'); +export const MENU_CELL_CHAT_WIDGET_STATUS = MenuId.for('cellChatWidget.status'); +export const MENU_CELL_CHAT_WIDGET_FEEDBACK = MenuId.for('cellChatWidget.feedback'); +export const MENU_CELL_CHAT_WIDGET_TOOLBAR = MenuId.for('cellChatWidget.toolbar'); diff --git a/src/vs/workbench/contrib/notebook/browser/controller/chat/notebookChatController.ts b/src/vs/workbench/contrib/notebook/browser/controller/chat/notebookChatController.ts new file mode 100644 index 0000000000000..9add14eccb0db --- /dev/null +++ b/src/vs/workbench/contrib/notebook/browser/controller/chat/notebookChatController.ts @@ -0,0 +1,1087 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Dimension, IFocusTracker, WindowIntervalTimer, getWindow, scheduleAtNextAnimationFrame, trackFocus } from 'vs/base/browser/dom'; +import { CancelablePromise, Queue, createCancelablePromise, disposableTimeout, raceCancellationError } from 'vs/base/common/async'; +import { CancellationToken, CancellationTokenSource } from 'vs/base/common/cancellation'; +import { Emitter, Event } from 'vs/base/common/event'; +import { MarkdownString } from 'vs/base/common/htmlContent'; +import { Disposable, DisposableStore, toDisposable } from 'vs/base/common/lifecycle'; +import { LRUCache } from 'vs/base/common/map'; +import { Schemas } from 'vs/base/common/network'; +import { MovingAverage } from 'vs/base/common/numbers'; +import { StopWatch } from 'vs/base/common/stopwatch'; +import { assertType } from 'vs/base/common/types'; +import { URI } from 'vs/base/common/uri'; +import { generateUuid } from 'vs/base/common/uuid'; +import { IActiveCodeEditor } from 'vs/editor/browser/editorBrowser'; +import { CodeEditorWidget } from 'vs/editor/browser/widget/codeEditor/codeEditorWidget'; +import { ISingleEditOperation } from 'vs/editor/common/core/editOperation'; +import { Position } from 'vs/editor/common/core/position'; +import { Selection } from 'vs/editor/common/core/selection'; +import { TextEdit } from 'vs/editor/common/languages'; +import { ILanguageService } from 'vs/editor/common/languages/language'; +import { ICursorStateComputer, ITextModel } from 'vs/editor/common/model'; +import { IEditorWorkerService } from 'vs/editor/common/services/editorWorker'; +import { IModelService } from 'vs/editor/common/services/model'; +import { localize } from 'vs/nls'; +import { ICommandService } from 'vs/platform/commands/common/commands'; +import { IContextKey, IContextKeyService } from 'vs/platform/contextkey/common/contextkey'; +import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; +import { AsyncProgress } from 'vs/platform/progress/common/progress'; +import { IStorageService, StorageScope, StorageTarget } from 'vs/platform/storage/common/storage'; +import { SaveReason } from 'vs/workbench/common/editor'; +import { GeneratingPhrase } from 'vs/workbench/contrib/chat/browser/chat'; +import { ChatAgentLocation } from 'vs/workbench/contrib/chat/common/chatAgents'; +import { countWords } from 'vs/workbench/contrib/chat/common/chatWordCounter'; +import { IInlineChatSavingService } from 'vs/workbench/contrib/inlineChat/browser/inlineChatSavingService'; +import { EmptyResponse, ErrorResponse, ReplyResponse, Session, SessionExchange, SessionPrompt } from 'vs/workbench/contrib/inlineChat/browser/inlineChatSession'; +import { IInlineChatSessionService } from 'vs/workbench/contrib/inlineChat/browser/inlineChatSessionService'; +import { ProgressingEditsOptions } from 'vs/workbench/contrib/inlineChat/browser/inlineChatStrategies'; +import { IInlineChatMessageAppender, InlineChatWidget } from 'vs/workbench/contrib/inlineChat/browser/inlineChatWidget'; +import { asProgressiveEdit, performAsyncTextEdit } from 'vs/workbench/contrib/inlineChat/browser/utils'; +import { CTX_INLINE_CHAT_LAST_RESPONSE_TYPE, EditMode, IInlineChatProgressItem, IInlineChatRequest, InlineChatResponseFeedbackKind, InlineChatResponseType } from 'vs/workbench/contrib/inlineChat/common/inlineChat'; +import { insertCell, runDeleteAction } from 'vs/workbench/contrib/notebook/browser/controller/cellOperations'; +import { CTX_NOTEBOOK_CELL_CHAT_FOCUSED, CTX_NOTEBOOK_CHAT_HAS_ACTIVE_REQUEST, CTX_NOTEBOOK_CHAT_OUTER_FOCUS_POSITION, CTX_NOTEBOOK_CHAT_USER_DID_EDIT, MENU_CELL_CHAT_INPUT, MENU_CELL_CHAT_WIDGET, MENU_CELL_CHAT_WIDGET_FEEDBACK, MENU_CELL_CHAT_WIDGET_STATUS } from 'vs/workbench/contrib/notebook/browser/controller/chat/notebookChatContext'; +import { ICellViewModel, INotebookEditor, INotebookEditorContribution, INotebookViewZone } from 'vs/workbench/contrib/notebook/browser/notebookBrowser'; +import { registerNotebookContribution } from 'vs/workbench/contrib/notebook/browser/notebookEditorExtensions'; +import { CellKind } from 'vs/workbench/contrib/notebook/common/notebookCommon'; +import { INotebookExecutionStateService, NotebookExecutionType } from 'vs/workbench/contrib/notebook/common/notebookExecutionStateService'; + +class NotebookChatWidget extends Disposable implements INotebookViewZone { + set afterModelPosition(afterModelPosition: number) { + this.notebookViewZone.afterModelPosition = afterModelPosition; + } + + get afterModelPosition(): number { + return this.notebookViewZone.afterModelPosition; + } + + set heightInPx(heightInPx: number) { + this.notebookViewZone.heightInPx = heightInPx; + } + + get heightInPx(): number { + return this.notebookViewZone.heightInPx; + } + + private _editingCell: ICellViewModel | null = null; + + get editingCell() { + return this._editingCell; + } + + constructor( + private readonly _notebookEditor: INotebookEditor, + readonly id: string, + readonly notebookViewZone: INotebookViewZone, + readonly domNode: HTMLElement, + readonly widgetContainer: HTMLElement, + readonly inlineChatWidget: InlineChatWidget, + readonly parentEditor: CodeEditorWidget, + private readonly _languageService: ILanguageService, + ) { + super(); + + this._register(inlineChatWidget.onDidChangeHeight(() => { + this.heightInPx = inlineChatWidget.contentHeight; + this._notebookEditor.changeViewZones(accessor => { + accessor.layoutZone(id); + }); + this._layoutWidget(inlineChatWidget, widgetContainer); + })); + + this._layoutWidget(inlineChatWidget, widgetContainer); + } + + restoreEditingCell(initEditingCell: ICellViewModel) { + this._editingCell = initEditingCell; + + const decorationIds = this._notebookEditor.deltaCellDecorations([], [{ + handle: this._editingCell.handle, + options: { className: 'nb-chatGenerationHighlight', outputClassName: 'nb-chatGenerationHighlight' } + }]); + + this._register(toDisposable(() => { + this._notebookEditor.deltaCellDecorations(decorationIds, []); + })); + } + + hasFocus() { + return this.inlineChatWidget.hasFocus(); + } + + focus() { + this.updateNotebookEditorFocusNSelections(); + this.inlineChatWidget.focus(); + } + + updateNotebookEditorFocusNSelections() { + this._notebookEditor.focusContainer(true); + this._notebookEditor.setFocus({ start: this.afterModelPosition, end: this.afterModelPosition }); + this._notebookEditor.setSelections([{ + start: this.afterModelPosition, + end: this.afterModelPosition + }]); + } + + getEditingCell() { + return this._editingCell; + } + + async getOrCreateEditingCell(): Promise<{ cell: ICellViewModel; editor: IActiveCodeEditor } | undefined> { + if (this._editingCell) { + const codeEditor = this._notebookEditor.codeEditors.find(ce => ce[0] === this._editingCell)?.[1]; + if (codeEditor?.hasModel()) { + return { + cell: this._editingCell, + editor: codeEditor + }; + } else { + return undefined; + } + } + + if (!this._notebookEditor.hasModel()) { + return undefined; + } + + const widgetHasFocus = this.inlineChatWidget.hasFocus(); + + this._editingCell = insertCell(this._languageService, this._notebookEditor, this.afterModelPosition, CellKind.Code, 'above'); + + if (!this._editingCell) { + return undefined; + } + + await this._notebookEditor.revealFirstLineIfOutsideViewport(this._editingCell); + + // update decoration + const decorationIds = this._notebookEditor.deltaCellDecorations([], [{ + handle: this._editingCell.handle, + options: { className: 'nb-chatGenerationHighlight', outputClassName: 'nb-chatGenerationHighlight' } + }]); + + this._register(toDisposable(() => { + this._notebookEditor.deltaCellDecorations(decorationIds, []); + })); + + if (widgetHasFocus) { + this.focus(); + } + + const codeEditor = this._notebookEditor.codeEditors.find(ce => ce[0] === this._editingCell)?.[1]; + if (codeEditor?.hasModel()) { + return { + cell: this._editingCell, + editor: codeEditor + }; + } + + return undefined; + } + + async discardChange() { + if (this._notebookEditor.hasModel() && this._editingCell) { + // remove the cell from the notebook + runDeleteAction(this._notebookEditor, this._editingCell); + } + } + + private _layoutWidget(inlineChatWidget: InlineChatWidget, widgetContainer: HTMLElement) { + const layoutConfiguration = this._notebookEditor.notebookOptions.getLayoutConfiguration(); + const rightMargin = layoutConfiguration.cellRightMargin; + const leftMargin = this._notebookEditor.notebookOptions.getCellEditorContainerLeftMargin(); + const maxWidth = 640; + const width = Math.min(maxWidth, this._notebookEditor.getLayoutInfo().width - leftMargin - rightMargin); + + inlineChatWidget.layout(new Dimension(width, this.heightInPx)); + inlineChatWidget.domNode.style.width = `${width}px`; + widgetContainer.style.left = `${leftMargin}px`; + } + + override dispose() { + this._notebookEditor.changeViewZones(accessor => { + accessor.removeZone(this.id); + }); + this.domNode.remove(); + super.dispose(); + } +} + +export interface INotebookCellTextModelLike { uri: URI; viewType: string } +class NotebookCellTextModelLikeId { + static str(k: INotebookCellTextModelLike): string { + return `${k.viewType}/${k.uri.toString()}`; + } + static obj(s: string): INotebookCellTextModelLike { + const idx = s.indexOf('/'); + return { + viewType: s.substring(0, idx), + uri: URI.parse(s.substring(idx + 1)) + }; + } +} + +export class NotebookChatController extends Disposable implements INotebookEditorContribution { + static id: string = 'workbench.notebook.chatController'; + static counter: number = 0; + + public static get(editor: INotebookEditor): NotebookChatController | null { + return editor.getContribution(NotebookChatController.id); + } + + // History + private static _storageKey = 'inline-chat-history'; + private static _promptHistory: string[] = []; + private _historyOffset: number = -1; + private _historyCandidate: string = ''; + private _historyUpdate: (prompt: string) => void; + private _promptCache = new LRUCache(1000, 0.7); + private readonly _onDidChangePromptCache = this._register(new Emitter<{ cell: URI }>()); + readonly onDidChangePromptCache = this._onDidChangePromptCache.event; + + private _strategy: EditStrategy | undefined; + private _sessionCtor: CancelablePromise | undefined; + private _activeSession?: Session; + private _warmupRequestCts?: CancellationTokenSource; + private _activeRequestCts?: CancellationTokenSource; + private readonly _ctxHasActiveRequest: IContextKey; + private readonly _ctxCellWidgetFocused: IContextKey; + private readonly _ctxUserDidEdit: IContextKey; + private readonly _ctxOuterFocusPosition: IContextKey<'above' | 'below' | ''>; + private readonly _userEditingDisposables = this._register(new DisposableStore()); + private readonly _ctxLastResponseType: IContextKey; + private _widget: NotebookChatWidget | undefined; + private readonly _widgetDisposableStore = this._register(new DisposableStore()); + private _focusTracker: IFocusTracker | undefined; + constructor( + private readonly _notebookEditor: INotebookEditor, + @IInstantiationService private readonly _instantiationService: IInstantiationService, + @IInlineChatSessionService private readonly _inlineChatSessionService: IInlineChatSessionService, + @IContextKeyService private readonly _contextKeyService: IContextKeyService, + @ICommandService private readonly _commandService: ICommandService, + @IEditorWorkerService private readonly _editorWorkerService: IEditorWorkerService, + @IInlineChatSavingService private readonly _inlineChatSavingService: IInlineChatSavingService, + @IModelService private readonly _modelService: IModelService, + @ILanguageService private readonly _languageService: ILanguageService, + @INotebookExecutionStateService private _executionStateService: INotebookExecutionStateService, + @IStorageService private readonly _storageService: IStorageService, + + ) { + super(); + this._ctxHasActiveRequest = CTX_NOTEBOOK_CHAT_HAS_ACTIVE_REQUEST.bindTo(this._contextKeyService); + this._ctxCellWidgetFocused = CTX_NOTEBOOK_CELL_CHAT_FOCUSED.bindTo(this._contextKeyService); + this._ctxLastResponseType = CTX_INLINE_CHAT_LAST_RESPONSE_TYPE.bindTo(this._contextKeyService); + this._ctxUserDidEdit = CTX_NOTEBOOK_CHAT_USER_DID_EDIT.bindTo(this._contextKeyService); + this._ctxOuterFocusPosition = CTX_NOTEBOOK_CHAT_OUTER_FOCUS_POSITION.bindTo(this._contextKeyService); + + this._registerFocusTracker(); + + NotebookChatController._promptHistory = JSON.parse(this._storageService.get(NotebookChatController._storageKey, StorageScope.PROFILE, '[]')); + this._historyUpdate = (prompt: string) => { + const idx = NotebookChatController._promptHistory.indexOf(prompt); + if (idx >= 0) { + NotebookChatController._promptHistory.splice(idx, 1); + } + NotebookChatController._promptHistory.unshift(prompt); + this._historyOffset = -1; + this._historyCandidate = ''; + this._storageService.store(NotebookChatController._storageKey, JSON.stringify(NotebookChatController._promptHistory), StorageScope.PROFILE, StorageTarget.USER); + }; + } + + private _registerFocusTracker() { + this._register(this._notebookEditor.onDidChangeFocus(() => { + if (!this._widget) { + this._ctxOuterFocusPosition.set(''); + return; + } + + const widgetIndex = this._widget.afterModelPosition; + const focus = this._notebookEditor.getFocus().start; + + if (focus + 1 === widgetIndex) { + this._ctxOuterFocusPosition.set('above'); + } else if (focus === widgetIndex) { + this._ctxOuterFocusPosition.set('below'); + } else { + this._ctxOuterFocusPosition.set(''); + } + })); + } + + run(index: number, input: string | undefined, autoSend: boolean | undefined): void { + if (this._widget) { + if (this._widget.afterModelPosition !== index) { + const window = getWindow(this._widget.domNode); + this._disposeWidget(); + + scheduleAtNextAnimationFrame(window, () => { + this._createWidget(index, input, autoSend, undefined); + }); + } + + return; + } + + this._createWidget(index, input, autoSend, undefined); + // TODO: reveal widget to the center if it's out of the viewport + } + + restore(editingCell: ICellViewModel, input: string) { + if (!this._notebookEditor.hasModel()) { + return; + } + + const index = this._notebookEditor.textModel.cells.indexOf(editingCell.model); + + if (index < 0) { + return; + } + + if (this._widget) { + if (this._widget.afterModelPosition !== index) { + this._disposeWidget(); + const window = getWindow(this._widget.domNode); + + scheduleAtNextAnimationFrame(window, () => { + this._createWidget(index, input, false, editingCell); + }); + } + + return; + } + + this._createWidget(index, input, false, editingCell); + } + + private _disposeWidget() { + this._widget?.dispose(); + this._widget = undefined; + this._widgetDisposableStore.clear(); + + this._historyOffset = -1; + this._historyCandidate = ''; + } + + + private _createWidget(index: number, input: string | undefined, autoSend: boolean | undefined, initEditingCell: ICellViewModel | undefined) { + if (!this._notebookEditor.hasModel()) { + return; + } + + // Clear the widget if it's already there + this._widgetDisposableStore.clear(); + + const viewZoneContainer = document.createElement('div'); + viewZoneContainer.classList.add('monaco-editor'); + const widgetContainer = document.createElement('div'); + widgetContainer.style.position = 'absolute'; + viewZoneContainer.appendChild(widgetContainer); + + this._focusTracker = this._widgetDisposableStore.add(trackFocus(viewZoneContainer)); + this._widgetDisposableStore.add(this._focusTracker.onDidFocus(() => { + this._updateNotebookEditorFocusNSelections(); + })); + + const fakeParentEditorElement = document.createElement('div'); + + const fakeParentEditor = this._widgetDisposableStore.add(this._instantiationService.createInstance( + CodeEditorWidget, + fakeParentEditorElement, + { + }, + { isSimpleWidget: true } + )); + + const inputBoxFragment = `notebook-chat-input-${NotebookChatController.counter++}`; + const notebookUri = this._notebookEditor.textModel.uri; + const inputUri = notebookUri.with({ scheme: Schemas.untitled, fragment: inputBoxFragment }); + const result: ITextModel = this._modelService.createModel('', null, inputUri, false); + fakeParentEditor.setModel(result); + + const inlineChatWidget = this._widgetDisposableStore.add(this._instantiationService.createInstance( + InlineChatWidget, + ChatAgentLocation.Notebook, + { + telemetrySource: 'notebook-generate-cell', + inputMenuId: MENU_CELL_CHAT_INPUT, + widgetMenuId: MENU_CELL_CHAT_WIDGET, + statusMenuId: MENU_CELL_CHAT_WIDGET_STATUS, + feedbackMenuId: MENU_CELL_CHAT_WIDGET_FEEDBACK + } + )); + inlineChatWidget.placeholder = localize('default.placeholder', "Ask a question"); + inlineChatWidget.updateInfo(localize('welcome.1', "AI-generated code may be incorrect")); + widgetContainer.appendChild(inlineChatWidget.domNode); + this._widgetDisposableStore.add(inlineChatWidget.onDidChangeInput(() => { + this._warmupRequestCts?.dispose(true); + this._warmupRequestCts = undefined; + })); + + this._notebookEditor.changeViewZones(accessor => { + const notebookViewZone = { + afterModelPosition: index, + heightInPx: 80, + domNode: viewZoneContainer + }; + + const id = accessor.addZone(notebookViewZone); + this._scrollWidgetIntoView(index); + + this._widget = new NotebookChatWidget( + this._notebookEditor, + id, + notebookViewZone, + viewZoneContainer, + widgetContainer, + inlineChatWidget, + fakeParentEditor, + this._languageService + ); + + if (initEditingCell) { + this._widget.restoreEditingCell(initEditingCell); + this._updateUserEditingState(); + } + + this._ctxCellWidgetFocused.set(true); + + disposableTimeout(() => { + this._focusWidget(); + }, 0, this._store); + + this._sessionCtor = createCancelablePromise(async token => { + + if (fakeParentEditor.hasModel()) { + await this._startSession(fakeParentEditor, token); + this._warmupRequestCts = new CancellationTokenSource(); + this._startInitialFolowups(fakeParentEditor, this._warmupRequestCts.token); + + if (this._widget) { + this._widget.inlineChatWidget.placeholder = this._activeSession?.session.placeholder ?? localize('default.placeholder', "Ask a question"); + this._widget.inlineChatWidget.updateInfo(this._activeSession?.session.message ?? localize('welcome.1', "AI-generated code may be incorrect")); + this._widget.inlineChatWidget.updateSlashCommands(this._activeSession?.session.slashCommands ?? []); + this._focusWidget(); + } + + if (this._widget && input) { + this._widget.inlineChatWidget.value = input; + + if (autoSend) { + this.acceptInput(); + } + } + } + }); + }); + } + + private _scrollWidgetIntoView(index: number) { + if (index === 0 || this._notebookEditor.getLength() === 0) { + // the cell is at the beginning of the notebook + this._notebookEditor.revealOffsetInCenterIfOutsideViewport(0); + } else { + // the cell is at the end of the notebook + const previousCell = this._notebookEditor.cellAt(Math.min(index - 1, this._notebookEditor.getLength() - 1)); + if (previousCell) { + const cellTop = this._notebookEditor.getAbsoluteTopOfElement(previousCell); + const cellHeight = this._notebookEditor.getHeightOfElement(previousCell); + + this._notebookEditor.revealOffsetInCenterIfOutsideViewport(cellTop + cellHeight + 48 /** center of the dialog */); + } + } + } + + private _focusWidget() { + if (!this._widget) { + return; + } + + this._updateNotebookEditorFocusNSelections(); + this._widget.focus(); + } + + private _updateNotebookEditorFocusNSelections() { + if (!this._widget) { + return; + } + + this._widget.updateNotebookEditorFocusNSelections(); + } + + async acceptInput() { + assertType(this._widget); + await this._sessionCtor; + assertType(this._activeSession); + this._warmupRequestCts?.dispose(true); + this._warmupRequestCts = undefined; + this._activeSession.addInput(new SessionPrompt(this._widget.inlineChatWidget.value, 0, true)); + + assertType(this._activeSession.lastInput); + const value = this._activeSession.lastInput.value; + + this._historyUpdate(value); + + const editor = this._widget.parentEditor; + const model = editor.getModel(); + + if (!editor.hasModel() || !model) { + return; + } + + if (this._widget.editingCell && this._widget.editingCell.textBuffer.getLength() > 0) { + // it already contains some text, clear it + const ref = await this._widget.editingCell.resolveTextModel(); + ref.setValue(''); + } + + const editingCellIndex = this._widget.editingCell ? this._notebookEditor.getCellIndex(this._widget.editingCell) : undefined; + if (editingCellIndex !== undefined) { + this._notebookEditor.setSelections([{ + start: editingCellIndex, + end: editingCellIndex + 1 + }]); + } else { + // Update selection to the widget index + this._notebookEditor.setSelections([{ + start: this._widget.afterModelPosition, + end: this._widget.afterModelPosition + }]); + } + + this._ctxHasActiveRequest.set(true); + this._widget.inlineChatWidget.updateSlashCommands(this._activeSession.session.slashCommands ?? []); + this._widget?.inlineChatWidget.updateProgress(true); + + const request: IInlineChatRequest = { + requestId: generateUuid(), + prompt: value, + attempt: this._activeSession.lastInput.attempt, + selection: { selectionStartLineNumber: 1, selectionStartColumn: 1, positionLineNumber: 1, positionColumn: 1 }, + wholeRange: { startLineNumber: 1, startColumn: 1, endLineNumber: 1, endColumn: 1 }, + live: true, + previewDocument: model.uri, + withIntentDetection: true, // TODO: don't hard code but allow in corresponding UI to run without intent detection? + }; + + //TODO: update progress in a newly inserted cell below the widget instead of the fake editor + + this._activeRequestCts?.cancel(); + this._activeRequestCts = new CancellationTokenSource(); + const progressEdits: TextEdit[][] = []; + + const progressiveEditsQueue = new Queue(); + const progressiveEditsClock = StopWatch.create(); + const progressiveEditsAvgDuration = new MovingAverage(); + const progressiveEditsCts = new CancellationTokenSource(this._activeRequestCts.token); + let progressiveChatResponse: IInlineChatMessageAppender | undefined; + const progress = new AsyncProgress(async data => { + // console.log('received chunk', data, request); + + if (this._activeRequestCts?.token.isCancellationRequested) { + return; + } + + if (data.message) { + this._widget?.inlineChatWidget.updateToolbar(false); + this._widget?.inlineChatWidget.updateInfo(data.message); + } + + if (data.edits?.length) { + if (!request.live) { + throw new Error('Progress in NOT supported in non-live mode'); + } + progressEdits.push(data.edits); + progressiveEditsAvgDuration.update(progressiveEditsClock.elapsed()); + progressiveEditsClock.reset(); + + progressiveEditsQueue.queue(async () => { + // making changes goes into a queue because otherwise the async-progress time will + // influence the time it takes to receive the changes and progressive typing will + // become infinitely fast + await this._makeChanges(data.edits!, data.editsShouldBeInstant + ? undefined + : { duration: progressiveEditsAvgDuration.value, token: progressiveEditsCts.token } + ); + }); + } + + if (data.markdownFragment) { + if (!progressiveChatResponse) { + const message = { + message: new MarkdownString(data.markdownFragment, { supportThemeIcons: true, supportHtml: true, isTrusted: false }), + providerId: this._activeSession!.provider.label, + requestId: request.requestId, + }; + progressiveChatResponse = this._widget?.inlineChatWidget.updateChatMessage(message, true); + } else { + progressiveChatResponse.appendContent(data.markdownFragment); + } + } + }); + + const task = this._activeSession.provider.provideResponse(this._activeSession.session, request, progress, this._activeRequestCts.token); + let response: ReplyResponse | ErrorResponse | EmptyResponse; + + try { + this._widget?.inlineChatWidget.updateChatMessage(undefined); + this._widget?.inlineChatWidget.updateFollowUps(undefined); + this._widget?.inlineChatWidget.updateProgress(true); + this._widget?.inlineChatWidget.updateInfo(!this._activeSession.lastExchange ? GeneratingPhrase + '\u2026' : ''); + this._ctxHasActiveRequest.set(true); + + const reply = await raceCancellationError(Promise.resolve(task), this._activeRequestCts.token); + if (progressiveEditsQueue.size > 0) { + // we must wait for all edits that came in via progress to complete + await Event.toPromise(progressiveEditsQueue.onDrained); + } + await progress.drain(); + + if (!reply) { + response = new EmptyResponse(); + } else { + const markdownContents = new MarkdownString('', { supportThemeIcons: true, supportHtml: true, isTrusted: false }); + const replyResponse = response = this._instantiationService.createInstance(ReplyResponse, reply, markdownContents, this._activeSession.textModelN.uri, this._activeSession.textModelN.getAlternativeVersionId(), progressEdits, request.requestId); + for (let i = progressEdits.length; i < replyResponse.allLocalEdits.length; i++) { + await this._makeChanges(replyResponse.allLocalEdits[i], undefined); + } + + if (this._activeSession?.provider.provideFollowups) { + const followupCts = new CancellationTokenSource(); + const followups = await this._activeSession.provider.provideFollowups(this._activeSession.session, replyResponse.raw, followupCts.token); + if (followups && this._widget) { + const widget = this._widget; + widget.inlineChatWidget.updateFollowUps(followups, async followup => { + if (followup.kind === 'reply') { + widget.inlineChatWidget.value = followup.message; + this.acceptInput(); + } else { + await this.acceptSession(); + this._commandService.executeCommand(followup.commandId, ...(followup.args ?? [])); + } + }); + } + } + + this._userEditingDisposables.clear(); + // monitor user edits + const editingCell = this._widget.getEditingCell(); + if (editingCell) { + this._userEditingDisposables.add(editingCell.model.onDidChangeContent(() => this._updateUserEditingState())); + this._userEditingDisposables.add(editingCell.model.onDidChangeLanguage(() => this._updateUserEditingState())); + this._userEditingDisposables.add(editingCell.model.onDidChangeMetadata(() => this._updateUserEditingState())); + this._userEditingDisposables.add(editingCell.model.onDidChangeInternalMetadata(() => this._updateUserEditingState())); + this._userEditingDisposables.add(editingCell.model.onDidChangeOutputs(() => this._updateUserEditingState())); + this._userEditingDisposables.add(this._executionStateService.onDidChangeExecution(e => { + if (e.type === NotebookExecutionType.cell && e.affectsCell(editingCell.uri)) { + this._updateUserEditingState(); + } + })); + } + } + } catch (e) { + response = new ErrorResponse(e); + } finally { + this._ctxHasActiveRequest.set(false); + this._widget?.inlineChatWidget.updateProgress(false); + this._widget?.inlineChatWidget.updateInfo(''); + this._widget?.inlineChatWidget.updateToolbar(true); + } + + this._ctxHasActiveRequest.set(false); + this._widget?.inlineChatWidget.updateProgress(false); + this._widget?.inlineChatWidget.updateInfo(''); + this._widget?.inlineChatWidget.updateToolbar(true); + + this._activeSession?.addExchange(new SessionExchange(this._activeSession.lastInput, response)); + this._ctxLastResponseType.set(response instanceof ReplyResponse ? response.raw.type : undefined); + } + + private async _startSession(editor: IActiveCodeEditor, token: CancellationToken) { + if (this._activeSession) { + this._inlineChatSessionService.releaseSession(this._activeSession); + } + + const session = await this._inlineChatSessionService.createSession( + editor, + { editMode: EditMode.Live }, + token + ); + + if (!session) { + return; + } + + this._activeSession = session; + this._strategy = new EditStrategy(session); + } + + private async _startInitialFolowups(editor: IActiveCodeEditor, token: CancellationToken) { + if (!this._activeSession || !this._activeSession.provider.provideFollowups) { + return; + } + + const request: IInlineChatRequest = { + requestId: generateUuid(), + prompt: '', + attempt: 0, + selection: { selectionStartLineNumber: 1, selectionStartColumn: 1, positionLineNumber: 1, positionColumn: 1 }, + wholeRange: { startLineNumber: 1, startColumn: 1, endLineNumber: 1, endColumn: 1 }, + live: true, + previewDocument: editor.getModel().uri, + withIntentDetection: true + }; + + const progress = new AsyncProgress(async data => { }); + const task = this._activeSession.provider.provideResponse(this._activeSession.session, request, progress, token); + const reply = await raceCancellationError(Promise.resolve(task), token); + if (token.isCancellationRequested) { + return; + } + + if (!reply) { + return; + } + + const markdownContents = new MarkdownString('', { supportThemeIcons: true, supportHtml: true, isTrusted: false }); + const response = this._instantiationService.createInstance(ReplyResponse, reply, markdownContents, this._activeSession.textModelN.uri, this._activeSession.textModelN.getAlternativeVersionId(), [], request.requestId); + const followups = await this._activeSession.provider.provideFollowups(this._activeSession.session, response.raw, token); + if (followups && this._widget) { + const widget = this._widget; + widget.inlineChatWidget.updateFollowUps(followups, async followup => { + if (followup.kind === 'reply') { + widget.inlineChatWidget.value = followup.message; + this.acceptInput(); + } else { + await this.acceptSession(); + this._commandService.executeCommand(followup.commandId, ...(followup.args ?? [])); + } + }); + } + } + + private async _makeChanges(edits: TextEdit[], opts: ProgressingEditsOptions | undefined) { + assertType(this._activeSession); + assertType(this._strategy); + assertType(this._widget); + + const editingCell = await this._widget.getOrCreateEditingCell(); + + if (!editingCell) { + return; + } + + const editor = editingCell.editor; + + const moreMinimalEdits = await this._editorWorkerService.computeMoreMinimalEdits(editor.getModel().uri, edits); + // this._log('edits from PROVIDER and after making them MORE MINIMAL', this._activeSession.provider.debugName, edits, moreMinimalEdits); + + if (moreMinimalEdits?.length === 0) { + // nothing left to do + return; + } + + const actualEdits = !opts && moreMinimalEdits ? moreMinimalEdits : edits; + const editOperations = actualEdits.map(TextEdit.asEditOperation); + + this._inlineChatSavingService.markChanged(this._activeSession); + try { + // this._ignoreModelContentChanged = true; + this._activeSession.wholeRange.trackEdits(editOperations); + if (opts) { + await this._strategy.makeProgressiveChanges(editor, editOperations, opts); + } else { + await this._strategy.makeChanges(editor, editOperations); + } + // this._ctxDidEdit.set(this._activeSession.hasChangedText); + } finally { + // this._ignoreModelContentChanged = false; + } + } + + private _updateUserEditingState() { + this._ctxUserDidEdit.set(true); + } + + async acceptSession() { + assertType(this._activeSession); + assertType(this._strategy); + + const editor = this._widget?.parentEditor; + if (!editor?.hasModel()) { + return; + } + + const editingCell = this._widget?.getEditingCell(); + + if (editingCell && this._notebookEditor.hasModel() && this._activeSession.lastInput) { + const cellId = NotebookCellTextModelLikeId.str({ uri: editingCell.uri, viewType: this._notebookEditor.textModel.viewType }); + const prompt = this._activeSession.lastInput.value; + this._promptCache.set(cellId, prompt); + this._onDidChangePromptCache.fire({ cell: editingCell.uri }); + } + + try { + await this._strategy.apply(editor); + this._inlineChatSessionService.releaseSession(this._activeSession); + } catch (_err) { } + + this.dismiss(false); + } + + async focusAbove() { + if (!this._widget) { + return; + } + + const index = this._widget.afterModelPosition; + const prev = index - 1; + if (prev < 0) { + return; + } + + const cell = this._notebookEditor.cellAt(prev); + if (!cell) { + return; + } + + await this._notebookEditor.focusNotebookCell(cell, 'editor'); + } + + async focusNext() { + if (!this._widget) { + return; + } + + const index = this._widget.afterModelPosition; + const cell = this._notebookEditor.cellAt(index); + if (!cell) { + return; + } + + await this._notebookEditor.focusNotebookCell(cell, 'editor'); + } + + hasFocus() { + return this._widget?.hasFocus() ?? false; + } + + focus() { + this._focusWidget(); + } + + focusNearestWidget(index: number, direction: 'above' | 'below') { + switch (direction) { + case 'above': + if (this._widget?.afterModelPosition === index) { + this._focusWidget(); + } + break; + case 'below': + if (this._widget?.afterModelPosition === index + 1) { + this._focusWidget(); + } + break; + default: + break; + } + } + + populateHistory(up: boolean) { + if (!this._widget) { + return; + } + + const len = NotebookChatController._promptHistory.length; + if (len === 0) { + return; + } + + if (this._historyOffset === -1) { + // remember the current value + this._historyCandidate = this._widget.inlineChatWidget.value; + } + + const newIdx = this._historyOffset + (up ? 1 : -1); + if (newIdx >= len) { + // reached the end + return; + } + + let entry: string; + if (newIdx < 0) { + entry = this._historyCandidate; + this._historyOffset = -1; + } else { + entry = NotebookChatController._promptHistory[newIdx]; + this._historyOffset = newIdx; + } + + this._widget.inlineChatWidget.value = entry; + this._widget.inlineChatWidget.selectAll(); + } + + async cancelCurrentRequest(discard: boolean) { + if (discard) { + this._strategy?.cancel(); + } + + this._activeRequestCts?.cancel(); + } + + getEditingCell() { + return this._widget?.getEditingCell(); + } + + discard() { + this._strategy?.cancel(); + this._activeRequestCts?.cancel(); + this._widget?.discardChange(); + this.dismiss(true); + } + + async feedbackLast(kind: InlineChatResponseFeedbackKind) { + if (this._activeSession?.lastExchange && this._activeSession.lastExchange.response instanceof ReplyResponse) { + this._activeSession.provider.handleInlineChatResponseFeedback?.(this._activeSession.session, this._activeSession.lastExchange.response.raw, kind); + this._widget?.inlineChatWidget.updateStatus('Thank you for your feedback!', { resetAfter: 1250 }); + } + } + + + dismiss(discard: boolean) { + const widget = this._widget; + const widgetIndex = widget?.afterModelPosition; + const currentFocus = this._notebookEditor.getFocus(); + const isWidgetFocused = currentFocus.start === widgetIndex && currentFocus.end === widgetIndex; + + if (widget && isWidgetFocused) { + // change focus only when the widget is focused + const editingCell = widget.getEditingCell(); + const shouldFocusEditingCell = editingCell && !discard; + const shouldFocusTopCell = widgetIndex === 0 && this._notebookEditor.getLength() > 0; + const shouldFocusAboveCell = widgetIndex !== 0 && this._notebookEditor.cellAt(widgetIndex - 1); + + if (shouldFocusEditingCell) { + this._notebookEditor.focusNotebookCell(editingCell, 'container'); + } else if (shouldFocusTopCell) { + this._notebookEditor.focusNotebookCell(this._notebookEditor.cellAt(0)!, 'container'); + } else if (shouldFocusAboveCell) { + this._notebookEditor.focusNotebookCell(this._notebookEditor.cellAt(widgetIndex - 1)!, 'container'); + } + } + + this._ctxCellWidgetFocused.set(false); + this._ctxUserDidEdit.set(false); + this._sessionCtor?.cancel(); + this._sessionCtor = undefined; + this._widget?.dispose(); + this._widget = undefined; + this._widgetDisposableStore.clear(); + } + + // check if a cell is generated by prompt by checking prompt cache + isCellGeneratedByChat(cell: ICellViewModel) { + if (!this._notebookEditor.hasModel()) { + // no model attached yet + return false; + } + + const cellId = NotebookCellTextModelLikeId.str({ uri: cell.uri, viewType: this._notebookEditor.textModel.viewType }); + return this._promptCache.has(cellId); + } + + // get prompt from cache + getPromptFromCache(cell: ICellViewModel) { + if (!this._notebookEditor.hasModel()) { + // no model attached yet + return undefined; + } + + const cellId = NotebookCellTextModelLikeId.str({ uri: cell.uri, viewType: this._notebookEditor.textModel.viewType }); + return this._promptCache.get(cellId); + } + public override dispose(): void { + this.dismiss(false); + super.dispose(); + } +} + +export class EditStrategy { + private _editCount: number = 0; + + constructor( + protected readonly _session: Session, + ) { + + } + + async makeProgressiveChanges(editor: IActiveCodeEditor, edits: ISingleEditOperation[], opts: ProgressingEditsOptions): Promise { + // push undo stop before first edit + if (++this._editCount === 1) { + editor.pushUndoStop(); + } + + const durationInSec = opts.duration / 1000; + for (const edit of edits) { + const wordCount = countWords(edit.text ?? ''); + const speed = wordCount / durationInSec; + // console.log({ durationInSec, wordCount, speed: wordCount / durationInSec }); + await performAsyncTextEdit(editor.getModel(), asProgressiveEdit(new WindowIntervalTimer(), edit, speed, opts.token)); + } + } + + async makeChanges(editor: IActiveCodeEditor, edits: ISingleEditOperation[]): Promise { + const cursorStateComputerAndInlineDiffCollection: ICursorStateComputer = (undoEdits) => { + let last: Position | null = null; + for (const edit of undoEdits) { + last = !last || last.isBefore(edit.range.getEndPosition()) ? edit.range.getEndPosition() : last; + // this._inlineDiffDecorations.collectEditOperation(edit); + } + return last && [Selection.fromPositions(last)]; + }; + + // push undo stop before first edit + if (++this._editCount === 1) { + editor.pushUndoStop(); + } + editor.executeEdits('inline-chat-live', edits, cursorStateComputerAndInlineDiffCollection); + } + + async apply(editor: IActiveCodeEditor) { + if (this._editCount > 0) { + editor.pushUndoStop(); + } + if (!(this._session.lastExchange?.response instanceof ReplyResponse)) { + return; + } + const { untitledTextModel } = this._session.lastExchange.response; + if (untitledTextModel && !untitledTextModel.isDisposed() && untitledTextModel.isDirty()) { + await untitledTextModel.save({ reason: SaveReason.EXPLICIT }); + } + } + + async cancel() { + const { textModelN: modelN, textModelNAltVersion, textModelNSnapshotAltVersion } = this._session; + if (modelN.isDisposed()) { + return; + } + + const targetAltVersion = textModelNSnapshotAltVersion ?? textModelNAltVersion; + while (targetAltVersion < modelN.getAlternativeVersionId() && modelN.canUndo()) { + modelN.undo(); + } + } + + createSnapshot(): void { + if (this._session && !this._session.textModel0.equalsTextBuffer(this._session.textModelN.getTextBuffer())) { + this._session.createSnapshot(); + } + } +} + + +registerNotebookContribution(NotebookChatController.id, NotebookChatController); diff --git a/src/vs/workbench/contrib/notebook/browser/controller/coreActions.ts b/src/vs/workbench/contrib/notebook/browser/controller/coreActions.ts index 087c143716c83..3cc7faf8354db 100644 --- a/src/vs/workbench/contrib/notebook/browser/controller/coreActions.ts +++ b/src/vs/workbench/contrib/notebook/browser/controller/coreActions.ts @@ -31,6 +31,7 @@ export const CELL_TITLE_CELL_GROUP_ID = 'inline/cell'; export const CELL_TITLE_OUTPUT_GROUP_ID = 'inline/output'; export const NOTEBOOK_EDITOR_WIDGET_ACTION_WEIGHT = KeybindingWeight.EditorContrib; // smaller than Suggest Widget, etc +export const NOTEBOOK_OUTPUT_WEBVIEW_ACTION_WEIGHT = KeybindingWeight.WorkbenchContrib + 1; // higher than Workbench contribution (such as Notebook List View), etc export const enum CellToolbarOrder { EditCell, diff --git a/src/vs/workbench/contrib/notebook/browser/controller/editActions.ts b/src/vs/workbench/contrib/notebook/browser/controller/editActions.ts index d43cc47b2c793..86ffcc2b33494 100644 --- a/src/vs/workbench/contrib/notebook/browser/controller/editActions.ts +++ b/src/vs/workbench/contrib/notebook/browser/controller/editActions.ts @@ -6,38 +6,41 @@ import { KeyCode, KeyMod } from 'vs/base/common/keyCodes'; import { Mimes } from 'vs/base/common/mime'; import { URI } from 'vs/base/common/uri'; +import { ICodeEditor } from 'vs/editor/browser/editorBrowser'; import { EditorContextKeys } from 'vs/editor/common/editorContextKeys'; +import { ILanguageService } from 'vs/editor/common/languages/language'; import { getIconClasses } from 'vs/editor/common/services/getIconClasses'; import { IModelService } from 'vs/editor/common/services/model'; -import { ILanguageService } from 'vs/editor/common/languages/language'; import { localize, localize2 } from 'vs/nls'; import { MenuId, registerAction2 } from 'vs/platform/actions/common/actions'; +import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; import { ContextKeyExpr } from 'vs/platform/contextkey/common/contextkey'; import { InputFocusedContext, InputFocusedContextKey } from 'vs/platform/contextkey/common/contextkeys'; -import { ServicesAccessor } from 'vs/platform/instantiation/common/instantiation'; +import { IConfirmationResult, IDialogService } from 'vs/platform/dialogs/common/dialogs'; +import { IInstantiationService, ServicesAccessor } from 'vs/platform/instantiation/common/instantiation'; import { KeybindingWeight } from 'vs/platform/keybinding/common/keybindingsRegistry'; +import { INotificationService } from 'vs/platform/notification/common/notification'; import { IQuickInputService, IQuickPickItem, QuickPickInput } from 'vs/platform/quickinput/common/quickInput'; +import { InlineChatController } from 'vs/workbench/contrib/inlineChat/browser/inlineChatController'; +import { CTX_INLINE_CHAT_FOCUSED } from 'vs/workbench/contrib/inlineChat/common/inlineChat'; import { changeCellToKind, runDeleteAction } from 'vs/workbench/contrib/notebook/browser/controller/cellOperations'; -import { CellToolbarOrder, CELL_TITLE_CELL_GROUP_ID, CELL_TITLE_OUTPUT_GROUP_ID, executeNotebookCondition, INotebookActionContext, INotebookCellActionContext, NotebookAction, NotebookCellAction, NOTEBOOK_EDITOR_WIDGET_ACTION_WEIGHT, findTargetCellEditor } from 'vs/workbench/contrib/notebook/browser/controller/coreActions'; -import { NOTEBOOK_CELL_EDITABLE, NOTEBOOK_CELL_HAS_OUTPUTS, NOTEBOOK_CELL_LIST_FOCUSED, NOTEBOOK_CELL_MARKDOWN_EDIT_MODE, NOTEBOOK_CELL_TYPE, NOTEBOOK_EDITOR_EDITABLE, NOTEBOOK_EDITOR_FOCUSED, NOTEBOOK_HAS_OUTPUTS, NOTEBOOK_IS_ACTIVE_EDITOR, NOTEBOOK_OUTPUT_FOCUSED, NOTEBOOK_USE_CONSOLIDATED_OUTPUT_BUTTON } from 'vs/workbench/contrib/notebook/common/notebookContextKeys'; -import { CellEditState, CHANGE_CELL_LANGUAGE, DETECT_CELL_LANGUAGE, QUIT_EDIT_CELL_COMMAND_ID } from 'vs/workbench/contrib/notebook/browser/notebookBrowser'; -import * as icons from 'vs/workbench/contrib/notebook/browser/notebookIcons'; +import { CELL_TITLE_CELL_GROUP_ID, CELL_TITLE_OUTPUT_GROUP_ID, CellToolbarOrder, INotebookActionContext, INotebookCellActionContext, NOTEBOOK_EDITOR_WIDGET_ACTION_WEIGHT, NotebookAction, NotebookCellAction, executeNotebookCondition, findTargetCellEditor } from 'vs/workbench/contrib/notebook/browser/controller/coreActions'; +import { NotebookChangeTabDisplaySize, NotebookIndentUsingSpaces, NotebookIndentUsingTabs, NotebookIndentationToSpacesAction, NotebookIndentationToTabsAction } from 'vs/workbench/contrib/notebook/browser/controller/notebookIndentationActions'; +import { CHANGE_CELL_LANGUAGE, CellEditState, DETECT_CELL_LANGUAGE, QUIT_EDIT_CELL_COMMAND_ID, getNotebookEditorFromEditorPane } from 'vs/workbench/contrib/notebook/browser/notebookBrowser'; import { CellEditType, CellKind, ICellEditOperation, NotebookCellExecutionState, NotebookSetting } from 'vs/workbench/contrib/notebook/common/notebookCommon'; -import { ICellRange } from 'vs/workbench/contrib/notebook/common/notebookRange'; -import { ILanguageDetectionService } from 'vs/workbench/services/languageDetection/common/languageDetectionWorkerService'; +import { NOTEBOOK_CELL_EDITABLE, NOTEBOOK_CELL_HAS_OUTPUTS, NOTEBOOK_CELL_LIST_FOCUSED, NOTEBOOK_CELL_MARKDOWN_EDIT_MODE, NOTEBOOK_CELL_TYPE, NOTEBOOK_EDITOR_EDITABLE, NOTEBOOK_EDITOR_FOCUSED, NOTEBOOK_HAS_OUTPUTS, NOTEBOOK_IS_ACTIVE_EDITOR, NOTEBOOK_OUTPUT_FOCUSED, NOTEBOOK_USE_CONSOLIDATED_OUTPUT_BUTTON } from 'vs/workbench/contrib/notebook/common/notebookContextKeys'; import { INotebookExecutionStateService } from 'vs/workbench/contrib/notebook/common/notebookExecutionStateService'; -import { INotificationService } from 'vs/platform/notification/common/notification'; import { INotebookKernelService } from 'vs/workbench/contrib/notebook/common/notebookKernelService'; -import { IDialogService, IConfirmationResult } from 'vs/platform/dialogs/common/dialogs'; -import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; -import { ICodeEditor } from 'vs/editor/browser/editorBrowser'; -import { InlineChatController } from 'vs/workbench/contrib/inlineChat/browser/inlineChatController'; - +import { ICellRange } from 'vs/workbench/contrib/notebook/common/notebookRange'; +import { IEditorService } from 'vs/workbench/services/editor/common/editorService'; +import { ILanguageDetectionService } from 'vs/workbench/services/languageDetection/common/languageDetectionWorkerService'; +import * as icons from 'vs/workbench/contrib/notebook/browser/notebookIcons'; const CLEAR_ALL_CELLS_OUTPUTS_COMMAND_ID = 'notebook.clearAllCellsOutputs'; const EDIT_CELL_COMMAND_ID = 'notebook.cell.edit'; const DELETE_CELL_COMMAND_ID = 'notebook.cell.delete'; export const CLEAR_CELL_OUTPUTS_COMMAND_ID = 'notebook.cell.clearOutputs'; +export const SELECT_NOTEBOOK_INDENTATION_ID = 'notebook.selectIndentation'; registerAction2(class EditCellAction extends NotebookCellAction { constructor() { @@ -84,7 +87,8 @@ registerAction2(class EditCellAction extends NotebookCellAction { const quitEditCondition = ContextKeyExpr.and( NOTEBOOK_EDITOR_FOCUSED, - InputFocusedContext + InputFocusedContext, + CTX_INLINE_CHAT_FOCUSED.toNegated() ); registerAction2(class QuitEditCellAction extends NotebookCellAction { constructor() { @@ -559,3 +563,60 @@ async function setCellToLanguage(languageId: string, context: IChangeCellContext ); } } + +registerAction2(class SelectNotebookIndentation extends NotebookAction { + constructor() { + super({ + id: SELECT_NOTEBOOK_INDENTATION_ID, + title: localize2('selectNotebookIndentation', 'Select Indentation'), + f1: true, + precondition: ContextKeyExpr.and(NOTEBOOK_IS_ACTIVE_EDITOR, NOTEBOOK_EDITOR_EDITABLE, NOTEBOOK_CELL_EDITABLE), + }); + } + + async runWithContext(accessor: ServicesAccessor, context: INotebookActionContext): Promise { + await this.showNotebookIndentationPicker(accessor, context); + } + + private async showNotebookIndentationPicker(accessor: ServicesAccessor, context: INotebookActionContext) { + const quickInputService = accessor.get(IQuickInputService); + const editorService = accessor.get(IEditorService); + const instantiationService = accessor.get(IInstantiationService); + + const activeNotebook = getNotebookEditorFromEditorPane(editorService.activeEditorPane); + if (!activeNotebook || activeNotebook.isDisposed) { + return quickInputService.pick([{ label: localize('noNotebookEditor', "No notebook editor active at this time") }]); + } + + if (activeNotebook.isReadOnly) { + return quickInputService.pick([{ label: localize('noWritableCodeEditor', "The active notebook editor is read-only.") }]); + } + + const picks: QuickPickInput[] = [ + new NotebookIndentUsingTabs(), // indent using tabs + new NotebookIndentUsingSpaces(), // indent using spaces + new NotebookChangeTabDisplaySize(), // change tab size + new NotebookIndentationToTabsAction(), // convert indentation to tabs + new NotebookIndentationToSpacesAction() // convert indentation to spaces + ].map(item => { + return { + id: item.desc.id, + label: item.desc.title.toString(), + run: () => { + instantiationService.invokeFunction(item.run); + } + }; + }); + + picks.splice(3, 0, { type: 'separator', label: localize('indentConvert', "convert file") }); + picks.unshift({ type: 'separator', label: localize('indentView', "change view") }); + + const action = await quickInputService.pick(picks, { placeHolder: localize('pickAction', "Select Action"), matchOnDetail: true }); + if (!action) { + return; + } + action.run(); + context.notebookEditor.focus(); + return; + } +}); diff --git a/src/vs/workbench/contrib/notebook/browser/controller/executeActions.ts b/src/vs/workbench/contrib/notebook/browser/controller/executeActions.ts index 2ca62a92f6fd3..ce41420deba05 100644 --- a/src/vs/workbench/contrib/notebook/browser/controller/executeActions.ts +++ b/src/vs/workbench/contrib/notebook/browser/controller/executeActions.ts @@ -20,6 +20,8 @@ import { IDebugService } from 'vs/workbench/contrib/debug/common/debug'; import { InlineChatController } from 'vs/workbench/contrib/inlineChat/browser/inlineChatController'; import { CTX_INLINE_CHAT_FOCUSED } from 'vs/workbench/contrib/inlineChat/common/inlineChat'; import { insertCell } from 'vs/workbench/contrib/notebook/browser/controller/cellOperations'; +import { CTX_NOTEBOOK_CELL_CHAT_FOCUSED } from 'vs/workbench/contrib/notebook/browser/controller/chat/notebookChatContext'; +import { NotebookChatController } from 'vs/workbench/contrib/notebook/browser/controller/chat/notebookChatController'; import { CELL_TITLE_CELL_GROUP_ID, CellToolbarOrder, INotebookActionContext, INotebookCellActionContext, INotebookCellToolbarActionContext, INotebookCommandContext, NOTEBOOK_EDITOR_WIDGET_ACTION_WEIGHT, NotebookAction, NotebookCellAction, NotebookMultiCellAction, cellExecutionArgs, executeNotebookCondition, getContextFromActiveEditor, getContextFromUri, parseMultiCellExecutionArgs } from 'vs/workbench/contrib/notebook/browser/controller/coreActions'; import { CellEditState, CellFocusMode, EXECUTE_CELL_COMMAND_ID, IFocusNotebookCellOptions, ScrollToRevealBehavior } from 'vs/workbench/contrib/notebook/browser/notebookBrowser'; import * as icons from 'vs/workbench/contrib/notebook/browser/notebookIcons'; @@ -198,7 +200,10 @@ registerAction2(class ExecuteCell extends NotebookMultiCellAction { precondition: executeThisCellCondition, title: localize('notebookActions.execute', "Execute Cell"), keybinding: { - when: NOTEBOOK_CELL_LIST_FOCUSED, + when: ContextKeyExpr.or( + NOTEBOOK_CELL_LIST_FOCUSED, + ContextKeyExpr.and(CTX_NOTEBOOK_CELL_CHAT_FOCUSED, CTX_INLINE_CHAT_FOCUSED) + ), primary: KeyMod.WinCtrl | KeyCode.Enter, win: { primary: KeyMod.CtrlCmd | KeyMod.Alt | KeyCode.Enter @@ -229,6 +234,21 @@ registerAction2(class ExecuteCell extends NotebookMultiCellAction { await context.notebookEditor.focusNotebookCell(context.cell, 'container', { skipReveal: true }); } + const chatController = NotebookChatController.get(context.notebookEditor); + const editingCell = chatController?.getEditingCell(); + if (chatController?.hasFocus() && editingCell) { + const group = editorGroupsService.activeGroup; + + if (group) { + if (group.activeEditor) { + group.pinEditor(group.activeEditor); + } + } + + await context.notebookEditor.executeNotebookCells([editingCell]); + return; + } + await runCell(editorGroupsService, context); } }); diff --git a/src/vs/workbench/contrib/notebook/browser/controller/insertCellActions.ts b/src/vs/workbench/contrib/notebook/browser/controller/insertCellActions.ts index 92ecb50915399..d998aed0f143f 100644 --- a/src/vs/workbench/contrib/notebook/browser/controller/insertCellActions.ts +++ b/src/vs/workbench/contrib/notebook/browser/controller/insertCellActions.ts @@ -17,6 +17,7 @@ import { INotebookActionContext, NotebookAction } from 'vs/workbench/contrib/not import { NOTEBOOK_CELL_LIST_FOCUSED, NOTEBOOK_EDITOR_EDITABLE } from 'vs/workbench/contrib/notebook/common/notebookContextKeys'; import { CellViewModel } from 'vs/workbench/contrib/notebook/browser/viewModel/notebookViewModelImpl'; import { CellKind, NotebookSetting } from 'vs/workbench/contrib/notebook/common/notebookCommon'; +import { CTX_NOTEBOOK_CHAT_OUTER_FOCUS_POSITION } from 'vs/workbench/contrib/notebook/browser/controller/chat/notebookChatContext'; const INSERT_CODE_CELL_ABOVE_COMMAND_ID = 'notebook.cell.insertCodeCellAbove'; const INSERT_CODE_CELL_BELOW_COMMAND_ID = 'notebook.cell.insertCodeCellBelow'; @@ -110,7 +111,7 @@ registerAction2(class InsertCodeCellBelowAction extends InsertCellCommand { title: localize('notebookActions.insertCodeCellBelow', "Insert Code Cell Below"), keybinding: { primary: KeyMod.CtrlCmd | KeyCode.Enter, - when: ContextKeyExpr.and(NOTEBOOK_CELL_LIST_FOCUSED, InputFocusedContext.toNegated()), + when: ContextKeyExpr.and(NOTEBOOK_CELL_LIST_FOCUSED, InputFocusedContext.toNegated(), CTX_NOTEBOOK_CHAT_OUTER_FOCUS_POSITION.isEqualTo('')), weight: KeybindingWeight.WorkbenchContrib }, menu: { diff --git a/src/vs/workbench/contrib/notebook/browser/controller/layoutActions.ts b/src/vs/workbench/contrib/notebook/browser/controller/layoutActions.ts index 2dfc3c83bc34b..19f30d4185f6c 100644 --- a/src/vs/workbench/contrib/notebook/browser/controller/layoutActions.ts +++ b/src/vs/workbench/contrib/notebook/browser/controller/layoutActions.ts @@ -6,9 +6,10 @@ import { Codicon } from 'vs/base/common/codicons'; import { URI, UriComponents } from 'vs/base/common/uri'; import { localize, localize2 } from 'vs/nls'; +import { Categories } from 'vs/platform/action/common/actionCommonCategories'; import { Action2, MenuId, MenuRegistry, registerAction2 } from 'vs/platform/actions/common/actions'; import { ICommandService } from 'vs/platform/commands/common/commands'; -import { ConfigurationTarget } from 'vs/platform/configuration/common/configuration'; +import { ConfigurationTarget, IConfigurationService } from 'vs/platform/configuration/common/configuration'; import { ContextKeyExpr } from 'vs/platform/contextkey/common/contextkey'; import { ServicesAccessor } from 'vs/platform/instantiation/common/instantiation'; import { IQuickInputService, IQuickPickItem } from 'vs/platform/quickinput/common/quickInput'; @@ -242,3 +243,35 @@ registerAction2(class NotebookWebviewResetAction extends Action2 { } } }); + +registerAction2(class ToggleNotebookStickyScroll extends Action2 { + constructor() { + super({ + id: 'notebook.action.toggleNotebookStickyScroll', + title: { + ...localize2('toggleStickyScroll', "Toggle Notebook Sticky Scroll"), + mnemonicTitle: localize({ key: 'mitoggleNotebookStickyScroll', comment: ['&& denotes a mnemonic'] }, "&&Toggle Notebook Sticky Scroll"), + }, + category: Categories.View, + toggled: { + condition: ContextKeyExpr.equals('config.notebook.stickyScroll.enabled', true), + title: localize('notebookStickyScroll', "Toggle Notebook Sticky Scroll"), + mnemonicTitle: localize({ key: 'mitoggleNotebookStickyScroll', comment: ['&& denotes a mnemonic'] }, "&&Toggle Notebook Sticky Scroll"), + }, + menu: [ + { id: MenuId.CommandPalette }, + { + id: MenuId.NotebookStickyScrollContext, + group: 'notebookView', + order: 2 + } + ] + }); + } + + override async run(accessor: ServicesAccessor): Promise { + const configurationService = accessor.get(IConfigurationService); + const newValue = !configurationService.getValue('notebook.stickyScroll.enabled'); + return configurationService.updateValue('notebook.stickyScroll.enabled', newValue); + } +}); diff --git a/src/vs/workbench/contrib/notebook/browser/controller/notebookIndentationActions.ts b/src/vs/workbench/contrib/notebook/browser/controller/notebookIndentationActions.ts new file mode 100644 index 0000000000000..c647ab969d7b3 --- /dev/null +++ b/src/vs/workbench/contrib/notebook/browser/controller/notebookIndentationActions.ts @@ -0,0 +1,259 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as nls from 'vs/nls'; +import { DisposableStore } from 'vs/base/common/lifecycle'; +import { ServicesAccessor } from 'vs/editor/browser/editorExtensions'; +import { IBulkEditService, ResourceTextEdit } from 'vs/editor/browser/services/bulkEditService'; +import { Range } from 'vs/editor/common/core/range'; +import { ITextModel } from 'vs/editor/common/model'; +import { ITextModelService } from 'vs/editor/common/services/resolverService'; +import { Action2, registerAction2 } from 'vs/platform/actions/common/actions'; +import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; +import { ILogService } from 'vs/platform/log/common/log'; +import { IQuickInputService } from 'vs/platform/quickinput/common/quickInput'; +import { INotebookEditorService } from 'vs/workbench/contrib/notebook/browser/services/notebookEditorService'; +import { NotebookSetting } from 'vs/workbench/contrib/notebook/common/notebookCommon'; +import { isNotebookEditorInput } from 'vs/workbench/contrib/notebook/common/notebookEditorInput'; +import { IEditorService } from 'vs/workbench/services/editor/common/editorService'; + +export class NotebookIndentUsingTabs extends Action2 { + public static readonly ID = 'notebook.action.indentUsingTabs'; + + constructor() { + super({ + id: NotebookIndentUsingTabs.ID, + title: nls.localize('indentUsingTabs', "Indent Using Tabs"), + precondition: undefined, + }); + } + + override run(accessor: ServicesAccessor, ...args: any[]): void { + changeNotebookIndentation(accessor, false, false); + } +} + +export class NotebookIndentUsingSpaces extends Action2 { + public static readonly ID = 'notebook.action.indentUsingSpaces'; + + constructor() { + super({ + id: NotebookIndentUsingSpaces.ID, + title: nls.localize('indentUsingSpaces', "Indent Using Spaces"), + precondition: undefined, + }); + } + + override run(accessor: ServicesAccessor, ...args: any[]): void { + changeNotebookIndentation(accessor, true, false); + } +} + +export class NotebookChangeTabDisplaySize extends Action2 { + public static readonly ID = 'notebook.action.changeTabDisplaySize'; + + constructor() { + super({ + id: NotebookChangeTabDisplaySize.ID, + title: nls.localize('changeTabDisplaySize', "Change Tab Display Size"), + precondition: undefined, + }); + } + + override run(accessor: ServicesAccessor, ...args: any[]): void { + changeNotebookIndentation(accessor, true, true); + } +} + +export class NotebookIndentationToSpacesAction extends Action2 { + public static readonly ID = 'notebook.action.convertIndentationToSpaces'; + + constructor() { + super({ + id: NotebookIndentationToSpacesAction.ID, + title: nls.localize('convertIndentationToSpaces', "Convert Indentation to Spaces"), + precondition: undefined, + }); + } + + override run(accessor: ServicesAccessor, ...args: any[]): void { + convertNotebookIndentation(accessor, true); + } +} + +export class NotebookIndentationToTabsAction extends Action2 { + public static readonly ID = 'notebook.action.convertIndentationToTabs'; + + constructor() { + super({ + id: NotebookIndentationToTabsAction.ID, + title: nls.localize('convertIndentationToTabs', "Convert Indentation to Tabs"), + precondition: undefined, + }); + } + + override run(accessor: ServicesAccessor, ...args: any[]): void { + convertNotebookIndentation(accessor, false); + } +} + +function changeNotebookIndentation(accessor: ServicesAccessor, insertSpaces: boolean, displaySizeOnly: boolean) { + const editorService = accessor.get(IEditorService); + const configurationService = accessor.get(IConfigurationService); + const notebookEditorService = accessor.get(INotebookEditorService); + const quickInputService = accessor.get(IQuickInputService); + + // keep this check here to pop on non-notebook actions + const activeInput = editorService.activeEditorPane?.input; + const isNotebook = isNotebookEditorInput(activeInput); + if (!isNotebook) { + return; + } + + // get notebook editor to access all codeEditors + const notebookEditor = notebookEditorService.retrieveExistingWidgetFromURI(activeInput.resource)?.value; + if (!notebookEditor) { + return; + } + + const picks = [1, 2, 3, 4, 5, 6, 7, 8].map(n => ({ + id: n.toString(), + label: n.toString(), + })); + + // store the initial values of the configuration + const initialConfig = configurationService.getValue(NotebookSetting.cellEditorOptionsCustomizations) as any; + const initialInsertSpaces = initialConfig['editor.insertSpaces']; + // remove the initial values from the configuration + delete initialConfig['editor.indentSize']; + delete initialConfig['editor.tabSize']; + delete initialConfig['editor.insertSpaces']; + + setTimeout(() => { + quickInputService.pick(picks, { placeHolder: nls.localize({ key: 'selectTabWidth', comment: ['Tab corresponds to the tab key'] }, "Select Tab Size for Current File") }).then(pick => { + if (pick) { + const pickedVal = parseInt(pick.label, 10); + if (displaySizeOnly) { + configurationService.updateValue(NotebookSetting.cellEditorOptionsCustomizations, { + ...initialConfig, + 'editor.tabSize': pickedVal, + 'editor.indentSize': pickedVal, + 'editor.insertSpaces': initialInsertSpaces + }); + } else { + configurationService.updateValue(NotebookSetting.cellEditorOptionsCustomizations, { + ...initialConfig, + 'editor.tabSize': pickedVal, + 'editor.indentSize': pickedVal, + 'editor.insertSpaces': insertSpaces + }); + } + + } + }); + }, 50/* quick input is sensitive to being opened so soon after another */); +} + +function convertNotebookIndentation(accessor: ServicesAccessor, tabsToSpaces: boolean): void { + const editorService = accessor.get(IEditorService); + const configurationService = accessor.get(IConfigurationService); + const logService = accessor.get(ILogService); + const textModelService = accessor.get(ITextModelService); + const notebookEditorService = accessor.get(INotebookEditorService); + const bulkEditService = accessor.get(IBulkEditService); + + // keep this check here to pop on non-notebook + const activeInput = editorService.activeEditorPane?.input; + const isNotebook = isNotebookEditorInput(activeInput); + if (!isNotebook) { + return; + } + + // get notebook editor to access all codeEditors + const notebookTextModel = notebookEditorService.retrieveExistingWidgetFromURI(activeInput.resource)?.value?.textModel; + if (!notebookTextModel) { + return; + } + + const disposable = new DisposableStore(); + try { + Promise.all(notebookTextModel.cells.map(async cell => { + const ref = await textModelService.createModelReference(cell.uri); + disposable.add(ref); + const textEditorModel = ref.object.textEditorModel; + + const modelOpts = cell.textModel?.getOptions(); + if (!modelOpts) { + return; + } + + const edits = getIndentationEditOperations(textEditorModel, modelOpts.tabSize, tabsToSpaces); + + bulkEditService.apply(edits, { label: nls.localize('convertIndentation', "Convert Indentation"), code: 'undoredo.convertIndentation', }); + + })).then(() => { + // store the initial values of the configuration + const initialConfig = configurationService.getValue(NotebookSetting.cellEditorOptionsCustomizations) as any; + const initialIndentSize = initialConfig['editor.indentSize']; + const initialTabSize = initialConfig['editor.tabSize']; + // remove the initial values from the configuration + delete initialConfig['editor.indentSize']; + delete initialConfig['editor.tabSize']; + delete initialConfig['editor.insertSpaces']; + + configurationService.updateValue(NotebookSetting.cellEditorOptionsCustomizations, { + ...initialConfig, + 'editor.tabSize': initialTabSize, + 'editor.indentSize': initialIndentSize, + 'editor.insertSpaces': tabsToSpaces + }); + disposable.dispose(); + }); + } catch { + logService.error('Failed to convert indentation to spaces for notebook cells.'); + } +} + +function getIndentationEditOperations(model: ITextModel, tabSize: number, tabsToSpaces: boolean): ResourceTextEdit[] { + if (model.getLineCount() === 1 && model.getLineMaxColumn(1) === 1) { + // Model is empty + return []; + } + + let spaces = ''; + for (let i = 0; i < tabSize; i++) { + spaces += ' '; + } + + const spacesRegExp = new RegExp(spaces, 'gi'); + + const edits: ResourceTextEdit[] = []; + for (let lineNumber = 1, lineCount = model.getLineCount(); lineNumber <= lineCount; lineNumber++) { + let lastIndentationColumn = model.getLineFirstNonWhitespaceColumn(lineNumber); + if (lastIndentationColumn === 0) { + lastIndentationColumn = model.getLineMaxColumn(lineNumber); + } + + if (lastIndentationColumn === 1) { + continue; + } + + const originalIndentationRange = new Range(lineNumber, 1, lineNumber, lastIndentationColumn); + const originalIndentation = model.getValueInRange(originalIndentationRange); + const newIndentation = ( + tabsToSpaces + ? originalIndentation.replace(/\t/ig, spaces) + : originalIndentation.replace(spacesRegExp, '\t') + ); + edits.push(new ResourceTextEdit(model.uri, { range: originalIndentationRange, text: newIndentation })); + } + return edits; +} + +registerAction2(NotebookIndentUsingSpaces); +registerAction2(NotebookIndentUsingTabs); +registerAction2(NotebookChangeTabDisplaySize); +registerAction2(NotebookIndentationToSpacesAction); +registerAction2(NotebookIndentationToTabsAction); diff --git a/src/vs/workbench/contrib/notebook/browser/controller/sectionActions.ts b/src/vs/workbench/contrib/notebook/browser/controller/sectionActions.ts new file mode 100644 index 0000000000000..b4831209b27c3 --- /dev/null +++ b/src/vs/workbench/contrib/notebook/browser/controller/sectionActions.ts @@ -0,0 +1,215 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { localize, localize2 } from 'vs/nls'; +import { Action2, MenuId, registerAction2 } from 'vs/platform/actions/common/actions'; +import { ContextKeyExpr } from 'vs/platform/contextkey/common/contextkey'; +import { ServicesAccessor } from 'vs/platform/instantiation/common/instantiation'; +import { NotebookOutlineContext } from 'vs/workbench/contrib/notebook/browser/contrib/outline/notebookOutline'; +import { FoldingController } from 'vs/workbench/contrib/notebook/browser/controller/foldingController'; +import { CellFoldingState, INotebookEditor } from 'vs/workbench/contrib/notebook/browser/notebookBrowser'; +import * as icons from 'vs/workbench/contrib/notebook/browser/notebookIcons'; +import { OutlineEntry } from 'vs/workbench/contrib/notebook/browser/viewModel/OutlineEntry'; +import { CellKind } from 'vs/workbench/contrib/notebook/common/notebookCommon'; +import { OutlineTarget } from 'vs/workbench/services/outline/browser/outline'; + +export type NotebookSectionArgs = { + notebookEditor: INotebookEditor | undefined; + outlineEntry: OutlineEntry; +}; + +export type ValidNotebookSectionArgs = { + notebookEditor: INotebookEditor; + outlineEntry: OutlineEntry; +}; + +export class NotebookRunSingleCellInSection extends Action2 { + constructor() { + super({ + id: 'notebook.section.runSingleCell', + title: { + ...localize2('runCell', "Run Cell"), + mnemonicTitle: localize({ key: 'mirunCell', comment: ['&& denotes a mnemonic'] }, "&&Run Cell"), + }, + shortTitle: localize('runCell', "Run Cell"), + icon: icons.executeIcon, + menu: [ + { + id: MenuId.NotebookOutlineActionMenu, + group: 'inline', + order: 1, + when: ContextKeyExpr.and( + NotebookOutlineContext.CellKind.isEqualTo(CellKind.Code), + NotebookOutlineContext.OutlineElementTarget.isEqualTo(OutlineTarget.OutlinePane), + NotebookOutlineContext.CellHasChildren.toNegated(), + NotebookOutlineContext.CellHasHeader.toNegated(), + ) + } + ] + }); + } + + override async run(_accessor: ServicesAccessor, context: NotebookSectionArgs): Promise { + if (!checkSectionContext(context)) { + return; + } + + context.notebookEditor.executeNotebookCells([context.outlineEntry.cell]); + } +} + +export class NotebookRunCellsInSection extends Action2 { + constructor() { + super({ + id: 'notebook.section.runCells', + title: { + ...localize2('runCellsInSection', "Run Cells In Section"), + mnemonicTitle: localize({ key: 'mirunCellsInSection', comment: ['&& denotes a mnemonic'] }, "&&Run Cells In Section"), + }, + shortTitle: localize('runCellsInSection', "Run Cells In Section"), + // icon: icons.executeBelowIcon, // TODO @Yoyokrazy replace this with new icon later + menu: [ + { + id: MenuId.NotebookStickyScrollContext, + group: 'notebookExecution', + order: 1 + }, + { + id: MenuId.NotebookOutlineActionMenu, + group: 'inline', + order: 1, + when: ContextKeyExpr.and( + NotebookOutlineContext.CellKind.isEqualTo(CellKind.Markup), + NotebookOutlineContext.OutlineElementTarget.isEqualTo(OutlineTarget.OutlinePane), + NotebookOutlineContext.CellHasChildren, + NotebookOutlineContext.CellHasHeader, + ) + } + ] + }); + } + + override async run(_accessor: ServicesAccessor, context: NotebookSectionArgs): Promise { + if (!checkSectionContext(context)) { + return; + } + + const cell = context.outlineEntry.cell; + const idx = context.notebookEditor.getViewModel()?.getCellIndex(cell); + if (idx === undefined) { + return; + } + const length = context.notebookEditor.getViewModel()?.getFoldedLength(idx); + if (length === undefined) { + return; + } + + const cells = context.notebookEditor.getCellsInRange({ start: idx, end: idx + length + 1 }); + context.notebookEditor.executeNotebookCells(cells); + } +} + +export class NotebookFoldSection extends Action2 { + constructor() { + super({ + id: 'notebook.section.foldSection', + title: { + ...localize2('foldSection', "Fold Section"), + mnemonicTitle: localize({ key: 'mifoldSection', comment: ['&& denotes a mnemonic'] }, "&&Fold Section"), + }, + shortTitle: localize('foldSection', "Fold Section"), + menu: [ + { + id: MenuId.NotebookOutlineActionMenu, + group: 'notebookFolding', + order: 2, + when: ContextKeyExpr.and( + NotebookOutlineContext.CellKind.isEqualTo(CellKind.Markup), + NotebookOutlineContext.OutlineElementTarget.isEqualTo(OutlineTarget.OutlinePane), + NotebookOutlineContext.CellHasChildren, + NotebookOutlineContext.CellHasHeader, + NotebookOutlineContext.CellFoldingState.isEqualTo(CellFoldingState.Expanded) + ) + } + ] + }); + } + + override async run(_accessor: ServicesAccessor, context: NotebookSectionArgs): Promise { + if (!checkSectionContext(context)) { + return; + } + + this.toggleFoldRange(context.outlineEntry, context.notebookEditor); + } + + private toggleFoldRange(entry: OutlineEntry, notebookEditor: INotebookEditor) { + const foldingController = notebookEditor.getContribution(FoldingController.id); + const index = entry.index; + const headerLevel = entry.level; + const newFoldingState = CellFoldingState.Collapsed; + + foldingController.setFoldingStateDown(index, newFoldingState, headerLevel); + } +} + +export class NotebookExpandSection extends Action2 { + constructor() { + super({ + id: 'notebook.section.expandSection', + title: { + ...localize2('expandSection', "Expand Section"), + mnemonicTitle: localize({ key: 'miexpandSection', comment: ['&& denotes a mnemonic'] }, "&&Expand Section"), + }, + shortTitle: localize('expandSection', "Expand Section"), + menu: [ + { + id: MenuId.NotebookOutlineActionMenu, + group: 'notebookFolding', + order: 2, + when: ContextKeyExpr.and( + NotebookOutlineContext.CellKind.isEqualTo(CellKind.Markup), + NotebookOutlineContext.OutlineElementTarget.isEqualTo(OutlineTarget.OutlinePane), + NotebookOutlineContext.CellHasChildren, + NotebookOutlineContext.CellHasHeader, + NotebookOutlineContext.CellFoldingState.isEqualTo(CellFoldingState.Collapsed) + ) + } + ] + }); + } + + override async run(_accessor: ServicesAccessor, context: NotebookSectionArgs): Promise { + if (!checkSectionContext(context)) { + return; + } + + this.toggleFoldRange(context.outlineEntry, context.notebookEditor); + } + + private toggleFoldRange(entry: OutlineEntry, notebookEditor: INotebookEditor) { + const foldingController = notebookEditor.getContribution(FoldingController.id); + const index = entry.index; + const headerLevel = entry.level; + const newFoldingState = CellFoldingState.Expanded; + + foldingController.setFoldingStateDown(index, newFoldingState, headerLevel); + } +} + +/** + * Take in context args and check if they exist + * + * @param context - Notebook Section Context containing a notebook editor and outline entry + * @returns true if context is valid, false otherwise + */ +function checkSectionContext(context: NotebookSectionArgs): context is ValidNotebookSectionArgs { + return !!(context && context.notebookEditor && context.outlineEntry); +} + +registerAction2(NotebookRunSingleCellInSection); +registerAction2(NotebookRunCellsInSection); +registerAction2(NotebookFoldSection); +registerAction2(NotebookExpandSection); diff --git a/src/vs/workbench/contrib/notebook/browser/diff/diffCellEditorOptions.ts b/src/vs/workbench/contrib/notebook/browser/diff/diffCellEditorOptions.ts index 745b141de35d5..c2b1d9cf2524d 100644 --- a/src/vs/workbench/contrib/notebook/browser/diff/diffCellEditorOptions.ts +++ b/src/vs/workbench/contrib/notebook/browser/diff/diffCellEditorOptions.ts @@ -50,4 +50,6 @@ export const fixedDiffEditorOptions: IDiffEditorConstructionOptions = { wordWrap: 'off', diffWordWrap: 'off', diffAlgorithm: 'advanced', + renderSideBySide: true, + useInlineViewWhenSpaceIsLimited: false }; diff --git a/src/vs/workbench/contrib/notebook/browser/diff/diffComponents.ts b/src/vs/workbench/contrib/notebook/browser/diff/diffComponents.ts index 23e8dec447835..de90f2796c576 100644 --- a/src/vs/workbench/contrib/notebook/browser/diff/diffComponents.ts +++ b/src/vs/workbench/contrib/notebook/browser/diff/diffComponents.ts @@ -9,7 +9,7 @@ import { Schemas } from 'vs/base/common/network'; import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; import { DiffElementViewModelBase, getFormattedMetadataJSON, getFormattedOutputJSON, OutputComparison, outputEqual, OUTPUT_EDITOR_HEIGHT_MAGIC, PropertyFoldingState, SideBySideDiffElementViewModel, SingleSideDiffElementViewModel } from 'vs/workbench/contrib/notebook/browser/diff/diffElementViewModel'; import { CellDiffSideBySideRenderTemplate, CellDiffSingleSideRenderTemplate, DiffSide, DIFF_CELL_MARGIN, INotebookTextDiffEditor, NOTEBOOK_DIFF_CELL_INPUT, NOTEBOOK_DIFF_CELL_PROPERTY, NOTEBOOK_DIFF_CELL_PROPERTY_EXPANDED } from 'vs/workbench/contrib/notebook/browser/diff/notebookDiffEditorBrowser'; -import { CodeEditorWidget, ICodeEditorWidgetOptions } from 'vs/editor/browser/widget/codeEditorWidget'; +import { CodeEditorWidget, ICodeEditorWidgetOptions } from 'vs/editor/browser/widget/codeEditor/codeEditorWidget'; import { IModelService } from 'vs/editor/common/services/model'; import { ILanguageService } from 'vs/editor/common/languages/language'; import { CellEditType, CellUri, NotebookCellMetadata } from 'vs/workbench/contrib/notebook/common/notebookCommon'; @@ -117,9 +117,9 @@ class PropertyHeader extends Disposable { const cellToolbarContainer = DOM.append(this.propertyHeaderContainer, DOM.$('div.property-toolbar')); this._toolbar = new WorkbenchToolBar(cellToolbarContainer, { - actionViewItemProvider: action => { + actionViewItemProvider: (action, options) => { if (action instanceof MenuItemAction) { - const item = new CodiconActionViewItem(action, undefined, this.keybindingService, this.notificationService, this.contextKeyService, this.themeService, this.contextMenuService, this.accessibilityService); + const item = new CodiconActionViewItem(action, { hoverDelegate: options.hoverDelegate }, this.keybindingService, this.notificationService, this.contextKeyService, this.themeService, this.contextMenuService, this.accessibilityService); return item; } @@ -233,15 +233,15 @@ interface IDiffElementLayoutState { } abstract class AbstractElementRenderer extends Disposable { - protected _metadataLocalDisposable = this._register(new DisposableStore()); - protected _outputLocalDisposable = this._register(new DisposableStore()); + protected readonly _metadataLocalDisposable = this._register(new DisposableStore()); + protected readonly _outputLocalDisposable = this._register(new DisposableStore()); protected _ignoreMetadata: boolean = false; protected _ignoreOutputs: boolean = false; protected _metadataHeaderContainer!: HTMLElement; protected _metadataHeader!: PropertyHeader; protected _metadataInfoContainer!: HTMLElement; protected _metadataEditorContainer?: HTMLElement; - protected _metadataEditorDisposeStore!: DisposableStore; + protected readonly _metadataEditorDisposeStore!: DisposableStore; protected _metadataEditor?: CodeEditorWidget | DiffEditorWidget; protected _outputHeaderContainer!: HTMLElement; @@ -255,7 +255,7 @@ abstract class AbstractElementRenderer extends Disposable { protected _outputEmptyElement?: HTMLElement; protected _outputLeftView?: OutputContainer; protected _outputRightView?: OutputContainer; - protected _outputEditorDisposeStore!: DisposableStore; + protected readonly _outputEditorDisposeStore!: DisposableStore; protected _outputEditor?: CodeEditorWidget | DiffEditorWidget; protected _outputMetadataEditor?: DiffEditorWidget; @@ -752,6 +752,8 @@ abstract class SingleSideDiffElement extends AbstractElementRenderer { ); this.cell = cell; this.templateData = templateData; + + this.updateBorders(); } init() { @@ -1243,6 +1245,8 @@ export class ModifiedElement extends AbstractElementRenderer { this.cell = cell; this.templateData = templateData; this._editorViewStateChanged = false; + + this.updateBorders(); } init() { } diff --git a/src/vs/workbench/contrib/notebook/browser/diff/diffNestedCellViewModel.ts b/src/vs/workbench/contrib/notebook/browser/diff/diffNestedCellViewModel.ts index 0f31bdfc6ca89..9b15ad922c37b 100644 --- a/src/vs/workbench/contrib/notebook/browser/diff/diffNestedCellViewModel.ts +++ b/src/vs/workbench/contrib/notebook/browser/diff/diffNestedCellViewModel.ts @@ -62,6 +62,15 @@ export class DiffNestedCellViewModel extends Disposable implements IDiffNestedCe this._onDidChangeState.fire({ outputIsFocusedChanged: true }); } + private _focusInputInOutput: boolean = false; + public get inputInOutputIsFocused(): boolean { + return this._focusInputInOutput; + } + + public set inputInOutputIsFocused(v: boolean) { + this._focusInputInOutput = v; + } + private _outputViewModels: ICellOutputViewModel[]; get outputsViewModels() { diff --git a/src/vs/workbench/contrib/notebook/browser/diff/notebookDiffEditor.ts b/src/vs/workbench/contrib/notebook/browser/diff/notebookDiffEditor.ts index e2de1a220961f..29dc02777d3a4 100644 --- a/src/vs/workbench/contrib/notebook/browser/diff/notebookDiffEditor.ts +++ b/src/vs/workbench/contrib/notebook/browser/diff/notebookDiffEditor.ts @@ -9,7 +9,7 @@ import { findLastIdx } from 'vs/base/common/arraysFind'; import { IStorageService } from 'vs/platform/storage/common/storage'; import { ITelemetryService } from 'vs/platform/telemetry/common/telemetry'; import { IThemeService, registerThemingParticipant } from 'vs/platform/theme/common/themeService'; -import { EditorPaneSelectionChangeReason, EditorPaneSelectionCompareResult, IEditorOpenContext, IEditorPaneSelection, IEditorPaneSelectionChangeEvent, IEditorPaneWithSelection } from 'vs/workbench/common/editor'; +import { EditorPaneSelectionChangeReason, EditorPaneSelectionCompareResult, IEditorOpenContext, IEditorPaneScrollPosition, IEditorPaneSelection, IEditorPaneSelectionChangeEvent, IEditorPaneWithScrolling, IEditorPaneWithSelection } from 'vs/workbench/common/editor'; import { getDefaultNotebookCreationOptions } from 'vs/workbench/contrib/notebook/browser/notebookEditorWidget'; import { IEditorGroup } from 'vs/workbench/services/editor/common/editorGroupsService'; import { NotebookDiffEditorInput } from '../../common/notebookDiffEditorInput'; @@ -47,7 +47,6 @@ import { IEditorOptions } from 'vs/platform/editor/common/editor'; import { cellIndexesToRanges, cellRangesToIndexes } from 'vs/workbench/contrib/notebook/common/notebookRange'; import { NotebookDiffOverviewRuler } from 'vs/workbench/contrib/notebook/browser/diff/notebookDiffOverviewRuler'; import { registerZIndex, ZIndex } from 'vs/platform/layout/browser/zIndexRegistry'; -import { mainWindow } from 'vs/base/browser/window'; import { ICodeEditorService } from 'vs/editor/browser/services/codeEditorService'; const $ = DOM.$; @@ -86,7 +85,7 @@ class NotebookDiffEditorSelection implements IEditorPaneSelection { } } -export class NotebookTextDiffEditor extends EditorPane implements INotebookTextDiffEditor, INotebookDelegateForWebview, IEditorPaneWithSelection { +export class NotebookTextDiffEditor extends EditorPane implements INotebookTextDiffEditor, INotebookDelegateForWebview, IEditorPaneWithSelection, IEditorPaneWithScrolling { public static readonly ENTIRE_DIFF_OVERVIEW_WIDTH = 30; creationOptions: INotebookEditorCreationOptions = getDefaultNotebookCreationOptions(); static readonly ID: string = NOTEBOOK_DIFF_EDITOR_ID; @@ -108,6 +107,7 @@ export class NotebookTextDiffEditor extends EditorPane implements INotebookTextD public readonly onMouseUp = this._onMouseUp.event; private readonly _onDidScroll = this._register(new Emitter()); readonly onDidScroll: Event = this._onDidScroll.event; + readonly onDidChangeScroll: Event = this._onDidScroll.event; private _eventDispatcher: NotebookDiffEditorEventDispatcher | undefined; protected _scopeContextKeyService!: IContextKeyService; private _model: INotebookDiffEditorModel | null = null; @@ -143,6 +143,7 @@ export class NotebookTextDiffEditor extends EditorPane implements INotebookTextD } constructor( + group: IEditorGroup, @IInstantiationService private readonly instantiationService: IInstantiationService, @IThemeService themeService: IThemeService, @IContextKeyService private readonly contextKeyService: IContextKeyService, @@ -153,8 +154,8 @@ export class NotebookTextDiffEditor extends EditorPane implements INotebookTextD @INotebookExecutionStateService notebookExecutionStateService: INotebookExecutionStateService, @ICodeEditorService codeEditorService: ICodeEditorService ) { - super(NotebookTextDiffEditor.ID, telemetryService, themeService, storageService); - this._notebookOptions = new NotebookOptions(DOM.getWindowById(this.group?.windowId, true).window ?? mainWindow, this.configurationService, notebookExecutionStateService, codeEditorService, false); + super(NotebookTextDiffEditor.ID, group, telemetryService, themeService, storageService); + this._notebookOptions = new NotebookOptions(this.window, this.configurationService, notebookExecutionStateService, codeEditorService, false); this._register(this._notebookOptions); this._revealFirst = true; } @@ -168,9 +169,8 @@ export class NotebookTextDiffEditor extends EditorPane implements INotebookTextD } private createFontInfo() { - const window = DOM.getWindowById(this.group?.windowId, true).window; const editorOptions = this.configurationService.getValue('editor'); - return FontMeasurements.readFontInfo(window, BareFontInfo.createFromRawSettings(editorOptions, PixelRatio.getInstance(window).value)); + return FontMeasurements.readFontInfo(this.window, BareFontInfo.createFromRawSettings(editorOptions, PixelRatio.getInstance(this.window).value)); } private isOverviewRulerEnabled(): boolean { @@ -210,6 +210,24 @@ export class NotebookTextDiffEditor extends EditorPane implements INotebookTextD return this._list?.scrollHeight ?? 0; } + getScrollPosition(): IEditorPaneScrollPosition { + return { + scrollTop: this.getScrollTop(), + scrollLeft: this._list?.scrollLeft ?? 0 + }; + } + + setScrollPosition(scrollPosition: IEditorPaneScrollPosition): void { + if (!this._list) { + return; + } + + this._list.scrollTop = scrollPosition.scrollTop; + if (scrollPosition.scrollLeft !== undefined) { + this._list.scrollLeft = scrollPosition.scrollLeft; + } + } + delegateVerticalScrollbarPointerDown(browserEvent: PointerEvent) { this._list?.delegateVerticalScrollbarPointerDown(browserEvent); } @@ -271,7 +289,7 @@ export class NotebookTextDiffEditor extends EditorPane implements INotebookTextD NotebookTextDiffList, 'NotebookTextDiff', this._listViewContainer, - this.instantiationService.createInstance(NotebookCellTextDiffListDelegate, DOM.getWindow(this._listViewContainer)), + this.instantiationService.createInstance(NotebookCellTextDiffListDelegate, this.window), renderers, this.contextKeyService, { @@ -462,7 +480,7 @@ export class NotebookTextDiffEditor extends EditorPane implements INotebookTextD private _attachModel() { this._eventDispatcher = new NotebookDiffEditorEventDispatcher(); const updateInsets = () => { - DOM.scheduleAtNextAnimationFrame(DOM.getWindow(this._listViewContainer), () => { + DOM.scheduleAtNextAnimationFrame(this.window, () => { if (this._isDisposed) { return; } @@ -499,7 +517,7 @@ export class NotebookTextDiffEditor extends EditorPane implements INotebookTextD }, undefined) as BackLayerWebView; // attach the webview container to the DOM tree first this._list.rowsContainer.insertAdjacentElement('afterbegin', this._modifiedWebview.element); - this._modifiedWebview.createWebview(DOM.getActiveWindow()); + this._modifiedWebview.createWebview(this.window); this._modifiedWebview.element.style.width = `calc(50% - 16px)`; this._modifiedWebview.element.style.left = `calc(50%)`; } @@ -516,7 +534,7 @@ export class NotebookTextDiffEditor extends EditorPane implements INotebookTextD }, undefined) as BackLayerWebView; // attach the webview container to the DOM tree first this._list.rowsContainer.insertAdjacentElement('afterbegin', this._originalWebview.element); - this._originalWebview.createWebview(DOM.getActiveWindow()); + this._originalWebview.createWebview(this.window); this._originalWebview.element.style.width = `calc(50% - 16px)`; this._originalWebview.element.style.left = `16px`; } @@ -776,7 +794,7 @@ export class NotebookTextDiffEditor extends EditorPane implements INotebookTextD const webview = diffSide === DiffSide.Modified ? this._modifiedWebview : this._originalWebview; - DOM.scheduleAtNextAnimationFrame(DOM.getWindow(this._listViewContainer), () => { + DOM.scheduleAtNextAnimationFrame(this.window, () => { webview?.ackHeight([{ cellId: cellInfo.cellId, outputId, height }]); }, 10); } @@ -794,7 +812,7 @@ export class NotebookTextDiffEditor extends EditorPane implements INotebookTextD } let r: () => void; - const layoutDisposable = DOM.scheduleAtNextAnimationFrame(DOM.getWindow(this._listViewContainer), () => { + const layoutDisposable = DOM.scheduleAtNextAnimationFrame(this.window, () => { this.pendingLayouts.delete(cell); relayout(cell, height); @@ -978,10 +996,6 @@ export class NotebookTextDiffEditor extends EditorPane implements INotebookTextD return this; } - protected override setEditorVisible(visible: boolean, group: IEditorGroup | undefined): void { - super.setEditorVisible(visible, group); - } - override clearInput(): void { super.clearInput(); diff --git a/src/vs/workbench/contrib/notebook/browser/diff/notebookDiffEditorBrowser.ts b/src/vs/workbench/contrib/notebook/browser/diff/notebookDiffEditorBrowser.ts index 6741f2a3ab870..031c2afdc7f31 100644 --- a/src/vs/workbench/contrib/notebook/browser/diff/notebookDiffEditorBrowser.ts +++ b/src/vs/workbench/contrib/notebook/browser/diff/notebookDiffEditorBrowser.ts @@ -9,7 +9,7 @@ import { Event } from 'vs/base/common/event'; import { BareFontInfo } from 'vs/editor/common/config/fontInfo'; import { DisposableStore } from 'vs/base/common/lifecycle'; import { NotebookTextModel } from 'vs/workbench/contrib/notebook/common/model/notebookTextModel'; -import { CodeEditorWidget } from 'vs/editor/browser/widget/codeEditorWidget'; +import { CodeEditorWidget } from 'vs/editor/browser/widget/codeEditor/codeEditorWidget'; import { IMouseWheelEvent } from 'vs/base/browser/mouseEvent'; import { RawContextKey } from 'vs/platform/contextkey/common/contextkey'; import { NotebookOptions } from 'vs/workbench/contrib/notebook/browser/notebookOptions'; diff --git a/src/vs/workbench/contrib/notebook/browser/diff/notebookDiffList.ts b/src/vs/workbench/contrib/notebook/browser/diff/notebookDiffList.ts index 11aa9bfb6b192..a3cfecd2a6f6d 100644 --- a/src/vs/workbench/contrib/notebook/browser/diff/notebookDiffList.ts +++ b/src/vs/workbench/contrib/notebook/browser/diff/notebookDiffList.ts @@ -17,7 +17,7 @@ import { IThemeService } from 'vs/platform/theme/common/themeService'; import { DiffElementViewModelBase, SideBySideDiffElementViewModel, SingleSideDiffElementViewModel } from 'vs/workbench/contrib/notebook/browser/diff/diffElementViewModel'; import { CellDiffSideBySideRenderTemplate, CellDiffSingleSideRenderTemplate, DIFF_CELL_MARGIN, INotebookTextDiffEditor } from 'vs/workbench/contrib/notebook/browser/diff/notebookDiffEditorBrowser'; import { DeletedElement, getOptimizedNestedCodeEditorWidgetOptions, InsertElement, ModifiedElement } from 'vs/workbench/contrib/notebook/browser/diff/diffComponents'; -import { CodeEditorWidget } from 'vs/editor/browser/widget/codeEditorWidget'; +import { CodeEditorWidget } from 'vs/editor/browser/widget/codeEditor/codeEditorWidget'; import { DiffEditorWidget } from 'vs/editor/browser/widget/diffEditor/diffEditorWidget'; import { IMenuService, MenuItemAction } from 'vs/platform/actions/common/actions'; import { IContextMenuService } from 'vs/platform/contextview/browser/contextView'; @@ -189,9 +189,9 @@ export class CellDiffSideBySideRenderer implements IListRenderer { + actionViewItemProvider: (action, options) => { if (action instanceof MenuItemAction) { - const item = new CodiconActionViewItem(action, undefined, this.keybindingService, this.notificationService, this.contextKeyService, this.themeService, this.contextMenuService, this.accessibilityService); + const item = new CodiconActionViewItem(action, { hoverDelegate: options.hoverDelegate }, this.keybindingService, this.notificationService, this.contextKeyService, this.themeService, this.contextMenuService, this.accessibilityService); return item; } @@ -248,7 +248,9 @@ export class CellDiffSideBySideRenderer implements IListRenderer .cell-list-container .notebook-folded-hint { position: absolute; user-select: none; + display: flex; + align-items: center; } .monaco-workbench .notebookOverlay > .cell-list-container .notebook-folded-hint-label { @@ -55,6 +57,22 @@ opacity: 0.7; } +.monaco-workbench .notebookOverlay > .cell-list-container .folded-cell-run-section-button { + position: relative; + left: 0px; + padding: 2px; + border-radius: 5px; + margin-right: 4px; + height: 16px; + width: 16px; + z-index: var(--z-index-notebook-cell-expand-part-button); +} + +.monaco-workbench .notebookOverlay > .cell-list-container .folded-cell-run-section-button:hover { + background-color: var(--vscode-editorStickyScrollHover-background); + cursor: pointer; +} + .monaco-workbench .notebookOverlay .cell-editor-container .monaco-editor .margin-view-overlays .codicon-folding-expanded, .monaco-workbench .notebookOverlay .cell-editor-container .monaco-editor .margin-view-overlays .codicon-folding-collapsed { margin-left: 0; diff --git a/src/vs/workbench/contrib/notebook/browser/media/notebookOutline.css b/src/vs/workbench/contrib/notebook/browser/media/notebookOutline.css index 6e71e660f8752..677f9c89ea790 100644 --- a/src/vs/workbench/contrib/notebook/browser/media/notebookOutline.css +++ b/src/vs/workbench/contrib/notebook/browser/media/notebookOutline.css @@ -43,3 +43,19 @@ /* Don't show markers inline with breadcrumbs */ display: none; } + +.monaco-list-row .notebook-outline-element .action-menu { + display: none; +} + +.monaco-list-row.focused.selected .notebook-outline-element .action-menu { + display: flex; +} + +.monaco-list-row:hover .notebook-outline-element .action-menu { + display: flex; +} + +.monaco-list-row .notebook-outline-element.notebook-outline-toolbar-dropdown-active .action-menu { + display: flex; +} diff --git a/src/vs/workbench/contrib/notebook/browser/media/notebookToolbar.css b/src/vs/workbench/contrib/notebook/browser/media/notebookToolbar.css index 28ea1557a52fb..cb19b73e48b29 100644 --- a/src/vs/workbench/contrib/notebook/browser/media/notebookToolbar.css +++ b/src/vs/workbench/contrib/notebook/browser/media/notebookToolbar.css @@ -70,6 +70,10 @@ display: inline-flex; } +.monaco-workbench .notebook-action-view-item-unified .monaco-dropdown { + pointer-events: none; +} + .monaco-workbench .notebookOverlay .notebook-toolbar-container .monaco-action-bar .action-item .notebook-label { background-size: 16px; padding: 0px 5px 0px 2px; diff --git a/src/vs/workbench/contrib/notebook/browser/notebook.contribution.ts b/src/vs/workbench/contrib/notebook/browser/notebook.contribution.ts index bcc27365df5ec..30866bc8fd4e1 100644 --- a/src/vs/workbench/contrib/notebook/browser/notebook.contribution.ts +++ b/src/vs/workbench/contrib/notebook/browser/notebook.contribution.ts @@ -62,11 +62,13 @@ import { INotebookRendererMessagingService } from 'vs/workbench/contrib/notebook import 'vs/workbench/contrib/notebook/browser/controller/coreActions'; import 'vs/workbench/contrib/notebook/browser/controller/insertCellActions'; import 'vs/workbench/contrib/notebook/browser/controller/executeActions'; +import 'vs/workbench/contrib/notebook/browser/controller/sectionActions'; import 'vs/workbench/contrib/notebook/browser/controller/layoutActions'; import 'vs/workbench/contrib/notebook/browser/controller/editActions'; import 'vs/workbench/contrib/notebook/browser/controller/cellOutputActions'; import 'vs/workbench/contrib/notebook/browser/controller/apiActions'; import 'vs/workbench/contrib/notebook/browser/controller/foldingController'; +import 'vs/workbench/contrib/notebook/browser/controller/chat/notebook.chat.contribution'; // Editor Contribution import 'vs/workbench/contrib/notebook/browser/contrib/editorHint/emptyCellEditorHint'; @@ -1077,19 +1079,8 @@ configurationRegistry.registerConfiguration({ ], default: 'fullCell' }, - [NotebookSetting.anchorToFocusedCell]: { - markdownDescription: nls.localize('notebook.scrolling.anchorToFocusedCell.description', "Experimental. Keep the focused cell steady while surrounding cells change size."), - type: 'string', - enum: ['auto', 'on', 'off'], - markdownEnumDescriptions: [ - nls.localize('notebook.scrolling.anchorToFocusedCell.auto.description', "Anchor the viewport to the focused cell depending on context unless {0} is set to {1}.", 'notebook.scrolling.revealCellBehavior', 'none'), - nls.localize('notebook.scrolling.anchorToFocusedCell.on.description', "Always anchor the viewport to the focused cell."), - nls.localize('notebook.scrolling.anchorToFocusedCell.off.description', "The focused cell may shift around as cells resize.") - ], - default: 'auto' - }, [NotebookSetting.cellChat]: { - markdownDescription: nls.localize('notebook.cellChat', "Enable experimental cell chat for notebooks."), + markdownDescription: nls.localize('notebook.cellChat', "Enable experimental floating chat widget in notebooks."), type: 'boolean', default: false }, @@ -1097,6 +1088,11 @@ configurationRegistry.registerConfiguration({ markdownDescription: nls.localize('notebook.VariablesView.description', "Enable the experimental notebook variables view within the debug panel."), type: 'boolean', default: false - } + }, + [NotebookSetting.cellFailureDiagnostics]: { + markdownDescription: nls.localize('notebook.cellFailureDiagnostics', "Show available diagnostics for cell failures."), + type: 'boolean', + default: true + }, } }); diff --git a/src/vs/workbench/contrib/notebook/browser/notebookAccessibilityProvider.ts b/src/vs/workbench/contrib/notebook/browser/notebookAccessibilityProvider.ts new file mode 100644 index 0000000000000..948db4cd3a1c8 --- /dev/null +++ b/src/vs/workbench/contrib/notebook/browser/notebookAccessibilityProvider.ts @@ -0,0 +1,96 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IListAccessibilityProvider } from 'vs/base/browser/ui/list/listWidget'; +import { Event, Emitter } from 'vs/base/common/event'; +import { Disposable } from 'vs/base/common/lifecycle'; +import { observableFromEvent } from 'vs/base/common/observable'; +import * as nls from 'vs/nls'; +import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; +import { IKeybindingService } from 'vs/platform/keybinding/common/keybinding'; +import { AccessibilityVerbositySettingId } from 'vs/workbench/contrib/accessibility/browser/accessibilityConfiguration'; +import { AccessibilityCommandId } from 'vs/workbench/contrib/accessibility/common/accessibilityCommands'; +import { CellViewModel, NotebookViewModel } from 'vs/workbench/contrib/notebook/browser/viewModel/notebookViewModelImpl'; +import { CellKind, NotebookCellExecutionState } from 'vs/workbench/contrib/notebook/common/notebookCommon'; +import { ICellExecutionStateChangedEvent, IExecutionStateChangedEvent, INotebookExecutionStateService, NotebookExecutionType } from 'vs/workbench/contrib/notebook/common/notebookExecutionStateService'; + +export class NotebookAccessibilityProvider extends Disposable implements IListAccessibilityProvider { + private readonly _onDidAriaLabelChange = new Emitter(); + private readonly onDidAriaLabelChange = this._onDidAriaLabelChange.event; + + constructor( + private readonly notebookExecutionStateService: INotebookExecutionStateService, + private readonly viewModel: () => NotebookViewModel | undefined, + private readonly keybindingService: IKeybindingService, + private readonly configurationService: IConfigurationService + ) { + super(); + this._register(Event.debounce( + this.notebookExecutionStateService.onDidChangeExecution, + (last: number[] | undefined, e: ICellExecutionStateChangedEvent | IExecutionStateChangedEvent) => this.mergeEvents(last, e), + 100 + )((cellHandles: number[]) => { + const viewModel = this.viewModel(); + if (viewModel) { + for (const handle of cellHandles) { + const cellModel = viewModel.getCellByHandle(handle); + if (cellModel) { + this._onDidAriaLabelChange.fire(cellModel as CellViewModel); + } + } + } + }, this)); + } + + getAriaLabel(element: CellViewModel) { + const event = Event.filter(this.onDidAriaLabelChange, e => e === element); + return observableFromEvent(event, () => { + const viewModel = this.viewModel(); + if (!viewModel) { + return ''; + } + const index = viewModel.getCellIndex(element); + + if (index >= 0) { + return this.getLabel(index, element); + } + + return ''; + }); + } + + private getLabel(index: number, element: CellViewModel) { + const executionState = this.notebookExecutionStateService.getCellExecution(element.uri)?.state; + const executionLabel = + executionState === NotebookCellExecutionState.Executing + ? ', executing' + : executionState === NotebookCellExecutionState.Pending + ? ', pending' + : ''; + return `Cell ${index}, ${element.cellKind === CellKind.Markup ? 'markdown' : 'code'} cell${executionLabel}`; + } + + getWidgetAriaLabel() { + const keybinding = this.keybindingService.lookupKeybinding(AccessibilityCommandId.OpenAccessibilityHelp)?.getLabel(); + + if (this.configurationService.getValue(AccessibilityVerbositySettingId.Notebook)) { + return keybinding + ? nls.localize('notebookTreeAriaLabelHelp', "Notebook\nUse {0} for accessibility help", keybinding) + : nls.localize('notebookTreeAriaLabelHelpNoKb', "Notebook\nRun the Open Accessibility Help command for more information", keybinding); + } + return nls.localize('notebookTreeAriaLabel', "Notebook"); + } + + private mergeEvents(last: number[] | undefined, e: ICellExecutionStateChangedEvent | IExecutionStateChangedEvent): number[] { + const viewModel = this.viewModel(); + const result = last || []; + if (viewModel && e.type === NotebookExecutionType.cell && e.affectsNotebook(viewModel.uri)) { + if (result.indexOf(e.cellHandle) < 0) { + result.push(e.cellHandle); + } + } + return result; + } +} diff --git a/src/vs/workbench/contrib/notebook/browser/notebookBrowser.ts b/src/vs/workbench/contrib/notebook/browser/notebookBrowser.ts index 6d0331e96d0a2..35eb6e5800e85 100644 --- a/src/vs/workbench/contrib/notebook/browser/notebookBrowser.ts +++ b/src/vs/workbench/contrib/notebook/browser/notebookBrowser.ts @@ -128,6 +128,7 @@ export interface IGenericCellViewModel { metadata: NotebookCellMetadata; outputIsHovered: boolean; outputIsFocused: boolean; + inputInOutputIsFocused: boolean; outputsViewModels: ICellOutputViewModel[]; getOutputOffset(index: number): number; updateOutputHeight(index: number, height: number, source?: string): void; @@ -449,6 +450,7 @@ export interface INotebookViewModel { layoutInfo: NotebookLayoutInfo | null; onDidChangeViewCells: Event; onDidChangeSelection: Event; + onDidFoldingStateChanged: Event; getNearestVisibleCellIndexUpwards(index: number): number; getTrackedRange(id: string): ICellRange | null; setTrackedRange(id: string | null, newRange: ICellRange | null, newStickiness: TrackedRangeStickiness): string | null; @@ -467,6 +469,7 @@ export interface INotebookEditor { readonly onDidChangeViewCells: Event; readonly onDidChangeVisibleRanges: Event; readonly onDidChangeSelection: Event; + readonly onDidChangeFocus: Event; /** * An event emitted when the model of this editor has changed. */ @@ -521,7 +524,7 @@ export interface INotebookEditor { /** * Focus the notebook cell list container */ - focusContainer(): void; + focusContainer(clearSelection?: boolean): void; hasEditorFocus(): boolean; hasWebviewFocus(): boolean; @@ -580,6 +583,16 @@ export interface INotebookEditor { * Copy the image in the specific cell output to the clipboard */ copyOutputImage(cellOutput: ICellOutputViewModel): Promise; + /** + * Select the contents of the first focused output of the cell. + * Implementation of Ctrl+A for an output item. + */ + selectOutputContent(cell: ICellViewModel): void; + /** + * Select the active input element of the first focused output of the cell. + * Implementation of Ctrl+A for an input element in an output item. + */ + selectInputContents(cell: ICellViewModel): void; readonly onDidReceiveMessage: Event; @@ -628,6 +641,11 @@ export interface INotebookEditor { */ revealInCenterIfOutsideViewport(cell: ICellViewModel): Promise; + /** + * Reveal the first line of the cell into the view if the cell is outside of the viewport. + */ + revealFirstLineIfOutsideViewport(cell: ICellViewModel): Promise; + /** * Reveal a line in notebook cell into viewport with minimal scrolling. */ @@ -663,6 +681,11 @@ export interface INotebookEditor { */ revealCellOffsetInCenter(cell: ICellViewModel, offset: number): void; + /** + * Reveal `offset` in the list view into viewport center if it is outside of the viewport. + */ + revealOffsetInCenterIfOutsideViewport(offset: number): void; + /** * Convert the view range to model range * @param startIndex Inclusive @@ -720,6 +743,7 @@ export interface INotebookEditor { hideProgress(): void; getAbsoluteTopOfElement(cell: ICellViewModel): number; + getHeightOfElement(cell: ICellViewModel): number; } export interface IActiveNotebookEditor extends INotebookEditor { diff --git a/src/vs/workbench/contrib/notebook/browser/notebookEditor.ts b/src/vs/workbench/contrib/notebook/browser/notebookEditor.ts index 10ae44a781d10..b4802da503f52 100644 --- a/src/vs/workbench/contrib/notebook/browser/notebookEditor.ts +++ b/src/vs/workbench/contrib/notebook/browser/notebookEditor.ts @@ -24,7 +24,7 @@ import { ITelemetryService } from 'vs/platform/telemetry/common/telemetry'; import { IThemeService } from 'vs/platform/theme/common/themeService'; import { Selection } from 'vs/editor/common/core/selection'; import { EditorPane } from 'vs/workbench/browser/parts/editor/editorPane'; -import { DEFAULT_EDITOR_ASSOCIATION, EditorPaneSelectionChangeReason, EditorPaneSelectionCompareResult, EditorResourceAccessor, IEditorMemento, IEditorOpenContext, IEditorPaneSelection, IEditorPaneSelectionChangeEvent, createEditorOpenError, createTooLargeFileError, isEditorOpenError } from 'vs/workbench/common/editor'; +import { DEFAULT_EDITOR_ASSOCIATION, EditorPaneSelectionChangeReason, EditorPaneSelectionCompareResult, EditorResourceAccessor, IEditorMemento, IEditorOpenContext, IEditorPaneScrollPosition, IEditorPaneSelection, IEditorPaneSelectionChangeEvent, IEditorPaneWithScrolling, createEditorOpenError, createTooLargeFileError, isEditorOpenError } from 'vs/workbench/common/editor'; import { EditorInput } from 'vs/workbench/common/editor/editorInput'; import { SELECT_KERNEL_ID } from 'vs/workbench/contrib/notebook/browser/controller/coreActions'; import { INotebookEditorOptions, INotebookEditorPane, INotebookEditorViewState } from 'vs/workbench/contrib/notebook/browser/notebookBrowser'; @@ -47,10 +47,11 @@ import { streamToBuffer } from 'vs/base/common/buffer'; import { ILogService } from 'vs/platform/log/common/log'; import { INotebookEditorWorkerService } from 'vs/workbench/contrib/notebook/common/services/notebookWorkerService'; import { IPreferencesService } from 'vs/workbench/services/preferences/common/preferences'; +import { IActionViewItemOptions } from 'vs/base/browser/ui/actionbar/actionViewItems'; const NOTEBOOK_EDITOR_VIEW_STATE_PREFERENCE_KEY = 'NotebookEditorViewState'; -export class NotebookEditor extends EditorPane implements INotebookEditorPane { +export class NotebookEditor extends EditorPane implements INotebookEditorPane, IEditorPaneWithScrolling { static readonly ID: string = NOTEBOOK_EDITOR_ID; private readonly _editorMemento: IEditorMemento; @@ -74,7 +75,11 @@ export class NotebookEditor extends EditorPane implements INotebookEditorPane { private readonly _onDidChangeSelection = this._register(new Emitter()); readonly onDidChangeSelection = this._onDidChangeSelection.event; + protected readonly _onDidChangeScroll = this._register(new Emitter()); + readonly onDidChangeScroll = this._onDidChangeScroll.event; + constructor( + group: IEditorGroup, @ITelemetryService telemetryService: ITelemetryService, @IThemeService themeService: IThemeService, @IInstantiationService private readonly _instantiationService: IInstantiationService, @@ -93,7 +98,7 @@ export class NotebookEditor extends EditorPane implements INotebookEditorPane { @INotebookEditorWorkerService private readonly _notebookEditorWorkerService: INotebookEditorWorkerService, @IPreferencesService private readonly _preferencesService: IPreferencesService ) { - super(NotebookEditor.ID, telemetryService, themeService, storageService); + super(NotebookEditor.ID, group, telemetryService, themeService, storageService); this._editorMemento = this.getEditorMemento(_editorGroupService, configurationService, NOTEBOOK_EDITOR_VIEW_STATE_PREFERENCE_KEY); this._register(this._fileService.onDidChangeFileSystemProviderCapabilities(e => this._onDidChangeFileSystemProvider(e.scheme))); @@ -137,10 +142,10 @@ export class NotebookEditor extends EditorPane implements INotebookEditorPane { this._rootElement.id = `notebook-editor-element-${generateUuid()}`; } - override getActionViewItem(action: IAction): IActionViewItem | undefined { + override getActionViewItem(action: IAction, options: IActionViewItemOptions): IActionViewItem | undefined { if (action.id === SELECT_KERNEL_ID) { // this is being disposed by the consumer - return this._instantiationService.createInstance(NotebooKernelActionViewItem, action, this); + return this._instantiationService.createInstance(NotebooKernelActionViewItem, action, this, options); } return undefined; } @@ -149,24 +154,22 @@ export class NotebookEditor extends EditorPane implements INotebookEditorPane { return this._widget.value; } - override setVisible(visible: boolean, group?: IEditorGroup | undefined): void { - super.setVisible(visible, group); + override setVisible(visible: boolean): void { + super.setVisible(visible); if (!visible) { this._widget.value?.onWillHide(); } } - protected override setEditorVisible(visible: boolean, group: IEditorGroup | undefined): void { - super.setEditorVisible(visible, group); - if (group) { - this._groupListener.clear(); - this._groupListener.add(group.onWillCloseEditor(e => this._saveEditorViewState(e.editor))); - this._groupListener.add(group.onDidModelChange(() => { - if (this._editorGroupService.activeGroup !== group) { - this._widget?.value?.updateEditorFocus(); - } - })); - } + protected override setEditorVisible(visible: boolean): void { + super.setEditorVisible(visible); + this._groupListener.clear(); + this._groupListener.add(this.group.onWillCloseEditor(e => this._saveEditorViewState(e.editor))); + this._groupListener.add(this.group.onDidModelChange(() => { + if (this._editorGroupService.activeGroup !== this.group) { + this._widget?.value?.updateEditorFocus(); + } + })); if (!visible) { this._saveEditorViewState(this.input); @@ -202,7 +205,6 @@ export class NotebookEditor extends EditorPane implements INotebookEditorPane { const perf = new NotebookPerfMarks(); perf.mark('startTime'); - const group = this.group!; this._inputListener.value = input.onDidChangeCapabilities(() => this._onDidChangeInputCapabilities(input)); @@ -212,7 +214,7 @@ export class NotebookEditor extends EditorPane implements INotebookEditorPane { // we need to hide it before getting a new widget this._widget.value?.onWillHide(); - this._widget = >this._instantiationService.invokeFunction(this._notebookWidgetService.retrieveWidget, group, input, undefined, this._pagePosition?.dimension, DOM.getWindowById(group.windowId, true).window); + this._widget = >this._instantiationService.invokeFunction(this._notebookWidgetService.retrieveWidget, this.group, input, undefined, this._pagePosition?.dimension, this.window); if (this._rootElement && this._widget.value!.getDomNode()) { this._rootElement.setAttribute('aria-flowto', this._widget.value!.getDomNode().id || ''); @@ -318,9 +320,11 @@ export class NotebookEditor extends EditorPane implements INotebookEditorPane { this._widgetDisposableStore.add(this._widget.value.onDidBlurWidget(() => this._onDidBlurWidget.fire())); this._widgetDisposableStore.add(this._editorGroupService.createEditorDropTarget(this._widget.value.getDomNode(), { - containsGroup: (group) => this.group?.id === group.id + containsGroup: (group) => this.group.id === group.id })); + this._widgetDisposableStore.add(this._widget.value.onDidScroll(() => { this._onDidChangeScroll.fire(); })); + perf.mark('editorLoaded'); fileOpenMonitor.cancel(); @@ -337,7 +341,7 @@ export class NotebookEditor extends EditorPane implements INotebookEditorPane { } // Handle case where a file is too large to open without confirmation - if ((e).fileOperationResult === FileOperationResult.FILE_TOO_LARGE && this.group) { + if ((e).fileOperationResult === FileOperationResult.FILE_TOO_LARGE) { let message: string; if (e instanceof TooLargeFileOperationError) { message = localize('notebookTooLargeForHeapErrorWithSize', "The notebook is not displayed in the notebook editor because it is very large ({0}).", ByteSize.formatSize(e.size)); @@ -509,9 +513,29 @@ export class NotebookEditor extends EditorPane implements INotebookEditorPane { return undefined; } + getScrollPosition(): IEditorPaneScrollPosition { + const widget = this.getControl(); + if (!widget) { + throw new Error('Notebook widget has not yet been initialized'); + } + + return { + scrollTop: widget.scrollTop, + scrollLeft: 0, + }; + } + + setScrollPosition(scrollPosition: IEditorPaneScrollPosition): void { + const editor = this.getControl(); + if (!editor) { + throw new Error('Control has not yet been initialized'); + } + + editor.setScrollTop(scrollPosition.scrollTop); + } private _saveEditorViewState(input: EditorInput | undefined): void { - if (this.group && this._widget.value && input instanceof NotebookEditorInput) { + if (this._widget.value && input instanceof NotebookEditorInput) { if (this._widget.value.isDisposed) { return; } @@ -522,10 +546,7 @@ export class NotebookEditor extends EditorPane implements INotebookEditorPane { } private _loadNotebookEditorViewState(input: NotebookEditorInput): INotebookEditorViewState | undefined { - let result: INotebookEditorViewState | undefined; - if (this.group) { - result = this._editorMemento.loadEditorState(this.group, input.resource); - } + const result = this._editorMemento.loadEditorState(this.group, input.resource); if (result) { return result; } @@ -544,11 +565,11 @@ export class NotebookEditor extends EditorPane implements INotebookEditorPane { this._rootElement.classList.toggle('narrow-width', dimension.width < 600); this._pagePosition = { dimension, position }; - if (!this._widget.value || !(this._input instanceof NotebookEditorInput)) { + if (!this._widget.value || !(this.input instanceof NotebookEditorInput)) { return; } - if (this._input.resource.toString() !== this.textModel?.uri.toString() && this._widget.value?.hasModel()) { + if (this.input.resource.toString() !== this.textModel?.uri.toString() && this._widget.value?.hasModel()) { // input and widget mismatch // this happens when // 1. open document A, pin the document diff --git a/src/vs/workbench/contrib/notebook/browser/notebookEditorWidget.ts b/src/vs/workbench/contrib/notebook/browser/notebookEditorWidget.ts index 0719ee2ca4fa1..0eaf57d26a54b 100644 --- a/src/vs/workbench/contrib/notebook/browser/notebookEditorWidget.ts +++ b/src/vs/workbench/contrib/notebook/browser/notebookEditorWidget.ts @@ -41,7 +41,7 @@ import { SuggestController } from 'vs/editor/contrib/suggest/browser/suggestCont import * as nls from 'vs/nls'; import { MenuId } from 'vs/platform/actions/common/actions'; import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; -import { IContextKey, IContextKeyService } from 'vs/platform/contextkey/common/contextkey'; +import { IContextKey, IContextKeyService, RawContextKey } from 'vs/platform/contextkey/common/contextkey'; import { IContextMenuService } from 'vs/platform/contextview/browser/contextView'; import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; import { ServiceCollection } from 'vs/platform/instantiation/common/serviceCollection'; @@ -100,9 +100,10 @@ import { NotebookCellOutlineProvider } from 'vs/workbench/contrib/notebook/brows import { AccessibilityVerbositySettingId } from 'vs/workbench/contrib/accessibility/browser/accessibilityConfiguration'; import { IKeybindingService } from 'vs/platform/keybinding/common/keybinding'; import { OutlineTarget } from 'vs/workbench/services/outline/browser/outline'; -import { AccessibilityCommandId } from 'vs/workbench/contrib/accessibility/common/accessibilityCommands'; import { PixelRatio } from 'vs/base/browser/pixelRatio'; import { ICodeEditorService } from 'vs/editor/browser/services/codeEditorService'; +import { PreventDefaultContextMenuItemsContextKeyName } from 'vs/workbench/contrib/webview/browser/webview.contribution'; +import { NotebookAccessibilityProvider } from 'vs/workbench/contrib/notebook/browser/notebookAccessibilityProvider'; const $ = DOM.$; @@ -154,6 +155,8 @@ export class NotebookEditorWidget extends Disposable implements INotebookEditorD readonly onDidScroll: Event = this._onDidScroll.event; private readonly _onDidChangeActiveCell = this._register(new Emitter()); readonly onDidChangeActiveCell: Event = this._onDidChangeActiveCell.event; + private readonly _onDidChangeFocus = this._register(new Emitter()); + readonly onDidChangeFocus: Event = this._onDidChangeFocus.event; private readonly _onDidChangeSelection = this._register(new Emitter()); readonly onDidChangeSelection: Event = this._onDidChangeSelection.event; private readonly _onDidChangeVisibleRanges = this._register(new Emitter()); @@ -201,7 +204,7 @@ export class NotebookEditorWidget extends Disposable implements INotebookEditorD private _renderedEditors: Map = new Map(); private _viewContext: ViewContext; private _notebookViewModel: NotebookViewModel | undefined; - private _localStore: DisposableStore = this._register(new DisposableStore()); + private readonly _localStore: DisposableStore = this._register(new DisposableStore()); private _localCellStateListeners: DisposableStore[] = []; private _fontInfo: FontInfo | undefined; private _dimension?: DOM.Dimension; @@ -298,10 +301,10 @@ export class NotebookEditorWidget extends Disposable implements INotebookEditorD @IContextMenuService private readonly contextMenuService: IContextMenuService, @ITelemetryService private readonly telemetryService: ITelemetryService, @INotebookExecutionService private readonly notebookExecutionService: INotebookExecutionService, - @INotebookExecutionStateService notebookExecutionStateService: INotebookExecutionStateService, + @INotebookExecutionStateService private readonly notebookExecutionStateService: INotebookExecutionStateService, @IEditorProgressService private editorProgressService: IEditorProgressService, - @INotebookLoggingService readonly logService: INotebookLoggingService, - @IKeybindingService readonly keybindingService: IKeybindingService, + @INotebookLoggingService private readonly logService: INotebookLoggingService, + @IKeybindingService private readonly keybindingService: IKeybindingService, @ICodeEditorService codeEditorService: ICodeEditorService ) { super(); @@ -313,16 +316,17 @@ export class NotebookEditorWidget extends Disposable implements INotebookEditorD this._notebookOptions = creationOptions.options ?? new NotebookOptions(this.creationOptions?.codeWindow ?? mainWindow, this.configurationService, notebookExecutionStateService, codeEditorService, this._readOnly); this._register(this._notebookOptions); + const eventDispatcher = this._register(new NotebookEventDispatcher()); this._viewContext = new ViewContext( this._notebookOptions, - new NotebookEventDispatcher(), + eventDispatcher, language => this.getBaseCellEditorOptions(language)); this._register(this._viewContext.eventDispatcher.onDidChangeCellState(e => { this._onDidChangeCellState.fire(e); })); this._overlayContainer = document.createElement('div'); - this.scopedContextKeyService = contextKeyService.createScoped(this._overlayContainer); + this.scopedContextKeyService = this._register(contextKeyService.createScoped(this._overlayContainer)); this.instantiationService = instantiationService.createChild(new ServiceCollection([IContextKeyService, this.scopedContextKeyService])); this._register(_notebookService.onDidChangeOutputRenderers(() => { @@ -389,7 +393,8 @@ export class NotebookEditorWidget extends Disposable implements INotebookEditorD } })); - this._register(editorGroupsService.activePart.onDidScroll(e => { + const container = creationOptions.codeWindow ? this.layoutService.getContainer(creationOptions.codeWindow) : this.layoutService.mainContainer; + this._register(editorGroupsService.getPart(container).onDidScroll(e => { if (!this._shadowElement || !this._isVisible) { return; } @@ -404,13 +409,10 @@ export class NotebookEditorWidget extends Disposable implements INotebookEditorD this._overlayContainer.id = `notebook-${id}`; this._overlayContainer.className = 'notebookOverlay'; this._overlayContainer.classList.add('notebook-editor'); + this._overlayContainer.inert = true; this._overlayContainer.style.visibility = 'hidden'; - if (creationOptions.codeWindow) { - this.layoutService.getContainer(creationOptions.codeWindow).appendChild(this._overlayContainer); - } else { - this.layoutService.mainContainer.appendChild(this._overlayContainer); - } + container.appendChild(this._overlayContainer); this._createBody(this._overlayContainer); this._generateFontInfo(); @@ -420,6 +422,8 @@ export class NotebookEditorWidget extends Disposable implements INotebookEditorD this._outputInputFocus = NOTEBOOK_OUPTUT_INPUT_FOCUSED.bindTo(this.scopedContextKeyService); this._editorEditable = NOTEBOOK_EDITOR_EDITABLE.bindTo(this.scopedContextKeyService); this._cursorNavMode = NOTEBOOK_CURSOR_NAVIGATION_MODE.bindTo(this.scopedContextKeyService); + // Never display the native cut/copy context menu items in notebooks + new RawContextKey(PreventDefaultContextMenuItemsContextKeyName, false).bindTo(this.scopedContextKeyService).set(true); this._editorEditable.set(!creationOptions.isReadOnly); @@ -896,16 +900,8 @@ export class NotebookEditorWidget extends Disposable implements INotebookEditorD this._listDelegate = this.instantiationService.createInstance(NotebookCellListDelegate, DOM.getWindow(this.getDomNode())); this._register(this._listDelegate); - const createNotebookAriaLabel = () => { - const keybinding = this.keybindingService.lookupKeybinding(AccessibilityCommandId.OpenAccessibilityHelp)?.getLabel(); - - if (this.configurationService.getValue(AccessibilityVerbositySettingId.Notebook)) { - return keybinding - ? nls.localize('notebookTreeAriaLabelHelp', "Notebook\nUse {0} for accessibility help", keybinding) - : nls.localize('notebookTreeAriaLabelHelpNoKb', "Notebook\nRun the Open Accessibility Help command for more information", keybinding); - } - return nls.localize('notebookTreeAriaLabel', "Notebook"); - }; + const accessibilityProvider = new NotebookAccessibilityProvider(this.notebookExecutionStateService, () => this.viewModel, this.keybindingService, this.configurationService); + this._register(accessibilityProvider); this._list = this.instantiationService.createInstance( NotebookCellList, @@ -947,21 +943,7 @@ export class NotebookEditorWidget extends Disposable implements INotebookEditorD listInactiveFocusBackground: notebookEditorBackground, listInactiveFocusOutline: notebookEditorBackground, }, - accessibilityProvider: { - getAriaLabel: (element: CellViewModel) => { - if (!this.viewModel) { - return ''; - } - const index = this.viewModel.getCellIndex(element); - - if (index >= 0) { - return `Cell ${index}, ${element.cellKind === CellKind.Markup ? 'markdown' : 'code'} cell`; - } - - return ''; - }, - getWidgetAriaLabel: createNotebookAriaLabel - }, + accessibilityProvider }, ); this._dndController.setList(this._list); @@ -1009,6 +991,7 @@ export class NotebookEditorWidget extends Disposable implements INotebookEditorD this._register(this._list.onDidChangeFocus(_e => { this._onDidChangeActiveEditor.fire(this); this._onDidChangeActiveCell.fire(); + this._onDidChangeFocus.fire(); this._cursorNavMode.set(false); })); @@ -1021,9 +1004,8 @@ export class NotebookEditorWidget extends Disposable implements INotebookEditorD })); this._register(this._list.onDidScroll((e) => { - this._onDidScroll.fire(); - if (e.scrollTop !== e.oldScrollTop) { + this._onDidScroll.fire(); this.clearActiveCellWidgets(); } })); @@ -1045,7 +1027,7 @@ export class NotebookEditorWidget extends Disposable implements INotebookEditorD this._register(this.configurationService.onDidChangeConfiguration(e => { if (e.affectsConfiguration(AccessibilityVerbositySettingId.Notebook)) { - this._list.ariaLabel = createNotebookAriaLabel(); + this._list.ariaLabel = accessibilityProvider?.getWidgetAriaLabel(); } })); } @@ -1858,6 +1840,7 @@ export class NotebookEditorWidget extends Disposable implements INotebookEditorD this._list.updateOptions({ paddingBottom: this._allowScrollBeyondLastLine() ? Math.max(0, (newCellListHeight - 50)) : 0, paddingTop: 0 }); } + this._overlayContainer.inert = false; this._overlayContainer.style.visibility = 'visible'; this._overlayContainer.style.display = 'block'; this._overlayContainer.style.position = 'absolute'; @@ -1968,17 +1951,26 @@ export class NotebookEditorWidget extends Disposable implements INotebookEditorD } } - focusContainer() { + focusContainer(clearSelection: boolean = false) { if (this._webviewFocused) { this._webview?.focusWebview(); } else { - this._list.focusContainer(); + this._list.focusContainer(clearSelection); } } + selectOutputContent(cell: ICellViewModel) { + this._webview?.selectOutputContents(cell); + } + + selectInputContents(cell: ICellViewModel) { + this._webview?.selectInputContents(cell); + } + onWillHide() { this._isVisible = false; this._editorFocus.set(false); + this._overlayContainer.inert = true; this._overlayContainer.style.visibility = 'hidden'; this._overlayContainer.style.left = '-50000px'; this._notebookTopToolbarContainer.style.display = 'none'; @@ -2086,6 +2078,10 @@ export class NotebookEditorWidget extends Disposable implements INotebookEditorD return this._list.getCellViewScrollTop(cell); } + getHeightOfElement(cell: ICellViewModel) { + return this._list.elementHeight(cell); + } + scrollToBottom() { this._list.scrollToBottom(); } @@ -2114,8 +2110,8 @@ export class NotebookEditorWidget extends Disposable implements INotebookEditorD await this._list.revealCell(cell, CellRevealType.CenterIfOutsideViewport); } - revealFirstLineIfOutsideViewport(cell: ICellViewModel) { - this._list.revealCell(cell, CellRevealType.FirstLineIfOutsideViewport); + async revealFirstLineIfOutsideViewport(cell: ICellViewModel) { + await this._list.revealCell(cell, CellRevealType.FirstLineIfOutsideViewport); } async revealLineInViewAsync(cell: ICellViewModel, line: number): Promise { @@ -2146,6 +2142,10 @@ export class NotebookEditorWidget extends Disposable implements INotebookEditorD return this._list.revealCellOffsetInCenter(cell, offset); } + revealOffsetInCenterIfOutsideViewport(offset: number) { + return this._list.revealOffsetInCenterIfOutsideViewport(offset); + } + getViewIndexByModelIndex(index: number): number { if (!this._listViewInfoAccessor) { return -1; @@ -2409,12 +2409,14 @@ export class NotebookEditorWidget extends Disposable implements INotebookEditorD return; } - const focusElementId = options?.outputId ?? cell.id; + const firstOutputId = cell.outputsViewModels.find(o => o.model.alternativeOutputId)?.model.alternativeOutputId; + const focusElementId = options?.outputId ?? firstOutputId ?? cell.id; this._webview.focusOutput(focusElementId, options?.altOutputId, options?.outputWebviewFocused || this._webviewFocused); cell.updateEditState(CellEditState.Preview, 'focusNotebookCell'); cell.focusMode = CellFocusMode.Output; cell.focusedOutputId = options?.outputId; + this._outputFocus.set(true); if (!options?.skipReveal) { this.revealInCenterIfOutsideViewport(cell); } @@ -2425,6 +2427,8 @@ export class NotebookEditorWidget extends Disposable implements INotebookEditorD (itemDOM.ownerDocument.activeElement as HTMLElement).blur(); } + this._webview?.blurOutput(); + cell.updateEditState(CellEditState.Preview, 'focusNotebookCell'); cell.focusMode = CellFocusMode.Container; @@ -2434,7 +2438,7 @@ export class NotebookEditorWidget extends Disposable implements INotebookEditorD this._cursorNavMode.set(true); await this.revealInView(cell); } else if (options?.revealBehavior === ScrollToRevealBehavior.firstLine) { - this.revealFirstLineIfOutsideViewport(cell); + await this.revealFirstLineIfOutsideViewport(cell); } else if (options?.revealBehavior === ScrollToRevealBehavior.fullCell) { await this.revealInView(cell); } else { diff --git a/src/vs/workbench/contrib/notebook/browser/notebookOptions.ts b/src/vs/workbench/contrib/notebook/browser/notebookOptions.ts index c4356bd6ed013..6679cb3356390 100644 --- a/src/vs/workbench/contrib/notebook/browser/notebookOptions.ts +++ b/src/vs/workbench/contrib/notebook/browser/notebookOptions.ts @@ -7,6 +7,7 @@ import { PixelRatio } from 'vs/base/browser/pixelRatio'; import { CodeWindow } from 'vs/base/browser/window'; import { Emitter } from 'vs/base/common/event'; import { Disposable } from 'vs/base/common/lifecycle'; +import { isObject } from 'vs/base/common/types'; import { URI } from 'vs/base/common/uri'; import { FontMeasurements } from 'vs/editor/browser/config/fontMeasurements'; import { ICodeEditorService } from 'vs/editor/browser/services/codeEditorService'; @@ -20,7 +21,7 @@ const SCROLLABLE_ELEMENT_PADDING_TOP = 18; export const OutputInnerContainerTopPadding = 4; -export interface NotebookDisplayOptions { +export interface NotebookDisplayOptions { // TODO @Yoyokrazy rename to a more generic name, not display showCellStatusBar: ShowCellStatusBarType; cellToolbarLocation: string | { [key: string]: string }; cellToolbarInteraction: string; @@ -45,7 +46,11 @@ export interface NotebookDisplayOptions { outputFontFamily: string; outputLineHeight: number; markupFontSize: number; - editorOptionsCustomizations: any | undefined; + editorOptionsCustomizations: Partial<{ + 'editor.indentSize': 'tabSize' | number; + 'editor.tabSize': number; + 'editor.insertSpaces': boolean; + }> | undefined; } export interface NotebookLayoutConfiguration { @@ -152,7 +157,12 @@ export class NotebookOptions extends Disposable { // const { bottomToolbarGap, bottomToolbarHeight } = this._computeBottomToolbarDimensions(compactView, insertToolbarPosition, insertToolbarAlignment); const fontSize = this.configurationService.getValue('editor.fontSize'); const markupFontSize = this.configurationService.getValue(NotebookSetting.markupFontSize); - const editorOptionsCustomizations = this.configurationService.getValue(NotebookSetting.cellEditorOptionsCustomizations); + let editorOptionsCustomizations = this.configurationService.getValue>(NotebookSetting.cellEditorOptionsCustomizations) ?? {}; + editorOptionsCustomizations = isObject(editorOptionsCustomizations) ? editorOptionsCustomizations : {}; const interactiveWindowCollapseCodeCells: InteractiveWindowCollapseCodeCells = this.configurationService.getValue(NotebookSetting.interactiveWindowCollapseCodeCells); // TOOD @rebornix remove after a few iterations of deprecated setting @@ -284,28 +294,33 @@ export class NotebookOptions extends Disposable { return; } - const options = this.codeEditorService.resolveDecorationOptions(e, true); - if (options.afterContentClassName || options.beforeContentClassName) { - const cssRules = this.codeEditorService.resolveDecorationCSSRules(e); - if (cssRules !== null) { - for (let i = 0; i < cssRules.length; i++) { - // The following ways to index into the list are equivalent - if ( - ((cssRules[i] as CSSStyleRule).selectorText.endsWith('::after') || (cssRules[i] as CSSStyleRule).selectorText.endsWith('::after')) - && (cssRules[i] as CSSStyleRule).cssText.indexOf('top:') > -1 - ) { - // there is a `::before` or `::after` text decoration whose position is above or below current line - // we at least make sure that the editor top padding is at least one line - const editorOptions = this.configurationService.getValue('editor'); - updateEditorTopPadding(BareFontInfo.createFromRawSettings(editorOptions, PixelRatio.getInstance(this.targetWindow).value).lineHeight + 2); - decorationTriggeredAdjustment = true; - break; + try { + const options = this.codeEditorService.resolveDecorationOptions(e, true); + if (options.afterContentClassName || options.beforeContentClassName) { + const cssRules = this.codeEditorService.resolveDecorationCSSRules(e); + if (cssRules !== null) { + for (let i = 0; i < cssRules.length; i++) { + // The following ways to index into the list are equivalent + if ( + ((cssRules[i] as CSSStyleRule).selectorText.endsWith('::after') || (cssRules[i] as CSSStyleRule).selectorText.endsWith('::after')) + && (cssRules[i] as CSSStyleRule).cssText.indexOf('top:') > -1 + ) { + // there is a `::before` or `::after` text decoration whose position is above or below current line + // we at least make sure that the editor top padding is at least one line + const editorOptions = this.configurationService.getValue('editor'); + updateEditorTopPadding(BareFontInfo.createFromRawSettings(editorOptions, PixelRatio.getInstance(this.targetWindow).value).lineHeight + 2); + decorationTriggeredAdjustment = true; + break; + } } } } + + decorationCheckSet.add(e); + } catch (_ex) { + // do not throw and break notebook } - decorationCheckSet.add(e); }; this._register(this.codeEditorService.onDecorationTypeRegistered(onDidAddDecorationType)); this.codeEditorService.listDecorationTypes().forEach(onDidAddDecorationType); diff --git a/src/vs/workbench/contrib/notebook/browser/services/notebookEditorServiceImpl.ts b/src/vs/workbench/contrib/notebook/browser/services/notebookEditorServiceImpl.ts index fddd0c63854e1..4be07da63b7b4 100644 --- a/src/vs/workbench/contrib/notebook/browser/services/notebookEditorServiceImpl.ts +++ b/src/vs/workbench/contrib/notebook/browser/services/notebookEditorServiceImpl.ts @@ -37,7 +37,7 @@ export class NotebookEditorWidgetService implements INotebookEditorService { private readonly _borrowableEditors = new Map>(); constructor( - @IEditorGroupsService readonly editorGroupService: IEditorGroupsService, + @IEditorGroupsService private readonly editorGroupService: IEditorGroupsService, @IEditorService editorService: IEditorService, @IContextKeyService contextKeyService: IContextKeyService ) { diff --git a/src/vs/workbench/contrib/notebook/browser/services/notebookExecutionStateServiceImpl.ts b/src/vs/workbench/contrib/notebook/browser/services/notebookExecutionStateServiceImpl.ts index a5736966b8a17..52157f4ae2946 100644 --- a/src/vs/workbench/contrib/notebook/browser/services/notebookExecutionStateServiceImpl.ts +++ b/src/vs/workbench/contrib/notebook/browser/services/notebookExecutionStateServiceImpl.ts @@ -9,7 +9,7 @@ import { ResourceMap } from 'vs/base/common/map'; import { isEqual } from 'vs/base/common/resources'; import { URI } from 'vs/base/common/uri'; import { generateUuid } from 'vs/base/common/uuid'; -import { AudioCue, IAudioCueService } from 'vs/platform/audioCues/browser/audioCueService'; +import { AccessibilitySignal, IAccessibilitySignalService } from 'vs/platform/accessibilitySignal/browser/accessibilitySignalService'; import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; import { ILogService } from 'vs/platform/log/common/log'; import { NotebookTextModel } from 'vs/workbench/contrib/notebook/common/model/notebookTextModel'; @@ -38,7 +38,7 @@ export class NotebookExecutionStateService extends Disposable implements INotebo @IInstantiationService private readonly _instantiationService: IInstantiationService, @ILogService private readonly _logService: ILogService, @INotebookService private readonly _notebookService: INotebookService, - @IAudioCueService private readonly _audioCueService: IAudioCueService + @IAccessibilitySignalService private readonly _accessibilitySignalService: IAccessibilitySignalService ) { super(); } @@ -112,11 +112,11 @@ export class NotebookExecutionStateService extends Disposable implements INotebo if (lastRunSuccess !== undefined) { if (lastRunSuccess) { if (this._executions.size === 0) { - this._audioCueService.playAudioCue(AudioCue.notebookCellCompleted); + this._accessibilitySignalService.playSignal(AccessibilitySignal.notebookCellCompleted); } this._clearLastFailedCell(notebookUri); } else { - this._audioCueService.playAudioCue(AudioCue.notebookCellFailed); + this._accessibilitySignalService.playSignal(AccessibilitySignal.notebookCellFailed); this._setLastFailedCell(notebookUri, cellHandle); } } @@ -527,6 +527,7 @@ class CellExecution extends Disposable implements INotebookCellExecution { lastRunSuccess: completionData.lastRunSuccess, runStartTime: this._didPause ? null : cellModel.internalMetadata.runStartTime, runEndTime: this._didPause ? null : completionData.runEndTime, + error: completionData.error } }; this._applyExecutionEdits([edit]); diff --git a/src/vs/workbench/contrib/notebook/browser/services/notebookKernelServiceImpl.ts b/src/vs/workbench/contrib/notebook/browser/services/notebookKernelServiceImpl.ts index d86b1b7f928e6..572a833e173d2 100644 --- a/src/vs/workbench/contrib/notebook/browser/services/notebookKernelServiceImpl.ts +++ b/src/vs/workbench/contrib/notebook/browser/services/notebookKernelServiceImpl.ts @@ -16,8 +16,7 @@ import { IContextKeyService } from 'vs/platform/contextkey/common/contextkey'; import { IAction } from 'vs/base/common/actions'; import { MarshalledId } from 'vs/base/common/marshallingIds'; import { Schemas } from 'vs/base/common/network'; -import { $window } from 'vs/base/browser/window'; -import { runWhenWindowIdle } from 'vs/base/browser/dom'; +import { getActiveWindow, runWhenWindowIdle } from 'vs/base/browser/dom'; class KernelInfo { @@ -171,7 +170,7 @@ export class NotebookKernelService extends Disposable implements INotebookKernel private _persistMementos(): void { this._persistSoonHandle?.dispose(); - this._persistSoonHandle = runWhenWindowIdle($window, () => { + this._persistSoonHandle = runWhenWindowIdle(getActiveWindow(), () => { this._storageService.store(NotebookKernelService._storageNotebookBinding, JSON.stringify(this._notebookBindings), StorageScope.WORKSPACE, StorageTarget.MACHINE); }, 100); } diff --git a/src/vs/workbench/contrib/notebook/browser/view/cellPart.ts b/src/vs/workbench/contrib/notebook/browser/view/cellPart.ts index 5c5b8caf566dd..546637fae83d1 100644 --- a/src/vs/workbench/contrib/notebook/browser/view/cellPart.ts +++ b/src/vs/workbench/contrib/notebook/browser/view/cellPart.ts @@ -16,7 +16,7 @@ import { ICellExecutionStateChangedEvent } from 'vs/workbench/contrib/notebook/c */ export abstract class CellContentPart extends Disposable { protected currentCell: ICellViewModel | undefined; - protected cellDisposables = new DisposableStore(); + protected readonly cellDisposables = new DisposableStore(); constructor() { super(); @@ -133,9 +133,9 @@ function safeInvokeNoArg(func: () => T): T | null { } export class CellPartsCollection extends Disposable { - private _scheduledOverlayRendering = this._register(new MutableDisposable()); - private _scheduledOverlayUpdateState = this._register(new MutableDisposable()); - private _scheduledOverlayUpdateExecutionState = this._register(new MutableDisposable()); + private readonly _scheduledOverlayRendering = this._register(new MutableDisposable()); + private readonly _scheduledOverlayUpdateState = this._register(new MutableDisposable()); + private readonly _scheduledOverlayUpdateExecutionState = this._register(new MutableDisposable()); constructor( private readonly targetWindow: Window, diff --git a/src/vs/workbench/contrib/notebook/browser/view/cellParts/cellActionView.ts b/src/vs/workbench/contrib/notebook/browser/view/cellParts/cellActionView.ts index a50b67ca2b678..854358af69d40 100644 --- a/src/vs/workbench/contrib/notebook/browser/view/cellParts/cellActionView.ts +++ b/src/vs/workbench/contrib/notebook/browser/view/cellParts/cellActionView.ts @@ -3,16 +3,22 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { renderLabelWithIcons } from 'vs/base/browser/ui/iconLabel/iconLabels'; import * as DOM from 'vs/base/browser/dom'; -import { IMenuEntryActionViewItemOptions, MenuEntryActionViewItem, SubmenuEntryActionViewItem } from 'vs/platform/actions/browser/menuEntryActionViewItem'; +import * as types from 'vs/base/common/types'; +import { EventType as TouchEventType } from 'vs/base/browser/touch'; import { IActionViewItemProvider } from 'vs/base/browser/ui/actionbar/actionbar'; import { IActionProvider } from 'vs/base/browser/ui/dropdown/dropdown'; +import { getDefaultHoverDelegate } from 'vs/base/browser/ui/hover/hoverDelegateFactory'; +import { renderLabelWithIcons } from 'vs/base/browser/ui/iconLabel/iconLabels'; +import { IAction } from 'vs/base/common/actions'; +import { ThemeIcon } from 'vs/base/common/themables'; +import { IMenuEntryActionViewItemOptions, MenuEntryActionViewItem, SubmenuEntryActionViewItem } from 'vs/platform/actions/browser/menuEntryActionViewItem'; import { MenuItemAction, SubmenuItemAction } from 'vs/platform/actions/common/actions'; import { IContextMenuService } from 'vs/platform/contextview/browser/contextView'; import { IKeybindingService } from 'vs/platform/keybinding/common/keybinding'; import { IThemeService } from 'vs/platform/theme/common/themeService'; -import { ThemeIcon } from 'vs/base/common/themables'; +import type { IUpdatableHover } from 'vs/base/browser/ui/hover/hover'; +import { IHoverService } from 'vs/platform/hover/browser/hover'; export class CodiconActionViewItem extends MenuEntryActionViewItem { @@ -38,12 +44,13 @@ export class ActionViewWithLabel extends MenuEntryActionViewItem { if (this._actionLabel) { this._actionLabel.classList.add('notebook-label'); this._actionLabel.innerText = this._action.label; - this._actionLabel.title = this._action.tooltip.length ? this._action.tooltip : this._action.label; } } } export class UnifiedSubmenuActionView extends SubmenuEntryActionViewItem { private _actionLabel?: HTMLAnchorElement; + private _hover?: IUpdatableHover; + private _primaryAction: IAction | undefined; constructor( action: SubmenuItemAction, @@ -53,23 +60,39 @@ export class UnifiedSubmenuActionView extends SubmenuEntryActionViewItem { readonly subActionViewItemProvider: IActionViewItemProvider | undefined, @IKeybindingService _keybindingService: IKeybindingService, @IContextMenuService _contextMenuService: IContextMenuService, - @IThemeService _themeService: IThemeService + @IThemeService _themeService: IThemeService, + @IHoverService private readonly _hoverService: IHoverService ) { - super(action, options, _keybindingService, _contextMenuService, _themeService); + super(action, { ...options, hoverDelegate: options?.hoverDelegate ?? getDefaultHoverDelegate('element') }, _keybindingService, _contextMenuService, _themeService); } override render(container: HTMLElement): void { super.render(container); container.classList.add('notebook-action-view-item'); + container.classList.add('notebook-action-view-item-unified'); this._actionLabel = document.createElement('a'); container.appendChild(this._actionLabel); + + this._hover = this._register(this._hoverService.setupUpdatableHover(this.options.hoverDelegate ?? getDefaultHoverDelegate('element'), this._actionLabel, '')); + this.updateLabel(); + + for (const event of [DOM.EventType.CLICK, DOM.EventType.MOUSE_DOWN, TouchEventType.Tap]) { + this._register(DOM.addDisposableListener(container, event, e => this.onClick(e, true))); + } + } + + override onClick(event: DOM.EventLike, preserveFocus = false): void { + DOM.EventHelper.stop(event, true); + const context = types.isUndefinedOrNull(this._context) ? this.options?.useEventAsContext ? event : { preserveFocus } : this._context; + this.actionRunner.run(this._primaryAction ?? this._action, context); } protected override updateLabel() { const actions = this.subActionProvider.getActions(); if (this._actionLabel) { const primaryAction = actions[0]; + this._primaryAction = primaryAction; if (primaryAction && primaryAction instanceof MenuItemAction) { const element = this.element; @@ -88,13 +111,13 @@ export class UnifiedSubmenuActionView extends SubmenuEntryActionViewItem { if (this.renderLabel) { this._actionLabel.classList.add('notebook-label'); this._actionLabel.innerText = this._action.label; - this._actionLabel.title = primaryAction.tooltip.length ? primaryAction.tooltip : primaryAction.label; + this._hover?.update(primaryAction.tooltip.length ? primaryAction.tooltip : primaryAction.label); } } else { if (this.renderLabel) { this._actionLabel.classList.add('notebook-label'); this._actionLabel.innerText = this._action.label; - this._actionLabel.title = this._action.tooltip.length ? this._action.tooltip : this._action.label; + this._hover?.update(this._action.tooltip.length ? this._action.tooltip : this._action.label); } } } diff --git a/src/vs/workbench/contrib/notebook/browser/view/cellParts/cellComments.ts b/src/vs/workbench/contrib/notebook/browser/view/cellParts/cellComments.ts index bf0fe703109c2..86682ca341dcd 100644 --- a/src/vs/workbench/contrib/notebook/browser/view/cellParts/cellComments.ts +++ b/src/vs/workbench/contrib/notebook/browser/view/cellParts/cellComments.ts @@ -53,11 +53,11 @@ export class CellComments extends CellContentPart { const info = await this._getCommentThreadForCell(element); if (info) { - this._createCommentTheadWidget(info.owner, info.thread); + await this._createCommentTheadWidget(info.owner, info.thread); } } - private _createCommentTheadWidget(owner: string, commentThread: languages.CommentThread) { + private async _createCommentTheadWidget(owner: string, commentThread: languages.CommentThread) { this._commentThreadWidget?.dispose(); this.commentTheadDisposables.clear(); this._commentThreadWidget = this.instantiationService.createInstance( @@ -84,7 +84,7 @@ export class CellComments extends CellContentPart { const layoutInfo = this.notebookEditor.getLayoutInfo(); - this._commentThreadWidget.display(layoutInfo.fontInfo.lineHeight); + await this._commentThreadWidget.display(layoutInfo.fontInfo.lineHeight); this._applyTheme(); this.commentTheadDisposables.add(this._commentThreadWidget.onDidResize(() => { @@ -99,7 +99,7 @@ export class CellComments extends CellContentPart { if (this.currentElement) { const info = await this._getCommentThreadForCell(this.currentElement); if (!this._commentThreadWidget && info) { - this._createCommentTheadWidget(info.owner, info.thread); + await this._createCommentTheadWidget(info.owner, info.thread); const layoutInfo = (this.currentElement as CodeCellViewModel).layoutInfo; this.container.style.top = `${layoutInfo.outputContainerOffset + layoutInfo.outputTotalHeight}px`; this.currentElement.commentHeight = this._calculateCommentThreadHeight(this._commentThreadWidget!.getDimensions().height); @@ -117,7 +117,7 @@ export class CellComments extends CellContentPart { return; } - this._commentThreadWidget.updateCommentThread(info.thread); + await this._commentThreadWidget.updateCommentThread(info.thread); this.currentElement.commentHeight = this._calculateCommentThreadHeight(this._commentThreadWidget.getDimensions().height); } } @@ -141,7 +141,7 @@ export class CellComments extends CellContentPart { if (this.notebookEditor.hasModel()) { const commentInfos = coalesce(await this.commentService.getNotebookComments(element.uri)); if (commentInfos.length && commentInfos[0].threads.length) { - return { owner: commentInfos[0].owner, thread: commentInfos[0].threads[0] }; + return { owner: commentInfos[0].uniqueOwner, thread: commentInfos[0].threads[0] }; } } diff --git a/src/vs/workbench/contrib/notebook/browser/view/cellParts/cellContextKeys.ts b/src/vs/workbench/contrib/notebook/browser/view/cellParts/cellContextKeys.ts index eb2c45f2f8ac8..fc8032f7853d2 100644 --- a/src/vs/workbench/contrib/notebook/browser/view/cellParts/cellContextKeys.ts +++ b/src/vs/workbench/contrib/notebook/browser/view/cellParts/cellContextKeys.ts @@ -6,13 +6,14 @@ import { Disposable, DisposableStore } from 'vs/base/common/lifecycle'; import { IContextKey, IContextKeyService } from 'vs/platform/contextkey/common/contextkey'; import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; +import { NotebookChatController } from 'vs/workbench/contrib/notebook/browser/controller/chat/notebookChatController'; import { CellEditState, CellFocusMode, ICellViewModel, INotebookEditorDelegate } from 'vs/workbench/contrib/notebook/browser/notebookBrowser'; import { CellViewModelStateChangeEvent } from 'vs/workbench/contrib/notebook/browser/notebookViewEvents'; import { CellContentPart } from 'vs/workbench/contrib/notebook/browser/view/cellPart'; import { CodeCellViewModel } from 'vs/workbench/contrib/notebook/browser/viewModel/codeCellViewModel'; import { MarkupCellViewModel } from 'vs/workbench/contrib/notebook/browser/viewModel/markupCellViewModel'; import { NotebookCellExecutionState } from 'vs/workbench/contrib/notebook/common/notebookCommon'; -import { NotebookCellExecutionStateContext, NOTEBOOK_CELL_EDITABLE, NOTEBOOK_CELL_EDITOR_FOCUSED, NOTEBOOK_CELL_EXECUTING, NOTEBOOK_CELL_EXECUTION_STATE, NOTEBOOK_CELL_FOCUSED, NOTEBOOK_CELL_HAS_OUTPUTS, NOTEBOOK_CELL_INPUT_COLLAPSED, NOTEBOOK_CELL_LINE_NUMBERS, NOTEBOOK_CELL_MARKDOWN_EDIT_MODE, NOTEBOOK_CELL_OUTPUT_COLLAPSED, NOTEBOOK_CELL_RESOURCE, NOTEBOOK_CELL_TYPE } from 'vs/workbench/contrib/notebook/common/notebookContextKeys'; +import { NotebookCellExecutionStateContext, NOTEBOOK_CELL_EDITABLE, NOTEBOOK_CELL_EDITOR_FOCUSED, NOTEBOOK_CELL_EXECUTING, NOTEBOOK_CELL_EXECUTION_STATE, NOTEBOOK_CELL_FOCUSED, NOTEBOOK_CELL_HAS_OUTPUTS, NOTEBOOK_CELL_INPUT_COLLAPSED, NOTEBOOK_CELL_LINE_NUMBERS, NOTEBOOK_CELL_MARKDOWN_EDIT_MODE, NOTEBOOK_CELL_OUTPUT_COLLAPSED, NOTEBOOK_CELL_RESOURCE, NOTEBOOK_CELL_TYPE, NOTEBOOK_CELL_GENERATED_BY_CHAT, NOTEBOOK_CELL_HAS_ERROR_DIAGNOSTICS } from 'vs/workbench/contrib/notebook/common/notebookContextKeys'; import { INotebookExecutionStateService, NotebookExecutionType } from 'vs/workbench/contrib/notebook/common/notebookExecutionStateService'; export class CellContextKeyPart extends CellContentPart { @@ -45,6 +46,8 @@ export class CellContextKeyManager extends Disposable { private cellOutputCollapsed!: IContextKey; private cellLineNumbers!: IContextKey<'on' | 'off' | 'inherit'>; private cellResource!: IContextKey; + private cellGeneratedByChat!: IContextKey; + private cellHasErrorDiagnostics!: IContextKey; private markdownEditMode!: IContextKey; @@ -70,7 +73,9 @@ export class CellContextKeyManager extends Disposable { this.cellContentCollapsed = NOTEBOOK_CELL_INPUT_COLLAPSED.bindTo(this._contextKeyService); this.cellOutputCollapsed = NOTEBOOK_CELL_OUTPUT_COLLAPSED.bindTo(this._contextKeyService); this.cellLineNumbers = NOTEBOOK_CELL_LINE_NUMBERS.bindTo(this._contextKeyService); + this.cellGeneratedByChat = NOTEBOOK_CELL_GENERATED_BY_CHAT.bindTo(this._contextKeyService); this.cellResource = NOTEBOOK_CELL_RESOURCE.bindTo(this._contextKeyService); + this.cellHasErrorDiagnostics = NOTEBOOK_CELL_HAS_ERROR_DIAGNOSTICS.bindTo(this._contextKeyService); if (element) { this.updateForElement(element); @@ -96,6 +101,7 @@ export class CellContextKeyManager extends Disposable { if (element instanceof CodeCellViewModel) { this.elementDisposables.add(element.onDidChangeOutputs(() => this.updateForOutputs())); + this.elementDisposables.add(element.cellDiagnostics.onDidDiagnosticsChange(() => this.updateForDiagnostics())); } this.elementDisposables.add(this.notebookEditor.onDidChangeActiveCell(() => this.updateForFocusState())); @@ -112,10 +118,22 @@ export class CellContextKeyManager extends Disposable { this.updateForEditState(); this.updateForCollapseState(); this.updateForOutputs(); + this.updateForChat(); + this.updateForDiagnostics(); this.cellLineNumbers.set(this.element!.lineNumbers); this.cellResource.set(this.element!.uri.toString()); }); + + const chatController = NotebookChatController.get(this.notebookEditor); + + if (chatController) { + this.elementDisposables.add(chatController.onDidChangePromptCache(e => { + if (e.cell.toString() === this.element!.uri.toString()) { + this.updateForChat(); + } + })); + } } private onDidChangeState(e: CellViewModelStateChangeEvent) { @@ -216,4 +234,21 @@ export class CellContextKeyManager extends Disposable { this.cellHasOutputs.set(false); } } + + private updateForChat() { + const chatController = NotebookChatController.get(this.notebookEditor); + + if (!chatController || !this.element) { + this.cellGeneratedByChat.set(false); + return; + } + + this.cellGeneratedByChat.set(chatController.isCellGeneratedByChat(this.element)); + } + + private updateForDiagnostics() { + if (this.element instanceof CodeCellViewModel) { + this.cellHasErrorDiagnostics.set(!!this.element.cellDiagnostics.ErrorDetails); + } + } } diff --git a/src/vs/workbench/contrib/notebook/browser/view/cellParts/cellEditorOptions.ts b/src/vs/workbench/contrib/notebook/browser/view/cellParts/cellEditorOptions.ts index 0804f29c3acec..345a7a0e4921e 100644 --- a/src/vs/workbench/contrib/notebook/browser/view/cellParts/cellEditorOptions.ts +++ b/src/vs/workbench/contrib/notebook/browser/view/cellParts/cellEditorOptions.ts @@ -21,14 +21,56 @@ import { CellContentPart } from 'vs/workbench/contrib/notebook/browser/view/cell import { NotebookCellInternalMetadata, NOTEBOOK_EDITOR_ID } from 'vs/workbench/contrib/notebook/common/notebookCommon'; import { NotebookOptions } from 'vs/workbench/contrib/notebook/browser/notebookOptions'; import { CellViewModelStateChangeEvent } from 'vs/workbench/contrib/notebook/browser/notebookViewEvents'; +import { ITextModelUpdateOptions } from 'vs/editor/common/model'; -export class CellEditorOptions extends CellContentPart { +//todo@Yoyokrazy implenets is needed or not? +export class CellEditorOptions extends CellContentPart implements ITextModelUpdateOptions { private _lineNumbers: 'on' | 'off' | 'inherit' = 'inherit'; + private _tabSize?: number; + private _indentSize?: number | 'tabSize'; + private _insertSpaces?: boolean; + + set tabSize(value: number | undefined) { + if (this._tabSize !== value) { + this._tabSize = value; + this._onDidChange.fire(); + } + } + + get tabSize() { + return this._tabSize; + } + + set indentSize(value: number | 'tabSize' | undefined) { + if (this._indentSize !== value) { + this._indentSize = value; + this._onDidChange.fire(); + } + } + + get indentSize() { + return this._indentSize; + } + + set insertSpaces(value: boolean | undefined) { + if (this._insertSpaces !== value) { + this._insertSpaces = value; + this._onDidChange.fire(); + } + } + + get insertSpaces() { + return this._insertSpaces; + } + private readonly _onDidChange = this._register(new Emitter()); readonly onDidChange: Event = this._onDidChange.event; private _value: IEditorOptions; - constructor(private readonly base: IBaseCellEditorOptions, readonly notebookOptions: NotebookOptions, readonly configurationService: IConfigurationService) { + constructor( + private readonly base: IBaseCellEditorOptions, + readonly notebookOptions: NotebookOptions, + readonly configurationService: IConfigurationService) { super(); this._register(base.onDidChange(() => { @@ -50,7 +92,23 @@ export class CellEditorOptions extends CellContentPart { } private _computeEditorOptions() { - const value = this.base.value; + const value = this.base.value; // base IEditorOptions + + // TODO @Yoyokrazy find a different way to get the editor overrides, this is not the right way + const cellEditorOverridesRaw = this.notebookOptions.getDisplayOptions().editorOptionsCustomizations; + const indentSize = cellEditorOverridesRaw?.['editor.indentSize']; + if (indentSize !== undefined) { + this.indentSize = indentSize; + } + const insertSpaces = cellEditorOverridesRaw?.['editor.insertSpaces']; + if (insertSpaces !== undefined) { + this.insertSpaces = insertSpaces; + } + const tabSize = cellEditorOverridesRaw?.['editor.tabSize']; + if (tabSize !== undefined) { + this.tabSize = tabSize; + } + let cellRenderLineNumber = value.lineNumbers; switch (this._lineNumbers) { diff --git a/src/vs/workbench/contrib/notebook/browser/view/cellParts/cellExecution.ts b/src/vs/workbench/contrib/notebook/browser/view/cellParts/cellExecution.ts index e62b28ee4dc78..d5c27d0100104 100644 --- a/src/vs/workbench/contrib/notebook/browser/view/cellParts/cellExecution.ts +++ b/src/vs/workbench/contrib/notebook/browser/view/cellParts/cellExecution.ts @@ -15,7 +15,7 @@ import { INotebookExecutionStateService } from 'vs/workbench/contrib/notebook/co const UPDATE_EXECUTION_ORDER_GRACE_PERIOD = 200; export class CellExecutionPart extends CellContentPart { - private kernelDisposables = this._register(new DisposableStore()); + private readonly kernelDisposables = this._register(new DisposableStore()); constructor( private readonly _notebookEditor: INotebookEditorDelegate, diff --git a/src/vs/workbench/contrib/notebook/browser/view/cellParts/cellStatusPart.ts b/src/vs/workbench/contrib/notebook/browser/view/cellParts/cellStatusPart.ts index 52e87ad808606..98b202b5b1c4d 100644 --- a/src/vs/workbench/contrib/notebook/browser/view/cellParts/cellStatusPart.ts +++ b/src/vs/workbench/contrib/notebook/browser/view/cellParts/cellStatusPart.ts @@ -27,11 +27,11 @@ import { CellContentPart } from 'vs/workbench/contrib/notebook/browser/view/cell import { ClickTargetType, IClickTarget } from 'vs/workbench/contrib/notebook/browser/view/cellParts/cellWidgets'; import { CodeCellViewModel } from 'vs/workbench/contrib/notebook/browser/viewModel/codeCellViewModel'; import { CellStatusbarAlignment, INotebookCellStatusBarItem } from 'vs/workbench/contrib/notebook/common/notebookCommon'; -import { ITooltipMarkdownString, setupCustomHover } from 'vs/base/browser/ui/iconLabel/iconLabelHover'; -import { IHoverDelegate, IHoverDelegateOptions } from 'vs/base/browser/ui/iconLabel/iconHoverDelegate'; +import { IHoverDelegate, IHoverDelegateOptions } from 'vs/base/browser/ui/hover/hoverDelegate'; import { IHoverService } from 'vs/platform/hover/browser/hover'; import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; import { HoverPosition } from 'vs/base/browser/ui/hover/hoverWidget'; +import type { IUpdatableHoverTooltipMarkdownString } from 'vs/base/browser/ui/hover/hover'; const $ = DOM.$; @@ -272,7 +272,7 @@ class CellStatusBarItem extends Disposable { } private _currentItem!: INotebookCellStatusBarItem; - private _itemDisposables = this._register(new DisposableStore()); + private readonly _itemDisposables = this._register(new DisposableStore()); constructor( private readonly _context: INotebookCellActionContext, @@ -284,6 +284,7 @@ class CellStatusBarItem extends Disposable { @ICommandService private readonly _commandService: ICommandService, @INotificationService private readonly _notificationService: INotificationService, @IThemeService private readonly _themeService: IThemeService, + @IHoverService private readonly _hoverService: IHoverService, ) { super(); @@ -294,7 +295,7 @@ class CellStatusBarItem extends Disposable { this._itemDisposables.clear(); if (!this._currentItem || this._currentItem.text !== item.text) { - new SimpleIconLabel(this.container).text = item.text.replace(/\n/g, ' '); + this._itemDisposables.add(new SimpleIconLabel(this.container)).text = item.text.replace(/\n/g, ' '); } const resolveColor = (color: ThemeColor | string) => { @@ -326,8 +327,8 @@ class CellStatusBarItem extends Disposable { this.container.setAttribute('role', role || ''); if (item.tooltip) { - const hoverContent = typeof item.tooltip === 'string' ? item.tooltip : { markdown: item.tooltip } as ITooltipMarkdownString; - this._itemDisposables.add(setupCustomHover(this._hoverDelegate, this.container, hoverContent)); + const hoverContent = typeof item.tooltip === 'string' ? item.tooltip : { markdown: item.tooltip } as IUpdatableHoverTooltipMarkdownString; + this._itemDisposables.add(this._hoverService.setupUpdatableHover(this._hoverDelegate, this.container, hoverContent)); } this.container.classList.toggle('cell-status-item-has-command', !!item.command); diff --git a/src/vs/workbench/contrib/notebook/browser/view/cellParts/cellToolbars.ts b/src/vs/workbench/contrib/notebook/browser/view/cellParts/cellToolbars.ts index 3a38688600a51..250cb9824ec01 100644 --- a/src/vs/workbench/contrib/notebook/browser/view/cellParts/cellToolbars.ts +++ b/src/vs/workbench/contrib/notebook/browser/view/cellParts/cellToolbars.ts @@ -22,6 +22,8 @@ import { CodiconActionViewItem } from 'vs/workbench/contrib/notebook/browser/vie import { CellOverlayPart } from 'vs/workbench/contrib/notebook/browser/view/cellPart'; import { registerCellToolbarStickyScroll } from 'vs/workbench/contrib/notebook/browser/view/cellParts/cellToolbarStickyScroll'; import { WorkbenchToolBar } from 'vs/platform/actions/browser/toolbar'; +import { createInstantHoverDelegate } from 'vs/base/browser/ui/hover/hoverDelegateFactory'; +import { IHoverDelegate } from 'vs/base/browser/ui/hover/hoverDelegate'; export class BetweenCellToolbar extends CellOverlayPart { private _betweenCellToolbar: ToolBar | undefined; @@ -44,12 +46,12 @@ export class BetweenCellToolbar extends CellOverlayPart { } const betweenCellToolbar = this._register(new ToolBar(this._bottomCellToolbarContainer, this.contextMenuService, { - actionViewItemProvider: action => { + actionViewItemProvider: (action, options) => { if (action instanceof MenuItemAction) { if (this._notebookEditor.notebookOptions.getDisplayOptions().insertToolbarAlignment === 'center') { - return this.instantiationService.createInstance(CodiconActionViewItem, action, undefined); + return this.instantiationService.createInstance(CodiconActionViewItem, action, { hoverDelegate: options.hoverDelegate }); } else { - return this.instantiationService.createInstance(MenuEntryActionViewItem, action, undefined); + return this.instantiationService.createInstance(MenuEntryActionViewItem, action, { hoverDelegate: options.hoverDelegate }); } } @@ -165,15 +167,16 @@ export class CellTitleToolbarPart extends CellOverlayPart { if (this._view) { return this._view; } - + const hoverDelegate = this._register(createInstantHoverDelegate()); const toolbar = this._register(this.instantiationService.createInstance(WorkbenchToolBar, this.toolbarContainer, { - actionViewItemProvider: action => { - return createActionViewItem(this.instantiationService, action); + actionViewItemProvider: (action, options) => { + return createActionViewItem(this.instantiationService, action, options); }, - renderDropdownAsChildElement: true + renderDropdownAsChildElement: true, + hoverDelegate })); - const deleteToolbar = this._register(this.instantiationService.invokeFunction(accessor => createDeleteToolbar(accessor, this.toolbarContainer, 'cell-delete-toolbar'))); + const deleteToolbar = this._register(this.instantiationService.invokeFunction(accessor => createDeleteToolbar(accessor, this.toolbarContainer, hoverDelegate, 'cell-delete-toolbar'))); if (model.deleteActions.primary.length !== 0 || model.deleteActions.secondary.length !== 0) { deleteToolbar.setActions(model.deleteActions.primary, model.deleteActions.secondary); } @@ -269,16 +272,17 @@ function getCellToolbarActions(menu: IMenu): { primary: IAction[]; secondary: IA return result; } -function createDeleteToolbar(accessor: ServicesAccessor, container: HTMLElement, elementClass?: string): ToolBar { +function createDeleteToolbar(accessor: ServicesAccessor, container: HTMLElement, hoverDelegate: IHoverDelegate, elementClass?: string): ToolBar { const contextMenuService = accessor.get(IContextMenuService); const keybindingService = accessor.get(IKeybindingService); const instantiationService = accessor.get(IInstantiationService); const toolbar = new ToolBar(container, contextMenuService, { getKeyBinding: action => keybindingService.lookupKeybinding(action.id), - actionViewItemProvider: action => { - return createActionViewItem(instantiationService, action); + actionViewItemProvider: (action, options) => { + return createActionViewItem(instantiationService, action, options); }, - renderDropdownAsChildElement: true + renderDropdownAsChildElement: true, + hoverDelegate }); if (elementClass) { diff --git a/src/vs/workbench/contrib/notebook/browser/view/cellParts/chat/cellChatController.ts b/src/vs/workbench/contrib/notebook/browser/view/cellParts/chat/cellChatController.ts deleted file mode 100644 index 829d4d0c4a5f8..0000000000000 --- a/src/vs/workbench/contrib/notebook/browser/view/cellParts/chat/cellChatController.ts +++ /dev/null @@ -1,560 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { Dimension, WindowIntervalTimer } from 'vs/base/browser/dom'; -import { CancelablePromise, Queue, createCancelablePromise, raceCancellationError } from 'vs/base/common/async'; -import { CancellationToken, CancellationTokenSource } from 'vs/base/common/cancellation'; -import { Event } from 'vs/base/common/event'; -import { MarkdownString } from 'vs/base/common/htmlContent'; -import { Disposable, DisposableStore, IDisposable } from 'vs/base/common/lifecycle'; -import { MovingAverage } from 'vs/base/common/numbers'; -import { StopWatch } from 'vs/base/common/stopwatch'; -import { assertType } from 'vs/base/common/types'; -import { generateUuid } from 'vs/base/common/uuid'; -import { IActiveCodeEditor } from 'vs/editor/browser/editorBrowser'; -import { ISingleEditOperation } from 'vs/editor/common/core/editOperation'; -import { Position } from 'vs/editor/common/core/position'; -import { Selection } from 'vs/editor/common/core/selection'; -import { TextEdit } from 'vs/editor/common/languages'; -import { ICursorStateComputer } from 'vs/editor/common/model'; -import { IEditorWorkerService } from 'vs/editor/common/services/editorWorker'; -import { localize } from 'vs/nls'; -import { MenuWorkbenchToolBar } from 'vs/platform/actions/browser/toolbar'; -import { MenuId } from 'vs/platform/actions/common/actions'; -import { ICommandService } from 'vs/platform/commands/common/commands'; -import { IContextKey, IContextKeyService, RawContextKey } from 'vs/platform/contextkey/common/contextkey'; -import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; -import { AsyncProgress } from 'vs/platform/progress/common/progress'; -import { SaveReason } from 'vs/workbench/common/editor'; -import { countWords } from 'vs/workbench/contrib/chat/common/chatWordCounter'; -import { InlineChatController } from 'vs/workbench/contrib/inlineChat/browser/inlineChatController'; -import { EmptyResponse, ErrorResponse, ReplyResponse, Session, SessionExchange, SessionPrompt } from 'vs/workbench/contrib/inlineChat/browser/inlineChatSession'; -import { IInlineChatSessionService } from 'vs/workbench/contrib/inlineChat/browser/inlineChatSessionService'; -import { ProgressingEditsOptions } from 'vs/workbench/contrib/inlineChat/browser/inlineChatStrategies'; -import { asProgressiveEdit, performAsyncTextEdit } from 'vs/workbench/contrib/inlineChat/browser/utils'; -import { InlineChatWidget } from 'vs/workbench/contrib/inlineChat/browser/inlineChatWidget'; -import { CTX_INLINE_CHAT_LAST_RESPONSE_TYPE, CTX_INLINE_CHAT_VISIBLE, EditMode, IInlineChatProgressItem, IInlineChatRequest, InlineChatResponseFeedbackKind, InlineChatResponseType } from 'vs/workbench/contrib/inlineChat/common/inlineChat'; -import { ICellViewModel, INotebookEditorDelegate } from 'vs/workbench/contrib/notebook/browser/notebookBrowser'; -import { INotebookExecutionStateService, NotebookExecutionType } from 'vs/workbench/contrib/notebook/common/notebookExecutionStateService'; -import { IInlineChatSavingService } from 'vs/workbench/contrib/inlineChat/browser/inlineChatSavingService'; - -export const CTX_NOTEBOOK_CELL_CHAT_FOCUSED = new RawContextKey('notebookCellChatFocused', false, localize('notebookCellChatFocused', "Whether the cell chat editor is focused")); -export const CTX_NOTEBOOK_CHAT_HAS_ACTIVE_REQUEST = new RawContextKey('notebookChatHasActiveRequest', false, localize('notebookChatHasActiveRequest', "Whether the cell chat editor has an active request")); -export const MENU_CELL_CHAT_INPUT = MenuId.for('cellChatInput'); -export const MENU_CELL_CHAT_WIDGET = MenuId.for('cellChatWidget'); -export const MENU_CELL_CHAT_WIDGET_STATUS = MenuId.for('cellChatWidget.status'); -export const MENU_CELL_CHAT_WIDGET_FEEDBACK = MenuId.for('cellChatWidget.feedback'); -export const MENU_CELL_CHAT_WIDGET_TOOLBAR = MenuId.for('cellChatWidget.toolbar'); - -interface ICellChatPart { - activeCell: ICellViewModel | undefined; -} - -export class NotebookCellChatController extends Disposable { - private static _cellChatControllers = new WeakMap(); - - static get(cell: ICellViewModel): NotebookCellChatController | undefined { - return NotebookCellChatController._cellChatControllers.get(cell); - } - - private _sessionCtor: CancelablePromise | undefined; - private _activeSession?: Session; - private readonly _ctxHasActiveRequest: IContextKey; - private _isVisible: boolean = false; - private _strategy: EditStrategy | undefined; - - private _inlineChatListener: IDisposable | undefined; - private _widget: InlineChatWidget | undefined; - private _toolbar: MenuWorkbenchToolBar | undefined; - private readonly _ctxVisible: IContextKey; - private readonly _ctxCellWidgetFocused: IContextKey; - private readonly _ctxLastResponseType: IContextKey; - private _widgetDisposableStore: DisposableStore = this._register(new DisposableStore()); - constructor( - private readonly _notebookEditor: INotebookEditorDelegate, - private readonly _chatPart: ICellChatPart, - private readonly _cell: ICellViewModel, - private readonly _partContainer: HTMLElement, - @IContextKeyService private readonly _contextKeyService: IContextKeyService, - @IInlineChatSessionService private readonly _inlineChatSessionService: IInlineChatSessionService, - @IEditorWorkerService private readonly _editorWorkerService: IEditorWorkerService, - @IInstantiationService private readonly _instantiationService: IInstantiationService, - @INotebookExecutionStateService private readonly _notebookExecutionStateService: INotebookExecutionStateService, - @ICommandService private readonly _commandService: ICommandService, - @IInlineChatSavingService private readonly _inlineChatSavingService: IInlineChatSavingService, - ) { - super(); - - NotebookCellChatController._cellChatControllers.set(this._cell, this); - this._ctxHasActiveRequest = CTX_NOTEBOOK_CHAT_HAS_ACTIVE_REQUEST.bindTo(this._contextKeyService); - this._ctxVisible = CTX_INLINE_CHAT_VISIBLE.bindTo(_contextKeyService); - this._ctxCellWidgetFocused = CTX_NOTEBOOK_CELL_CHAT_FOCUSED.bindTo(this._contextKeyService); - this._ctxLastResponseType = CTX_INLINE_CHAT_LAST_RESPONSE_TYPE.bindTo(this._contextKeyService); - - this._register(this._cell.onDidChangeEditorAttachState(() => { - const editor = this._getCellEditor(); - this._inlineChatListener?.dispose(); - - if (!editor) { - return; - } - - if (!this._widget && this._isVisible) { - this._initialize(editor); - } - - const inlineChatController = InlineChatController.get(editor); - if (inlineChatController) { - this._inlineChatListener = inlineChatController.onWillStartSession(() => { - this.dismiss(false); - }); - } - })); - } - - private _initialize(editor: IActiveCodeEditor) { - this._widget = this._instantiationService.createInstance(InlineChatWidget, editor, { - menuId: MENU_CELL_CHAT_INPUT, - widgetMenuId: MENU_CELL_CHAT_WIDGET, - statusMenuId: MENU_CELL_CHAT_WIDGET_STATUS, - feedbackMenuId: MENU_CELL_CHAT_WIDGET_FEEDBACK - }); - - this._widgetDisposableStore.add(this._widget.onDidChangeHeight(() => { - this._updateHeight(); - })); - - this._widgetDisposableStore.add(this._notebookExecutionStateService.onDidChangeExecution(e => { - if (e.notebook.toString() !== this._notebookEditor.textModel?.uri.toString()) { - return; - } - - if (e.type === NotebookExecutionType.cell && e.affectsCell(this._cell.uri) && e.changed === undefined /** complete */) { - // check if execution is successfull - const { lastRunSuccess } = this._cell.internalMetadata; - if (lastRunSuccess) { - this._strategy?.createSnapshot(); - } - } - })); - - - this._partContainer.appendChild(this._widget.domNode); - } - - public override dispose(): void { - if (this._isVisible) { - // detach the chat widget - this._widget?.reset(); - this._sessionCtor?.cancel(); - this._sessionCtor = undefined; - } - - try { - if (this._widget) { - this._partContainer.removeChild(this._widget.domNode); - } - - } catch (_ex) { - // might not be attached - } - - // dismiss since we can't restore the widget properly now - this.dismiss(false); - this._widget?.dispose(); - this._inlineChatListener?.dispose(); - this._toolbar?.dispose(); - this._inlineChatListener = undefined; - this._ctxHasActiveRequest.reset(); - this._ctxVisible.reset(); - NotebookCellChatController._cellChatControllers.delete(this._cell); - super.dispose(); - } - - isWidgetVisible() { - return this._isVisible; - } - - layout() { - if (this._isVisible && this._widget) { - const width = this._notebookEditor.getLayoutInfo().width - (/** margin */ 16 + 6) - (/** padding */ 6 * 2); - const height = this._widget.getHeight(); - this._widget.layout(new Dimension(width, height)); - } - } - - private _updateHeight() { - const surrounding = 6 * 2 /** padding */ + 6 /** cell chat widget margin bottom */ + 2 /** border */; - const heightWithPadding = this._isVisible && this._widget - ? (this._widget.getHeight() - 8 /** shadow */ - 18 /** padding */ - 6 /** widget's internal margin top */ + surrounding) - : 0; - - if (this._cell.chatHeight === heightWithPadding) { - return; - } - - this._cell.chatHeight = heightWithPadding; - this._partContainer.style.height = `${heightWithPadding - surrounding}px`; - } - - async show(input?: string, autoSend?: boolean) { - this._isVisible = true; - if (!this._widget) { - const editor = this._getCellEditor(); - if (editor) { - this._initialize(editor); - } - } - - this._partContainer.style.display = 'flex'; - this._widget?.focus(); - this._widget?.updateInfo(localize('welcome.1', "AI-generated code may be incorrect")); - this._ctxVisible.set(true); - this._ctxCellWidgetFocused.set(true); - this._updateHeight(); - - this._sessionCtor = createCancelablePromise(async token => { - if (this._cell.editorAttached) { - const editor = this._getCellEditor(); - if (editor) { - await this._startSession(editor, token); - } - } else { - await Event.toPromise(Event.once(this._cell.onDidChangeEditorAttachState)); - if (token.isCancellationRequested) { - return; - } - - const editor = this._getCellEditor(); - if (editor) { - await this._startSession(editor, token); - } - } - - if (this._widget) { - this._widget.placeholder = this._activeSession?.session.placeholder ?? localize('default.placeholder', "Ask a question"); - this._widget.updateInfo(this._activeSession?.session.message ?? localize('welcome.1', "AI-generated code may be incorrect")); - this._widget.focus(); - } - - if (this._widget && input) { - this._widget.value = input; - - if (autoSend) { - this.acceptInput(); - } - } - }); - } - - async focusWidget() { - this._widget?.focus(); - } - - private _getCellEditor() { - const editors = this._notebookEditor.codeEditors.find(editor => editor[0] === this._chatPart.activeCell); - if (!editors || !editors[1].hasModel()) { - return; - } - - const editor = editors[1]; - return editor; - } - - private async _startSession(editor: IActiveCodeEditor, token: CancellationToken) { - if (this._activeSession) { - this._inlineChatSessionService.releaseSession(this._activeSession); - } - - const session = await this._inlineChatSessionService.createSession( - editor, - { editMode: EditMode.LivePreview }, - token - ); - - if (!session) { - return; - } - - this._activeSession = session; - this._strategy = new EditStrategy(session); - } - - async acceptInput() { - assertType(this._activeSession); - assertType(this._widget); - this._activeSession.addInput(new SessionPrompt(this._widget.value)); - - assertType(this._activeSession.lastInput); - - const value = this._activeSession.lastInput.value; - const editors = this._notebookEditor.codeEditors.find(editor => editor[0] === this._chatPart.activeCell); - if (!editors || !editors[1].hasModel()) { - return; - } - - const editor = editors[1]; - - this._ctxHasActiveRequest.set(true); - this._widget?.updateProgress(true); - - const request: IInlineChatRequest = { - requestId: generateUuid(), - prompt: value, - attempt: 0, - selection: { selectionStartLineNumber: 1, selectionStartColumn: 1, positionLineNumber: 1, positionColumn: 1 }, - wholeRange: { startLineNumber: 1, startColumn: 1, endLineNumber: 1, endColumn: 1 }, - live: true, - previewDocument: editor.getModel().uri, - withIntentDetection: true, // TODO: don't hard code but allow in corresponding UI to run without intent detection? - }; - - const requestCts = new CancellationTokenSource(); - const progressEdits: TextEdit[][] = []; - const progressiveEditsQueue = new Queue(); - const progressiveEditsClock = StopWatch.create(); - const progressiveEditsAvgDuration = new MovingAverage(); - const progressiveEditsCts = new CancellationTokenSource(requestCts.token); - const progress = new AsyncProgress(async data => { - // console.log('received chunk', data, request); - - if (requestCts.token.isCancellationRequested) { - return; - } - - if (data.message) { - this._widget?.updateToolbar(false); - this._widget?.updateInfo(data.message); - } - - if (data.edits?.length) { - if (!request.live) { - throw new Error('Progress in NOT supported in non-live mode'); - } - progressEdits.push(data.edits); - progressiveEditsAvgDuration.update(progressiveEditsClock.elapsed()); - progressiveEditsClock.reset(); - - progressiveEditsQueue.queue(async () => { - // making changes goes into a queue because otherwise the async-progress time will - // influence the time it takes to receive the changes and progressive typing will - // become infinitely fast - await this._makeChanges(editor, data.edits!, data.editsShouldBeInstant - ? undefined - : { duration: progressiveEditsAvgDuration.value, token: progressiveEditsCts.token } - ); - }); - } - }); - - const task = this._activeSession.provider.provideResponse(this._activeSession.session, request, progress, requestCts.token); - let response: ReplyResponse | ErrorResponse | EmptyResponse; - - try { - this._widget?.updateChatMessage(undefined); - this._widget?.updateFollowUps(undefined); - this._widget?.updateProgress(true); - this._widget?.updateInfo(!this._activeSession.lastExchange ? localize('thinking', "Thinking\u2026") : ''); - this._ctxHasActiveRequest.set(true); - - const reply = await raceCancellationError(Promise.resolve(task), requestCts.token); - if (progressiveEditsQueue.size > 0) { - // we must wait for all edits that came in via progress to complete - await Event.toPromise(progressiveEditsQueue.onDrained); - } - await progress.drain(); - - if (!reply) { - response = new EmptyResponse(); - } else { - const markdownContents = new MarkdownString('', { supportThemeIcons: true, supportHtml: true, isTrusted: false }); - const replyResponse = response = this._instantiationService.createInstance(ReplyResponse, reply, markdownContents, this._activeSession.textModelN.uri, this._activeSession.textModelN.getAlternativeVersionId(), progressEdits, request.requestId); - for (let i = progressEdits.length; i < replyResponse.allLocalEdits.length; i++) { - await this._makeChanges(editor, replyResponse.allLocalEdits[i], undefined); - } - - if (this._activeSession?.provider.provideFollowups) { - const followupCts = new CancellationTokenSource(); - const followups = await this._activeSession.provider.provideFollowups(this._activeSession.session, replyResponse.raw, followupCts.token); - if (followups && this._widget) { - const widget = this._widget; - widget.updateFollowUps(followups, async followup => { - if (followup.kind === 'reply') { - widget.value = followup.message; - this.acceptInput(); - } else { - await this.acceptSession(); - this._commandService.executeCommand(followup.commandId, ...(followup.args ?? [])); - } - }); - } - } - } - } catch (e) { - response = new ErrorResponse(e); - } finally { - this._ctxHasActiveRequest.set(false); - this._widget?.updateProgress(false); - this._widget?.updateInfo(''); - this._widget?.updateToolbar(true); - } - - this._ctxHasActiveRequest.set(false); - this._widget?.updateProgress(false); - this._widget?.updateInfo(''); - this._widget?.updateToolbar(true); - - this._activeSession.addExchange(new SessionExchange(this._activeSession.lastInput, response)); - this._ctxLastResponseType.set(response instanceof ReplyResponse ? response.raw.type : undefined); - } - - async cancelCurrentRequest(discard: boolean) { - if (discard) { - this._strategy?.cancel(); - } - - if (this._activeSession) { - this._inlineChatSessionService.releaseSession(this._activeSession); - } - - this._activeSession = undefined; - } - - async acceptSession() { - assertType(this._activeSession); - assertType(this._strategy); - - const editor = this._getCellEditor(); - assertType(editor); - - try { - await this._strategy.apply(editor); - } catch (_err) { } - - this._inlineChatSessionService.releaseSession(this._activeSession); - this.dismiss(false); - } - - async dismiss(discard: boolean) { - this._isVisible = false; - this._partContainer.style.display = 'none'; - this.cancelCurrentRequest(discard); - this._ctxCellWidgetFocused.set(false); - this._ctxVisible.set(false); - this._ctxLastResponseType.reset(); - this._widget?.reset(); - this._updateHeight(); - } - - async feedbackLast(kind: InlineChatResponseFeedbackKind) { - if (this._activeSession?.lastExchange && this._activeSession.lastExchange.response instanceof ReplyResponse) { - this._activeSession.provider.handleInlineChatResponseFeedback?.(this._activeSession.session, this._activeSession.lastExchange.response.raw, kind); - this._widget?.updateStatus('Thank you for your feedback!', { resetAfter: 1250 }); - } - } - - private async _makeChanges(editor: IActiveCodeEditor, edits: TextEdit[], opts: ProgressingEditsOptions | undefined) { - assertType(this._activeSession); - assertType(this._strategy); - - const moreMinimalEdits = await this._editorWorkerService.computeMoreMinimalEdits(this._activeSession.textModelN.uri, edits); - // this._log('edits from PROVIDER and after making them MORE MINIMAL', this._activeSession.provider.debugName, edits, moreMinimalEdits); - - if (moreMinimalEdits?.length === 0) { - // nothing left to do - return; - } - - const actualEdits = !opts && moreMinimalEdits ? moreMinimalEdits : edits; - const editOperations = actualEdits.map(TextEdit.asEditOperation); - - this._inlineChatSavingService.markChanged(this._activeSession); - try { - // this._ignoreModelContentChanged = true; - this._activeSession.wholeRange.trackEdits(editOperations); - if (opts) { - await this._strategy.makeProgressiveChanges(editor, editOperations, opts); - } else { - await this._strategy.makeChanges(editor, editOperations); - } - // this._ctxDidEdit.set(this._activeSession.hasChangedText); - } finally { - // this._ignoreModelContentChanged = false; - } - } -} - -class EditStrategy { - private _editCount: number = 0; - - constructor( - protected readonly _session: Session, - ) { - - } - - async makeProgressiveChanges(editor: IActiveCodeEditor, edits: ISingleEditOperation[], opts: ProgressingEditsOptions): Promise { - // push undo stop before first edit - if (++this._editCount === 1) { - editor.pushUndoStop(); - } - - const durationInSec = opts.duration / 1000; - for (const edit of edits) { - const wordCount = countWords(edit.text ?? ''); - const speed = wordCount / durationInSec; - // console.log({ durationInSec, wordCount, speed: wordCount / durationInSec }); - await performAsyncTextEdit(editor.getModel(), asProgressiveEdit(new WindowIntervalTimer(), edit, speed, opts.token)); - } - } - - async makeChanges(editor: IActiveCodeEditor, edits: ISingleEditOperation[]): Promise { - const cursorStateComputerAndInlineDiffCollection: ICursorStateComputer = (undoEdits) => { - let last: Position | null = null; - for (const edit of undoEdits) { - last = !last || last.isBefore(edit.range.getEndPosition()) ? edit.range.getEndPosition() : last; - // this._inlineDiffDecorations.collectEditOperation(edit); - } - return last && [Selection.fromPositions(last)]; - }; - - // push undo stop before first edit - if (++this._editCount === 1) { - editor.pushUndoStop(); - } - editor.executeEdits('inline-chat-live', edits, cursorStateComputerAndInlineDiffCollection); - } - - async apply(editor: IActiveCodeEditor) { - if (this._editCount > 0) { - editor.pushUndoStop(); - } - if (!(this._session.lastExchange?.response instanceof ReplyResponse)) { - return; - } - const { untitledTextModel } = this._session.lastExchange.response; - if (untitledTextModel && !untitledTextModel.isDisposed() && untitledTextModel.isDirty()) { - await untitledTextModel.save({ reason: SaveReason.EXPLICIT }); - } - } - - async cancel() { - const { textModelN: modelN, textModelNAltVersion, textModelNSnapshotAltVersion } = this._session; - if (modelN.isDisposed()) { - return; - } - - const targetAltVersion = textModelNSnapshotAltVersion ?? textModelNAltVersion; - while (targetAltVersion < modelN.getAlternativeVersionId() && modelN.canUndo()) { - modelN.undo(); - } - } - - createSnapshot(): void { - if (this._session && !this._session.textModel0.equalsTextBuffer(this._session.textModelN.getTextBuffer())) { - this._session.createSnapshot(); - } - } -} diff --git a/src/vs/workbench/contrib/notebook/browser/view/cellParts/chat/cellChatPart.ts b/src/vs/workbench/contrib/notebook/browser/view/cellParts/chat/cellChatPart.ts index 461dc85c66419..059f5ab7b1417 100644 --- a/src/vs/workbench/contrib/notebook/browser/view/cellParts/chat/cellChatPart.ts +++ b/src/vs/workbench/contrib/notebook/browser/view/cellParts/chat/cellChatPart.ts @@ -3,54 +3,35 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; import { ICellViewModel, INotebookEditorDelegate } from 'vs/workbench/contrib/notebook/browser/notebookBrowser'; import { CellContentPart } from 'vs/workbench/contrib/notebook/browser/view/cellPart'; -import { NotebookCellChatController } from 'vs/workbench/contrib/notebook/browser/view/cellParts/chat/cellChatController'; - -import 'vs/workbench/contrib/notebook/browser/view/cellParts/chat/cellChatActions'; -import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; -import { NotebookSetting } from 'vs/workbench/contrib/notebook/common/notebookCommon'; export class CellChatPart extends CellContentPart { - private _controller: NotebookCellChatController | undefined; + // private _controller: NotebookCellChatController | undefined; get activeCell() { return this.currentCell; } constructor( - private readonly _notebookEditor: INotebookEditorDelegate, - private readonly _partContainer: HTMLElement, - @IInstantiationService private readonly _instantiationService: IInstantiationService, - @IConfigurationService private readonly _configurationService: IConfigurationService, + _notebookEditor: INotebookEditorDelegate, + _partContainer: HTMLElement, ) { super(); } override didRenderCell(element: ICellViewModel): void { - this._controller?.dispose(); - const enabled = this._configurationService.getValue(NotebookSetting.cellChat); - if (enabled) { - this._controller = this._instantiationService.createInstance(NotebookCellChatController, this._notebookEditor, this, element, this._partContainer); - } - super.didRenderCell(element); } override unrenderCell(element: ICellViewModel): void { - this._controller?.dispose(); - this._controller = undefined; super.unrenderCell(element); } override updateInternalLayoutNow(element: ICellViewModel): void { - this._controller?.layout(); } override dispose() { - this._controller?.dispose(); - this._controller = undefined; super.dispose(); } } diff --git a/src/vs/workbench/contrib/notebook/browser/view/cellParts/codeCell.ts b/src/vs/workbench/contrib/notebook/browser/view/cellParts/codeCell.ts index cdfd236913a1c..2333273262864 100644 --- a/src/vs/workbench/contrib/notebook/browser/view/cellParts/codeCell.ts +++ b/src/vs/workbench/contrib/notebook/browser/view/cellParts/codeCell.ts @@ -42,6 +42,7 @@ export class CodeCell extends Disposable { private readonly cellParts: CellPartsCollection; private _collapsedExecutionIcon: CollapsedCodeCellExecutionIcon; + private _cellEditorOptions: CellEditorOptions; constructor( private readonly notebookEditor: IActiveNotebookEditorDelegate, @@ -52,13 +53,13 @@ export class CodeCell extends Disposable { @IOpenerService openerService: IOpenerService, @ILanguageService private readonly languageService: ILanguageService, @IConfigurationService private configurationService: IConfigurationService, - @INotebookExecutionStateService notebookExecutionStateService: INotebookExecutionStateService + @INotebookExecutionStateService notebookExecutionStateService: INotebookExecutionStateService, ) { super(); - const cellEditorOptions = this._register(new CellEditorOptions(this.notebookEditor.getBaseCellEditorOptions(viewCell.language), this.notebookEditor.notebookOptions, this.configurationService)); + this._cellEditorOptions = this._register(new CellEditorOptions(this.notebookEditor.getBaseCellEditorOptions(viewCell.language), this.notebookEditor.notebookOptions, this.configurationService)); this._outputContainerRenderer = this.instantiationService.createInstance(CellOutputContainer, notebookEditor, viewCell, templateData, { limit: outputDisplayLimit }); - this.cellParts = this._register(templateData.cellParts.concatContentPart([cellEditorOptions, this._outputContainerRenderer], DOM.getWindow(notebookEditor.getDomNode()))); + this.cellParts = this._register(templateData.cellParts.concatContentPart([this._cellEditorOptions, this._outputContainerRenderer], DOM.getWindow(notebookEditor.getDomNode()))); // this.viewCell.layoutInfo.editorHeight or estimation when this.viewCell.layoutInfo.editorHeight === 0 const editorHeight = this.calculateInitEditorHeight(); @@ -135,9 +136,28 @@ export class CodeCell extends Disposable { this._register(Event.runAndSubscribe(viewCell.onDidChangeOutputs, this.updateForOutputs.bind(this))); this._register(Event.runAndSubscribe(viewCell.onDidChangeLayout, this.updateForLayout.bind(this))); - cellEditorOptions.setLineNumbers(this.viewCell.lineNumbers); - this._register(cellEditorOptions.onDidChange(() => templateData.editor.updateOptions(cellEditorOptions.getUpdatedValue(this.viewCell.internalMetadata, this.viewCell.uri)))); - templateData.editor.updateOptions(cellEditorOptions.getUpdatedValue(this.viewCell.internalMetadata, this.viewCell.uri)); + this._cellEditorOptions.setLineNumbers(this.viewCell.lineNumbers); + templateData.editor.updateOptions(this._cellEditorOptions.getUpdatedValue(this.viewCell.internalMetadata, this.viewCell.uri)); + } + + private updateCodeCellOptions(templateData: CodeCellRenderTemplate) { + templateData.editor.updateOptions(this._cellEditorOptions.getUpdatedValue(this.viewCell.internalMetadata, this.viewCell.uri)); + + const cts = new CancellationTokenSource(); + this._register({ dispose() { cts.dispose(true); } }); + raceCancellation(this.viewCell.resolveTextModel(), cts.token).then(model => { + if (this._isDisposed) { + return; + } + + if (model) { + model.updateOptions({ + indentSize: this._cellEditorOptions.indentSize, + tabSize: this._cellEditorOptions.tabSize, + insertSpaces: this._cellEditorOptions.insertSpaces, + }); + } + }); } private _pendingLayout: IDisposable | undefined; @@ -186,6 +206,11 @@ export class CodeCell extends Disposable { if (model && this.templateData.editor) { this._reigsterModelListeners(model); this.templateData.editor.setModel(model); + model.updateOptions({ + indentSize: this._cellEditorOptions.indentSize, + tabSize: this._cellEditorOptions.tabSize, + insertSpaces: this._cellEditorOptions.insertSpaces, + }); this.viewCell.attachTextEditor(this.templateData.editor, this.viewCell.layoutInfo.estimatedHasHorizontalScrolling); const focusEditorIfNeeded = () => { if ( @@ -205,6 +230,8 @@ export class CodeCell extends Disposable { focusEditorIfNeeded(); } + + this._register(this._cellEditorOptions.onDidChange(() => this.updateCodeCellOptions(this.templateData))); }); } diff --git a/src/vs/workbench/contrib/notebook/browser/view/cellParts/codeCellRunToolbar.ts b/src/vs/workbench/contrib/notebook/browser/view/cellParts/codeCellRunToolbar.ts index 48974ad10a5bc..de2c0e912bf84 100644 --- a/src/vs/workbench/contrib/notebook/browser/view/cellParts/codeCellRunToolbar.ts +++ b/src/vs/workbench/contrib/notebook/browser/view/cellParts/codeCellRunToolbar.ts @@ -84,7 +84,7 @@ export class RunToolbar extends CellContentPart { const executionContextKeyService = this._register(getCodeCellExecutionContextKeyService(contextKeyService)); this.toolbar = this._register(new ToolBar(container, this.contextMenuService, { getKeyBinding: keybindingProvider, - actionViewItemProvider: _action => { + actionViewItemProvider: (_action, _options) => { actionViewItemDisposables.clear(); const primary = this.getCellToolbarActions(this.primaryMenu).primary[0]; @@ -104,6 +104,7 @@ export class RunToolbar extends CellContentPart { 'notebook-cell-run-toolbar', this.contextMenuService, { + ..._options, getKeyBinding: keybindingProvider }); actionViewItemDisposables.add(item.onDidChangeDropdownVisibility(visible => { diff --git a/src/vs/workbench/contrib/notebook/browser/view/cellParts/foldedCellHint.ts b/src/vs/workbench/contrib/notebook/browser/view/cellParts/foldedCellHint.ts index 2fe72e05af8b7..211e85e9a62e5 100644 --- a/src/vs/workbench/contrib/notebook/browser/view/cellParts/foldedCellHint.ts +++ b/src/vs/workbench/contrib/notebook/browser/view/cellParts/foldedCellHint.ts @@ -11,12 +11,21 @@ import { FoldingController } from 'vs/workbench/contrib/notebook/browser/control import { CellEditState, CellFoldingState, INotebookEditor } from 'vs/workbench/contrib/notebook/browser/notebookBrowser'; import { CellContentPart } from 'vs/workbench/contrib/notebook/browser/view/cellPart'; import { MarkupCellViewModel } from 'vs/workbench/contrib/notebook/browser/viewModel/markupCellViewModel'; +import { ICellRange } from 'vs/workbench/contrib/notebook/common/notebookRange'; +import { executingStateIcon } from 'vs/workbench/contrib/notebook/browser/notebookIcons'; +import { INotebookExecutionStateService } from 'vs/workbench/contrib/notebook/common/notebookExecutionStateService'; +import { NotebookCellExecutionState } from 'vs/workbench/contrib/notebook/common/notebookCommon'; +import { MutableDisposable } from 'vs/base/common/lifecycle'; export class FoldedCellHint extends CellContentPart { + private readonly _runButtonListener = this._register(new MutableDisposable()); + private readonly _cellExecutionListener = this._register(new MutableDisposable()); + constructor( private readonly _notebookEditor: INotebookEditor, private readonly _container: HTMLElement, + @INotebookExecutionStateService private readonly _notebookExecutionStateService: INotebookExecutionStateService ) { super(); } @@ -27,20 +36,27 @@ export class FoldedCellHint extends CellContentPart { private update(element: MarkupCellViewModel) { if (!this._notebookEditor.hasModel()) { + this._cellExecutionListener.clear(); + this._runButtonListener.clear(); return; } if (element.isInputCollapsed || element.getEditState() === CellEditState.Editing) { + this._cellExecutionListener.clear(); + this._runButtonListener.clear(); DOM.hide(this._container); } else if (element.foldingState === CellFoldingState.Collapsed) { const idx = this._notebookEditor.getViewModel().getCellIndex(element); const length = this._notebookEditor.getViewModel().getFoldedLength(idx); - DOM.reset(this._container, this.getHiddenCellsLabel(length), this.getHiddenCellHintButton(element)); + + DOM.reset(this._container, this.getRunFoldedSectionButton({ start: idx, end: idx + length }), this.getHiddenCellsLabel(length), this.getHiddenCellHintButton(element)); DOM.show(this._container); const foldHintTop = element.layoutInfo.previewHeight; this._container.style.top = `${foldHintTop}px`; } else { + this._cellExecutionListener.clear(); + this._runButtonListener.clear(); DOM.hide(this._container); } } @@ -67,6 +83,40 @@ export class FoldedCellHint extends CellContentPart { return expandIcon; } + private getRunFoldedSectionButton(range: ICellRange): HTMLElement { + const runAllContainer = DOM.$('span.folded-cell-run-section-button'); + const cells = this._notebookEditor.getCellsInRange(range); + + const isRunning = cells.some(cell => { + const cellExecution = this._notebookExecutionStateService.getCellExecution(cell.uri); + return cellExecution && cellExecution.state === NotebookCellExecutionState.Executing; + }); + + const runAllIcon = isRunning ? + ThemeIcon.modify(executingStateIcon, 'spin') : + Codicon.play; + runAllContainer.classList.add(...ThemeIcon.asClassNameArray(runAllIcon)); + + this._runButtonListener.value = DOM.addDisposableListener(runAllContainer, DOM.EventType.CLICK, () => { + this._notebookEditor.executeNotebookCells(cells); + }); + + this._cellExecutionListener.value = this._notebookExecutionStateService.onDidChangeExecution(() => { + const isRunning = cells.some(cell => { + const cellExecution = this._notebookExecutionStateService.getCellExecution(cell.uri); + return cellExecution && cellExecution.state === NotebookCellExecutionState.Executing; + }); + + const runAllIcon = isRunning ? + ThemeIcon.modify(executingStateIcon, 'spin') : + Codicon.play; + runAllContainer.className = ''; + runAllContainer.classList.add('folded-cell-run-section-button', ...ThemeIcon.asClassNameArray(runAllIcon)); + }); + + return runAllContainer; + } + override updateInternalLayoutNow(element: MarkupCellViewModel) { this.update(element); } diff --git a/src/vs/workbench/contrib/notebook/browser/view/cellParts/markupCell.ts b/src/vs/workbench/contrib/notebook/browser/view/cellParts/markupCell.ts index 7636dbf004c93..d334bb1db3f20 100644 --- a/src/vs/workbench/contrib/notebook/browser/view/cellParts/markupCell.ts +++ b/src/vs/workbench/contrib/notebook/browser/view/cellParts/markupCell.ts @@ -11,7 +11,7 @@ import { Codicon } from 'vs/base/common/codicons'; import { ThemeIcon } from 'vs/base/common/themables'; import { Disposable, DisposableStore, MutableDisposable, toDisposable } from 'vs/base/common/lifecycle'; import { ICodeEditor } from 'vs/editor/browser/editorBrowser'; -import { CodeEditorWidget } from 'vs/editor/browser/widget/codeEditorWidget'; +import { CodeEditorWidget } from 'vs/editor/browser/widget/codeEditor/codeEditorWidget'; import { IEditorOptions } from 'vs/editor/common/config/editorOptions'; import { EditorContextKeys } from 'vs/editor/common/editorContextKeys'; import { ILanguageService } from 'vs/editor/common/languages/language'; @@ -44,6 +44,7 @@ export class MarkupCell extends Disposable { private foldingState: CellFoldingState; private cellEditorOptions: CellEditorOptions; private editorOptions: IEditorOptions; + private _isDisposed: boolean = false; constructor( private readonly notebookEditor: IActiveNotebookEditorDelegate, @@ -174,9 +175,31 @@ export class MarkupCell extends Disposable { } })); - this._register(this.cellEditorOptions.onDidChange(() => { - this.updateEditorOptions(this.cellEditorOptions.getUpdatedValue(this.viewCell.internalMetadata, this.viewCell.uri)); - })); + this._register(this.cellEditorOptions.onDidChange(() => this.updateMarkupCellOptions())); + } + + private updateMarkupCellOptions(): any { + this.updateEditorOptions(this.cellEditorOptions.getUpdatedValue(this.viewCell.internalMetadata, this.viewCell.uri)); + + if (this.editor) { + this.editor.updateOptions(this.cellEditorOptions.getUpdatedValue(this.viewCell.internalMetadata, this.viewCell.uri)); + + const cts = new CancellationTokenSource(); + this._register({ dispose() { cts.dispose(true); } }); + raceCancellation(this.viewCell.resolveTextModel(), cts.token).then(model => { + if (this._isDisposed) { + return; + } + + if (model) { + model.updateOptions({ + indentSize: this.cellEditorOptions.indentSize, + tabSize: this.cellEditorOptions.tabSize, + insertSpaces: this.cellEditorOptions.insertSpaces, + }); + } + }); + } } private updateCollapsedState() { @@ -223,6 +246,8 @@ export class MarkupCell extends Disposable { } override dispose() { + this._isDisposed = true; + // move focus back to the cell list otherwise the focus goes to body if (this.notebookEditor.getActiveCell() === this.viewCell && this.viewCell.focusMode === CellFocusMode.Editor && (this.notebookEditor.hasEditorFocus() || this.notebookEditor.getDomNode().ownerDocument.activeElement === this.notebookEditor.getDomNode().ownerDocument.body)) { this.notebookEditor.focusContainer(); @@ -354,6 +379,11 @@ export class MarkupCell extends Disposable { } this.editor!.setModel(model); + model.updateOptions({ + indentSize: this.cellEditorOptions.indentSize, + tabSize: this.cellEditorOptions.tabSize, + insertSpaces: this.cellEditorOptions.insertSpaces, + }); const realContentHeight = this.editor!.getContentHeight(); if (realContentHeight !== editorHeight) { diff --git a/src/vs/workbench/contrib/notebook/browser/view/notebookCellAnchor.ts b/src/vs/workbench/contrib/notebook/browser/view/notebookCellAnchor.ts index a592bd2656b81..7e1b5d0a13d3d 100644 --- a/src/vs/workbench/contrib/notebook/browser/view/notebookCellAnchor.ts +++ b/src/vs/workbench/contrib/notebook/browser/view/notebookCellAnchor.ts @@ -38,12 +38,11 @@ export class NotebookCellAnchor implements IDisposable { const newFocusBottom = cellListView.elementTop(focusedIndex) + cellListView.elementHeight(focusedIndex) + heightDelta; const viewBottom = cellListView.renderHeight + cellListView.getScrollTop(); const focusStillVisible = viewBottom > newFocusBottom; - const anchorFocusedSetting = this.configurationService.getValue(NotebookSetting.anchorToFocusedCell); const allowScrolling = this.configurationService.getValue(NotebookSetting.scrollToRevealCell) !== 'none'; const growing = heightDelta > 0; - const autoAnchor = allowScrolling && growing && !focusStillVisible && anchorFocusedSetting !== 'off'; + const autoAnchor = allowScrolling && growing && !focusStillVisible; - if (autoAnchor || anchorFocusedSetting === 'on') { + if (autoAnchor) { this.watchAchorDuringExecution(executingCellUri); return true; } diff --git a/src/vs/workbench/contrib/notebook/browser/view/notebookCellList.ts b/src/vs/workbench/contrib/notebook/browser/view/notebookCellList.ts index 62cf6f3b7665d..48910a20dba99 100644 --- a/src/vs/workbench/contrib/notebook/browser/view/notebookCellList.ts +++ b/src/vs/workbench/contrib/notebook/browser/view/notebookCellList.ts @@ -592,6 +592,11 @@ export class NotebookCellList extends WorkbenchList implements ID return modelIndex; } + if (modelIndex >= this.hiddenRangesPrefixSum.getTotalSum()) { + // it's already after the last hidden range + return Math.min(this.length, this.hiddenRangesPrefixSum.getTotalSum()); + } + return this.hiddenRangesPrefixSum.getIndexOf(modelIndex).index; } @@ -920,8 +925,12 @@ export class NotebookCellList extends WorkbenchList implements ID break; } - // wait for the editor to be created only if the cell is in editing mode (meaning it has an editor and will focus the editor) - if (cell.getEditState() === CellEditState.Editing && !cell.editorAttached) { + if (( + // wait for the editor to be created if the cell is in editing mode + cell.getEditState() === CellEditState.Editing + // wait for the editor to be created if we are revealing the first line of the cell + || (revealType === CellRevealType.FirstLineIfOutsideViewport && cell.cellKind === CellKind.Code) + ) && !cell.editorAttached) { return getEditorAttachedPromise(cell); } @@ -1018,19 +1027,19 @@ export class NotebookCellList extends WorkbenchList implements ID const element = this.view.element(viewIndex); if (element.editorAttached) { - this._revealRangeCommon(viewIndex, range, false, false); + this._revealRangeCommon(viewIndex, range); } else { const elementHeight = this.view.elementHeight(viewIndex); - let upwards = false; + let alignHint: 'top' | 'bottom' | undefined = undefined; if (elementTop + elementHeight <= scrollTop) { - // scroll downwards + // scroll up this.view.setScrollTop(elementTop); - upwards = false; + alignHint = 'top'; } else if (elementTop >= wrapperBottom) { - // scroll upwards + // scroll down this.view.setScrollTop(elementTop - this.view.renderHeight / 2); - upwards = true; + alignHint = 'bottom'; } const editorAttachedPromise = new Promise((resolve, reject) => { @@ -1040,7 +1049,7 @@ export class NotebookCellList extends WorkbenchList implements ID }); return editorAttachedPromise.then(() => { - this._revealRangeCommon(viewIndex, range, true, upwards); + this._revealRangeCommon(viewIndex, range, alignHint); }); } } @@ -1107,7 +1116,7 @@ export class NotebookCellList extends WorkbenchList implements ID } } - private _revealRangeCommon(viewIndex: number, range: Selection | Range, newlyCreated: boolean, alignToBottom: boolean) { + private _revealRangeCommon(viewIndex: number, range: Selection | Range, alignHint?: 'top' | 'bottom' | undefined) { const element = this.view.element(viewIndex); const scrollTop = this.getViewScrollTop(); const wrapperBottom = this.getViewScrollBottom(); @@ -1129,17 +1138,15 @@ export class NotebookCellList extends WorkbenchList implements ID this.view.setScrollTop(positionTop - 30); } else if (positionTop > wrapperBottom) { this.view.setScrollTop(scrollTop + positionTop - wrapperBottom + 30); - } else if (newlyCreated) { - // newly scrolled into view - if (alignToBottom) { - // align to the bottom - this.view.setScrollTop(scrollTop + positionTop - wrapperBottom + 30); - } else { - // align to to top - this.view.setScrollTop(positionTop - 30); - } + } else if (alignHint === 'bottom') { + // Scrolled into view from below + this.view.setScrollTop(scrollTop + positionTop - wrapperBottom + 30); + } else if (alignHint === 'top') { + // Scrolled into view from above + this.view.setScrollTop(positionTop - 30); } + element.revealRangeInCenter(range); } //#endregion @@ -1166,6 +1173,15 @@ export class NotebookCellList extends WorkbenchList implements ID } } + revealOffsetInCenterIfOutsideViewport(offset: number) { + const scrollTop = this.getViewScrollTop(); + const wrapperBottom = this.getViewScrollBottom(); + + if (offset < scrollTop || offset > wrapperBottom) { + this.view.setScrollTop(offset - this.view.renderHeight / 2); + } + } + private _revealInCenterIfOutsideViewport(viewIndex: number) { this._revealInternal(viewIndex, true, CellRevealPosition.Center); } @@ -1276,7 +1292,18 @@ export class NotebookCellList extends WorkbenchList implements ID super.domFocus(); } - focusContainer() { + focusContainer(clearSelection: boolean) { + if (clearSelection) { + // allow focus to be between cells + this._viewModel?.updateSelectionsState({ + kind: SelectionStateType.Handle, + primary: null, + selections: [] + }, 'view'); + this.setFocus([], undefined, true); + this.setSelection([], undefined, true); + } + super.domFocus(); } diff --git a/src/vs/workbench/contrib/notebook/browser/view/notebookCellListView.ts b/src/vs/workbench/contrib/notebook/browser/view/notebookCellListView.ts index c2752fe8103d6..399934d7c496a 100644 --- a/src/vs/workbench/contrib/notebook/browser/view/notebookCellListView.ts +++ b/src/vs/workbench/contrib/notebook/browser/view/notebookCellListView.ts @@ -16,6 +16,7 @@ export interface IWhitespace { */ afterPosition: number; size: number; + priority: number; } export class NotebookCellsLayout implements IRangeMap { private _items: IItem[] = []; @@ -49,6 +50,15 @@ export class NotebookCellsLayout implements IRangeMap { this._size = this._paddingTop; } + getWhitespaces(): IWhitespace[] { + return this._whitespace; + } + + restoreWhitespace(items: IWhitespace[]) { + this._whitespace = items; + this._size = this._paddingTop + this._items.reduce((total, item) => total + item.size, 0) + this._whitespace.reduce((total, ws) => total + ws.size, 0); + } + /** */ splice(index: number, deleteCount: number, items?: IItem[] | undefined): void { @@ -63,10 +73,11 @@ export class NotebookCellsLayout implements IRangeMap { const newSizes = []; for (let i = 0; i < inserts.length; i++) { const insertIndex = i + index; - const existingWhitespace = this._whitespace.find(ws => ws.afterPosition === insertIndex + 1); + const existingWhitespaces = this._whitespace.filter(ws => ws.afterPosition === insertIndex + 1); + - if (existingWhitespace) { - newSizes.push(inserts[i].size + existingWhitespace.size); + if (existingWhitespaces.length > 0) { + newSizes.push(inserts[i].size + existingWhitespaces.reduce((acc, ws) => acc + ws.size, 0)); } else { newSizes.push(inserts[i].size); } @@ -76,9 +87,9 @@ export class NotebookCellsLayout implements IRangeMap { // Now that the items array has been updated, and the whitespaces are updated elsewhere, if an item is removed/inserted, the accumlated size of the items are all updated. // Loop through all items from the index where the splice started, to the end for (let i = index; i < this._items.length; i++) { - const existingWhitespace = this._whitespace.find(ws => ws.afterPosition === i + 1); - if (existingWhitespace) { - this._prefixSumComputer.setValue(i, this._items[i].size + existingWhitespace.size); + const existingWhitespaces = this._whitespace.filter(ws => ws.afterPosition === i + 1); + if (existingWhitespaces.length > 0) { + this._prefixSumComputer.setValue(i, this._items[i].size + existingWhitespaces.reduce((acc, ws) => acc + ws.size, 0)); } else { this._prefixSumComputer.setValue(i, this._items[i].size); } @@ -86,14 +97,20 @@ export class NotebookCellsLayout implements IRangeMap { } insertWhitespace(id: string, afterPosition: number, size: number): void { - const existingWhitespace = this._whitespace.find(ws => ws.afterPosition === afterPosition); - if (existingWhitespace) { - throw new Error('Whitespace already exists at the specified position'); + let priority = 0; + const existingWhitespaces = this._whitespace.filter(ws => ws.afterPosition === afterPosition); + if (existingWhitespaces.length > 0) { + priority = Math.max(...existingWhitespaces.map(ws => ws.priority)) + 1; } - this._whitespace.push({ id, afterPosition: afterPosition, size }); + this._whitespace.push({ id, afterPosition: afterPosition, size, priority }); this._size += size; // Update the total size to include the whitespace - this._whitespace.sort((a, b) => a.afterPosition - b.afterPosition); // Keep the whitespace sorted by index + this._whitespace.sort((a, b) => { + if (a.afterPosition === b.afterPosition) { + return a.priority - b.priority; + } + return a.afterPosition - b.afterPosition; + }); // find item size of index if (afterPosition > 0) { @@ -141,7 +158,8 @@ export class NotebookCellsLayout implements IRangeMap { if (whitespace.afterPosition > 0) { const index = whitespace.afterPosition - 1; const itemSize = this._items[index].size; - const accSize = itemSize; + const remainingWhitespaces = this._whitespace.filter(ws => ws.afterPosition === whitespace.afterPosition); + const accSize = itemSize + remainingWhitespaces.reduce((acc, ws) => acc + ws.size, 0); this._prefixSumComputer.setValue(index, accSize); } } @@ -160,16 +178,20 @@ export class NotebookCellsLayout implements IRangeMap { const afterPosition = whitespace.afterPosition; if (afterPosition === 0) { - return this.paddingTop; + // find all whitespaces at the same position but with higher priority (smaller number) + const whitespaces = this._whitespace.filter(ws => ws.afterPosition === afterPosition && ws.priority < whitespace.priority); + return whitespaces.reduce((acc, ws) => acc + ws.size, 0) + this.paddingTop; } - const whitespaceBeforeFirstItem = this._whitespace.length > 0 && this._whitespace[0].afterPosition === 0 ? this._whitespace[0].size : 0; + const whitespaceBeforeFirstItem = this._whitespace.filter(ws => ws.afterPosition === 0).reduce((acc, ws) => acc + ws.size, 0); // previous item index const index = afterPosition - 1; const previousItemPosition = this._prefixSumComputer.getPrefixSum(index); const previousItemSize = this._items[index].size; - return previousItemPosition + previousItemSize + whitespaceBeforeFirstItem + this.paddingTop; + const previousWhitespace = this._whitespace.filter(ws => ws.afterPosition === afterPosition - 1); + const whitespaceBefore = previousWhitespace.reduce((acc, ws) => acc + ws.size, 0); + return previousItemPosition + previousItemSize + whitespaceBeforeFirstItem + this.paddingTop + whitespaceBefore; } indexAt(position: number): number { @@ -177,7 +199,7 @@ export class NotebookCellsLayout implements IRangeMap { return -1; } - const whitespaceBeforeFirstItem = this._whitespace.length > 0 && this._whitespace[0].afterPosition === 0 ? this._whitespace[0].size : 0; + const whitespaceBeforeFirstItem = this._whitespace.filter(ws => ws.afterPosition === 0).reduce((acc, ws) => acc + ws.size, 0); const offset = position - (this._paddingTop + whitespaceBeforeFirstItem); if (offset <= 0) { @@ -210,7 +232,7 @@ export class NotebookCellsLayout implements IRangeMap { return -1; } - const whitespaceBeforeFirstItem = this._whitespace.length > 0 && this._whitespace[0].afterPosition === 0 ? this._whitespace[0].size : 0; + const whitespaceBeforeFirstItem = this._whitespace.filter(ws => ws.afterPosition === 0).reduce((acc, ws) => acc + ws.size, 0); return this._prefixSumComputer.getPrefixSum(index/** count */) + this._paddingTop + whitespaceBeforeFirstItem; } } @@ -240,15 +262,28 @@ export class NotebookCellListView extends ListView { } protected override createRangeMap(paddingTop: number): IRangeMap { - return new NotebookCellsLayout(paddingTop); + const existingMap = this.rangeMap as NotebookCellsLayout | undefined; + if (existingMap) { + const layout = new NotebookCellsLayout(paddingTop); + layout.restoreWhitespace(existingMap.getWhitespaces()); + return layout; + } else { + return new NotebookCellsLayout(paddingTop); + } + } insertWhitespace(afterPosition: number, size: number): string { const scrollTop = this.scrollTop; const id = `${++this._lastWhitespaceId}`; + const previousRenderRange = this.getRenderRange(this.lastRenderTop, this.lastRenderHeight); + const elementPosition = this.elementTop(afterPosition); + const aboveScrollTop = scrollTop > elementPosition; this.notebookRangeMap.insertWhitespace(id, afterPosition, size); - this._rerender(scrollTop, this.renderHeight, false); + const newScrolltop = aboveScrollTop ? scrollTop + size : scrollTop; + this.render(previousRenderRange, newScrolltop, this.lastRenderHeight, undefined, undefined, false); + this._rerender(newScrolltop, this.renderHeight, false); this.eventuallyUpdateScrollDimensions(); return id; @@ -260,8 +295,20 @@ export class NotebookCellListView extends ListView { } removeWhitespace(id: string): void { - this.notebookRangeMap.removeWhitespace(id); - this.eventuallyUpdateScrollDimensions(); + const scrollTop = this.scrollTop; + const previousRenderRange = this.getRenderRange(this.lastRenderTop, this.lastRenderHeight); + const currentPosition = this.notebookRangeMap.getWhitespacePosition(id); + + if (currentPosition > scrollTop) { + this.notebookRangeMap.removeWhitespace(id); + this.render(previousRenderRange, scrollTop, this.lastRenderHeight, undefined, undefined, false); + this._rerender(scrollTop, this.renderHeight, false); + this.eventuallyUpdateScrollDimensions(); + } else { + this.notebookRangeMap.removeWhitespace(id); + this.eventuallyUpdateScrollDimensions(); + } + } getWhitespacePosition(id: string): number { diff --git a/src/vs/workbench/contrib/notebook/browser/view/notebookRenderingCommon.ts b/src/vs/workbench/contrib/notebook/browser/view/notebookRenderingCommon.ts index 01e4f3434586a..f2700c72a213a 100644 --- a/src/vs/workbench/contrib/notebook/browser/view/notebookRenderingCommon.ts +++ b/src/vs/workbench/contrib/notebook/browser/view/notebookRenderingCommon.ts @@ -63,6 +63,7 @@ export interface INotebookCellList extends ICoordinatesConverter { revealCells(range: ICellRange): void; revealRangeInCell(cell: ICellViewModel, range: Selection | Range, revealType: CellRevealRangeType): Promise; revealCellOffsetInCenter(element: ICellViewModel, offset: number): void; + revealOffsetInCenterIfOutsideViewport(offset: number): void; setHiddenAreas(_ranges: ICellRange[], triggerViewUpdate: boolean): boolean; changeViewZones(callback: (accessor: INotebookViewZoneChangeAccessor) => void): void; domElementOfElement(element: ICellViewModel): HTMLElement | null; @@ -70,7 +71,7 @@ export interface INotebookCellList extends ICoordinatesConverter { triggerScrollFromMouseWheelEvent(browserEvent: IMouseWheelEvent): void; updateElementHeight2(element: ICellViewModel, size: number, anchorElementIndex?: number | null): void; domFocus(): void; - focusContainer(): void; + focusContainer(clearSelection: boolean): void; setCellEditorSelection(element: ICellViewModel, range: Range): void; style(styles: IListStyles): void; getRenderHeight(): number; diff --git a/src/vs/workbench/contrib/notebook/browser/view/renderers/backLayerWebView.ts b/src/vs/workbench/contrib/notebook/browser/view/renderers/backLayerWebView.ts index dd421641fb8fc..6a6e150c4a093 100644 --- a/src/vs/workbench/contrib/notebook/browser/view/renderers/backLayerWebView.ts +++ b/src/vs/workbench/contrib/notebook/browser/view/renderers/backLayerWebView.ts @@ -391,6 +391,14 @@ export class BackLayerWebView extends Themable { background-color: var(--theme-notebook-symbol-highlight-background); } + #container .nb-symbolHighlight .output_container .output { + background-color: var(--theme-notebook-symbol-highlight-background); + } + + #container .nb-chatGenerationHighlight .output_container .output { + background-color: var(--vscode-notebook-selectedCellBackground); + } + #container > div.nb-cellDeleted .output_container { background-color: var(--theme-notebook-diff-removed-background); } @@ -513,10 +521,10 @@ export class BackLayerWebView extends Themable { return !!this.webview; } - createWebview(codeWindow: CodeWindow): Promise { + createWebview(targetWindow: CodeWindow): Promise { const baseUrl = this.asWebviewUri(this.getNotebookBaseUri(), undefined); const htmlContent = this.generateContent(baseUrl.toString()); - return this._initialize(htmlContent, codeWindow); + return this._initialize(htmlContent, targetWindow); } private getNotebookBaseUri() { @@ -551,16 +559,16 @@ export class BackLayerWebView extends Themable { ]; } - private _initialize(content: string, codeWindow: CodeWindow): Promise { + private _initialize(content: string, targetWindow: CodeWindow): Promise { if (!getWindow(this.element).document.body.contains(this.element)) { throw new Error('Element is already detached from the DOM tree'); } - this.webview = this._createInset(this.webviewService, content, codeWindow); - this.webview.mountTo(this.element); + this.webview = this._createInset(this.webviewService, content); + this.webview.mountTo(this.element, targetWindow); this._register(this.webview); - this._register(new WebviewWindowDragMonitor(() => this.webview)); + this._register(new WebviewWindowDragMonitor(targetWindow, () => this.webview)); const initializePromise = new DeferredPromise(); @@ -678,6 +686,7 @@ export class BackLayerWebView extends Themable { const latestCell = this.notebookEditor.getCellByInfo(resolvedResult.cellInfo); if (latestCell) { latestCell.outputIsFocused = false; + latestCell.inputInOutputIsFocused = false; } } break; @@ -900,9 +909,19 @@ export class BackLayerWebView extends Themable { } case 'notebookPerformanceMessage': { this.notebookEditor.updatePerformanceMetadata(data.cellId, data.executionId, data.duration, data.rendererId); + if (data.mimeType && data.outputSize && data.rendererId === 'vscode.builtin-renderer') { + this._sendPerformanceData(data.mimeType, data.outputSize, data.duration); + } break; } case 'outputInputFocus': { + const resolvedResult = this.resolveOutputId(data.id); + if (resolvedResult) { + const latestCell = this.notebookEditor.getCellByInfo(resolvedResult.cellInfo); + if (latestCell) { + latestCell.inputInOutputIsFocused = data.inputFocused; + } + } this.notebookEditor.didFocusOutputInputChange(data.inputFocused); } } @@ -911,6 +930,30 @@ export class BackLayerWebView extends Themable { return initializePromise.p; } + private _sendPerformanceData(mimeType: string, outputSize: number, renderTime: number) { + type NotebookOutputRenderClassification = { + owner: 'amunger'; + comment: 'Track performance data for output rendering'; + mimeType: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Presentation type of the output.' }; + outputSize: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Size of the output data buffer.'; isMeasurement: true }; + renderTime: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Time spent rendering output.'; isMeasurement: true }; + }; + + type NotebookOutputRenderEvent = { + mimeType: string; + outputSize: number; + renderTime: number; + }; + + const telemetryData = { + mimeType, + outputSize, + renderTime + }; + + this.telemetryService.publicLog2('NotebookCellOutputRender', telemetryData); + } + private _handleNotebookCellResource(uri: URI) { const notebookResource = uri.path.length > 0 ? uri : this.documentUri; @@ -1123,7 +1166,7 @@ export class BackLayerWebView extends Themable { await this.openerService.open(newFileUri); } - private _createInset(webviewService: IWebviewService, content: string, codeWindow: CodeWindow) { + private _createInset(webviewService: IWebviewService, content: string) { this.localResourceRootsCache = this._getResourceRootsCache(); const webview = webviewService.createWebviewElement({ origin: BackLayerWebView.getOriginStore(this.storageService).getOrigin(this.notebookViewType, undefined), @@ -1139,8 +1182,7 @@ export class BackLayerWebView extends Themable { localResourceRoots: this.localResourceRootsCache, }, extension: undefined, - providedViewType: 'notebook.output', - codeWindow: codeWindow + providedViewType: 'notebook.output' }); webview.setHtml(content); @@ -1674,6 +1716,30 @@ export class BackLayerWebView extends Themable { this.webview?.focus(); } + selectOutputContents(cell: ICellViewModel) { + if (this._disposed) { + return; + } + const output = cell.outputsViewModels.find(o => o.model.outputId === cell.focusedOutputId); + const outputId = output ? this.insetMapping.get(output)?.outputId : undefined; + this._sendMessageToWebview({ + type: 'select-output-contents', + cellOrOutputId: outputId || cell.id + }); + } + + selectInputContents(cell: ICellViewModel) { + if (this._disposed) { + return; + } + const output = cell.outputsViewModels.find(o => o.model.outputId === cell.focusedOutputId); + const outputId = output ? this.insetMapping.get(output)?.outputId : undefined; + this._sendMessageToWebview({ + type: 'select-input-contents', + cellOrOutputId: outputId || cell.id + }); + } + focusOutput(cellOrOutputId: string, alternateId: string | undefined, viewFocused: boolean) { if (this._disposed) { return; @@ -1690,6 +1756,16 @@ export class BackLayerWebView extends Themable { }); } + blurOutput() { + if (this._disposed) { + return; + } + + this._sendMessageToWebview({ + type: 'blur-output' + }); + } + async find(query: string, options: { wholeWord?: boolean; caseSensitive?: boolean; includeMarkup: boolean; includeOutput: boolean; shouldGetSearchPreviewInfo: boolean; ownerID: string }): Promise { if (query === '') { this._sendMessageToWebview({ diff --git a/src/vs/workbench/contrib/notebook/browser/view/renderers/cellRenderer.ts b/src/vs/workbench/contrib/notebook/browser/view/renderers/cellRenderer.ts index 6913333712507..9f6f5b8dac591 100644 --- a/src/vs/workbench/contrib/notebook/browser/view/renderers/cellRenderer.ts +++ b/src/vs/workbench/contrib/notebook/browser/view/renderers/cellRenderer.ts @@ -9,7 +9,7 @@ import { FastDomNode } from 'vs/base/browser/fastDomNode'; import { IListRenderer, IListVirtualDelegate } from 'vs/base/browser/ui/list/list'; import { Disposable, DisposableStore } from 'vs/base/common/lifecycle'; import { ICodeEditor } from 'vs/editor/browser/editorBrowser'; -import { CodeEditorWidget } from 'vs/editor/browser/widget/codeEditorWidget'; +import { CodeEditorWidget } from 'vs/editor/browser/widget/codeEditor/codeEditorWidget'; import { IEditorOptions } from 'vs/editor/common/config/editorOptions'; import { BareFontInfo } from 'vs/editor/common/config/fontInfo'; import { EditorContextKeys } from 'vs/editor/common/editorContextKeys'; @@ -49,6 +49,7 @@ import { CodeCellViewModel } from 'vs/workbench/contrib/notebook/browser/viewMod import { MarkupCellViewModel } from 'vs/workbench/contrib/notebook/browser/viewModel/markupCellViewModel'; import { CellViewModel } from 'vs/workbench/contrib/notebook/browser/viewModel/notebookViewModelImpl'; import { CellKind } from 'vs/workbench/contrib/notebook/common/notebookCommon'; +import { INotebookExecutionStateService } from 'vs/workbench/contrib/notebook/common/notebookExecutionStateService'; const $ = DOM.$; @@ -109,6 +110,8 @@ abstract class AbstractCellRenderer { export class MarkupCellRenderer extends AbstractCellRenderer implements IListRenderer { static readonly TEMPLATE_ID = 'markdown_cell'; + private _notebookExecutionStateService: INotebookExecutionStateService; + constructor( notebookEditor: INotebookEditorDelegate, dndController: CellDragAndDropController, @@ -120,8 +123,10 @@ export class MarkupCellRenderer extends AbstractCellRenderer implements IListRen @IMenuService menuService: IMenuService, @IKeybindingService keybindingService: IKeybindingService, @INotificationService notificationService: INotificationService, + @INotebookExecutionStateService notebookExecutionStateService: INotebookExecutionStateService ) { super(instantiationService, notebookEditor, contextMenuService, menuService, configurationService, keybindingService, notificationService, contextKeyServiceProvider, 'markdown', dndController); + this._notebookExecutionStateService = notebookExecutionStateService; } get templateId() { @@ -169,7 +174,7 @@ export class MarkupCellRenderer extends AbstractCellRenderer implements IListRen templateDisposables.add(scopedInstaService.createInstance(CellChatPart, this.notebookEditor, cellChatPart)), templateDisposables.add(scopedInstaService.createInstance(CellEditorStatusBar, this.notebookEditor, container, editorPart, undefined)), templateDisposables.add(new CellFocusIndicator(this.notebookEditor, titleToolbar, focusIndicatorTop, focusIndicatorLeft, focusIndicatorRight, focusIndicatorBottom)), - templateDisposables.add(new FoldedCellHint(this.notebookEditor, DOM.append(container, $('.notebook-folded-hint')))), + templateDisposables.add(new FoldedCellHint(this.notebookEditor, DOM.append(container, $('.notebook-folded-hint')), this._notebookExecutionStateService)), templateDisposables.add(new CellDecorations(rootContainer, decorationContainer)), templateDisposables.add(scopedInstaService.createInstance(CellComments, this.notebookEditor, cellCommentPartContainer)), templateDisposables.add(new CollapsedCellInput(this.notebookEditor, cellInputCollapsedContainer)), diff --git a/src/vs/workbench/contrib/notebook/browser/view/renderers/webviewMessages.ts b/src/vs/workbench/contrib/notebook/browser/view/renderers/webviewMessages.ts index b46964be30748..5d0e13ad3b25b 100644 --- a/src/vs/workbench/contrib/notebook/browser/view/renderers/webviewMessages.ts +++ b/src/vs/workbench/contrib/notebook/browser/view/renderers/webviewMessages.ts @@ -50,6 +50,7 @@ export interface IOutputBlurMessage extends BaseToWebviewMessage { export interface IOutputInputFocusMessage extends BaseToWebviewMessage { readonly type: 'outputInputFocus'; readonly inputFocused: boolean; + readonly id: string; } export interface IScrollToRevealMessage extends BaseToWebviewMessage { @@ -68,7 +69,7 @@ export interface IScrollAckMessage extends BaseToWebviewMessage { readonly version: number; } -export interface IBlurOutputMessage extends BaseToWebviewMessage { +export interface IFocusEditorMessage extends BaseToWebviewMessage { readonly type: 'focus-editor'; readonly cellId: string; readonly focusNext?: boolean; @@ -168,23 +169,6 @@ export interface IClearMessage { readonly type: 'clear'; } -export interface IOutputRequestMetadata { - /** - * Additional attributes of a cell metadata. - */ - readonly custom?: { readonly [key: string]: unknown }; -} - -export interface IOutputRequestDto { - /** - * { mime_type: value } - */ - readonly data: { readonly [key: string]: unknown }; - - readonly metadata?: IOutputRequestMetadata; - readonly outputId: string; -} - export interface OutputItemEntry { readonly mime: string; readonly valueBytes: Uint8Array; @@ -278,6 +262,10 @@ export interface IFocusOutputMessage { readonly alternateId?: string; } +export interface IBlurOutputMessage { + readonly type: 'blur-output'; +} + export interface IAckOutputHeight { readonly cellId: string; readonly outputId: string; @@ -476,6 +464,15 @@ export interface IReturnOutputItemMessage { readonly output: OutputItemEntry | undefined; } +export interface ISelectOutputItemMessage { + readonly type: 'select-output-contents'; + readonly cellOrOutputId: string; +} +export interface ISelectInputOutputItemMessage { + readonly type: 'select-input-contents'; + readonly cellOrOutputId: string; +} + export interface ILogRendererDebugMessage extends BaseToWebviewMessage { readonly type: 'logRendererDebugMessage'; readonly message: string; @@ -488,6 +485,8 @@ export interface IPerformanceMessage extends BaseToWebviewMessage { readonly cellId: string; readonly duration: number; readonly rendererId: string; + readonly outputSize?: number; + readonly mimeType?: string; } @@ -501,7 +500,7 @@ export type FromWebviewMessage = WebviewInitialized | IScrollToRevealMessage | IWheelMessage | IScrollAckMessage | - IBlurOutputMessage | + IFocusEditorMessage | ICustomKernelMessage | ICustomRendererMessage | IClickedDataUrlMessage | @@ -527,6 +526,7 @@ export type FromWebviewMessage = WebviewInitialized | export type ToWebviewMessage = IClearMessage | IFocusOutputMessage | + IBlurOutputMessage | IAckOutputHeightMessage | ICreationRequestMessage | IViewScrollTopRequestMessage | @@ -555,7 +555,9 @@ export type ToWebviewMessage = IClearMessage | IFindHighlightCurrentMessage | IFindUnHighlightCurrentMessage | IFindStopMessage | - IReturnOutputItemMessage; + IReturnOutputItemMessage | + ISelectOutputItemMessage | + ISelectInputOutputItemMessage; export type AnyMessage = FromWebviewMessage | ToWebviewMessage; diff --git a/src/vs/workbench/contrib/notebook/browser/view/renderers/webviewPreloads.ts b/src/vs/workbench/contrib/notebook/browser/view/renderers/webviewPreloads.ts index ebbf9d2703209..bde42dd0ea59a 100644 --- a/src/vs/workbench/contrib/notebook/browser/view/renderers/webviewPreloads.ts +++ b/src/vs/workbench/contrib/notebook/browser/view/renderers/webviewPreloads.ts @@ -3,7 +3,6 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import type * as DOM from 'vs/base/browser/window'; import type { Event } from 'vs/base/common/event'; import type { IDisposable } from 'vs/base/common/lifecycle'; import type * as webviewMessages from 'vs/workbench/contrib/notebook/browser/view/renderers/webviewMessages'; @@ -89,8 +88,13 @@ declare function cancelIdleCallback(handle: number): void; declare function __import(path: string): Promise; async function webviewPreloads(ctx: PreloadContext) { - // eslint-disable-next-line no-restricted-globals - const $window = window as typeof DOM.$window; + + /* eslint-disable no-restricted-globals */ + + // The use of global `window` should be fine in this context, even + // with aux windows. This code is running from within an `iframe` + // where there is only one `window` object anyway. + const userAgent = navigator.userAgent; const isChrome = (userAgent.indexOf('Chrome') >= 0); const textEncoder = new TextEncoder(); @@ -155,20 +159,47 @@ async function webviewPreloads(ctx: PreloadContext) { } }; }; + function getOutputContainer(event: FocusEvent | MouseEvent) { + for (const node of event.composedPath()) { + if (node instanceof HTMLElement && node.classList.contains('output')) { + return { + id: node.id + }; + } + } + return; + } + let lastFocusedOutput: { id: string } | undefined = undefined; + const handleOutputFocusOut = (event: FocusEvent) => { + const outputFocus = event && getOutputContainer(event); + if (!outputFocus) { + return; + } + // Possible we're tabbing through the elements of the same output. + // Lets see if focus is set back to the same output. + lastFocusedOutput = undefined; + setTimeout(() => { + if (lastFocusedOutput?.id === outputFocus.id) { + return; + } + postNotebookMessage('outputBlur', outputFocus); + }, 0); + }; // check if an input element is focused within the output element - const checkOutputInputFocus = () => { - - const activeElement = $window.document.activeElement; + const checkOutputInputFocus = (e: FocusEvent) => { + lastFocusedOutput = getOutputContainer(e); + const activeElement = window.document.activeElement; if (!activeElement) { return; } - if (activeElement.tagName === 'INPUT' || activeElement.tagName === 'TEXTAREA') { - postNotebookMessage('outputInputFocus', { inputFocused: true }); + const id = lastFocusedOutput?.id; + if (id && (activeElement.tagName === 'INPUT' || activeElement.tagName === 'TEXTAREA')) { + postNotebookMessage('outputInputFocus', { inputFocused: true, id }); activeElement.addEventListener('blur', () => { - postNotebookMessage('outputInputFocus', { inputFocused: false }); + postNotebookMessage('outputInputFocus', { inputFocused: false, id }); }, { once: true }); } }; @@ -178,16 +209,7 @@ async function webviewPreloads(ctx: PreloadContext) { return; } - let outputFocus: { id: string } | undefined = undefined; - for (const node of event.composedPath()) { - if (node instanceof HTMLElement && node.classList.contains('output')) { - outputFocus = { - id: node.id - }; - break; - } - } - + const outputFocus = lastFocusedOutput = getOutputContainer(event); for (const node of event.composedPath()) { if (node instanceof HTMLAnchorElement && node.href) { if (node.href.startsWith('blob:')) { @@ -250,6 +272,101 @@ async function webviewPreloads(ctx: PreloadContext) { } }; + const blurOutput = () => { + const selection = window.getSelection(); + if (!selection) { + return; + } + selection.removeAllRanges(); + }; + + const selectOutputContents = (cellOrOutputId: string) => { + const selection = window.getSelection(); + if (!selection) { + return; + } + const cellOutputContainer = window.document.getElementById(cellOrOutputId); + if (!cellOutputContainer) { + return; + } + selection.removeAllRanges(); + const range = document.createRange(); + range.selectNode(cellOutputContainer); + selection.addRange(range); + + }; + + const selectInputContents = (cellOrOutputId: string) => { + const cellOutputContainer = window.document.getElementById(cellOrOutputId); + if (!cellOutputContainer) { + return; + } + const activeElement = window.document.activeElement; + if (activeElement?.tagName === 'INPUT' || activeElement?.tagName === 'TEXTAREA') { + (activeElement as HTMLInputElement).select(); + } + }; + + const onPageUpDownSelectionHandler = (e: KeyboardEvent) => { + if (!lastFocusedOutput?.id || !e.shiftKey) { + return; + } + + // If we're pressing `Shift+Up/Down` then we want to select a line at a time. + if (e.shiftKey && (e.code === 'ArrowUp' || e.code === 'ArrowDown')) { + e.stopPropagation(); // We don't want the notebook to handle this, default behavior is what we need. + return; + } + + // We want to handle just `Shift + PageUp/PageDown` & `Shift + Cmd + ArrowUp/ArrowDown` (for mac) + if (!(e.code === 'PageUp' || e.code === 'PageDown') && !(e.metaKey && (e.code === 'ArrowDown' || e.code === 'ArrowUp'))) { + return; + } + const outputContainer = window.document.getElementById(lastFocusedOutput.id); + const selection = window.getSelection(); + if (!outputContainer || !selection?.anchorNode) { + return; + } + const activeElement = window.document.activeElement; + if (activeElement?.tagName === 'INPUT' || activeElement?.tagName === 'TEXTAREA') { + // Leave for default behavior. + return; + } + + // These should change the scroll position, not adjust the selected cell in the notebook + e.stopPropagation(); // We don't want the notebook to handle this. + e.preventDefault(); // We will handle selection. + + const { anchorNode, anchorOffset } = selection; + const range = document.createRange(); + if (e.code === 'PageDown' || e.code === 'ArrowDown') { + range.setStart(anchorNode, anchorOffset); + range.setEnd(outputContainer, 1); + } + else { + range.setStart(outputContainer, 0); + range.setEnd(anchorNode, anchorOffset); + } + selection.removeAllRanges(); + selection.addRange(range); + }; + + const disableNativeSelectAll = (e: KeyboardEvent) => { + if (!lastFocusedOutput?.id) { + return; + } + const activeElement = window.document.activeElement; + if (activeElement?.tagName === 'INPUT' || activeElement?.tagName === 'TEXTAREA') { + // The input element will handle this. + return; + } + + if ((e.key === 'a' && e.ctrlKey) || (e.metaKey && e.key === 'a')) { + e.preventDefault(); // We will handle selection in editor code. + return; + } + }; + const handleDataUrl = async (data: string | ArrayBuffer | null, downloadName: string) => { postNotebookMessage('clicked-data-url', { data, @@ -271,8 +388,11 @@ async function webviewPreloads(ctx: PreloadContext) { } }; - $window.document.body.addEventListener('click', handleInnerClick); - $window.document.body.addEventListener('focusin', checkOutputInputFocus); + window.document.body.addEventListener('click', handleInnerClick); + window.document.body.addEventListener('focusin', checkOutputInputFocus); + window.document.body.addEventListener('focusout', handleOutputFocusOut); + window.document.body.addEventListener('keydown', onPageUpDownSelectionHandler); + window.document.body.addEventListener('keydown', disableNativeSelectAll); interface RendererContext extends rendererApi.RendererContext { readonly onDidChangeSettings: Event; @@ -373,7 +493,7 @@ async function webviewPreloads(ctx: PreloadContext) { constructor() { this._observer = new ResizeObserver(entries => { for (const entry of entries) { - if (!$window.document.body.contains(entry.target)) { + if (!window.document.body.contains(entry.target)) { continue; } @@ -405,7 +525,7 @@ async function webviewPreloads(ctx: PreloadContext) { if (shouldUpdatePadding) { // Do not update dimension in resize observer - $window.requestAnimationFrame(() => { + window.requestAnimationFrame(() => { if (newHeight !== 0) { entry.target.style.padding = `${ctx.style.outputNodePadding}px ${ctx.style.outputNodePadding}px ${ctx.style.outputNodePadding}px ${ctx.style.outputNodeLeftPadding}px`; } else { @@ -451,7 +571,52 @@ async function webviewPreloads(ctx: PreloadContext) { } }; - function scrollWillGoToParent(event: WheelEvent) { + let previousDelta: number | undefined; + let scrollTimeout: any /* NodeJS.Timeout */ | undefined; + let scrolledElement: Element | undefined; + let lastTimeScrolled: number | undefined; + function flagRecentlyScrolled(node: Element, deltaY?: number) { + scrolledElement = node; + if (deltaY === undefined) { + lastTimeScrolled = Date.now(); + previousDelta = undefined; + node.setAttribute('recentlyScrolled', 'true'); + clearTimeout(scrollTimeout); + scrollTimeout = setTimeout(() => { scrolledElement?.removeAttribute('recentlyScrolled'); }, 300); + return true; + } + + if (node.hasAttribute('recentlyScrolled')) { + if (lastTimeScrolled && Date.now() - lastTimeScrolled > 300) { + // it has been a while since we actually scrolled + // if scroll velocity increases, it's likely a new scroll event + if (!!previousDelta && deltaY < 0 && deltaY < previousDelta - 2) { + clearTimeout(scrollTimeout); + scrolledElement?.removeAttribute('recentlyScrolled'); + return false; + } else if (!!previousDelta && deltaY > 0 && deltaY > previousDelta + 2) { + clearTimeout(scrollTimeout); + scrolledElement?.removeAttribute('recentlyScrolled'); + return false; + } + + // the tail end of a smooth scrolling event (from a trackpad) can go on for a while + // so keep swallowing it, but we can shorten the timeout since the events occur rapidly + clearTimeout(scrollTimeout); + scrollTimeout = setTimeout(() => { scrolledElement?.removeAttribute('recentlyScrolled'); }, 50); + } else { + clearTimeout(scrollTimeout); + scrollTimeout = setTimeout(() => { scrolledElement?.removeAttribute('recentlyScrolled'); }, 300); + } + + previousDelta = deltaY; + return true; + } + + return false; + } + + function eventTargetShouldHandleScroll(event: WheelEvent) { for (let node = event.target as Node | null; node; node = node.parentNode) { if (!(node instanceof Element) || node.id === 'container' || node.classList.contains('cell_container') || node.classList.contains('markup') || node.classList.contains('output_container')) { return false; @@ -460,6 +625,7 @@ async function webviewPreloads(ctx: PreloadContext) { // scroll up if (event.deltaY < 0 && node.scrollTop > 0) { // there is still some content to scroll + flagRecentlyScrolled(node); return true; } @@ -473,10 +639,15 @@ async function webviewPreloads(ctx: PreloadContext) { } // if the node is not scrollable, we can continue. We don't check the computed style always as it's expensive - if ($window.getComputedStyle(node).overflowY === 'hidden' || $window.getComputedStyle(node).overflowY === 'visible') { + if (window.getComputedStyle(node).overflowY === 'hidden' || window.getComputedStyle(node).overflowY === 'visible') { continue; } + flagRecentlyScrolled(node); + return true; + } + + if (flagRecentlyScrolled(node, event.deltaY)) { return true; } } @@ -485,7 +656,7 @@ async function webviewPreloads(ctx: PreloadContext) { } const handleWheel = (event: WheelEvent & { wheelDeltaX?: number; wheelDeltaY?: number; wheelDelta?: number }) => { - if (event.defaultPrevented || scrollWillGoToParent(event)) { + if (event.defaultPrevented || eventTargetShouldHandleScroll(event)) { return; } postNotebookMessage('did-scroll-wheel', { @@ -495,9 +666,9 @@ async function webviewPreloads(ctx: PreloadContext) { deltaY: event.deltaY, deltaZ: event.deltaZ, // Refs https://github.com/microsoft/vscode/issues/146403#issuecomment-1854538928 - wheelDelta: event.wheelDelta && isChrome ? (event.wheelDelta / $window.devicePixelRatio) : event.wheelDelta, - wheelDeltaX: event.wheelDeltaX && isChrome ? (event.wheelDeltaX / $window.devicePixelRatio) : event.wheelDeltaX, - wheelDeltaY: event.wheelDeltaY && isChrome ? (event.wheelDeltaY / $window.devicePixelRatio) : event.wheelDeltaY, + wheelDelta: event.wheelDelta && isChrome ? (event.wheelDelta / window.devicePixelRatio) : event.wheelDelta, + wheelDeltaX: event.wheelDeltaX && isChrome ? (event.wheelDeltaX / window.devicePixelRatio) : event.wheelDeltaX, + wheelDeltaY: event.wheelDeltaY && isChrome ? (event.wheelDeltaY / window.devicePixelRatio) : event.wheelDeltaY, detail: event.detail, shiftKey: event.shiftKey, type: event.type @@ -506,19 +677,25 @@ async function webviewPreloads(ctx: PreloadContext) { }; function focusFirstFocusableOrContainerInOutput(cellOrOutputId: string, alternateId?: string) { - const cellOutputContainer = $window.document.getElementById(cellOrOutputId) ?? - (alternateId ? $window.document.getElementById(alternateId) : undefined); + const cellOutputContainer = window.document.getElementById(cellOrOutputId) ?? + (alternateId ? window.document.getElementById(alternateId) : undefined); if (cellOutputContainer) { - if (cellOutputContainer.contains($window.document.activeElement)) { + if (cellOutputContainer.contains(window.document.activeElement)) { return; } - + const id = cellOutputContainer.id; let focusableElement = cellOutputContainer.querySelector('[tabindex="0"], [href], button, input, option, select, textarea') as HTMLElement | null; if (!focusableElement) { focusableElement = cellOutputContainer; focusableElement.tabIndex = -1; + postNotebookMessage('outputInputFocus', { inputFocused: false, id }); + } else { + const inputFocused = focusableElement.tagName === 'INPUT' || focusableElement.tagName === 'TEXTAREA'; + postNotebookMessage('outputInputFocus', { inputFocused, id }); } + lastFocusedOutput = cellOutputContainer; + postNotebookMessage('outputFocus', { id: cellOutputContainer.id }); focusableElement.focus(); } } @@ -528,7 +705,7 @@ async function webviewPreloads(ctx: PreloadContext) { element.id = `focus-sink-${cellId}`; element.tabIndex = 0; element.addEventListener('focus', () => { - postNotebookMessage('focus-editor', { + postNotebookMessage('focus-editor', { cellId: cellId, focusNext }); @@ -688,7 +865,7 @@ async function webviewPreloads(ctx: PreloadContext) { } function selectRange(_range: ICommonRange) { - const sel = $window.getSelection(); + const sel = window.getSelection(); if (sel) { try { sel.removeAllRanges(); @@ -721,8 +898,8 @@ async function webviewPreloads(ctx: PreloadContext) { } }; } else { - $window.document.execCommand('hiliteColor', false, matchColor); - const cloneRange = $window.getSelection()!.getRangeAt(0).cloneRange(); + window.document.execCommand('hiliteColor', false, matchColor); + const cloneRange = window.getSelection()!.getRangeAt(0).cloneRange(); const _range = { collapsed: cloneRange.collapsed, commonAncestorContainer: cloneRange.commonAncestorContainer, @@ -737,9 +914,9 @@ async function webviewPreloads(ctx: PreloadContext) { selectRange(_range); try { document.designMode = 'On'; - $window.document.execCommand('removeFormat', false, undefined); + window.document.execCommand('removeFormat', false, undefined); document.designMode = 'Off'; - $window.getSelection()?.removeAllRanges(); + window.getSelection()?.removeAllRanges(); } catch (e) { console.log(e); } @@ -748,10 +925,10 @@ async function webviewPreloads(ctx: PreloadContext) { selectRange(_range); try { document.designMode = 'On'; - $window.document.execCommand('removeFormat', false, undefined); - $window.document.execCommand('hiliteColor', false, color); + window.document.execCommand('removeFormat', false, undefined); + window.document.execCommand('hiliteColor', false, color); document.designMode = 'Off'; - $window.getSelection()?.removeAllRanges(); + window.getSelection()?.removeAllRanges(); } catch (e) { console.log(e); } @@ -922,12 +1099,12 @@ async function webviewPreloads(ctx: PreloadContext) { const onDidReceiveKernelMessage = createEmitter(); - const ttPolicy = $window.trustedTypes?.createPolicy('notebookRenderer', { + const ttPolicy = window.trustedTypes?.createPolicy('notebookRenderer', { createHTML: value => value, // CodeQL [SM03712] The rendered content is provided by renderer extensions, which are responsible for sanitizing their content themselves. The notebook webview is also sandboxed. createScript: value => value, // CodeQL [SM03712] The rendered content is provided by renderer extensions, which are responsible for sanitizing their content themselves. The notebook webview is also sandboxed. }); - $window.addEventListener('wheel', handleWheel); + window.addEventListener('wheel', handleWheel); interface IFindMatch { type: 'preview' | 'output'; @@ -961,8 +1138,8 @@ async function webviewPreloads(ctx: PreloadContext) { currentMatchIndex: number; } - const matchColor = $window.getComputedStyle($window.document.getElementById('_defaultColorPalatte')!).color; - const currentMatchColor = $window.getComputedStyle($window.document.getElementById('_defaultColorPalatte')!).backgroundColor; + const matchColor = window.getComputedStyle(window.document.getElementById('_defaultColorPalatte')!).color; + const currentMatchColor = window.getComputedStyle(window.document.getElementById('_defaultColorPalatte')!).backgroundColor; class JSHighlighter implements IHighlighter { private _activeHighlightInfo: Map; @@ -1008,11 +1185,11 @@ async function webviewPreloads(ctx: PreloadContext) { const match = highlightInfo.matches[index]; highlightInfo.currentMatchIndex = index; - const sel = $window.getSelection(); + const sel = window.getSelection(); if (!!match && !!sel && match.highlightResult) { let offset = 0; try { - const outputOffset = $window.document.getElementById(match.id)!.getBoundingClientRect().top; + const outputOffset = window.document.getElementById(match.id)!.getBoundingClientRect().top; const tempRange = document.createRange(); tempRange.selectNode(match.highlightResult.range.startContainer); @@ -1028,7 +1205,7 @@ async function webviewPreloads(ctx: PreloadContext) { match.highlightResult?.update(currentMatchColor, match.isShadow ? undefined : 'current-find-match'); - $window.document.getSelection()?.removeAllRanges(); + window.document.getSelection()?.removeAllRanges(); postNotebookMessage('didFindHighlightCurrent', { offset }); @@ -1047,7 +1224,7 @@ async function webviewPreloads(ctx: PreloadContext) { } dispose() { - $window.document.getSelection()?.removeAllRanges(); + window.document.getSelection()?.removeAllRanges(); this._activeHighlightInfo.forEach(highlightInfo => { highlightInfo.matches.forEach(match => { match.highlightResult?.dispose(); @@ -1122,7 +1299,7 @@ async function webviewPreloads(ctx: PreloadContext) { if (match) { let offset = 0; try { - const outputOffset = $window.document.getElementById(match.id)!.getBoundingClientRect().top; + const outputOffset = window.document.getElementById(match.id)!.getBoundingClientRect().top; match.originalRange.startContainer.parentElement?.scrollIntoView({ behavior: 'auto', block: 'end', inline: 'nearest' }); const rangeOffset = match.originalRange.getBoundingClientRect().top; offset = rangeOffset - outputOffset; @@ -1151,7 +1328,7 @@ async function webviewPreloads(ctx: PreloadContext) { } dispose(): void { - $window.document.getSelection()?.removeAllRanges(); + window.document.getSelection()?.removeAllRanges(); this._currentMatchesHighlight.clear(); this._matchesHighlight.clear(); } @@ -1252,8 +1429,8 @@ async function webviewPreloads(ctx: PreloadContext) { const matches: IFindMatch[] = []; const range = document.createRange(); - range.selectNodeContents($window.document.getElementById('findStart')!); - const sel = $window.getSelection(); + range.selectNodeContents(window.document.getElementById('findStart')!); + const sel = window.getSelection(); sel?.removeAllRanges(); sel?.addRange(range); @@ -1263,7 +1440,7 @@ async function webviewPreloads(ctx: PreloadContext) { document.designMode = 'On'; while (find && matches.length < 500) { - find = ($window as any).find(query, /* caseSensitive*/ !!options.caseSensitive, + find = (window as any).find(query, /* caseSensitive*/ !!options.caseSensitive, /* backwards*/ false, /* wrapAround*/ false, /* wholeWord */ !!options.wholeWord, @@ -1271,7 +1448,7 @@ async function webviewPreloads(ctx: PreloadContext) { false); if (find) { - const selection = $window.getSelection(); + const selection = window.getSelection(); if (!selection) { console.log('no selection'); break; @@ -1360,7 +1537,7 @@ async function webviewPreloads(ctx: PreloadContext) { break; } - if (node.id === 'container' || node === $window.document.body) { + if (node.id === 'container' || node === window.document.body) { break; } } @@ -1376,7 +1553,7 @@ async function webviewPreloads(ctx: PreloadContext) { } _highlighter.addHighlights(matches, options.ownerID); - $window.document.getSelection()?.removeAllRanges(); + window.document.getSelection()?.removeAllRanges(); viewModel.toggleDragDropEnabled(currentOptions.dragAndDropEnabled); @@ -1394,7 +1571,7 @@ async function webviewPreloads(ctx: PreloadContext) { }; const copyOutputImage = async (outputId: string, altOutputId: string, retries = 5) => { - if (!$window.document.hasFocus() && retries > 0) { + if (!window.document.hasFocus() && retries > 0) { // copyImage can be called from outside of the webview, which means this function may be running whilst the webview is gaining focus. // Since navigator.clipboard.write requires the document to be focused, we need to wait for focus. // We cannot use a listener, as there is a high chance the focus is gained during the setup of the listener resulting in us missing it. @@ -1403,8 +1580,8 @@ async function webviewPreloads(ctx: PreloadContext) { } try { - const outputElement = $window.document.getElementById(outputId) - ?? $window.document.getElementById(altOutputId); + const outputElement = window.document.getElementById(outputId) + ?? window.document.getElementById(altOutputId); let image = outputElement?.querySelector('img'); @@ -1446,7 +1623,7 @@ async function webviewPreloads(ctx: PreloadContext) { } }; - $window.addEventListener('message', async rawEvent => { + window.addEventListener('message', async rawEvent => { const event = rawEvent as ({ data: webviewMessages.ToWebviewMessage }); switch (event.data.type) { @@ -1520,7 +1697,7 @@ async function webviewPreloads(ctx: PreloadContext) { case 'clear': renderers.clearAll(); viewModel.clearAll(); - $window.document.getElementById('container')!.innerText = ''; + window.document.getElementById('container')!.innerText = ''; break; case 'clearOutput': { @@ -1571,11 +1748,20 @@ async function webviewPreloads(ctx: PreloadContext) { case 'focus-output': focusFirstFocusableOrContainerInOutput(event.data.cellOrOutputId, event.data.alternateId); break; + case 'blur-output': + blurOutput(); + break; + case 'select-output-contents': + selectOutputContents(event.data.cellOrOutputId); + break; + case 'select-input-contents': + selectInputContents(event.data.cellOrOutputId); + break; case 'decorations': { - let outputContainer = $window.document.getElementById(event.data.cellId); + let outputContainer = window.document.getElementById(event.data.cellId); if (!outputContainer) { viewModel.ensureOutputCell(event.data.cellId, -100000, true); - outputContainer = $window.document.getElementById(event.data.cellId); + outputContainer = window.document.getElementById(event.data.cellId); } outputContainer?.classList.add(...event.data.addedClassNames); outputContainer?.classList.remove(...event.data.removedClassNames); @@ -1588,7 +1774,7 @@ async function webviewPreloads(ctx: PreloadContext) { renderers.getRenderer(event.data.rendererId)?.receiveMessage(event.data.message); break; case 'notebookStyles': { - const documentStyle = $window.document.documentElement.style; + const documentStyle = window.document.documentElement.style; for (let i = documentStyle.length - 1; i >= 0; i--) { const property = documentStyle[i]; @@ -1969,7 +2155,7 @@ async function webviewPreloads(ctx: PreloadContext) { public async render(item: ExtendedOutputItem, preferredRendererId: string | undefined, element: HTMLElement, signal: AbortSignal): Promise { const primaryRenderer = this.findRenderer(preferredRendererId, item); if (!primaryRenderer) { - const errorMessage = ($window.document.documentElement.style.getPropertyValue('--notebook-cell-renderer-not-found-error') || '').replace('$0', () => item.mime); + const errorMessage = (window.document.documentElement.style.getPropertyValue('--notebook-cell-renderer-not-found-error') || '').replace('$0', () => item.mime); this.showRenderError(item, element, errorMessage); return; } @@ -2001,7 +2187,7 @@ async function webviewPreloads(ctx: PreloadContext) { } // All renderers have failed and there is nothing left to fallback to - const errorMessage = ($window.document.documentElement.style.getPropertyValue('--notebook-cell-renderer-fallbacks-exhausted') || '').replace('$0', () => item.mime); + const errorMessage = (window.document.documentElement.style.getPropertyValue('--notebook-cell-renderer-fallbacks-exhausted') || '').replace('$0', () => item.mime); this.showRenderError(item, element, errorMessage); } @@ -2318,7 +2504,7 @@ async function webviewPreloads(ctx: PreloadContext) { }] }); - const root = $window.document.getElementById('container')!; + const root = window.document.getElementById('container')!; const markupCell = document.createElement('div'); markupCell.className = 'markup'; markupCell.style.position = 'absolute'; @@ -2486,7 +2672,7 @@ async function webviewPreloads(ctx: PreloadContext) { private readonly outputElements = new Map(); constructor(cellId: string) { - const container = $window.document.getElementById('container')!; + const container = window.document.getElementById('container')!; const upperWrapperElement = createFocusSink(cellId); container.appendChild(upperWrapperElement); @@ -2532,7 +2718,21 @@ async function webviewPreloads(ctx: PreloadContext) { outputElement/** outputNode */.element.style.visibility = data.initiallyHidden ? 'hidden' : ''; if (!!data.executionId && !!data.rendererId) { - postNotebookMessage('notebookPerformanceMessage', { cellId: data.cellId, executionId: data.executionId, duration: Date.now() - startTime, rendererId: data.rendererId }); + let outputSize: number | undefined = undefined; + let mimeType: string | undefined = undefined; + if (data.content.type === 1 /* extension */) { + outputSize = data.content.output.valueBytes.length; + mimeType = data.content.output.mime; + } + + postNotebookMessage('notebookPerformanceMessage', { + cellId: data.cellId, + executionId: data.executionId, + duration: Date.now() - startTime, + rendererId: data.rendererId, + outputSize, + mimeType + }); } } @@ -2779,12 +2979,12 @@ async function webviewPreloads(ctx: PreloadContext) { private dragOverlay?: HTMLElement; constructor() { - $window.document.addEventListener('dragover', e => { + window.document.addEventListener('dragover', e => { // Allow dropping dragged markup cells e.preventDefault(); }); - $window.document.addEventListener('drop', e => { + window.document.addEventListener('drop', e => { e.preventDefault(); const drag = this.currentDrag; @@ -2823,7 +3023,7 @@ async function webviewPreloads(ctx: PreloadContext) { this.dragOverlay.style.width = '100%'; this.dragOverlay.style.height = '100%'; this.dragOverlay.style.background = 'transparent'; - $window.document.body.appendChild(this.dragOverlay); + window.document.body.appendChild(this.dragOverlay); } (e.target as HTMLElement).style.zIndex = `${overlayZIndex + 1}`; (e.target as HTMLElement).classList.add('dragging'); @@ -2844,9 +3044,9 @@ async function webviewPreloads(ctx: PreloadContext) { cellId: cellId, dragOffsetY: this.currentDrag.clientY, }); - $window.requestAnimationFrame(trySendDragUpdate); + window.requestAnimationFrame(trySendDragUpdate); }; - $window.requestAnimationFrame(trySendDragUpdate); + window.requestAnimationFrame(trySendDragUpdate); } updateDrag(e: DragEvent, cellId: string) { @@ -2865,7 +3065,7 @@ async function webviewPreloads(ctx: PreloadContext) { }); if (this.dragOverlay) { - $window.document.body.removeChild(this.dragOverlay); + window.document.body.removeChild(this.dragOverlay); this.dragOverlay = undefined; } diff --git a/src/vs/workbench/contrib/notebook/browser/viewModel/baseCellViewModel.ts b/src/vs/workbench/contrib/notebook/browser/viewModel/baseCellViewModel.ts index 548a5a7c043db..a8d2b94c781ac 100644 --- a/src/vs/workbench/contrib/notebook/browser/viewModel/baseCellViewModel.ts +++ b/src/vs/workbench/contrib/notebook/browser/viewModel/baseCellViewModel.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { Emitter, Event } from 'vs/base/common/event'; -import { Disposable, dispose, IDisposable, IReference } from 'vs/base/common/lifecycle'; +import { Disposable, IDisposable, IReference, MutableDisposable, dispose } from 'vs/base/common/lifecycle'; import { Mimes } from 'vs/base/common/mime'; import { ICodeEditor } from 'vs/editor/browser/editorBrowser'; import { ICodeEditorService } from 'vs/editor/browser/services/codeEditorService'; @@ -19,11 +19,11 @@ import { IConfigurationService } from 'vs/platform/configuration/common/configur import { IUndoRedoService } from 'vs/platform/undoRedo/common/undoRedo'; import { IWordWrapTransientState, readTransientState, writeTransientState } from 'vs/workbench/contrib/codeEditor/browser/toggleWordWrap'; import { CellEditState, CellFocusMode, CursorAtBoundary, CursorAtLineBoundary, IEditableCellViewModel, INotebookCellDecorationOptions } from 'vs/workbench/contrib/notebook/browser/notebookBrowser'; +import { NotebookOptionsChangeEvent } from 'vs/workbench/contrib/notebook/browser/notebookOptions'; import { CellViewModelStateChangeEvent } from 'vs/workbench/contrib/notebook/browser/notebookViewEvents'; import { ViewContext } from 'vs/workbench/contrib/notebook/browser/viewModel/viewContext'; import { NotebookCellTextModel } from 'vs/workbench/contrib/notebook/common/model/notebookCellTextModel'; import { CellKind, INotebookCellStatusBarItem, INotebookSearchOptions } from 'vs/workbench/contrib/notebook/common/notebookCommon'; -import { NotebookOptionsChangeEvent } from 'vs/workbench/contrib/notebook/browser/notebookOptions'; export abstract class BaseCellViewModel extends Disposable { @@ -103,6 +103,7 @@ export abstract class BaseCellViewModel extends Disposable { private _editorViewStates: editorCommon.ICodeEditorViewState | null = null; private _editorTransientState: IWordWrapTransientState | null = null; private _resolvedCellDecorations = new Map(); + private readonly _textModelRefChangeDisposable = this._register(new MutableDisposable()); private readonly _cellDecorationsChanged = this._register(new Emitter<{ added: INotebookCellDecorationOptions[]; removed: INotebookCellDecorationOptions[] }>()); onCellDecorationsChanged: Event<{ added: INotebookCellDecorationOptions[]; removed: INotebookCellDecorationOptions[] }> = this._cellDecorationsChanged.event; @@ -299,6 +300,7 @@ export abstract class BaseCellViewModel extends Disposable { this._textModelRef.dispose(); this._textModelRef = undefined; } + this._textModelRefChangeDisposable.clear(); } getText(): string { @@ -618,8 +620,7 @@ export abstract class BaseCellViewModel extends Disposable { if (!this._textModelRef) { throw new Error(`Cannot resolve text model for ${this.uri}`); } - - this._register(this.textModel!.onDidChangeContent(() => this.onDidChangeTextModelContent())); + this._textModelRefChangeDisposable.value = this.textModel!.onDidChangeContent(() => this.onDidChangeTextModelContent()); } return this.textModel!; diff --git a/src/vs/workbench/contrib/notebook/browser/viewModel/cellEdit.ts b/src/vs/workbench/contrib/notebook/browser/viewModel/cellEdit.ts index cc3aa9cbca9cd..6c73c4348fffb 100644 --- a/src/vs/workbench/contrib/notebook/browser/viewModel/cellEdit.ts +++ b/src/vs/workbench/contrib/notebook/browser/viewModel/cellEdit.ts @@ -22,7 +22,7 @@ export interface IViewCellEditingDelegate extends ITextCellEditingDelegate { export class JoinCellEdit implements IResourceUndoRedoElement { type: UndoRedoElementType.Resource = UndoRedoElementType.Resource; label: string = 'Join Cell'; - code: string = 'undoredo.notebooks.joinCell'; + code: string = 'undoredo.textBufferEdit'; private _deletedRawCell: NotebookCellTextModel; constructor( public resource: URI, diff --git a/src/vs/workbench/contrib/notebook/browser/viewModel/cellEditorOptions.ts b/src/vs/workbench/contrib/notebook/browser/viewModel/cellEditorOptions.ts index e093b79bc402c..84e444ac3884d 100644 --- a/src/vs/workbench/contrib/notebook/browser/viewModel/cellEditorOptions.ts +++ b/src/vs/workbench/contrib/notebook/browser/viewModel/cellEditorOptions.ts @@ -32,7 +32,7 @@ export class BaseCellEditorOptions extends Disposable implements IBaseCellEditor lineNumbersMinChars: 3 }; - private _localDisposableStore = this._register(new DisposableStore()); + private readonly _localDisposableStore = this._register(new DisposableStore()); private readonly _onDidChange = this._register(new Emitter()); readonly onDidChange: Event = this._onDidChange.event; private _value: IEditorOptions; @@ -83,11 +83,13 @@ export class BaseCellEditorOptions extends Disposable implements IBaseCellEditor private _computeEditorOptions() { const editorOptions = deepClone(this.configurationService.getValue('editor', { overrideIdentifier: this.language })); - const editorOptionsOverrideRaw = this.notebookOptions.getDisplayOptions().editorOptionsCustomizations ?? {}; - const editorOptionsOverride: { [key: string]: any } = {}; - for (const key in editorOptionsOverrideRaw) { - if (key.indexOf('editor.') === 0) { - editorOptionsOverride[key.substring(7)] = editorOptionsOverrideRaw[key]; + const editorOptionsOverrideRaw = this.notebookOptions.getDisplayOptions().editorOptionsCustomizations; + const editorOptionsOverride: Record = {}; + if (editorOptionsOverrideRaw) { + for (const key in editorOptionsOverrideRaw) { + if (key.indexOf('editor.') === 0) { + editorOptionsOverride[key.substring(7)] = editorOptionsOverrideRaw[key as keyof typeof editorOptionsOverrideRaw]; + } } } const computed = Object.freeze({ diff --git a/src/vs/workbench/contrib/notebook/browser/viewModel/codeCellViewModel.ts b/src/vs/workbench/contrib/notebook/browser/viewModel/codeCellViewModel.ts index 93ae763c6e90f..da9b53fff5ffe 100644 --- a/src/vs/workbench/contrib/notebook/browser/viewModel/codeCellViewModel.ts +++ b/src/vs/workbench/contrib/notebook/browser/viewModel/codeCellViewModel.ts @@ -22,6 +22,8 @@ import { INotebookService } from 'vs/workbench/contrib/notebook/common/notebookS import { BaseCellViewModel } from './baseCellViewModel'; import { NotebookLayoutInfo } from 'vs/workbench/contrib/notebook/browser/notebookViewEvents'; import { ICellExecutionStateChangedEvent } from 'vs/workbench/contrib/notebook/common/notebookExecutionStateService'; +import { CellDiagnostics } from 'vs/workbench/contrib/notebook/browser/contrib/cellDiagnostics/cellDiagnostics'; +import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; export const outputDisplayLimit = 500; @@ -44,6 +46,11 @@ export class CodeCellViewModel extends BaseCellViewModel implements ICellViewMod private _outputCollection: number[] = []; + private readonly _cellDiagnostics: CellDiagnostics; + get cellDiagnostics() { + return this._cellDiagnostics; + } + private _outputsTop: PrefixSumComputer | null = null; protected _pauseableEmitter = this._register(new PauseableEmitter()); @@ -108,6 +115,15 @@ export class CodeCellViewModel extends BaseCellViewModel implements ICellViewMod this._onDidChangeState.fire({ outputIsFocusedChanged: true }); } + private _focusInputInOutput: boolean = false; + public get inputInOutputIsFocused(): boolean { + return this._focusInputInOutput; + } + + public set inputInOutputIsFocused(v: boolean) { + this._focusInputInOutput = v; + } + private _outputMinHeight: number = 0; private get outputMinHeight() { @@ -143,7 +159,8 @@ export class CodeCellViewModel extends BaseCellViewModel implements ICellViewMod @INotebookService private readonly _notebookService: INotebookService, @ITextModelService modelService: ITextModelService, @IUndoRedoService undoRedoService: IUndoRedoService, - @ICodeEditorService codeEditorService: ICodeEditorService + @ICodeEditorService codeEditorService: ICodeEditorService, + @IInstantiationService instantiationService: IInstantiationService ) { super(viewType, model, UUID.generateUuid(), viewContext, configurationService, modelService, undoRedoService, codeEditorService); this._outputViewModels = this.model.outputs.map(output => new CellOutputViewModel(this, output, this._notebookService)); @@ -166,11 +183,17 @@ export class CodeCellViewModel extends BaseCellViewModel implements ICellViewMod if (outputLayoutChange) { this.layoutChange({ outputHeight: true }, 'CodeCellViewModel#model.onDidChangeOutputs'); } + if (this._outputCollection.length === 0) { + this._cellDiagnostics.clear(); + } dispose(removedOutputs); })); this._outputCollection = new Array(this.model.outputs.length); + this._cellDiagnostics = instantiationService.createInstance(CellDiagnostics, this); + this._register(this._cellDiagnostics); + this._layoutInfo = { fontInfo: initialNotebookLayoutInfo?.fontInfo || null, editorHeight: 0, @@ -425,6 +448,7 @@ export class CodeCellViewModel extends BaseCellViewModel implements ICellViewMod this.updateEditState(CellEditState.Editing, 'onDidChangeTextModelContent'); this._onDidChangeState.fire({ contentChanged: true }); } + this._cellDiagnostics.clear(); } onDeselect() { diff --git a/src/vs/workbench/contrib/notebook/browser/viewModel/foldingModel.ts b/src/vs/workbench/contrib/notebook/browser/viewModel/foldingModel.ts index 143d969f6ba54..4b86fdf842775 100644 --- a/src/vs/workbench/contrib/notebook/browser/viewModel/foldingModel.ts +++ b/src/vs/workbench/contrib/notebook/browser/viewModel/foldingModel.ts @@ -317,7 +317,7 @@ export function* getMarkdownHeadersInCell(cellContent: string): Iterable<{ reado if (token.type === 'heading') { yield { depth: token.depth, - text: renderMarkdownAsPlaintext({ value: token.text }).trim() + text: renderMarkdownAsPlaintext({ value: token.raw }).trim() }; } } diff --git a/src/vs/workbench/contrib/notebook/browser/viewModel/markupCellViewModel.ts b/src/vs/workbench/contrib/notebook/browser/viewModel/markupCellViewModel.ts index 41fbef9a00797..0f255cc20db21 100644 --- a/src/vs/workbench/contrib/notebook/browser/viewModel/markupCellViewModel.ts +++ b/src/vs/workbench/contrib/notebook/browser/viewModel/markupCellViewModel.ts @@ -93,6 +93,14 @@ export class MarkupCellViewModel extends BaseCellViewModel implements ICellViewM this._focusOnOutput = v; } + public get inputInOutputIsFocused(): boolean { + return false; + } + + public set inputInOutputIsFocused(_: boolean) { + // + } + private _hoveringCell = false; public get cellIsHovered(): boolean { return this._hoveringCell; diff --git a/src/vs/workbench/contrib/notebook/browser/viewModel/notebookOutlineEntryFactory.ts b/src/vs/workbench/contrib/notebook/browser/viewModel/notebookOutlineEntryFactory.ts index 54335576ac92e..0222b835bda33 100644 --- a/src/vs/workbench/contrib/notebook/browser/viewModel/notebookOutlineEntryFactory.ts +++ b/src/vs/workbench/contrib/notebook/browser/viewModel/notebookOutlineEntryFactory.ts @@ -14,6 +14,7 @@ import { CellKind } from 'vs/workbench/contrib/notebook/common/notebookCommon'; import { INotebookExecutionStateService } from 'vs/workbench/contrib/notebook/common/notebookExecutionStateService'; import { IRange } from 'vs/editor/common/core/range'; import { SymbolKind } from 'vs/editor/common/languages'; +import { OutlineTarget } from 'vs/workbench/services/outline/browser/outline'; type entryDesc = { name: string; @@ -30,7 +31,7 @@ export class NotebookOutlineEntryFactory { private readonly executionStateService: INotebookExecutionStateService ) { } - public getOutlineEntries(cell: ICellViewModel, index: number): OutlineEntry[] { + public getOutlineEntries(cell: ICellViewModel, target: OutlineTarget, index: number): OutlineEntry[] { const entries: OutlineEntry[] = []; const isMarkdown = cell.cellKind === CellKind.Markup; @@ -65,26 +66,30 @@ export class NotebookOutlineEntryFactory { } if (!hasHeader) { + const exeState = !isMarkdown && this.executionStateService.getCellExecution(cell.uri); + let preview = content.trim(); + if (!isMarkdown && cell.model.textModel) { const cachedEntries = this.cellOutlineEntryCache[cell.model.textModel.id]; // Gathering symbols from the model is an async operation, but this provider is syncronous. // So symbols need to be precached before this function is called to get the full list. if (cachedEntries) { + // push code cell that is a parent of cached symbols if we are targeting the outlinePane + if (target === OutlineTarget.OutlinePane) { + entries.push(new OutlineEntry(index++, 7, cell, preview, !!exeState, exeState ? exeState.isPaused : false)); + } cachedEntries.forEach((cached) => { entries.push(new OutlineEntry(index++, cached.level, cell, cached.name, false, false, cached.range, cached.kind)); }); } } - const exeState = !isMarkdown && this.executionStateService.getCellExecution(cell.uri); - if (entries.length === 0) { - let preview = content.trim(); + if (entries.length === 0) { // if there are no cached entries, use the first line of the cell as a code cell if (preview.length === 0) { // empty or just whitespace preview = localize('empty', "empty cell"); } - entries.push(new OutlineEntry(index++, 7, cell, preview, !!exeState, exeState ? exeState.isPaused : false)); } } @@ -95,7 +100,7 @@ export class NotebookOutlineEntryFactory { public async cacheSymbols(cell: ICellViewModel, outlineModelService: IOutlineModelService, cancelToken: CancellationToken) { const textModel = await cell.resolveTextModel(); const outlineModel = await outlineModelService.getOrCreate(textModel, cancelToken); - const entries = createOutlineEntries(outlineModel.getTopLevelSymbols(), 7); + const entries = createOutlineEntries(outlineModel.getTopLevelSymbols(), 8); this.cellOutlineEntryCache[textModel.id] = entries; } } diff --git a/src/vs/workbench/contrib/notebook/browser/viewModel/notebookOutlineProvider.ts b/src/vs/workbench/contrib/notebook/browser/viewModel/notebookOutlineProvider.ts index bdb16299dc1e8..ee0f792b3bc37 100644 --- a/src/vs/workbench/contrib/notebook/browser/viewModel/notebookOutlineProvider.ts +++ b/src/vs/workbench/contrib/notebook/browser/viewModel/notebookOutlineProvider.ts @@ -10,8 +10,8 @@ import { URI } from 'vs/base/common/uri'; import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; import { IMarkerService } from 'vs/platform/markers/common/markers'; import { IThemeService } from 'vs/platform/theme/common/themeService'; -import { IActiveNotebookEditor, INotebookEditor, INotebookViewCellsUpdateEvent } from 'vs/workbench/contrib/notebook/browser/notebookBrowser'; -import { CellKind } from 'vs/workbench/contrib/notebook/common/notebookCommon'; +import { IActiveNotebookEditor, ICellViewModel, INotebookEditor, INotebookViewCellsUpdateEvent } from 'vs/workbench/contrib/notebook/browser/notebookBrowser'; +import { CellKind, NotebookSetting } from 'vs/workbench/contrib/notebook/common/notebookCommon'; import { INotebookExecutionStateService, NotebookExecutionType } from 'vs/workbench/contrib/notebook/common/notebookExecutionStateService'; import { OutlineChangeEvent, OutlineConfigKeys, OutlineTarget } from 'vs/workbench/services/outline/browser/outline'; import { OutlineEntry } from './OutlineEntry'; @@ -20,7 +20,7 @@ import { CancellationToken } from 'vs/base/common/cancellation'; import { NotebookOutlineEntryFactory } from 'vs/workbench/contrib/notebook/browser/viewModel/notebookOutlineEntryFactory'; export class NotebookCellOutlineProvider { - private readonly _dispoables = new DisposableStore(); + private readonly _disposables = new DisposableStore(); private readonly _onDidChange = new Emitter(); readonly onDidChange: Event = this._onDidChange.event; @@ -54,7 +54,7 @@ export class NotebookCellOutlineProvider { this._outlineEntryFactory = new NotebookOutlineEntryFactory(notebookExecutionStateService); const selectionListener = new MutableDisposable(); - this._dispoables.add(selectionListener); + this._disposables.add(selectionListener); selectionListener.value = combinedDisposable( Event.debounce( @@ -69,17 +69,21 @@ export class NotebookCellOutlineProvider { )(this._recomputeState, this) ); - this._dispoables.add(_configurationService.onDidChangeConfiguration(e => { - if (e.affectsConfiguration('notebook.outline.showCodeCells')) { + this._disposables.add(_configurationService.onDidChangeConfiguration(e => { + if (e.affectsConfiguration(NotebookSetting.outlineShowMarkdownHeadersOnly) || + e.affectsConfiguration(NotebookSetting.outlineShowCodeCells) || + e.affectsConfiguration(NotebookSetting.outlineShowCodeCellSymbols) || + e.affectsConfiguration(NotebookSetting.breadcrumbsShowCodeCells) + ) { this._recomputeState(); } })); - this._dispoables.add(themeService.onDidFileIconThemeChange(() => { + this._disposables.add(themeService.onDidFileIconThemeChange(() => { this._onDidChange.fire({}); })); - this._dispoables.add(notebookExecutionStateService.onDidChangeExecution(e => { + this._disposables.add(notebookExecutionStateService.onDidChangeExecution(e => { if (e.type === NotebookExecutionType.cell && !!this._editor.textModel && e.affectsNotebook(this._editor.textModel?.uri)) { this._recomputeState(); } @@ -92,7 +96,7 @@ export class NotebookCellOutlineProvider { this._entries.length = 0; this._activeEntry = undefined; this._entriesDisposables.dispose(); - this._dispoables.dispose(); + this._disposables.dispose(); } init(): void { @@ -136,17 +140,20 @@ export class NotebookCellOutlineProvider { } let includeCodeCells = true; - if (this._target === OutlineTarget.OutlinePane) { - includeCodeCells = this._configurationService.getValue('notebook.outline.showCodeCells'); - } else if (this._target === OutlineTarget.Breadcrumbs) { + if (this._target === OutlineTarget.Breadcrumbs) { includeCodeCells = this._configurationService.getValue('notebook.breadcrumbs.showCodeCells'); } - const notebookCells = notebookEditorWidget.getViewModel().viewCells.filter((cell) => cell.cellKind === CellKind.Markup || includeCodeCells); + let notebookCells: ICellViewModel[]; + if (this._target === OutlineTarget.Breadcrumbs) { + notebookCells = notebookEditorWidget.getViewModel().viewCells.filter((cell) => cell.cellKind === CellKind.Markup || includeCodeCells); + } else { + notebookCells = notebookEditorWidget.getViewModel().viewCells; + } const entries: OutlineEntry[] = []; for (const cell of notebookCells) { - entries.push(...this._outlineEntryFactory.getOutlineEntries(cell, entries.length)); + entries.push(...this._outlineEntryFactory.getOutlineEntries(cell, this._target, entries.length)); // send an event whenever any of the cells change this._entriesDisposables.add(cell.model.onDidChangeContent(() => { this._recomputeState(); @@ -262,8 +269,6 @@ export class NotebookCellOutlineProvider { } } - - get isEmpty(): boolean { return this._entries.length === 0; } diff --git a/src/vs/workbench/contrib/notebook/browser/viewModel/notebookViewModelImpl.ts b/src/vs/workbench/contrib/notebook/browser/viewModel/notebookViewModelImpl.ts index 385a37ad94883..b7fa0bc7ecfe6 100644 --- a/src/vs/workbench/contrib/notebook/browser/viewModel/notebookViewModelImpl.ts +++ b/src/vs/workbench/contrib/notebook/browser/viewModel/notebookViewModelImpl.ts @@ -102,7 +102,7 @@ export interface NotebookViewModelOptions { } export class NotebookViewModel extends Disposable implements EditorFoldingStateDelegate, INotebookViewModel { - private _localStore: DisposableStore = this._register(new DisposableStore()); + private readonly _localStore = this._register(new DisposableStore()); private _handleToViewCellMapping = new Map(); get options(): NotebookViewModelOptions { return this._options; } private readonly _onDidChangeOptions = this._register(new Emitter()); @@ -177,6 +177,8 @@ export class NotebookViewModel extends Disposable implements EditorFoldingStateD private readonly _instanceId: string; public readonly id: string; private _foldingRanges: FoldingRegions | null = null; + private _onDidFoldingStateChanged = new Emitter(); + onDidFoldingStateChanged: Event = this._onDidFoldingStateChanged.event; private _hiddenRanges: ICellRange[] = []; private _focused: boolean = true; @@ -361,9 +363,6 @@ export class NotebookViewModel extends Disposable implements EditorFoldingStateD this._focused = focused; } - /** - * Empty selection will be turned to `null` - */ validateRange(cellRange: ICellRange | null | undefined): ICellRange | null { if (!cellRange) { return null; @@ -372,11 +371,7 @@ export class NotebookViewModel extends Disposable implements EditorFoldingStateD const start = clamp(cellRange.start, 0, this.length); const end = clamp(cellRange.end, 0, this.length); - if (start === end) { - return null; - } - - if (start < end) { + if (start <= end) { return { start, end }; } else { return { start: end, end: start }; @@ -477,6 +472,7 @@ export class NotebookViewModel extends Disposable implements EditorFoldingStateD if (updateHiddenAreas || k < this._hiddenRanges.length) { this._hiddenRanges = newHiddenAreas; + this._onDidFoldingStateChanged.fire(); } this._viewCells.forEach(cell => { diff --git a/src/vs/workbench/contrib/notebook/browser/viewParts/notebookEditorStickyScroll.ts b/src/vs/workbench/contrib/notebook/browser/viewParts/notebookEditorStickyScroll.ts index a02e1bb20103b..5900460feb9f5 100644 --- a/src/vs/workbench/contrib/notebook/browser/viewParts/notebookEditorStickyScroll.ts +++ b/src/vs/workbench/contrib/notebook/browser/viewParts/notebookEditorStickyScroll.ts @@ -3,17 +3,12 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { localize, localize2 } from 'vs/nls'; import * as DOM from 'vs/base/browser/dom'; import { EventType as TouchEventType } from 'vs/base/browser/touch'; import { StandardMouseEvent } from 'vs/base/browser/mouseEvent'; import { Emitter, Event } from 'vs/base/common/event'; import { Disposable, DisposableStore } from 'vs/base/common/lifecycle'; -import { ServicesAccessor } from 'vs/editor/browser/editorExtensions'; -import { Categories } from 'vs/platform/action/common/actionCommonCategories'; -import { Action2, MenuId, registerAction2 } from 'vs/platform/actions/common/actions'; -import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; -import { ContextKeyExpr } from 'vs/platform/contextkey/common/contextkey'; +import { MenuId } from 'vs/platform/actions/common/actions'; import { IContextMenuService } from 'vs/platform/contextview/browser/contextView'; import { CellFoldingState, INotebookEditor } from 'vs/workbench/contrib/notebook/browser/notebookBrowser'; import { INotebookCellList } from 'vs/workbench/contrib/notebook/browser/view/notebookRenderingCommon'; @@ -26,35 +21,7 @@ import { foldingCollapsedIcon, foldingExpandedIcon } from 'vs/editor/contrib/fol import { MarkupCellViewModel } from 'vs/workbench/contrib/notebook/browser/viewModel/markupCellViewModel'; import { FoldingController } from 'vs/workbench/contrib/notebook/browser/controller/foldingController'; import { NotebookOptionsChangeEvent } from 'vs/workbench/contrib/notebook/browser/notebookOptions'; - -export class ToggleNotebookStickyScroll extends Action2 { - - constructor() { - super({ - id: 'notebook.action.toggleNotebookStickyScroll', - title: { - ...localize2('toggleStickyScroll', "Toggle Notebook Sticky Scroll"), - mnemonicTitle: localize({ key: 'mitoggleStickyScroll', comment: ['&& denotes a mnemonic'] }, "&&Toggle Notebook Sticky Scroll"), - }, - category: Categories.View, - toggled: { - condition: ContextKeyExpr.equals('config.notebook.stickyScroll.enabled', true), - title: localize('notebookStickyScroll', "Notebook Sticky Scroll"), - mnemonicTitle: localize({ key: 'miNotebookStickyScroll', comment: ['&& denotes a mnemonic'] }, "&&Notebook Sticky Scroll"), - }, - menu: [ - { id: MenuId.CommandPalette }, - { id: MenuId.NotebookStickyScrollContext } - ] - }); - } - - override async run(accessor: ServicesAccessor): Promise { - const configurationService = accessor.get(IConfigurationService); - const newValue = !configurationService.getValue('notebook.stickyScroll.enabled'); - return configurationService.updateValue('notebook.stickyScroll.enabled', newValue); - } -} +import { NotebookSectionArgs } from 'vs/workbench/contrib/notebook/browser/controller/sectionActions'; export class NotebookStickyLine extends Disposable { constructor( @@ -78,14 +45,6 @@ export class NotebookStickyLine extends Disposable { } })); - // folding icon hovers - // this._register(DOM.addDisposableListener(this.element, DOM.EventType.MOUSE_OVER, () => { - // this.foldingIcon.setVisible(true); - // })); - // this._register(DOM.addDisposableListener(this.element, DOM.EventType.MOUSE_OUT, () => { - // this.foldingIcon.setVisible(false); - // })); - } private toggleFoldRange(currentState: CellFoldingState) { @@ -95,7 +54,7 @@ export class NotebookStickyLine extends Disposable { const headerLevel = this.entry.level; const newFoldingState = (currentState === CellFoldingState.Collapsed) ? CellFoldingState.Expanded : CellFoldingState.Collapsed; - foldingController.setFoldingStateUp(index, newFoldingState, headerLevel); + foldingController.setFoldingStateDown(index, newFoldingState, headerLevel); this.focusCell(); } @@ -140,12 +99,10 @@ class StickyFoldingIcon { export class NotebookStickyScroll extends Disposable { private readonly _disposables = new DisposableStore(); private currentStickyLines = new Map(); - private filteredOutlineEntries: OutlineEntry[] = []; private readonly _onDidChangeNotebookStickyScroll = this._register(new Emitter()); readonly onDidChangeNotebookStickyScroll: Event = this._onDidChangeNotebookStickyScroll.event; - getDomNode(): HTMLElement { return this.domNode; } @@ -205,9 +162,22 @@ export class NotebookStickyScroll extends Disposable { private onContextMenu(e: MouseEvent) { const event = new StandardMouseEvent(DOM.getWindow(this.domNode), e); + + const selectedElement = event.target.parentElement; + const selectedOutlineEntry = Array.from(this.currentStickyLines.values()).find(entry => entry.line.element.contains(selectedElement))?.line.entry; + if (!selectedOutlineEntry) { + return; + } + + const args: NotebookSectionArgs = { + outlineEntry: selectedOutlineEntry, + notebookEditor: this.notebookEditor, + }; + this._contextMenuService.showContextMenu({ menuId: MenuId.NotebookStickyScrollContext, getAnchor: () => event, + menuActionOptions: { shouldForwardArgs: true, arg: args }, }); } @@ -223,18 +193,16 @@ export class NotebookStickyScroll extends Disposable { this.updateDisplay(); } } else if (e.stickyScrollMode && this.notebookEditor.notebookOptions.getDisplayOptions().stickyScrollEnabled) { - this.updateContent(computeContent(this.notebookEditor, this.notebookCellList, this.filteredOutlineEntries, this.getCurrentStickyHeight())); + this.updateContent(computeContent(this.notebookEditor, this.notebookCellList, this.notebookOutline.entries, this.getCurrentStickyHeight())); } } private init() { this.notebookOutline.init(); - this.filteredOutlineEntries = this.notebookOutline.entries.filter(entry => entry.level !== 7); - this.updateContent(computeContent(this.notebookEditor, this.notebookCellList, this.filteredOutlineEntries, this.getCurrentStickyHeight())); + this.updateContent(computeContent(this.notebookEditor, this.notebookCellList, this.notebookOutline.entries, this.getCurrentStickyHeight())); this._disposables.add(this.notebookOutline.onDidChange(() => { - this.filteredOutlineEntries = this.notebookOutline.entries.filter(entry => entry.level !== 7); - const recompute = computeContent(this.notebookEditor, this.notebookCellList, this.filteredOutlineEntries, this.getCurrentStickyHeight()); + const recompute = computeContent(this.notebookEditor, this.notebookCellList, this.notebookOutline.entries, this.getCurrentStickyHeight()); if (!this.compareStickyLineMaps(recompute, this.currentStickyLines)) { this.updateContent(recompute); } @@ -242,14 +210,14 @@ export class NotebookStickyScroll extends Disposable { this._disposables.add(this.notebookEditor.onDidAttachViewModel(() => { this.notebookOutline.init(); - this.updateContent(computeContent(this.notebookEditor, this.notebookCellList, this.filteredOutlineEntries, this.getCurrentStickyHeight())); + this.updateContent(computeContent(this.notebookEditor, this.notebookCellList, this.notebookOutline.entries, this.getCurrentStickyHeight())); })); this._disposables.add(this.notebookEditor.onDidScroll(() => { const d = new Delayer(100); d.trigger(() => { d.dispose(); - const recompute = computeContent(this.notebookEditor, this.notebookCellList, this.filteredOutlineEntries, this.getCurrentStickyHeight()); + const recompute = computeContent(this.notebookEditor, this.notebookCellList, this.notebookOutline.entries, this.getCurrentStickyHeight()); if (!this.compareStickyLineMaps(recompute, this.currentStickyLines)) { this.updateContent(recompute); } @@ -313,7 +281,7 @@ export class NotebookStickyScroll extends Disposable { static computeStickyHeight(entry: OutlineEntry) { let height = 0; - if (entry.cell.cellKind === CellKind.Markup && entry.level !== 7) { + if (entry.cell.cellKind === CellKind.Markup && entry.level < 7) { height += 22; } while (entry.parent) { @@ -329,8 +297,8 @@ export class NotebookStickyScroll extends Disposable { const elementsToRender = []; while (currentEntry) { - if (currentEntry.level === 7) { - // level 7 represents a non-header entry, which we don't want to render + if (currentEntry.level >= 7) { + // level 7+ represents a non-header entry, which we don't want to render currentEntry = currentEntry.parent; continue; } @@ -384,6 +352,7 @@ export class NotebookStickyScroll extends Disposable { stickyHeader.innerText = entry.label; stickyElement.append(stickyFoldingIcon.domNode, stickyHeader); + return new NotebookStickyLine(stickyElement, stickyFoldingIcon, stickyHeader, entry, notebookEditor); } @@ -413,7 +382,7 @@ export function computeContent(notebookEditor: INotebookEditor, notebookCellList if (visibleRange.start === 0) { const firstCell = notebookEditor.cellAt(0); const firstCellEntry = NotebookStickyScroll.getVisibleOutlineEntry(0, notebookOutlineEntries); - if (firstCell && firstCellEntry && firstCell.cellKind === CellKind.Markup && firstCellEntry.level !== 7) { + if (firstCell && firstCellEntry && firstCell.cellKind === CellKind.Markup && firstCellEntry.level < 7) { if (notebookEditor.scrollTop > 22) { const newMap = NotebookStickyScroll.checkCollapsedStickyLines(firstCellEntry, 100, notebookEditor); return newMap; @@ -433,7 +402,7 @@ export function computeContent(notebookEditor: INotebookEditor, notebookCellList } cellEntry = NotebookStickyScroll.getVisibleOutlineEntry(currentIndex, notebookOutlineEntries); if (!cellEntry) { - return new Map(); + continue; } const nextCell = notebookEditor.cellAt(currentIndex + 1); @@ -445,11 +414,11 @@ export function computeContent(notebookEditor: INotebookEditor, notebookCellList } const nextCellEntry = NotebookStickyScroll.getVisibleOutlineEntry(currentIndex + 1, notebookOutlineEntries); if (!nextCellEntry) { - return new Map(); + continue; } // check next cell, if markdown with non level 7 entry, that means this is the end of the section (new header) --------------------- - if (nextCell.cellKind === CellKind.Markup && nextCellEntry.level !== 7) { + if (nextCell.cellKind === CellKind.Markup && nextCellEntry.level < 7) { const sectionBottom = notebookCellList.getCellViewScrollTop(nextCell); const currentSectionStickyHeight = NotebookStickyScroll.computeStickyHeight(cellEntry); const nextSectionStickyHeight = NotebookStickyScroll.computeStickyHeight(nextCellEntry); @@ -488,5 +457,3 @@ export function computeContent(notebookEditor: INotebookEditor, notebookCellList const newMap = NotebookStickyScroll.checkCollapsedStickyLines(cellEntry, linesToRender, notebookEditor); return newMap; } - -registerAction2(ToggleNotebookStickyScroll); diff --git a/src/vs/workbench/contrib/notebook/browser/viewParts/notebookEditorToolbar.ts b/src/vs/workbench/contrib/notebook/browser/viewParts/notebookEditorToolbar.ts index e2972e82e8aea..969127bc91714 100644 --- a/src/vs/workbench/contrib/notebook/browser/viewParts/notebookEditorToolbar.ts +++ b/src/vs/workbench/contrib/notebook/browser/viewParts/notebookEditorToolbar.ts @@ -28,6 +28,8 @@ import { NotebookOptions } from 'vs/workbench/contrib/notebook/browser/notebookO import { IActionViewItem, IActionViewItemProvider } from 'vs/base/browser/ui/actionbar/actionbar'; import { disposableTimeout } from 'vs/base/common/async'; import { HiddenItemStrategy, IWorkbenchToolBarOptions, WorkbenchToolBar } from 'vs/platform/actions/browser/toolbar'; +import { IActionViewItemOptions } from 'vs/base/browser/ui/actionbar/actionViewItems'; +import { WorkbenchHoverDelegate } from 'vs/platform/hover/browser/hover'; interface IActionModel { action: IAction; @@ -75,18 +77,18 @@ class WorkbenchAlwaysLabelStrategy implements IActionLayoutStrategy { readonly goToMenu: IMenu, readonly instantiationService: IInstantiationService) { } - actionProvider(action: IAction): IActionViewItem | undefined { + actionProvider(action: IAction, options: IActionViewItemOptions): IActionViewItem | undefined { if (action.id === SELECT_KERNEL_ID) { // this is being disposed by the consumer - return this.instantiationService.createInstance(NotebooKernelActionViewItem, action, this.notebookEditor); + return this.instantiationService.createInstance(NotebooKernelActionViewItem, action, this.notebookEditor, options); } if (action instanceof MenuItemAction) { - return this.instantiationService.createInstance(ActionViewWithLabel, action, undefined); + return this.instantiationService.createInstance(ActionViewWithLabel, action, { hoverDelegate: options.hoverDelegate }); } if (action instanceof SubmenuItemAction && action.item.submenu.id === MenuId.NotebookCellExecuteGoTo.id) { - return this.instantiationService.createInstance(UnifiedSubmenuActionView, action, undefined, true, { + return this.instantiationService.createInstance(UnifiedSubmenuActionView, action, { hoverDelegate: options.hoverDelegate }, true, { getActions: () => { return this.goToMenu.getActions().find(([group]) => group === 'navigation/execute')?.[1] ?? []; } @@ -115,25 +117,25 @@ class WorkbenchNeverLabelStrategy implements IActionLayoutStrategy { readonly goToMenu: IMenu, readonly instantiationService: IInstantiationService) { } - actionProvider(action: IAction): IActionViewItem | undefined { + actionProvider(action: IAction, options: IActionViewItemOptions): IActionViewItem | undefined { if (action.id === SELECT_KERNEL_ID) { // this is being disposed by the consumer - return this.instantiationService.createInstance(NotebooKernelActionViewItem, action, this.notebookEditor); + return this.instantiationService.createInstance(NotebooKernelActionViewItem, action, this.notebookEditor, options); } if (action instanceof MenuItemAction) { - return this.instantiationService.createInstance(MenuEntryActionViewItem, action, undefined); + return this.instantiationService.createInstance(MenuEntryActionViewItem, action, { hoverDelegate: options.hoverDelegate }); } if (action instanceof SubmenuItemAction) { if (action.item.submenu.id === MenuId.NotebookCellExecuteGoTo.id) { - return this.instantiationService.createInstance(UnifiedSubmenuActionView, action, undefined, false, { + return this.instantiationService.createInstance(UnifiedSubmenuActionView, action, { hoverDelegate: options.hoverDelegate }, false, { getActions: () => { return this.goToMenu.getActions().find(([group]) => group === 'navigation/execute')?.[1] ?? []; } }, this.actionProvider.bind(this)); } else { - return this.instantiationService.createInstance(SubmenuEntryActionViewItem, action, undefined); + return this.instantiationService.createInstance(SubmenuEntryActionViewItem, action, { hoverDelegate: options.hoverDelegate }); } } @@ -159,20 +161,20 @@ class WorkbenchDynamicLabelStrategy implements IActionLayoutStrategy { readonly goToMenu: IMenu, readonly instantiationService: IInstantiationService) { } - actionProvider(action: IAction): IActionViewItem | undefined { + actionProvider(action: IAction, options: IActionViewItemOptions): IActionViewItem | undefined { if (action.id === SELECT_KERNEL_ID) { // this is being disposed by the consumer - return this.instantiationService.createInstance(NotebooKernelActionViewItem, action, this.notebookEditor); + return this.instantiationService.createInstance(NotebooKernelActionViewItem, action, this.notebookEditor, options); } const a = this.editorToolbar.primaryActions.find(a => a.action.id === action.id); if (!a || a.renderLabel) { if (action instanceof MenuItemAction) { - return this.instantiationService.createInstance(ActionViewWithLabel, action, undefined); + return this.instantiationService.createInstance(ActionViewWithLabel, action, { hoverDelegate: options.hoverDelegate }); } if (action instanceof SubmenuItemAction && action.item.submenu.id === MenuId.NotebookCellExecuteGoTo.id) { - return this.instantiationService.createInstance(UnifiedSubmenuActionView, action, undefined, true, { + return this.instantiationService.createInstance(UnifiedSubmenuActionView, action, { hoverDelegate: options.hoverDelegate }, true, { getActions: () => { return this.goToMenu.getActions().find(([group]) => group === 'navigation/execute')?.[1] ?? []; } @@ -182,18 +184,18 @@ class WorkbenchDynamicLabelStrategy implements IActionLayoutStrategy { return undefined; } else { if (action instanceof MenuItemAction) { - this.instantiationService.createInstance(MenuEntryActionViewItem, action, undefined); + this.instantiationService.createInstance(MenuEntryActionViewItem, action, { hoverDelegate: options.hoverDelegate }); } if (action instanceof SubmenuItemAction) { if (action.item.submenu.id === MenuId.NotebookCellExecuteGoTo.id) { - return this.instantiationService.createInstance(UnifiedSubmenuActionView, action, undefined, false, { + return this.instantiationService.createInstance(UnifiedSubmenuActionView, action, { hoverDelegate: options.hoverDelegate }, false, { getActions: () => { return this.goToMenu.getActions().find(([group]) => group === 'navigation/execute')?.[1] ?? []; } }, this.actionProvider.bind(this)); } else { - return this.instantiationService.createInstance(SubmenuEntryActionViewItem, action, undefined); + return this.instantiationService.createInstance(SubmenuEntryActionViewItem, action, { hoverDelegate: options.hoverDelegate }); } } @@ -321,24 +323,29 @@ export class NotebookEditorWorkbenchToolbar extends Disposable { notebookEditor: this.notebookEditor }; - const actionProvider = (action: IAction) => { + const actionProvider = (action: IAction, options: IActionViewItemOptions) => { if (action.id === SELECT_KERNEL_ID) { // this is being disposed by the consumer - return this.instantiationService.createInstance(NotebooKernelActionViewItem, action, this.notebookEditor); + return this.instantiationService.createInstance(NotebooKernelActionViewItem, action, this.notebookEditor, options); } if (this._renderLabel !== RenderLabel.Never) { const a = this._primaryActions.find(a => a.action.id === action.id); if (a && a.renderLabel) { - return action instanceof MenuItemAction ? this.instantiationService.createInstance(ActionViewWithLabel, action, undefined) : undefined; + return action instanceof MenuItemAction ? this.instantiationService.createInstance(ActionViewWithLabel, action, { hoverDelegate: options.hoverDelegate }) : undefined; } else { - return action instanceof MenuItemAction ? this.instantiationService.createInstance(MenuEntryActionViewItem, action, undefined) : undefined; + return action instanceof MenuItemAction ? this.instantiationService.createInstance(MenuEntryActionViewItem, action, { hoverDelegate: options.hoverDelegate }) : undefined; } } else { - return action instanceof MenuItemAction ? this.instantiationService.createInstance(MenuEntryActionViewItem, action, undefined) : undefined; + return action instanceof MenuItemAction ? this.instantiationService.createInstance(MenuEntryActionViewItem, action, { hoverDelegate: options.hoverDelegate }) : undefined; } }; + // Make sure both toolbars have the same hover delegate for instant hover to work + // Due to the elements being further apart than normal toolbars, the default time limit is to short and has to be increased + const hoverDelegate = this._register(this.instantiationService.createInstance(WorkbenchHoverDelegate, 'element', true, {})); + hoverDelegate.setInstantHoverTimeLimit(600); + const leftToolbarOptions: IWorkbenchToolBarOptions = { hiddenItemStrategy: HiddenItemStrategy.RenderInSecondaryGroup, resetMenu: MenuId.NotebookToolbar, @@ -347,6 +354,7 @@ export class NotebookEditorWorkbenchToolbar extends Disposable { }, getKeyBinding: action => this.keybindingService.lookupKeybinding(action.id), renderDropdownAsChildElement: true, + hoverDelegate }; this._notebookLeftToolbar = this.instantiationService.createInstance( @@ -363,7 +371,8 @@ export class NotebookEditorWorkbenchToolbar extends Disposable { this._notebookRightToolbar = new ToolBar(this._notebookTopRightToolbarContainer, this.contextMenuService, { getKeyBinding: action => this.keybindingService.lookupKeybinding(action.id), actionViewItemProvider: actionProvider, - renderDropdownAsChildElement: true + renderDropdownAsChildElement: true, + hoverDelegate }); this._register(this._notebookRightToolbar); this._notebookRightToolbar.context = context; diff --git a/src/vs/workbench/contrib/notebook/browser/viewParts/notebookKernelQuickPickStrategy.ts b/src/vs/workbench/contrib/notebook/browser/viewParts/notebookKernelQuickPickStrategy.ts index 4113623348614..25feb7f91233d 100644 --- a/src/vs/workbench/contrib/notebook/browser/viewParts/notebookKernelQuickPickStrategy.ts +++ b/src/vs/workbench/contrib/notebook/browser/viewParts/notebookKernelQuickPickStrategy.ts @@ -402,7 +402,7 @@ abstract class KernelPickerStrategyBase implements IKernelPickerStrategy { */ private getSuggestedLanguage(notebookTextModel: NotebookTextModel): string | undefined { const metaData = notebookTextModel.metadata; - let suggestedKernelLanguage: string | undefined = (metaData.custom as any)?.metadata?.language_info?.name; + let suggestedKernelLanguage: string | undefined = (metaData as any)?.metadata?.language_info?.name; // TODO how do we suggest multi language notebooks? if (!suggestedKernelLanguage) { const cellLanguages = notebookTextModel.cells.map(cell => cell.language).filter(language => language !== 'markdown'); diff --git a/src/vs/workbench/contrib/notebook/browser/viewParts/notebookKernelView.ts b/src/vs/workbench/contrib/notebook/browser/viewParts/notebookKernelView.ts index 64bd903fae6a0..8c3ecd8243687 100644 --- a/src/vs/workbench/contrib/notebook/browser/viewParts/notebookKernelView.ts +++ b/src/vs/workbench/contrib/notebook/browser/viewParts/notebookKernelView.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { ActionViewItem } from 'vs/base/browser/ui/actionbar/actionViewItems'; +import { ActionViewItem, IActionViewItemOptions } from 'vs/base/browser/ui/actionbar/actionViewItems'; import { Action, IAction } from 'vs/base/common/actions'; import { Event } from 'vs/base/common/event'; import { localize, localize2 } from 'vs/nls'; @@ -136,13 +136,14 @@ export class NotebooKernelActionViewItem extends ActionViewItem { constructor( actualAction: IAction, private readonly _editor: { onDidChangeModel: Event; textModel: NotebookTextModel | undefined; scopedContextKeyService?: IContextKeyService } | INotebookEditor, + options: IActionViewItemOptions, @INotebookKernelService private readonly _notebookKernelService: INotebookKernelService, @INotebookKernelHistoryService private readonly _notebookKernelHistoryService: INotebookKernelHistoryService, ) { super( undefined, new Action('fakeAction', undefined, ThemeIcon.asClassName(selectKernelIcon), true, (event) => actualAction.run(event)), - { label: false, icon: true } + { ...options, label: false, icon: true } ); this._register(_editor.onDidChangeModel(this._update, this)); this._register(_notebookKernelService.onDidAddKernel(this._update, this)); @@ -166,7 +167,6 @@ export class NotebooKernelActionViewItem extends ActionViewItem { if (this._kernelLabel) { this._kernelLabel.classList.add('kernel-label'); this._kernelLabel.innerText = this._action.label; - this._kernelLabel.title = this._action.tooltip; } } diff --git a/src/vs/workbench/contrib/notebook/browser/viewParts/notebookTopCellToolbar.ts b/src/vs/workbench/contrib/notebook/browser/viewParts/notebookTopCellToolbar.ts index 9a558d0dd288e..9856cbf900b4c 100644 --- a/src/vs/workbench/contrib/notebook/browser/viewParts/notebookTopCellToolbar.ts +++ b/src/vs/workbench/contrib/notebook/browser/viewParts/notebookTopCellToolbar.ts @@ -17,7 +17,7 @@ import { CodiconActionViewItem } from 'vs/workbench/contrib/notebook/browser/vie export class ListTopCellToolbar extends Disposable { private readonly topCellToolbarContainer: HTMLElement; private topCellToolbar: HTMLElement; - private viewZone: MutableDisposable = this._register(new MutableDisposable()); + private readonly viewZone: MutableDisposable = this._register(new MutableDisposable()); private readonly _modelDisposables = this._register(new DisposableStore()); constructor( protected readonly notebookEditor: INotebookEditorDelegate, @@ -96,9 +96,9 @@ export class ListTopCellToolbar extends Disposable { DOM.clearNode(this.topCellToolbar); const toolbar = this.instantiationService.createInstance(MenuWorkbenchToolBar, this.topCellToolbar, this.notebookEditor.creationOptions.menuIds.cellTopInsertToolbar, { - actionViewItemProvider: action => { + actionViewItemProvider: (action, options) => { if (action instanceof MenuItemAction) { - const item = this.instantiationService.createInstance(CodiconActionViewItem, action, undefined); + const item = this.instantiationService.createInstance(CodiconActionViewItem, action, { hoverDelegate: options.hoverDelegate }); return item; } diff --git a/src/vs/workbench/contrib/notebook/browser/viewParts/notebookViewZones.ts b/src/vs/workbench/contrib/notebook/browser/viewParts/notebookViewZones.ts index a56dd5381559b..7332529b231bb 100644 --- a/src/vs/workbench/contrib/notebook/browser/viewParts/notebookViewZones.ts +++ b/src/vs/workbench/contrib/notebook/browser/viewParts/notebookViewZones.ts @@ -120,7 +120,8 @@ export class NotebookViewZones extends Disposable { } private _addZone(zone: INotebookViewZone): string { - const whitespaceId = this.listView.insertWhitespace(zone.afterModelPosition, zone.heightInPx); + const viewPosition = this.coordinator.convertModelIndexToViewIndex(zone.afterModelPosition); + const whitespaceId = this.listView.insertWhitespace(viewPosition, zone.heightInPx); const isInHiddenArea = this._isInHiddenRanges(zone); const myZone: IZoneWidget = { whitespaceId: whitespaceId, @@ -149,6 +150,8 @@ export class NotebookViewZones extends Disposable { return; } + this._updateWhitespace(this._zones[id]); + const isInHiddenArea = this._isInHiddenRanges(zoneWidget.zone); if (isInHiddenArea) { diff --git a/src/vs/workbench/contrib/notebook/common/model/cellEdit.ts b/src/vs/workbench/contrib/notebook/common/model/cellEdit.ts index 3291110ac933b..0625af50b5d44 100644 --- a/src/vs/workbench/contrib/notebook/common/model/cellEdit.ts +++ b/src/vs/workbench/contrib/notebook/common/model/cellEdit.ts @@ -21,8 +21,10 @@ export interface ITextCellEditingDelegate { export class MoveCellEdit implements IResourceUndoRedoElement { type: UndoRedoElementType.Resource = UndoRedoElementType.Resource; - label: string = 'Move Cell'; - code: string = 'undoredo.notebooks.moveCell'; + get label() { + return this.length === 1 ? 'Move Cell' : 'Move Cells'; + } + code: string = 'undoredo.textBufferEdit'; constructor( public resource: URI, @@ -54,8 +56,18 @@ export class MoveCellEdit implements IResourceUndoRedoElement { export class SpliceCellsEdit implements IResourceUndoRedoElement { type: UndoRedoElementType.Resource = UndoRedoElementType.Resource; - label: string = 'Insert Cell'; - code: string = 'undoredo.notebooks.insertCell'; + get label() { + // Compute the most appropriate labels + if (this.diffs.length === 1 && this.diffs[0][1].length === 0) { + return this.diffs[0][2].length > 1 ? 'Insert Cells' : 'Insert Cell'; + } + if (this.diffs.length === 1 && this.diffs[0][2].length === 0) { + return this.diffs[0][1].length > 1 ? 'Delete Cells' : 'Delete Cell'; + } + // Default to Insert Cell + return 'Insert Cell'; + } + code: string = 'undoredo.textBufferEdit'; constructor( public resource: URI, private diffs: [number, NotebookCellTextModel[], NotebookCellTextModel[]][], @@ -89,7 +101,7 @@ export class SpliceCellsEdit implements IResourceUndoRedoElement { export class CellMetadataEdit implements IResourceUndoRedoElement { type: UndoRedoElementType.Resource = UndoRedoElementType.Resource; label: string = 'Update Cell Metadata'; - code: string = 'undoredo.notebooks.updateCellMetadata'; + code: string = 'undoredo.textBufferEdit'; constructor( public resource: URI, readonly index: number, diff --git a/src/vs/workbench/contrib/notebook/common/model/notebookTextModel.ts b/src/vs/workbench/contrib/notebook/common/model/notebookTextModel.ts index 2f72b6278408a..3f57341e228b3 100644 --- a/src/vs/workbench/contrib/notebook/common/model/notebookTextModel.ts +++ b/src/vs/workbench/contrib/notebook/common/model/notebookTextModel.ts @@ -25,17 +25,21 @@ import { isDefined } from 'vs/base/common/types'; class StackOperation implements IWorkspaceUndoRedoElement { type: UndoRedoElementType.Workspace; - readonly code = 'undoredo.notebooks.stackOperation'; + public get code() { + return this._operations.length === 1 ? this._operations[0].code : 'undoredo.notebooks.stackOperation'; + } private _operations: IUndoRedoElement[] = []; private _beginSelectionState: ISelectionState | undefined = undefined; private _resultSelectionState: ISelectionState | undefined = undefined; private _beginAlternativeVersionId: string; private _resultAlternativeVersionId: string; + public get label() { + return this._operations.length === 1 ? this._operations[0].label : 'edit'; + } constructor( readonly textModel: NotebookTextModel, - readonly label: string, readonly undoRedoGroup: UndoRedoGroup | undefined, private _pauseableEmitter: PauseableEmitter, private _postUndoRedo: (alternativeVersionId: string) => void, @@ -56,16 +60,18 @@ class StackOperation implements IWorkspaceUndoRedoElement { } pushEndState(alternativeVersionId: string, selectionState: ISelectionState | undefined) { + // https://github.com/microsoft/vscode/issues/207523 this._resultAlternativeVersionId = alternativeVersionId; - this._resultSelectionState = selectionState; + this._resultSelectionState = selectionState || this._resultSelectionState; } - pushEditOperation(element: IUndoRedoElement, beginSelectionState: ISelectionState | undefined, resultSelectionState: ISelectionState | undefined) { + pushEditOperation(element: IUndoRedoElement, beginSelectionState: ISelectionState | undefined, resultSelectionState: ISelectionState | undefined, alternativeVersionId: string) { if (this._operations.length === 0) { this._beginSelectionState = this._beginSelectionState ?? beginSelectionState; } this._operations.push(element); this._resultSelectionState = resultSelectionState; + this._resultAlternativeVersionId = alternativeVersionId; } async undo(): Promise { @@ -114,26 +120,20 @@ class NotebookOperationManager { return this._pendingStackOperation === null || this._pendingStackOperation.isEmpty; } - pushStackElement(label: string, selectionState: ISelectionState | undefined, undoRedoGroup: UndoRedoGroup | undefined, alternativeVersionId: string) { - if (this._pendingStackOperation) { + pushStackElement(alternativeVersionId: string, selectionState: ISelectionState | undefined) { + if (this._pendingStackOperation && !this._pendingStackOperation.isEmpty) { this._pendingStackOperation.pushEndState(alternativeVersionId, selectionState); - if (!this._pendingStackOperation.isEmpty) { - this._undoService.pushElement(this._pendingStackOperation, this._pendingStackOperation.undoRedoGroup); - } - this._pendingStackOperation = null; - return; + this._undoService.pushElement(this._pendingStackOperation, this._pendingStackOperation.undoRedoGroup); } - - this._pendingStackOperation = new StackOperation(this._textModel, label, undoRedoGroup, this._pauseableEmitter, this._postUndoRedo, selectionState, alternativeVersionId); + this._pendingStackOperation = null; + } + private _getOrCreateEditStackElement(beginSelectionState: ISelectionState | undefined, undoRedoGroup: UndoRedoGroup | undefined, alternativeVersionId: string) { + return this._pendingStackOperation ??= new StackOperation(this._textModel, undoRedoGroup, this._pauseableEmitter, this._postUndoRedo, beginSelectionState, alternativeVersionId || ''); } - pushEditOperation(element: IUndoRedoElement, beginSelectionState: ISelectionState | undefined, resultSelectionState: ISelectionState | undefined) { - if (this._pendingStackOperation) { - this._pendingStackOperation.pushEditOperation(element, beginSelectionState, resultSelectionState); - return; - } - - this._undoService.pushElement(element); + pushEditOperation(element: IUndoRedoElement, beginSelectionState: ISelectionState | undefined, resultSelectionState: ISelectionState | undefined, alternativeVersionId: string, undoRedoGroup: UndoRedoGroup | undefined) { + const pendingStackOperation = this._getOrCreateEditStackElement(beginSelectionState, undoRedoGroup, alternativeVersionId); + pendingStackOperation.pushEditOperation(element, beginSelectionState, resultSelectionState, alternativeVersionId); } } @@ -364,8 +364,8 @@ export class NotebookTextModel extends Disposable implements INotebookTextModel super.dispose(); } - pushStackElement(label: string, selectionState: ISelectionState | undefined, undoRedoGroup: UndoRedoGroup | undefined) { - this._operationManager.pushStackElement(label, selectionState, undoRedoGroup, this.alternativeVersionId); + pushStackElement() { + // https://github.com/microsoft/vscode/issues/207523 } private _getCellIndexByHandle(handle: number) { @@ -505,10 +505,10 @@ export class NotebookTextModel extends Disposable implements INotebookTextModel applyEdits(rawEdits: ICellEditOperation[], synchronous: boolean, beginSelectionState: ISelectionState | undefined, endSelectionsComputer: () => ISelectionState | undefined, undoRedoGroup: UndoRedoGroup | undefined, computeUndoRedo: boolean): boolean { this._pauseableEmitter.pause(); - this.pushStackElement('edit', beginSelectionState, undoRedoGroup); + this._operationManager.pushStackElement(this._alternativeVersionId, undefined); try { - this._doApplyEdits(rawEdits, synchronous, computeUndoRedo); + this._doApplyEdits(rawEdits, synchronous, computeUndoRedo, beginSelectionState, undoRedoGroup); return true; } finally { // Update selection and versionId after applying edits. @@ -516,7 +516,7 @@ export class NotebookTextModel extends Disposable implements INotebookTextModel this._increaseVersionId(this._operationManager.isUndoStackEmpty() && !this._pauseableEmitter.isDirtyEvent()); // Finalize undo element - this.pushStackElement('edit', endSelections, undefined); + this._operationManager.pushStackElement(this._alternativeVersionId, endSelections); // Broadcast changes this._pauseableEmitter.fire({ rawEvents: [], versionId: this.versionId, synchronous: synchronous, endSelectionState: endSelections }); @@ -524,7 +524,7 @@ export class NotebookTextModel extends Disposable implements INotebookTextModel } } - private _doApplyEdits(rawEdits: ICellEditOperation[], synchronous: boolean, computeUndoRedo: boolean): void { + private _doApplyEdits(rawEdits: ICellEditOperation[], synchronous: boolean, computeUndoRedo: boolean, beginSelectionState: ISelectionState | undefined, undoRedoGroup: UndoRedoGroup | undefined): void { const editsWithDetails = rawEdits.map((edit, index) => { let cellIndex: number = -1; if ('index' in edit) { @@ -606,7 +606,7 @@ export class NotebookTextModel extends Disposable implements INotebookTextModel for (const { edit, cellIndex } of flattenEdits) { switch (edit.editType) { case CellEditType.Replace: - this._replaceCells(edit.index, edit.count, edit.cells, synchronous, computeUndoRedo); + this._replaceCells(edit.index, edit.count, edit.cells, synchronous, computeUndoRedo, beginSelectionState, undoRedoGroup); break; case CellEditType.Output: { this._assertIndex(cellIndex); @@ -632,11 +632,11 @@ export class NotebookTextModel extends Disposable implements INotebookTextModel case CellEditType.Metadata: this._assertIndex(edit.index); - this._changeCellMetadata(this._cells[edit.index], edit.metadata, computeUndoRedo); + this._changeCellMetadata(this._cells[edit.index], edit.metadata, computeUndoRedo, beginSelectionState, undoRedoGroup); break; case CellEditType.PartialMetadata: this._assertIndex(cellIndex); - this._changeCellMetadataPartial(this._cells[cellIndex], edit.metadata, computeUndoRedo); + this._changeCellMetadataPartial(this._cells[cellIndex], edit.metadata, computeUndoRedo, beginSelectionState, undoRedoGroup); break; case CellEditType.PartialInternalMetadata: this._assertIndex(cellIndex); @@ -644,13 +644,13 @@ export class NotebookTextModel extends Disposable implements INotebookTextModel break; case CellEditType.CellLanguage: this._assertIndex(edit.index); - this._changeCellLanguage(this._cells[edit.index], edit.language, computeUndoRedo); + this._changeCellLanguage(this._cells[edit.index], edit.language, computeUndoRedo, beginSelectionState, undoRedoGroup); break; case CellEditType.DocumentMetadata: - this._updateNotebookMetadata(edit.metadata, computeUndoRedo); + this._updateNotebookCellMetadata(edit.metadata, computeUndoRedo, beginSelectionState, undoRedoGroup); break; case CellEditType.Move: - this._moveCellToIdx(edit.index, edit.length, edit.newIdx, synchronous, computeUndoRedo, undefined, undefined); + this._moveCellToIdx(edit.index, edit.length, edit.newIdx, synchronous, computeUndoRedo, beginSelectionState, undefined, undoRedoGroup); break; } } @@ -695,7 +695,7 @@ export class NotebookTextModel extends Disposable implements INotebookTextModel return cellDto.collapseState ?? (defaultConfig ?? undefined); } - private _replaceCells(index: number, count: number, cellDtos: ICellDto2[], synchronous: boolean, computeUndoRedo: boolean): void { + private _replaceCells(index: number, count: number, cellDtos: ICellDto2[], synchronous: boolean, computeUndoRedo: boolean, beginSelectionState: ISelectionState | undefined, undoRedoGroup: UndoRedoGroup | undefined): void { if (count === 0 && cellDtos.length === 0) { return; @@ -763,7 +763,7 @@ export class NotebookTextModel extends Disposable implements INotebookTextModel insertCell: (index, cell, endSelections) => { this._insertNewCell(index, [cell], true, endSelections); }, deleteCell: (index, endSelections) => { this._removeCell(index, 1, true, endSelections); }, replaceCell: (index, count, cells, endSelections) => { this._replaceNewCells(index, count, cells, true, endSelections); }, - }, undefined, undefined), undefined, undefined); + }, undefined, undefined), beginSelectionState, undefined, this._alternativeVersionId, undoRedoGroup); } // should be deferred @@ -788,7 +788,7 @@ export class NotebookTextModel extends Disposable implements INotebookTextModel this._notebookSpecificAlternativeId = Number(newAlternativeVersionId.substring(0, newAlternativeVersionId.indexOf('_'))); } - private _updateNotebookMetadata(metadata: NotebookDocumentMetadata, computeUndoRedo: boolean) { + private _updateNotebookCellMetadata(metadata: NotebookDocumentMetadata, computeUndoRedo: boolean, beginSelectionState: ISelectionState | undefined, undoRedoGroup: UndoRedoGroup | undefined) { const oldMetadata = this.metadata; const triggerDirtyChange = this._isDocumentMetadataChanged(this.metadata, metadata); @@ -800,15 +800,15 @@ export class NotebookTextModel extends Disposable implements INotebookTextModel get resource() { return that.uri; } - readonly label = 'Update Notebook Metadata'; - readonly code = 'undoredo.notebooks.updateCellMetadata'; + readonly label = 'Update Cell Metadata'; + readonly code = 'undoredo.textBufferEdit'; undo() { - that._updateNotebookMetadata(oldMetadata, false); + that._updateNotebookCellMetadata(oldMetadata, false, beginSelectionState, undoRedoGroup); } redo() { - that._updateNotebookMetadata(metadata, false); + that._updateNotebookCellMetadata(metadata, false, beginSelectionState, undoRedoGroup); } - }(), undefined, undefined); + }(), beginSelectionState, undefined, this._alternativeVersionId, undoRedoGroup); } } @@ -950,7 +950,7 @@ export class NotebookTextModel extends Disposable implements INotebookTextModel return true; } - private _changeCellMetadataPartial(cell: NotebookCellTextModel, metadata: NullablePartialNotebookCellMetadata, computeUndoRedo: boolean) { + private _changeCellMetadataPartial(cell: NotebookCellTextModel, metadata: NullablePartialNotebookCellMetadata, computeUndoRedo: boolean, beginSelectionState: ISelectionState | undefined, undoRedoGroup: UndoRedoGroup | undefined) { const newMetadata: NotebookCellMetadata = { ...cell.metadata }; @@ -960,10 +960,10 @@ export class NotebookTextModel extends Disposable implements INotebookTextModel newMetadata[k] = value as any; } - return this._changeCellMetadata(cell, newMetadata, computeUndoRedo); + return this._changeCellMetadata(cell, newMetadata, computeUndoRedo, beginSelectionState, undoRedoGroup); } - private _changeCellMetadata(cell: NotebookCellTextModel, metadata: NotebookCellMetadata, computeUndoRedo: boolean) { + private _changeCellMetadata(cell: NotebookCellTextModel, metadata: NotebookCellMetadata, computeUndoRedo: boolean, beginSelectionState: ISelectionState | undefined, undoRedoGroup: UndoRedoGroup | undefined) { const triggerDirtyChange = this._isCellMetadataChanged(cell.metadata, metadata); if (triggerDirtyChange) { @@ -975,9 +975,9 @@ export class NotebookTextModel extends Disposable implements INotebookTextModel if (!cell) { return; } - this._changeCellMetadata(cell, newMetadata, false); + this._changeCellMetadata(cell, newMetadata, false, beginSelectionState, undoRedoGroup); } - }), undefined, undefined); + }), beginSelectionState, undefined, this._alternativeVersionId, undoRedoGroup); } } @@ -1010,7 +1010,7 @@ export class NotebookTextModel extends Disposable implements INotebookTextModel }); } - private _changeCellLanguage(cell: NotebookCellTextModel, languageId: string, computeUndoRedo: boolean) { + private _changeCellLanguage(cell: NotebookCellTextModel, languageId: string, computeUndoRedo: boolean, beginSelectionState: ISelectionState | undefined, undoRedoGroup: UndoRedoGroup | undefined) { if (cell.language === languageId) { return; } @@ -1026,14 +1026,14 @@ export class NotebookTextModel extends Disposable implements INotebookTextModel return that.uri; } readonly label = 'Update Cell Language'; - readonly code = 'undoredo.notebooks.updateCellLanguage'; + readonly code = 'undoredo.textBufferEdit'; undo() { - that._changeCellLanguage(cell, oldLanguage, false); + that._changeCellLanguage(cell, oldLanguage, false, beginSelectionState, undoRedoGroup); } redo() { - that._changeCellLanguage(cell, languageId, false); + that._changeCellLanguage(cell, languageId, false, beginSelectionState, undoRedoGroup); } - }(), undefined, undefined); + }(), beginSelectionState, undefined, this._alternativeVersionId, undoRedoGroup); } this._pauseableEmitter.fire({ @@ -1121,13 +1121,13 @@ export class NotebookTextModel extends Disposable implements INotebookTextModel } } - private _moveCellToIdx(index: number, length: number, newIdx: number, synchronous: boolean, pushedToUndoStack: boolean, beforeSelections: ISelectionState | undefined, endSelections: ISelectionState | undefined): boolean { + private _moveCellToIdx(index: number, length: number, newIdx: number, synchronous: boolean, pushedToUndoStack: boolean, beforeSelections: ISelectionState | undefined, endSelections: ISelectionState | undefined, undoRedoGroup: UndoRedoGroup | undefined): boolean { if (pushedToUndoStack) { this._operationManager.pushEditOperation(new MoveCellEdit(this.uri, index, length, newIdx, { moveCell: (fromIndex: number, length: number, toIndex: number, beforeSelections: ISelectionState | undefined, endSelections: ISelectionState | undefined) => { - this._moveCellToIdx(fromIndex, length, toIndex, true, false, beforeSelections, endSelections); + this._moveCellToIdx(fromIndex, length, toIndex, true, false, beforeSelections, endSelections, undoRedoGroup); }, - }, beforeSelections, endSelections), beforeSelections, endSelections); + }, beforeSelections, endSelections), beforeSelections, endSelections, this._alternativeVersionId, undoRedoGroup); } this._assertIndex(index); diff --git a/src/vs/workbench/contrib/notebook/common/notebookCommon.ts b/src/vs/workbench/contrib/notebook/common/notebookCommon.ts index c673ae8ad79a1..9f56fd024d37a 100644 --- a/src/vs/workbench/contrib/notebook/common/notebookCommon.ts +++ b/src/vs/workbench/contrib/notebook/common/notebookCommon.ts @@ -32,6 +32,7 @@ import { IWorkingCopyBackupMeta, IWorkingCopySaveEvent } from 'vs/workbench/serv import { IMarkdownString } from 'vs/base/common/htmlContent'; import { IFileReadLimits } from 'vs/platform/files/common/files'; import { parse as parseUri, generate as generateUri } from 'vs/workbench/services/notebook/common/notebookDocumentService'; +import { ICellExecutionError } from 'vs/workbench/contrib/notebook/common/notebookExecutionStateService'; export const NOTEBOOK_EDITOR_ID = 'workbench.editor.notebook'; export const NOTEBOOK_DIFF_EDITOR_ID = 'workbench.editor.notebookTextDiffEditor'; @@ -120,6 +121,7 @@ export interface NotebookCellInternalMetadata { runStartTimeAdjustment?: number; runEndTime?: number; renderDuration?: { [key: string]: number }; + error?: ICellExecutionError; } export interface NotebookCellCollapseState { @@ -531,6 +533,13 @@ export interface IWorkspaceNotebookCellEdit { cellEdit: ICellPartialMetadataEdit | IDocumentMetadataEdit | ICellReplaceEdit; } +export interface IWorkspaceNotebookCellEditDto { + metadata?: WorkspaceEditMetadata; + resource: URI; + notebookVersionId: number | undefined; + cellEdit: ICellPartialMetadataEdit | IDocumentMetadataEdit | ICellReplaceEdit; +} + export interface NotebookData { readonly cells: ICellDto2[]; readonly metadata: NotebookDocumentMetadata; @@ -900,7 +909,6 @@ export interface INotebookCellStatusBarItemList { } export type ShowCellStatusBarType = 'hidden' | 'visible' | 'visibleAfterExecute'; - export const NotebookSetting = { displayOrder: 'notebook.displayOrder', cellToolbarLocation: 'notebook.cellToolbarLocation', @@ -945,10 +953,15 @@ export const NotebookSetting = { confirmDeleteRunningCell: 'notebook.confirmDeleteRunningCell', remoteSaving: 'notebook.experimental.remoteSave', gotoSymbolsAllSymbols: 'notebook.gotoSymbols.showAllSymbols', + outlineShowMarkdownHeadersOnly: 'notebook.outline.showMarkdownHeadersOnly', + outlineShowCodeCells: 'notebook.outline.showCodeCells', + outlineShowCodeCellSymbols: 'notebook.outline.showCodeCellSymbols', + breadcrumbsShowCodeCells: 'notebook.breadcrumbs.showCodeCells', scrollToRevealCell: 'notebook.scrolling.revealNextCellOnExecute', - anchorToFocusedCell: 'notebook.scrolling.experimental.anchorToFocusedCell', cellChat: 'notebook.experimental.cellChat', - notebookVariablesView: 'notebook.experimental.variablesView' + notebookVariablesView: 'notebook.experimental.variablesView', + InteractiveWindowPromptToSave: 'interactiveWindow.promptToSaveOnClose', + cellFailureDiagnostics: 'notebook.cellFailureDiagnostics', } as const; export const enum CellStatusbarAlignment { diff --git a/src/vs/workbench/contrib/notebook/common/notebookContextKeys.ts b/src/vs/workbench/contrib/notebook/common/notebookContextKeys.ts index 42b5d294c13f1..a487c2c0fda71 100644 --- a/src/vs/workbench/contrib/notebook/common/notebookContextKeys.ts +++ b/src/vs/workbench/contrib/notebook/common/notebookContextKeys.ts @@ -46,6 +46,8 @@ export const NOTEBOOK_CELL_HAS_OUTPUTS = new RawContextKey('notebookCel export const NOTEBOOK_CELL_INPUT_COLLAPSED = new RawContextKey('notebookCellInputIsCollapsed', false); export const NOTEBOOK_CELL_OUTPUT_COLLAPSED = new RawContextKey('notebookCellOutputIsCollapsed', false); export const NOTEBOOK_CELL_RESOURCE = new RawContextKey('notebookCellResource', ''); +export const NOTEBOOK_CELL_GENERATED_BY_CHAT = new RawContextKey('notebookCellGenerateByChat', false); +export const NOTEBOOK_CELL_HAS_ERROR_DIAGNOSTICS = new RawContextKey('notebookCellHasErrorDiagnostics', false); // Kernels export const NOTEBOOK_KERNEL = new RawContextKey('notebookKernel', undefined); diff --git a/src/vs/workbench/contrib/notebook/common/notebookEditorInput.ts b/src/vs/workbench/contrib/notebook/common/notebookEditorInput.ts index db98d0bbb96ff..0ac6de7110079 100644 --- a/src/vs/workbench/contrib/notebook/common/notebookEditorInput.ts +++ b/src/vs/workbench/contrib/notebook/common/notebookEditorInput.ts @@ -30,6 +30,7 @@ import { localize } from 'vs/nls'; import { IEditorService } from 'vs/workbench/services/editor/common/editorService'; import { IMarkdownString } from 'vs/base/common/htmlContent'; import { ITextResourceConfigurationService } from 'vs/editor/common/services/textResourceConfiguration'; +import { ICustomEditorLabelService } from 'vs/workbench/services/editor/common/customEditorLabelService'; export interface NotebookEditorInputOptions { startDirty?: boolean; @@ -42,23 +43,11 @@ export interface NotebookEditorInputOptions { export class NotebookEditorInput extends AbstractResourceEditorInput { - private static EditorCache: Record = {}; - static getOrCreate(instantiationService: IInstantiationService, resource: URI, preferredResource: URI | undefined, viewType: string, options: NotebookEditorInputOptions = {}) { - const cacheId = `${resource.toString()}|${viewType}|${options._workingCopy?.typeId}`; - let editor = NotebookEditorInput.EditorCache[cacheId]; - - if (!editor) { - editor = instantiationService.createInstance(NotebookEditorInput, resource, preferredResource, viewType, options); - NotebookEditorInput.EditorCache[cacheId] = editor; - - editor.onWillDispose(() => { - delete NotebookEditorInput.EditorCache[cacheId]; - }); - } else if (preferredResource) { + const editor = instantiationService.createInstance(NotebookEditorInput, resource, preferredResource, viewType, options); + if (preferredResource) { editor.setPreferredResource(preferredResource); } - return editor; } @@ -81,9 +70,10 @@ export class NotebookEditorInput extends AbstractResourceEditorInput { @IFilesConfigurationService filesConfigurationService: IFilesConfigurationService, @IExtensionService extensionService: IExtensionService, @IEditorService editorService: IEditorService, - @ITextResourceConfigurationService textResourceConfigurationService: ITextResourceConfigurationService + @ITextResourceConfigurationService textResourceConfigurationService: ITextResourceConfigurationService, + @ICustomEditorLabelService customEditorLabelService: ICustomEditorLabelService ) { - super(resource, preferredResource, labelService, fileService, filesConfigurationService, textResourceConfigurationService); + super(resource, preferredResource, labelService, fileService, filesConfigurationService, textResourceConfigurationService, customEditorLabelService); this._defaultDirtyState = !!options.startDirty; // Automatically resolve this input when the "wanted" model comes to life via @@ -374,3 +364,9 @@ export function isCompositeNotebookEditorInput(thing: unknown): thing is ICompos && Array.isArray((thing).editorInputs) && ((thing).editorInputs.every(input => input instanceof NotebookEditorInput)); } + +export function isNotebookEditorInput(thing: unknown): thing is NotebookEditorInput { + return !!thing + && typeof thing === 'object' + && thing instanceof NotebookEditorInput; +} diff --git a/src/vs/workbench/contrib/notebook/common/notebookEditorModel.ts b/src/vs/workbench/contrib/notebook/common/notebookEditorModel.ts index f54d5f73092d9..e91e96ece99cf 100644 --- a/src/vs/workbench/contrib/notebook/common/notebookEditorModel.ts +++ b/src/vs/workbench/contrib/notebook/common/notebookEditorModel.ts @@ -52,11 +52,12 @@ export class SimpleNotebookEditorModel extends EditorModel implements INotebookE private readonly _hasAssociatedFilePath: boolean, readonly viewType: string, private readonly _workingCopyManager: IFileWorkingCopyManager, + scratchpad: boolean, @IFilesConfigurationService private readonly _filesConfigurationService: IFilesConfigurationService ) { super(); - this.scratchPad = viewType === 'interactive'; + this.scratchPad = scratchpad; } override dispose(): void { @@ -308,7 +309,7 @@ export class NotebookFileWorkingCopyModel extends Disposable implements IStoredF } pushStackElement(): void { - this._notebookModel.pushStackElement('save', undefined, undefined); + this._notebookModel.pushStackElement(); } } diff --git a/src/vs/workbench/contrib/notebook/common/notebookEditorModelResolverServiceImpl.ts b/src/vs/workbench/contrib/notebook/common/notebookEditorModelResolverServiceImpl.ts index bbc69d31aa2cc..0a8a17a170da4 100644 --- a/src/vs/workbench/contrib/notebook/common/notebookEditorModelResolverServiceImpl.ts +++ b/src/vs/workbench/contrib/notebook/common/notebookEditorModelResolverServiceImpl.ts @@ -5,7 +5,7 @@ import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; import { URI } from 'vs/base/common/uri'; -import { CellUri, IResolvedNotebookEditorModel, NotebookWorkingCopyTypeIdentifier } from 'vs/workbench/contrib/notebook/common/notebookCommon'; +import { CellUri, IResolvedNotebookEditorModel, NotebookSetting, NotebookWorkingCopyTypeIdentifier } from 'vs/workbench/contrib/notebook/common/notebookCommon'; import { NotebookFileWorkingCopyModel, NotebookFileWorkingCopyModelFactory, SimpleNotebookEditorModel } from 'vs/workbench/contrib/notebook/common/notebookEditorModel'; import { combinedDisposable, DisposableStore, dispose, IDisposable, IReference, ReferenceCollection, toDisposable } from 'vs/base/common/lifecycle'; import { INotebookService } from 'vs/workbench/contrib/notebook/common/notebookService'; @@ -77,7 +77,8 @@ class NotebookModelReferenceCollection extends ReferenceCollection(NotebookSetting.InteractiveWindowPromptToSave) !== true; + const model = this._instantiationService.createInstance(SimpleNotebookEditorModel, uri, hasAssociatedFilePath, viewType, workingCopyManager, scratchPad); const result = await model.load({ limits }); diff --git a/src/vs/workbench/contrib/notebook/common/notebookExecutionStateService.ts b/src/vs/workbench/contrib/notebook/common/notebookExecutionStateService.ts index 98a24d28adff8..5b98e7ca2622e 100644 --- a/src/vs/workbench/contrib/notebook/common/notebookExecutionStateService.ts +++ b/src/vs/workbench/contrib/notebook/common/notebookExecutionStateService.ts @@ -5,7 +5,8 @@ import { Event } from 'vs/base/common/event'; import { IDisposable } from 'vs/base/common/lifecycle'; -import { URI } from 'vs/base/common/uri'; +import { URI, UriComponents } from 'vs/base/common/uri'; +import { IRange } from 'vs/editor/common/core/range'; import { createDecorator } from 'vs/platform/instantiation/common/instantiation'; import { NotebookCellExecutionState, NotebookExecutionState } from 'vs/workbench/contrib/notebook/common/notebookCommon'; import { CellExecutionUpdateType, ICellExecuteOutputEdit, ICellExecuteOutputItemEdit } from 'vs/workbench/contrib/notebook/common/notebookExecutionService'; @@ -20,9 +21,16 @@ export interface ICellExecutionStateUpdate { isPaused?: boolean; } +export interface ICellExecutionError { + message: string; + stack: string | undefined; + uri: UriComponents; + location: IRange | undefined; +} export interface ICellExecutionComplete { runEndTime?: number; lastRunSuccess?: boolean; + error?: ICellExecutionError; } export enum NotebookExecutionType { cell, diff --git a/src/vs/workbench/contrib/notebook/common/notebookRange.ts b/src/vs/workbench/contrib/notebook/common/notebookRange.ts index 6760e454fa3b8..75a7a105757e3 100644 --- a/src/vs/workbench/contrib/notebook/common/notebookRange.ts +++ b/src/vs/workbench/contrib/notebook/common/notebookRange.ts @@ -65,7 +65,7 @@ export function reduceCellRanges(ranges: ICellRange[]): ICellRange[] { return []; } - return sorted.reduce((prev: ICellRange[], curr) => { + const reduced = sorted.reduce((prev: ICellRange[], curr) => { const last = prev[prev.length - 1]; if (last.end >= curr.start) { last.end = Math.max(last.end, curr.end); @@ -74,6 +74,13 @@ export function reduceCellRanges(ranges: ICellRange[]): ICellRange[] { } return prev; }, [first] as ICellRange[]); + + if (reduced.length > 1) { + // remove the (0, 0) range + return reduced.filter(range => !(range.start === range.end && range.start === 0)); + } + + return reduced; } export function cellRangesEqual(a: ICellRange[], b: ICellRange[]) { diff --git a/src/vs/workbench/contrib/notebook/test/browser/contrib/notebookSymbols.test.ts b/src/vs/workbench/contrib/notebook/test/browser/contrib/notebookSymbols.test.ts index 8826eb3dda787..fdb19d202424e 100644 --- a/src/vs/workbench/contrib/notebook/test/browser/contrib/notebookSymbols.test.ts +++ b/src/vs/workbench/contrib/notebook/test/browser/contrib/notebookSymbols.test.ts @@ -12,6 +12,7 @@ import { IOutlineModelService, OutlineModel } from 'vs/editor/contrib/documentSy import { ICellViewModel } from 'vs/workbench/contrib/notebook/browser/notebookBrowser'; import { NotebookOutlineEntryFactory } from 'vs/workbench/contrib/notebook/browser/viewModel/notebookOutlineEntryFactory'; import { INotebookExecutionStateService } from 'vs/workbench/contrib/notebook/common/notebookExecutionStateService'; +import { OutlineTarget } from 'vs/workbench/services/outline/browser/outline'; suite('Notebook Symbols', function () { ensureNoDisposablesAreLeakedInTestSuite(); @@ -66,7 +67,7 @@ suite('Notebook Symbols', function () { test('Cell without symbols cache', function () { setSymbolsForTextModel([{ name: 'var', range: {} }]); const entryFactory = new NotebookOutlineEntryFactory(executionService); - const entries = entryFactory.getOutlineEntries(createCellViewModel(), 0); + const entries = entryFactory.getOutlineEntries(createCellViewModel(), OutlineTarget.QuickPick, 0); assert.equal(entries.length, 1, 'no entries created'); assert.equal(entries[0].label, '# code', 'entry should fall back to first line of cell'); @@ -78,15 +79,15 @@ suite('Notebook Symbols', function () { const cell = createCellViewModel(); await entryFactory.cacheSymbols(cell, outlineModelService, CancellationToken.None); - const entries = entryFactory.getOutlineEntries(cell, 0); + const entries = entryFactory.getOutlineEntries(cell, OutlineTarget.QuickPick, 0); assert.equal(entries.length, 2, 'wrong number of outline entries'); assert.equal(entries[0].label, 'var1'); // 6 levels for markdown, all code symbols are greater than the max markdown level - assert.equal(entries[0].level, 7); + assert.equal(entries[0].level, 8); assert.equal(entries[0].index, 0); assert.equal(entries[1].label, 'var2'); - assert.equal(entries[1].level, 7); + assert.equal(entries[1].level, 8); assert.equal(entries[1].index, 1); }); @@ -99,19 +100,19 @@ suite('Notebook Symbols', function () { const cell = createCellViewModel(); await entryFactory.cacheSymbols(cell, outlineModelService, CancellationToken.None); - const entries = entryFactory.getOutlineEntries(createCellViewModel(), 0); + const entries = entryFactory.getOutlineEntries(createCellViewModel(), OutlineTarget.QuickPick, 0); assert.equal(entries.length, 5, 'wrong number of outline entries'); assert.equal(entries[0].label, 'root1'); - assert.equal(entries[0].level, 7); + assert.equal(entries[0].level, 8); assert.equal(entries[1].label, 'nested1'); - assert.equal(entries[1].level, 8); + assert.equal(entries[1].level, 9); assert.equal(entries[2].label, 'nested2'); - assert.equal(entries[2].level, 8); + assert.equal(entries[2].level, 9); assert.equal(entries[3].label, 'root2'); - assert.equal(entries[3].level, 7); + assert.equal(entries[3].level, 8); assert.equal(entries[4].label, 'nested1'); - assert.equal(entries[4].level, 8); + assert.equal(entries[4].level, 9); }); test('Multiple Cells with symbols', async function () { @@ -124,8 +125,8 @@ suite('Notebook Symbols', function () { await entryFactory.cacheSymbols(cell1, outlineModelService, CancellationToken.None); await entryFactory.cacheSymbols(cell2, outlineModelService, CancellationToken.None); - const entries1 = entryFactory.getOutlineEntries(createCellViewModel(1, '$1'), 0); - const entries2 = entryFactory.getOutlineEntries(createCellViewModel(1, '$2'), 0); + const entries1 = entryFactory.getOutlineEntries(createCellViewModel(1, '$1'), OutlineTarget.QuickPick, 0); + const entries2 = entryFactory.getOutlineEntries(createCellViewModel(1, '$2'), OutlineTarget.QuickPick, 0); assert.equal(entries1.length, 1, 'wrong number of outline entries'); diff --git a/src/vs/workbench/contrib/notebook/test/browser/notebookCellList.test.ts b/src/vs/workbench/contrib/notebook/test/browser/notebookCellList.test.ts index 2e32a24b22fe8..6f9de776b5334 100644 --- a/src/vs/workbench/contrib/notebook/test/browser/notebookCellList.test.ts +++ b/src/vs/workbench/contrib/notebook/test/browser/notebookCellList.test.ts @@ -26,10 +26,7 @@ suite('NotebookCellList', () => { setup(() => { testDisposables = new DisposableStore(); instantiationService = setupInstantiationService(testDisposables); - config = new TestConfigurationService({ - [NotebookSetting.anchorToFocusedCell]: 'auto' - }); - + config = new TestConfigurationService(); instantiationService.stub(IConfigurationService, config); }); diff --git a/src/vs/workbench/contrib/notebook/test/browser/notebookCommon.test.ts b/src/vs/workbench/contrib/notebook/test/browser/notebookCommon.test.ts index 476c7aa53274d..d18126e162b19 100644 --- a/src/vs/workbench/contrib/notebook/test/browser/notebookCommon.test.ts +++ b/src/vs/workbench/contrib/notebook/test/browser/notebookCommon.test.ts @@ -399,6 +399,12 @@ suite('CellRange', function () { { start: 0, end: 4 } ]); }); + + test('Reduce ranges 2, empty ranges', function () { + assert.deepStrictEqual(reduceCellRanges([{ start: 0, end: 0 }, { start: 0, end: 0 }]), [{ start: 0, end: 0 }]); + assert.deepStrictEqual(reduceCellRanges([{ start: 0, end: 0 }, { start: 1, end: 2 }]), [{ start: 1, end: 2 }]); + assert.deepStrictEqual(reduceCellRanges([{ start: 2, end: 2 }]), [{ start: 2, end: 2 }]); + }); }); suite('NotebookWorkingCopyTypeIdentifier', function () { diff --git a/src/vs/workbench/contrib/notebook/test/browser/notebookDiff.test.ts b/src/vs/workbench/contrib/notebook/test/browser/notebookDiff.test.ts index 6136451c28ab5..654fe7ee807a4 100644 --- a/src/vs/workbench/contrib/notebook/test/browser/notebookDiff.test.ts +++ b/src/vs/workbench/contrib/notebook/test/browser/notebookDiff.test.ts @@ -35,9 +35,9 @@ suite('NotebookCommon', () => { test('diff different source', async () => { await withTestNotebookDiffModel([ - ['x', 'javascript', CellKind.Code, [{ outputId: 'someOtherId', outputs: [{ mime: Mimes.text, data: VSBuffer.wrap(new Uint8Array([3])) }] }], { custom: { metadata: { collapsed: false } }, executionOrder: 3 }], + ['x', 'javascript', CellKind.Code, [{ outputId: 'someOtherId', outputs: [{ mime: Mimes.text, data: VSBuffer.wrap(new Uint8Array([3])) }] }], { metadata: { collapsed: false }, executionOrder: 3 }], ], [ - ['y', 'javascript', CellKind.Code, [{ outputId: 'someOtherId', outputs: [{ mime: Mimes.text, data: VSBuffer.wrap(new Uint8Array([3])) }] }], { custom: { metadata: { collapsed: false } }, executionOrder: 3 }], + ['y', 'javascript', CellKind.Code, [{ outputId: 'someOtherId', outputs: [{ mime: Mimes.text, data: VSBuffer.wrap(new Uint8Array([3])) }] }], { metadata: { collapsed: false }, executionOrder: 3 }], ], (model, disposables, accessor) => { const diff = new LcsDiff(new CellSequence(model.original.notebook), new CellSequence(model.modified.notebook)); const diffResult = diff.ComputeDiff(false); @@ -72,10 +72,10 @@ suite('NotebookCommon', () => { test('diff different output', async () => { await withTestNotebookDiffModel([ - ['x', 'javascript', CellKind.Code, [{ outputId: 'someId', outputs: [{ mime: Mimes.text, data: VSBuffer.wrap(new Uint8Array([5])) }] }], { custom: { metadata: { collapsed: false } }, executionOrder: 5 }], + ['x', 'javascript', CellKind.Code, [{ outputId: 'someId', outputs: [{ mime: Mimes.text, data: VSBuffer.wrap(new Uint8Array([5])) }] }], { metadata: { collapsed: false }, executionOrder: 5 }], ['', 'javascript', CellKind.Code, [], {}] ], [ - ['x', 'javascript', CellKind.Code, [{ outputId: 'someOtherId', outputs: [{ mime: Mimes.text, data: VSBuffer.wrap(new Uint8Array([3])) }] }], { custom: { metadata: { collapsed: false } }, executionOrder: 3 }], + ['x', 'javascript', CellKind.Code, [{ outputId: 'someOtherId', outputs: [{ mime: Mimes.text, data: VSBuffer.wrap(new Uint8Array([3])) }] }], { metadata: { collapsed: false }, executionOrder: 3 }], ['', 'javascript', CellKind.Code, [], {}] ], (model, disposables, accessor) => { const diff = new LcsDiff(new CellSequence(model.original.notebook), new CellSequence(model.modified.notebook)); @@ -197,12 +197,12 @@ suite('NotebookCommon', () => { test('diff foo/foe', async () => { await withTestNotebookDiffModel([ - [['def foe(x, y):\n', ' return x + y\n', 'foe(3, 2)'].join(''), 'javascript', CellKind.Code, [{ outputId: 'someId', outputs: [{ mime: Mimes.text, data: VSBuffer.wrap(new Uint8Array([6])) }] }], { custom: { metadata: { collapsed: false } }, executionOrder: 5 }], - [['def foo(x, y):\n', ' return x * y\n', 'foo(1, 2)'].join(''), 'javascript', CellKind.Code, [{ outputId: 'someId', outputs: [{ mime: Mimes.text, data: VSBuffer.wrap(new Uint8Array([2])) }] }], { custom: { metadata: { collapsed: false } }, executionOrder: 6 }], + [['def foe(x, y):\n', ' return x + y\n', 'foe(3, 2)'].join(''), 'javascript', CellKind.Code, [{ outputId: 'someId', outputs: [{ mime: Mimes.text, data: VSBuffer.wrap(new Uint8Array([6])) }] }], { metadata: { collapsed: false }, executionOrder: 5 }], + [['def foo(x, y):\n', ' return x * y\n', 'foo(1, 2)'].join(''), 'javascript', CellKind.Code, [{ outputId: 'someId', outputs: [{ mime: Mimes.text, data: VSBuffer.wrap(new Uint8Array([2])) }] }], { metadata: { collapsed: false }, executionOrder: 6 }], ['', 'javascript', CellKind.Code, [], {}] ], [ - [['def foo(x, y):\n', ' return x * y\n', 'foo(1, 2)'].join(''), 'javascript', CellKind.Code, [{ outputId: 'someId', outputs: [{ mime: Mimes.text, data: VSBuffer.wrap(new Uint8Array([6])) }] }], { custom: { metadata: { collapsed: false } }, executionOrder: 5 }], - [['def foe(x, y):\n', ' return x + y\n', 'foe(3, 2)'].join(''), 'javascript', CellKind.Code, [{ outputId: 'someId', outputs: [{ mime: Mimes.text, data: VSBuffer.wrap(new Uint8Array([2])) }] }], { custom: { metadata: { collapsed: false } }, executionOrder: 6 }], + [['def foo(x, y):\n', ' return x * y\n', 'foo(1, 2)'].join(''), 'javascript', CellKind.Code, [{ outputId: 'someId', outputs: [{ mime: Mimes.text, data: VSBuffer.wrap(new Uint8Array([6])) }] }], { metadata: { collapsed: false }, executionOrder: 5 }], + [['def foe(x, y):\n', ' return x + y\n', 'foe(3, 2)'].join(''), 'javascript', CellKind.Code, [{ outputId: 'someId', outputs: [{ mime: Mimes.text, data: VSBuffer.wrap(new Uint8Array([2])) }] }], { metadata: { collapsed: false }, executionOrder: 6 }], ['', 'javascript', CellKind.Code, [], {}] ], (model, disposables, accessor) => { const diff = new LcsDiff(new CellSequence(model.original.notebook), new CellSequence(model.modified.notebook)); @@ -407,15 +407,15 @@ suite('NotebookCommon', () => { test('LCS', async () => { await withTestNotebookDiffModel([ - ['# Description', 'markdown', CellKind.Markup, [], { custom: { metadata: {} } }], - ['x = 3', 'javascript', CellKind.Code, [], { custom: { metadata: { collapsed: true } }, executionOrder: 1 }], - ['x', 'javascript', CellKind.Code, [{ outputId: 'someId', outputs: [{ mime: Mimes.text, data: VSBuffer.wrap(new Uint8Array([3])) }] }], { custom: { metadata: { collapsed: false } }, executionOrder: 1 }], - ['x', 'javascript', CellKind.Code, [], { custom: { metadata: { collapsed: false } } }] + ['# Description', 'markdown', CellKind.Markup, [], { metadata: {} }], + ['x = 3', 'javascript', CellKind.Code, [], { metadata: { collapsed: true }, executionOrder: 1 }], + ['x', 'javascript', CellKind.Code, [{ outputId: 'someId', outputs: [{ mime: Mimes.text, data: VSBuffer.wrap(new Uint8Array([3])) }] }], { metadata: { collapsed: false }, executionOrder: 1 }], + ['x', 'javascript', CellKind.Code, [], { metadata: { collapsed: false } }] ], [ - ['# Description', 'markdown', CellKind.Markup, [], { custom: { metadata: {} } }], - ['x = 3', 'javascript', CellKind.Code, [], { custom: { metadata: { collapsed: true } }, executionOrder: 1 }], - ['x', 'javascript', CellKind.Code, [], { custom: { metadata: { collapsed: false } } }], - ['x', 'javascript', CellKind.Code, [{ outputId: 'someId', outputs: [{ mime: Mimes.text, data: VSBuffer.wrap(new Uint8Array([3])) }] }], { custom: { metadata: { collapsed: false } }, executionOrder: 1 }] + ['# Description', 'markdown', CellKind.Markup, [], { metadata: {} }], + ['x = 3', 'javascript', CellKind.Code, [], { metadata: { collapsed: true }, executionOrder: 1 }], + ['x', 'javascript', CellKind.Code, [], { metadata: { collapsed: false } }], + ['x', 'javascript', CellKind.Code, [{ outputId: 'someId', outputs: [{ mime: Mimes.text, data: VSBuffer.wrap(new Uint8Array([3])) }] }], { metadata: { collapsed: false }, executionOrder: 1 }] ], async (model) => { const diff = new LcsDiff(new CellSequence(model.original.notebook), new CellSequence(model.modified.notebook)); const diffResult = diff.ComputeDiff(false); @@ -440,18 +440,18 @@ suite('NotebookCommon', () => { test('LCS 2', async () => { await withTestNotebookDiffModel([ - ['# Description', 'markdown', CellKind.Markup, [], { custom: { metadata: {} } }], - ['x = 3', 'javascript', CellKind.Code, [], { custom: { metadata: { collapsed: true } }, executionOrder: 1 }], - ['x', 'javascript', CellKind.Code, [{ outputId: 'someId', outputs: [{ mime: Mimes.text, data: VSBuffer.wrap(new Uint8Array([3])) }] }], { custom: { metadata: { collapsed: false } }, executionOrder: 1 }], - ['x', 'javascript', CellKind.Code, [], { custom: { metadata: { collapsed: false } } }], + ['# Description', 'markdown', CellKind.Markup, [], { metadata: {} }], + ['x = 3', 'javascript', CellKind.Code, [], { metadata: { collapsed: true }, executionOrder: 1 }], + ['x', 'javascript', CellKind.Code, [{ outputId: 'someId', outputs: [{ mime: Mimes.text, data: VSBuffer.wrap(new Uint8Array([3])) }] }], { metadata: { collapsed: false }, executionOrder: 1 }], + ['x', 'javascript', CellKind.Code, [], { metadata: { collapsed: false } }], ['x = 5', 'javascript', CellKind.Code, [], {}], ['x', 'javascript', CellKind.Code, [], {}], ['x', 'javascript', CellKind.Code, [{ outputId: 'someId', outputs: [{ mime: Mimes.text, data: VSBuffer.wrap(new Uint8Array([5])) }] }], {}], ], [ - ['# Description', 'markdown', CellKind.Markup, [], { custom: { metadata: {} } }], - ['x = 3', 'javascript', CellKind.Code, [], { custom: { metadata: { collapsed: true } }, executionOrder: 1 }], - ['x', 'javascript', CellKind.Code, [], { custom: { metadata: { collapsed: false } } }], - ['x', 'javascript', CellKind.Code, [{ outputId: 'someId', outputs: [{ mime: Mimes.text, data: VSBuffer.wrap(new Uint8Array([3])) }] }], { custom: { metadata: { collapsed: false } }, executionOrder: 1 }], + ['# Description', 'markdown', CellKind.Markup, [], { metadata: {} }], + ['x = 3', 'javascript', CellKind.Code, [], { metadata: { collapsed: true }, executionOrder: 1 }], + ['x', 'javascript', CellKind.Code, [], { metadata: { collapsed: false } }], + ['x', 'javascript', CellKind.Code, [{ outputId: 'someId', outputs: [{ mime: Mimes.text, data: VSBuffer.wrap(new Uint8Array([3])) }] }], { metadata: { collapsed: false }, executionOrder: 1 }], ['x = 5', 'javascript', CellKind.Code, [], {}], ['x', 'javascript', CellKind.Code, [{ outputId: 'someId', outputs: [{ mime: Mimes.text, data: VSBuffer.wrap(new Uint8Array([5])) }] }], {}], ['x', 'javascript', CellKind.Code, [], {}], @@ -528,11 +528,11 @@ suite('NotebookCommon', () => { test('diff output', async () => { await withTestNotebookDiffModel([ - ['x', 'javascript', CellKind.Code, [{ outputId: 'someOtherId', outputs: [{ mime: Mimes.text, data: VSBuffer.wrap(new Uint8Array([3])) }] }], { custom: { metadata: { collapsed: false } }, executionOrder: 3 }], - ['y', 'javascript', CellKind.Code, [{ outputId: 'someOtherId', outputs: [{ mime: Mimes.text, data: VSBuffer.wrap(new Uint8Array([4])) }] }], { custom: { metadata: { collapsed: false } }, executionOrder: 3 }], + ['x', 'javascript', CellKind.Code, [{ outputId: 'someOtherId', outputs: [{ mime: Mimes.text, data: VSBuffer.wrap(new Uint8Array([3])) }] }], { metadata: { collapsed: false }, executionOrder: 3 }], + ['y', 'javascript', CellKind.Code, [{ outputId: 'someOtherId', outputs: [{ mime: Mimes.text, data: VSBuffer.wrap(new Uint8Array([4])) }] }], { metadata: { collapsed: false }, executionOrder: 3 }], ], [ - ['x', 'javascript', CellKind.Code, [{ outputId: 'someOtherId', outputs: [{ mime: Mimes.text, data: VSBuffer.wrap(new Uint8Array([3])) }] }], { custom: { metadata: { collapsed: false } }, executionOrder: 3 }], - ['y', 'javascript', CellKind.Code, [{ outputId: 'someOtherId', outputs: [{ mime: Mimes.text, data: VSBuffer.wrap(new Uint8Array([5])) }] }], { custom: { metadata: { collapsed: false } }, executionOrder: 3 }], + ['x', 'javascript', CellKind.Code, [{ outputId: 'someOtherId', outputs: [{ mime: Mimes.text, data: VSBuffer.wrap(new Uint8Array([3])) }] }], { metadata: { collapsed: false }, executionOrder: 3 }], + ['y', 'javascript', CellKind.Code, [{ outputId: 'someOtherId', outputs: [{ mime: Mimes.text, data: VSBuffer.wrap(new Uint8Array([5])) }] }], { metadata: { collapsed: false }, executionOrder: 3 }], ], (model, disposables, accessor) => { const diff = new LcsDiff(new CellSequence(model.original.notebook), new CellSequence(model.modified.notebook)); const diffResult = diff.ComputeDiff(false); @@ -557,11 +557,11 @@ suite('NotebookCommon', () => { test('diff output fast check', async () => { await withTestNotebookDiffModel([ - ['x', 'javascript', CellKind.Code, [{ outputId: 'someOtherId', outputs: [{ mime: Mimes.text, data: VSBuffer.wrap(new Uint8Array([3])) }] }], { custom: { metadata: { collapsed: false } }, executionOrder: 3 }], - ['y', 'javascript', CellKind.Code, [{ outputId: 'someOtherId', outputs: [{ mime: Mimes.text, data: VSBuffer.wrap(new Uint8Array([4])) }] }], { custom: { metadata: { collapsed: false } }, executionOrder: 3 }], + ['x', 'javascript', CellKind.Code, [{ outputId: 'someOtherId', outputs: [{ mime: Mimes.text, data: VSBuffer.wrap(new Uint8Array([3])) }] }], { metadata: { collapsed: false }, executionOrder: 3 }], + ['y', 'javascript', CellKind.Code, [{ outputId: 'someOtherId', outputs: [{ mime: Mimes.text, data: VSBuffer.wrap(new Uint8Array([4])) }] }], { metadata: { collapsed: false }, executionOrder: 3 }], ], [ - ['x', 'javascript', CellKind.Code, [{ outputId: 'someOtherId', outputs: [{ mime: Mimes.text, data: VSBuffer.wrap(new Uint8Array([3])) }] }], { custom: { metadata: { collapsed: false } }, executionOrder: 3 }], - ['y', 'javascript', CellKind.Code, [{ outputId: 'someOtherId', outputs: [{ mime: Mimes.text, data: VSBuffer.wrap(new Uint8Array([5])) }] }], { custom: { metadata: { collapsed: false } }, executionOrder: 3 }], + ['x', 'javascript', CellKind.Code, [{ outputId: 'someOtherId', outputs: [{ mime: Mimes.text, data: VSBuffer.wrap(new Uint8Array([3])) }] }], { metadata: { collapsed: false }, executionOrder: 3 }], + ['y', 'javascript', CellKind.Code, [{ outputId: 'someOtherId', outputs: [{ mime: Mimes.text, data: VSBuffer.wrap(new Uint8Array([5])) }] }], { metadata: { collapsed: false }, executionOrder: 3 }], ], (model, disposables, accessor) => { const diff = new LcsDiff(new CellSequence(model.original.notebook), new CellSequence(model.modified.notebook)); const diffResult = diff.ComputeDiff(false); diff --git a/src/vs/workbench/contrib/notebook/test/browser/notebookSelection.test.ts b/src/vs/workbench/contrib/notebook/test/browser/notebookSelection.test.ts index a45fd46828f42..5bbbf50a502a7 100644 --- a/src/vs/workbench/contrib/notebook/test/browser/notebookSelection.test.ts +++ b/src/vs/workbench/contrib/notebook/test/browser/notebookSelection.test.ts @@ -278,7 +278,7 @@ suite('NotebookCellList focus/selection', () => { (editor, viewModel) => { assert.deepStrictEqual(viewModel.validateRange(null), null); assert.deepStrictEqual(viewModel.validateRange(undefined), null); - assert.deepStrictEqual(viewModel.validateRange({ start: 0, end: 0 }), null); + assert.deepStrictEqual(viewModel.validateRange({ start: 0, end: 0 }), { start: 0, end: 0 }); assert.deepStrictEqual(viewModel.validateRange({ start: 0, end: 2 }), { start: 0, end: 2 }); assert.deepStrictEqual(viewModel.validateRange({ start: 0, end: 3 }), { start: 0, end: 2 }); assert.deepStrictEqual(viewModel.validateRange({ start: -1, end: 3 }), { start: 0, end: 2 }); diff --git a/src/vs/workbench/contrib/notebook/test/browser/notebookVariablesDataSource.test.ts b/src/vs/workbench/contrib/notebook/test/browser/notebookVariablesDataSource.test.ts index 8de6a93e27ee1..fa082d78e2353 100644 --- a/src/vs/workbench/contrib/notebook/test/browser/notebookVariablesDataSource.test.ts +++ b/src/vs/workbench/contrib/notebook/test/browser/notebookVariablesDataSource.test.ts @@ -94,6 +94,18 @@ suite('NotebookVariableDataSource', () => { assert.equal(variables[0].extHostId, parent.extHostId, 'ExtHostId should match the parent since we will use it to get the real children'); }); + test('Get children for very large list', async () => { + const parent = { kind: 'variable', notebook: notebookModel, id: '1', extHostId: 1, name: 'list', value: '[...]', hasNamedChildren: false, indexedChildrenCount: 1_000_000 } as INotebookVariableElement; + results = []; + + const groups = await dataSource.getChildren(parent); + const children = await dataSource.getChildren(groups[99]); + + assert(children.length === 100, 'We should have a full page of child groups'); + assert(!provideVariablesCalled, 'provideVariables should not be called'); + assert.equal(children[0].extHostId, parent.extHostId, 'ExtHostId should match the parent since we will use it to get the real children'); + }); + test('Cancel while enumerating through children', async () => { const parent = { kind: 'variable', notebook: notebookModel, id: '1', extHostId: 1, name: 'list', value: '[...]', hasNamedChildren: false, indexedChildrenCount: 10 } as INotebookVariableElement; results = [ diff --git a/src/vs/workbench/contrib/notebook/test/browser/notebookViewZones.test.ts b/src/vs/workbench/contrib/notebook/test/browser/notebookViewZones.test.ts index fb8562f0a1e29..bace7743e882a 100644 --- a/src/vs/workbench/contrib/notebook/test/browser/notebookViewZones.test.ts +++ b/src/vs/workbench/contrib/notebook/test/browser/notebookViewZones.test.ts @@ -12,7 +12,7 @@ import { TestConfigurationService } from 'vs/platform/configuration/test/common/ import { TestInstantiationService } from 'vs/platform/instantiation/test/common/instantiationServiceMock'; import { NotebookCellsLayout } from 'vs/workbench/contrib/notebook/browser/view/notebookCellListView'; import { FoldingModel } from 'vs/workbench/contrib/notebook/browser/viewModel/foldingModel'; -import { CellEditType, CellKind, NotebookSetting } from 'vs/workbench/contrib/notebook/common/notebookCommon'; +import { CellEditType, CellKind } from 'vs/workbench/contrib/notebook/common/notebookCommon'; import { createNotebookCellList, setupInstantiationService, withTestNotebook } from 'vs/workbench/contrib/notebook/test/browser/testNotebookEditor'; suite('NotebookRangeMap', () => { @@ -339,13 +339,58 @@ suite('NotebookRangeMap with whitesspaces', () => { setup(() => { testDisposables = new DisposableStore(); instantiationService = setupInstantiationService(testDisposables); - config = new TestConfigurationService({ - [NotebookSetting.anchorToFocusedCell]: 'auto' - }); - + config = new TestConfigurationService(); instantiationService.stub(IConfigurationService, config); }); + test('simple', () => { + const rangeMap = new NotebookCellsLayout(0); + rangeMap.splice(0, 0, [{ size: 479 }, { size: 163 }, { size: 182 }, { size: 106 }, { size: 106 }, { size: 106 }, { size: 87 }]); + + const start = rangeMap.indexAt(650); + const end = rangeMap.indexAfter(650 + 890 - 1); + assert.strictEqual(start, 2); + assert.strictEqual(end, 7); + + rangeMap.insertWhitespace('1', 0, 18); + assert.strictEqual(rangeMap.indexAt(650), 1); + }); + + test('Whitespace CRUD', async function () { + const twenty = { size: 20 }; + + const rangeMap = new NotebookCellsLayout(0); + rangeMap.splice(0, 0, [twenty, twenty, twenty]); + rangeMap.insertWhitespace('0', 0, 5); + rangeMap.insertWhitespace('1', 0, 5); + assert.strictEqual(rangeMap.indexAt(0), 0); + assert.strictEqual(rangeMap.indexAt(1), 0); + assert.strictEqual(rangeMap.indexAt(10), 0); + assert.strictEqual(rangeMap.indexAt(11), 0); + assert.strictEqual(rangeMap.indexAt(21), 0); + assert.strictEqual(rangeMap.indexAt(31), 1); + assert.strictEqual(rangeMap.positionAt(0), 10); + + assert.strictEqual(rangeMap.getWhitespacePosition('0'), 0); + assert.strictEqual(rangeMap.getWhitespacePosition('1'), 5); + + assert.strictEqual(rangeMap.positionAt(0), 10); + assert.strictEqual(rangeMap.positionAt(1), 30); + + rangeMap.changeOneWhitespace('0', 0, 10); + assert.strictEqual(rangeMap.getWhitespacePosition('0'), 0); + assert.strictEqual(rangeMap.getWhitespacePosition('1'), 10); + + assert.strictEqual(rangeMap.positionAt(0), 15); + assert.strictEqual(rangeMap.positionAt(1), 35); + + rangeMap.removeWhitespace('1'); + assert.strictEqual(rangeMap.getWhitespacePosition('0'), 0); + + assert.strictEqual(rangeMap.positionAt(0), 10); + assert.strictEqual(rangeMap.positionAt(1), 30); + }); + test('Whitespace with editing', async function () { await withTestNotebook( [ @@ -630,4 +675,64 @@ suite('NotebookRangeMap with whitesspaces', () => { }); }); }); + + test('Whitespace with multiple viewzones at same position', async function () { + await withTestNotebook( + [ + ['# header a', 'markdown', CellKind.Markup, [], {}], + ['var b = 1;', 'javascript', CellKind.Code, [], {}], + ['# header b', 'markdown', CellKind.Markup, [], {}], + ['var b = 2;', 'javascript', CellKind.Code, [], {}], + ['# header c', 'markdown', CellKind.Markup, [], {}] + ], + async (editor, viewModel, disposables) => { + viewModel.restoreEditorViewState({ + editingCells: [false, false, false, false, false], + cellLineNumberStates: {}, + editorViewStates: [null, null, null, null, null], + cellTotalHeights: [50, 100, 50, 100, 50], + collapsedInputCells: {}, + collapsedOutputCells: {}, + }); + + const cellList = createNotebookCellList(instantiationService, disposables); + disposables.add(cellList); + cellList.attachViewModel(viewModel); + + // render height 210, it can render 3 full cells and 1 partial cell + cellList.layout(210, 100); + assert.strictEqual(cellList.scrollHeight, 350); + + cellList.changeViewZones(accessor => { + const first = accessor.addZone({ + afterModelPosition: 0, + heightInPx: 20, + domNode: document.createElement('div') + }); + + accessor.layoutZone(first); + assert.strictEqual(cellList.scrollHeight, 370); + + const second = accessor.addZone({ + afterModelPosition: 0, + heightInPx: 20, + domNode: document.createElement('div') + }); + accessor.layoutZone(second); + assert.strictEqual(cellList.scrollHeight, 390); + + assert.strictEqual(cellList.getElementTop(0), 40); + assert.strictEqual(cellList.getElementTop(1), 90); + assert.strictEqual(cellList.getElementTop(2), 190); + assert.strictEqual(cellList.getElementTop(3), 240); + assert.strictEqual(cellList.getElementTop(4), 340); + + + accessor.removeZone(first); + assert.strictEqual(cellList.scrollHeight, 370); + accessor.removeZone(second); + assert.strictEqual(cellList.scrollHeight, 350); + }); + }); + }); }); diff --git a/src/vs/workbench/contrib/notebook/test/browser/testNotebookEditor.ts b/src/vs/workbench/contrib/notebook/test/browser/testNotebookEditor.ts index dbcfcbe1a411c..228e0bb1fdf9b 100644 --- a/src/vs/workbench/contrib/notebook/test/browser/testNotebookEditor.ts +++ b/src/vs/workbench/contrib/notebook/test/browser/testNotebookEditor.ts @@ -65,6 +65,8 @@ import { EditorFontLigatures, EditorFontVariations } from 'vs/editor/common/conf import { ICodeEditorService } from 'vs/editor/browser/services/codeEditorService'; import { mainWindow } from 'vs/base/browser/window'; import { TestCodeEditorService } from 'vs/editor/test/browser/editorTestServices'; +import { IInlineChatService } from 'vs/workbench/contrib/inlineChat/common/inlineChat'; +import { InlineChatServiceImpl } from 'vs/workbench/contrib/inlineChat/common/inlineChatServiceImpl'; export class TestCell extends NotebookCellTextModel { constructor( @@ -197,7 +199,7 @@ export function setupInstantiationService(disposables: DisposableStore) { instantiationService.stub(IKeybindingService, new MockKeybindingService()); instantiationService.stub(INotebookCellStatusBarService, disposables.add(new NotebookCellStatusBarService())); instantiationService.stub(ICodeEditorService, disposables.add(new TestCodeEditorService(testThemeService))); - + instantiationService.stub(IInlineChatService, instantiationService.createInstance(InlineChatServiceImpl)); return instantiationService; } diff --git a/src/vs/workbench/contrib/outline/browser/outlinePane.ts b/src/vs/workbench/contrib/outline/browser/outlinePane.ts index 6756a2dd0fee9..33192e08dc28a 100644 --- a/src/vs/workbench/contrib/outline/browser/outlinePane.ts +++ b/src/vs/workbench/contrib/outline/browser/outlinePane.ts @@ -6,7 +6,7 @@ import 'vs/css!./outlinePane'; import * as dom from 'vs/base/browser/dom'; import { ProgressBar } from 'vs/base/browser/ui/progressbar/progressbar'; -import { TimeoutTimer } from 'vs/base/common/async'; +import { TimeoutTimer, timeout } from 'vs/base/common/async'; import { IDisposable, toDisposable, DisposableStore, MutableDisposable } from 'vs/base/common/lifecycle'; import { LRUCache } from 'vs/base/common/map'; import { localize } from 'vs/nls'; @@ -36,6 +36,7 @@ import { AbstractTreeViewState, IAbstractTreeViewState, TreeFindMode } from 'vs/ import { URI } from 'vs/base/common/uri'; import { ctxAllCollapsed, ctxFilterOnType, ctxFollowsCursor, ctxSortMode, IOutlinePane, OutlineSortOrder } from 'vs/workbench/contrib/outline/browser/outline'; import { defaultProgressBarStyles } from 'vs/platform/theme/browser/defaultStyles'; +import { IHoverService } from 'vs/platform/hover/browser/hover'; class OutlineTreeSorter implements ITreeSorter { @@ -94,8 +95,9 @@ export class OutlinePane extends ViewPane implements IOutlinePane { @IOpenerService openerService: IOpenerService, @IThemeService themeService: IThemeService, @ITelemetryService telemetryService: ITelemetryService, + @IHoverService hoverService: IHoverService, ) { - super(options, keybindingService, contextMenuService, configurationService, contextKeyService, viewDescriptorService, _instantiationService, openerService, themeService, telemetryService); + super(options, keybindingService, contextMenuService, configurationService, contextKeyService, viewDescriptorService, _instantiationService, openerService, themeService, telemetryService, hoverService); this._outlineViewState.restore(this._storageService); this._disposables.add(this._outlineViewState); @@ -264,7 +266,7 @@ export class OutlinePane extends ViewPane implements IOutlinePane { multipleSelectionSupport: false, hideTwistiesOfChildlessElements: true, defaultFindMode: this._outlineViewState.filterOnType ? TreeFindMode.Filter : TreeFindMode.Highlight, - overrideStyles: { listBackground: this.getBackgroundColor() } + overrideStyles: this.getLocationBasedColors().listOverrideStyles } ); @@ -295,7 +297,7 @@ export class OutlinePane extends ViewPane implements IOutlinePane { // feature: apply panel background to tree this._editorControlDisposables.add(this.viewDescriptorService.onDidChangeLocation(({ views }) => { if (views.some(v => v.id === this.id)) { - tree.updateOptions({ overrideStyles: { listBackground: this.getBackgroundColor() } }); + tree.updateOptions({ overrideStyles: this.getLocationBasedColors().listOverrideStyles }); } })); @@ -304,7 +306,19 @@ export class OutlinePane extends ViewPane implements IOutlinePane { // feature: reveal outline selection in editor // on change -> reveal/select defining range - this._editorControlDisposables.add(tree.onDidOpen(e => newOutline.reveal(e.element, e.editorOptions, e.sideBySide))); + let idPool = 0; + this._editorControlDisposables.add(tree.onDidOpen(async e => { + const myId = ++idPool; + const isDoubleClick = e.browserEvent?.type === 'dblclick'; + if (!isDoubleClick) { + // workaround for https://github.com/microsoft/vscode/issues/206424 + await timeout(150); + if (myId !== idPool) { + return; + } + } + await newOutline.reveal(e.element, e.editorOptions, e.sideBySide, isDoubleClick); + })); // feature: reveal editor selection in outline const revealActiveElement = () => { if (!this._outlineViewState.followCursor || !newOutline.activeElement) { diff --git a/src/vs/workbench/contrib/output/browser/output.contribution.ts b/src/vs/workbench/contrib/output/browser/output.contribution.ts index 5d31bf6585046..6d3f4d9ad31d7 100644 --- a/src/vs/workbench/contrib/output/browser/output.contribution.ts +++ b/src/vs/workbench/contrib/output/browser/output.contribution.ts @@ -10,7 +10,7 @@ import { Registry } from 'vs/platform/registry/common/platform'; import { MenuId, registerAction2, Action2, MenuRegistry } from 'vs/platform/actions/common/actions'; import { InstantiationType, registerSingleton } from 'vs/platform/instantiation/common/extensions'; import { OutputService } from 'vs/workbench/contrib/output/browser/outputServices'; -import { OUTPUT_MODE_ID, OUTPUT_MIME, OUTPUT_VIEW_ID, IOutputService, CONTEXT_IN_OUTPUT, LOG_MODE_ID, LOG_MIME, CONTEXT_ACTIVE_FILE_OUTPUT, CONTEXT_OUTPUT_SCROLL_LOCK, IOutputChannelDescriptor, IFileOutputChannelDescriptor, ACTIVE_OUTPUT_CHANNEL_CONTEXT, IOutputChannelRegistry, Extensions } from 'vs/workbench/services/output/common/output'; +import { OUTPUT_MODE_ID, OUTPUT_MIME, OUTPUT_VIEW_ID, IOutputService, CONTEXT_IN_OUTPUT, LOG_MODE_ID, LOG_MIME, CONTEXT_ACTIVE_FILE_OUTPUT, CONTEXT_OUTPUT_SCROLL_LOCK, IOutputChannelDescriptor, IFileOutputChannelDescriptor, ACTIVE_OUTPUT_CHANNEL_CONTEXT, CONTEXT_ACTIVE_OUTPUT_LEVEL_SETTABLE, IOutputChannelRegistry, Extensions, CONTEXT_ACTIVE_OUTPUT_LEVEL, CONTEXT_ACTIVE_OUTPUT_LEVEL_IS_DEFAULT } from 'vs/workbench/services/output/common/output'; import { OutputViewPane } from 'vs/workbench/contrib/output/browser/outputView'; import { SyncDescriptor } from 'vs/platform/instantiation/common/descriptors'; import { IWorkbenchContributionsRegistry, Extensions as WorkbenchExtensions, IWorkbenchContribution } from 'vs/workbench/common/contributions'; @@ -20,7 +20,7 @@ import { ViewContainer, IViewContainersRegistry, ViewContainerLocation, Extensio import { IViewsService } from 'vs/workbench/services/views/common/viewsService'; import { ViewPaneContainer } from 'vs/workbench/browser/parts/views/viewPaneContainer'; import { IConfigurationRegistry, Extensions as ConfigurationExtensions, ConfigurationScope } from 'vs/platform/configuration/common/configurationRegistry'; -import { IQuickPickItem, IQuickInputService, IQuickPickSeparator } from 'vs/platform/quickinput/common/quickInput'; +import { IQuickPickItem, IQuickInputService, IQuickPickSeparator, QuickPickInput } from 'vs/platform/quickinput/common/quickInput'; import { AUX_WINDOW_GROUP, AUX_WINDOW_GROUP_TYPE, IEditorService } from 'vs/workbench/services/editor/common/editorService'; import { assertIsDefined } from 'vs/base/common/types'; import { ContextKeyExpr } from 'vs/platform/contextkey/common/contextkey'; @@ -29,7 +29,9 @@ import { registerIcon } from 'vs/platform/theme/common/iconRegistry'; import { Categories } from 'vs/platform/action/common/actionCommonCategories'; import { Disposable, dispose, IDisposable, toDisposable } from 'vs/base/common/lifecycle'; import { IFilesConfigurationService } from 'vs/workbench/services/filesConfiguration/common/filesConfigurationService'; -import { AudioCue, IAudioCueService } from 'vs/platform/audioCues/browser/audioCueService'; +import { AccessibilitySignal, IAccessibilitySignalService } from 'vs/platform/accessibilitySignal/browser/accessibilitySignalService'; +import { ILoggerService, LogLevel, LogLevelToLocalizedString, LogLevelToString } from 'vs/platform/log/common/log'; +import { IDefaultLogLevelsService } from 'vs/workbench/contrib/logs/common/defaultLogLevels'; // Register Service registerSingleton(IOutputService, OutputService, InstantiationType.Delayed); @@ -99,6 +101,7 @@ class OutputContribution extends Disposable implements IWorkbenchContribution { this.registerOpenActiveOutputFileInAuxWindowAction(); this.registerShowLogsAction(); this.registerOpenLogFileAction(); + this.registerConfigureActiveOutputLogLevelAction(); } private registerSwitchOutputAction(): void { @@ -224,11 +227,11 @@ class OutputContribution extends Disposable implements IWorkbenchContribution { } async run(accessor: ServicesAccessor): Promise { const outputService = accessor.get(IOutputService); - const audioCueService = accessor.get(IAudioCueService); + const accessibilitySignalService = accessor.get(IAccessibilitySignalService); const activeChannel = outputService.getActiveChannel(); if (activeChannel) { activeChannel.clear(); - audioCueService.playAudioCue(AudioCue.clear); + accessibilitySignalService.playSignal(AccessibilitySignal.clear); } } })); @@ -334,6 +337,78 @@ class OutputContribution extends Disposable implements IWorkbenchContribution { return null; } + private registerConfigureActiveOutputLogLevelAction(): void { + const that = this; + const logLevelMenu = new MenuId('workbench.output.menu.logLevel'); + this._register(MenuRegistry.appendMenuItem(MenuId.ViewTitle, { + submenu: logLevelMenu, + title: nls.localize('logLevel.label', "Set Log Level..."), + group: 'navigation', + when: ContextKeyExpr.and(ContextKeyExpr.equals('view', OUTPUT_VIEW_ID), CONTEXT_ACTIVE_OUTPUT_LEVEL_SETTABLE), + icon: Codicon.gear, + order: 6 + })); + + let order = 0; + const registerLogLevel = (logLevel: LogLevel) => { + this._register(registerAction2(class extends Action2 { + constructor() { + super({ + id: `workbench.action.output.activeOutputLogLevel.${logLevel}`, + title: LogLevelToLocalizedString(logLevel).value, + toggled: CONTEXT_ACTIVE_OUTPUT_LEVEL.isEqualTo(LogLevelToString(logLevel)), + menu: { + id: logLevelMenu, + order: order++, + group: '0_level' + } + }); + } + async run(accessor: ServicesAccessor): Promise { + const channel = that.outputService.getActiveChannel(); + if (channel) { + const channelDescriptor = that.outputService.getChannelDescriptor(channel.id); + if (channelDescriptor?.log && channelDescriptor.file) { + return accessor.get(ILoggerService).setLogLevel(channelDescriptor.file, logLevel); + } + } + } + })); + }; + + registerLogLevel(LogLevel.Trace); + registerLogLevel(LogLevel.Debug); + registerLogLevel(LogLevel.Info); + registerLogLevel(LogLevel.Warning); + registerLogLevel(LogLevel.Error); + registerLogLevel(LogLevel.Off); + + this._register(registerAction2(class extends Action2 { + constructor() { + super({ + id: `workbench.action.output.activeOutputLogLevelDefault`, + title: nls.localize('logLevelDefault.label', "Set As Default"), + menu: { + id: logLevelMenu, + order, + group: '1_default' + }, + precondition: CONTEXT_ACTIVE_OUTPUT_LEVEL_IS_DEFAULT.negate() + }); + } + async run(accessor: ServicesAccessor): Promise { + const channel = that.outputService.getActiveChannel(); + if (channel) { + const channelDescriptor = that.outputService.getChannelDescriptor(channel.id); + if (channelDescriptor?.log && channelDescriptor.file) { + const logLevel = accessor.get(ILoggerService).getLogLevel(channelDescriptor.file); + return await accessor.get(IDefaultLogLevelsService).setDefaultLogLevel(logLevel, channelDescriptor.extensionId); + } + } + } + })); + } + private registerShowLogsAction(): void { this._register(registerAction2(class extends Action2 { constructor() { @@ -408,16 +483,30 @@ class OutputContribution extends Disposable implements IWorkbenchContribution { const editorService = accessor.get(IEditorService); const fileConfigurationService = accessor.get(IFilesConfigurationService); - const entries: IOutputChannelQuickPickItem[] = outputService.getChannelDescriptors().filter(c => c.file && c.log) - .map(channel => ({ id: channel.id, label: channel.label, channel })); - - const argName = args && typeof args === 'string' ? args : undefined; let entry: IOutputChannelQuickPickItem | undefined; - if (argName) { - entry = entries.find(e => e.id === argName); + const argName = args && typeof args === 'string' ? args : undefined; + const extensionChannels: IOutputChannelQuickPickItem[] = []; + const coreChannels: IOutputChannelQuickPickItem[] = []; + for (const c of outputService.getChannelDescriptors()) { + if (c.file && c.log) { + const e = { id: c.id, label: c.label, channel: c }; + if (c.extensionId) { + extensionChannels.push(e); + } else { + coreChannels.push(e); + } + if (e.id === argName) { + entry = e; + } + } } if (!entry) { - entry = await quickInputService.pick(entries, { placeHolder: nls.localize('selectlogFile', "Select Log File") }); + const entries: QuickPickInput[] = [...extensionChannels.sort((a, b) => a.label.localeCompare(b.label))]; + if (entries.length && coreChannels.length) { + entries.push({ type: 'separator' }); + entries.push(...coreChannels.sort((a, b) => a.label.localeCompare(b.label))); + } + entry = await quickInputService.pick(entries, { placeHolder: nls.localize('selectlogFile', "Select Log File") }); } if (entry) { const resource = assertIsDefined(entry.channel.file); diff --git a/src/vs/workbench/contrib/output/browser/outputServices.ts b/src/vs/workbench/contrib/output/browser/outputServices.ts index 26fed6ffc4ebe..eb83c902631f7 100644 --- a/src/vs/workbench/contrib/output/browser/outputServices.ts +++ b/src/vs/workbench/contrib/output/browser/outputServices.ts @@ -5,15 +5,15 @@ import { Event, Emitter } from 'vs/base/common/event'; import { URI } from 'vs/base/common/uri'; -import { IDisposable, dispose, Disposable } from 'vs/base/common/lifecycle'; +import { Disposable } from 'vs/base/common/lifecycle'; import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; import { IStorageService, StorageScope, StorageTarget } from 'vs/platform/storage/common/storage'; import { Registry } from 'vs/platform/registry/common/platform'; -import { IOutputChannel, IOutputService, OUTPUT_VIEW_ID, OUTPUT_SCHEME, LOG_MIME, OUTPUT_MIME, OutputChannelUpdateMode, IOutputChannelDescriptor, Extensions, IOutputChannelRegistry, ACTIVE_OUTPUT_CHANNEL_CONTEXT, CONTEXT_ACTIVE_FILE_OUTPUT } from 'vs/workbench/services/output/common/output'; +import { IOutputChannel, IOutputService, OUTPUT_VIEW_ID, OUTPUT_SCHEME, LOG_MIME, OUTPUT_MIME, OutputChannelUpdateMode, IOutputChannelDescriptor, Extensions, IOutputChannelRegistry, ACTIVE_OUTPUT_CHANNEL_CONTEXT, CONTEXT_ACTIVE_FILE_OUTPUT, CONTEXT_ACTIVE_OUTPUT_LEVEL_SETTABLE, CONTEXT_ACTIVE_OUTPUT_LEVEL, CONTEXT_ACTIVE_OUTPUT_LEVEL_IS_DEFAULT } from 'vs/workbench/services/output/common/output'; import { OutputLinkProvider } from 'vs/workbench/contrib/output/browser/outputLinkProvider'; import { ITextModelService, ITextModelContentProvider } from 'vs/editor/common/services/resolverService'; import { ITextModel } from 'vs/editor/common/model'; -import { ILogService } from 'vs/platform/log/common/log'; +import { ILogService, ILoggerService, LogLevelToString } from 'vs/platform/log/common/log'; import { ILifecycleService } from 'vs/workbench/services/lifecycle/common/lifecycle'; import { IOutputChannelModel } from 'vs/workbench/contrib/output/common/outputChannelModel'; import { IViewsService } from 'vs/workbench/services/views/common/viewsService'; @@ -21,6 +21,8 @@ import { OutputViewPane } from 'vs/workbench/contrib/output/browser/outputView'; import { IOutputChannelModelService } from 'vs/workbench/contrib/output/common/outputChannelModelService'; import { ILanguageService } from 'vs/editor/common/languages/language'; import { IContextKey, IContextKeyService } from 'vs/platform/contextkey/common/contextkey'; +import { SetLogLevelAction } from 'vs/workbench/contrib/logs/common/logsActions'; +import { IDefaultLogLevelsService } from 'vs/workbench/contrib/logs/common/defaultLogLevels'; const OUTPUT_ACTIVE_CHANNEL_KEY = 'output.activechannel'; @@ -74,15 +76,20 @@ export class OutputService extends Disposable implements IOutputService, ITextMo private readonly activeOutputChannelContext: IContextKey; private readonly activeFileOutputChannelContext: IContextKey; + private readonly activeOutputChannelLevelSettableContext: IContextKey; + private readonly activeOutputChannelLevelContext: IContextKey; + private readonly activeOutputChannelLevelIsDefaultContext: IContextKey; constructor( @IStorageService private readonly storageService: IStorageService, @IInstantiationService private readonly instantiationService: IInstantiationService, @ITextModelService textModelResolverService: ITextModelService, @ILogService private readonly logService: ILogService, + @ILoggerService private readonly loggerService: ILoggerService, @ILifecycleService private readonly lifecycleService: ILifecycleService, @IViewsService private readonly viewsService: IViewsService, @IContextKeyService contextKeyService: IContextKeyService, + @IDefaultLogLevelsService private readonly defaultLogLevelsService: IDefaultLogLevelsService ) { super(); this.activeChannelIdInStorage = this.storageService.get(OUTPUT_ACTIVE_CHANNEL_KEY, StorageScope.WORKSPACE, ''); @@ -91,6 +98,9 @@ export class OutputService extends Disposable implements IOutputService, ITextMo this._register(this.onActiveOutputChannel(channel => this.activeOutputChannelContext.set(channel))); this.activeFileOutputChannelContext = CONTEXT_ACTIVE_FILE_OUTPUT.bindTo(contextKeyService); + this.activeOutputChannelLevelSettableContext = CONTEXT_ACTIVE_OUTPUT_LEVEL_SETTABLE.bindTo(contextKeyService); + this.activeOutputChannelLevelContext = CONTEXT_ACTIVE_OUTPUT_LEVEL.bindTo(contextKeyService); + this.activeOutputChannelLevelIsDefaultContext = CONTEXT_ACTIVE_OUTPUT_LEVEL_IS_DEFAULT.bindTo(contextKeyService); // Register as text model content provider for output textModelResolverService.registerTextModelContentProvider(OUTPUT_SCHEME, this); @@ -115,6 +125,14 @@ export class OutputService extends Disposable implements IOutputService, ITextMo } })); + this._register(this.loggerService.onDidChangeLogLevel(_level => { + this.setLevelContext(); + this.setLevelIsDefaultContext(); + })); + this._register(this.defaultLogLevelsService.onDidChangeDefaultLogLevels(() => { + this.setLevelIsDefaultContext(); + })); + this._register(this.lifecycleService.onDidShutdown(() => this.dispose())); } @@ -166,9 +184,8 @@ export class OutputService extends Disposable implements IOutputService, ITextMo } private createChannel(id: string): OutputChannel { - const channelDisposables: IDisposable[] = []; const channel = this.instantiateChannel(id); - channel.model.onDispose(() => { + this._register(Event.once(channel.model.onDispose)(() => { if (this.activeChannel === channel) { const channels = this.getChannelDescriptors(); const channel = channels.length ? this.getChannel(channels[0].id) : undefined; @@ -179,8 +196,7 @@ export class OutputService extends Disposable implements IOutputService, ITextMo } } Registry.as(Extensions.OutputChannels).removeChannel(id); - dispose(channelDisposables); - }, channelDisposables); + })); return channel; } @@ -194,9 +210,30 @@ export class OutputService extends Disposable implements IOutputService, ITextMo return this.instantiationService.createInstance(OutputChannel, channelData); } + private setLevelContext(): void { + const descriptor = this.activeChannel?.outputChannelDescriptor; + const channelLogLevel = descriptor?.log ? this.loggerService.getLogLevel(descriptor.file) : undefined; + this.activeOutputChannelLevelContext.set(channelLogLevel !== undefined ? LogLevelToString(channelLogLevel) : ''); + } + + private async setLevelIsDefaultContext(): Promise { + const descriptor = this.activeChannel?.outputChannelDescriptor; + if (descriptor?.log) { + const channelLogLevel = this.loggerService.getLogLevel(descriptor.file); + const channelDefaultLogLevel = await this.defaultLogLevelsService.getDefaultLogLevel(descriptor.extensionId); + this.activeOutputChannelLevelIsDefaultContext.set(channelDefaultLogLevel === channelLogLevel); + } else { + this.activeOutputChannelLevelIsDefaultContext.set(false); + } + } + private setActiveChannel(channel: OutputChannel | undefined): void { this.activeChannel = channel; - this.activeFileOutputChannelContext.set(!!channel?.outputChannelDescriptor?.file); + const descriptor = channel?.outputChannelDescriptor; + this.activeFileOutputChannelContext.set(!!descriptor?.file); + this.activeOutputChannelLevelSettableContext.set(descriptor !== undefined && SetLogLevelAction.isLevelSettable(descriptor)); + this.setLevelIsDefaultContext(); + this.setLevelContext(); if (this.activeChannel) { this.storageService.store(OUTPUT_ACTIVE_CHANNEL_KEY, this.activeChannel.id, StorageScope.WORKSPACE, StorageTarget.MACHINE); diff --git a/src/vs/workbench/contrib/output/browser/outputView.ts b/src/vs/workbench/contrib/output/browser/outputView.ts index b7484919efaba..19e5642f145fc 100644 --- a/src/vs/workbench/contrib/output/browser/outputView.ts +++ b/src/vs/workbench/contrib/output/browser/outputView.ts @@ -33,6 +33,8 @@ import { IFileService } from 'vs/platform/files/common/files'; import { ResourceContextKey } from 'vs/workbench/common/contextkeys'; import { ServiceCollection } from 'vs/platform/instantiation/common/serviceCollection'; import { IEditorConfiguration } from 'vs/workbench/browser/parts/editor/textEditor'; +import { computeEditorAriaLabel } from 'vs/workbench/browser/editor'; +import { IHoverService } from 'vs/platform/hover/browser/hover'; export class OutputViewPane extends ViewPane { @@ -55,8 +57,9 @@ export class OutputViewPane extends ViewPane { @IOpenerService openerService: IOpenerService, @IThemeService themeService: IThemeService, @ITelemetryService telemetryService: ITelemetryService, + @IHoverService hoverService: IHoverService, ) { - super(options, keybindingService, contextMenuService, configurationService, contextKeyService, viewDescriptorService, instantiationService, openerService, themeService, telemetryService); + super(options, keybindingService, contextMenuService, configurationService, contextKeyService, viewDescriptorService, instantiationService, openerService, themeService, telemetryService, hoverService); this.scrollLockContextKey = CONTEXT_OUTPUT_SCROLL_LOCK.bindTo(this.contextKeyService); const editorInstantiationService = instantiationService.createChild(new ServiceCollection([IContextKeyService, this.scopedContextKeyService])); @@ -159,10 +162,9 @@ class OutputEditor extends AbstractTextResourceEditor { @IThemeService themeService: IThemeService, @IEditorGroupsService editorGroupService: IEditorGroupsService, @IEditorService editorService: IEditorService, - @IFileService fileService: IFileService, - @IContextKeyService contextKeyService: IContextKeyService, + @IFileService fileService: IFileService ) { - super(OUTPUT_VIEW_ID, telemetryService, instantiationService, storageService, textResourceConfigurationService, themeService, editorGroupService, editorService, fileService); + super(OUTPUT_VIEW_ID, editorGroupService.activeGroup /* TODO@bpasero this is wrong */, telemetryService, instantiationService, storageService, textResourceConfigurationService, themeService, editorGroupService, editorService, fileService); this.resourceContext = this._register(instantiationService.createInstance(ResourceContextKey)); } @@ -213,6 +215,10 @@ class OutputEditor extends AbstractTextResourceEditor { return this.input ? this.input.getAriaLabel() : nls.localize('outputViewAriaLabel', "Output panel"); } + protected override computeAriaLabel(): string { + return this.input ? computeEditorAriaLabel(this.input, undefined, undefined, this.editorGroupService.count) : this.getAriaLabel(); + } + override async setInput(input: TextResourceEditorInput, options: ITextEditorOptions | undefined, context: IEditorOpenContext, token: CancellationToken): Promise { const focus = !(options && options.preserveFocus); if (this.input && input.matches(this.input)) { diff --git a/src/vs/workbench/contrib/output/common/outputLinkComputer.ts b/src/vs/workbench/contrib/output/common/outputLinkComputer.ts index efab6b060751a..0de2f9f2e1502 100644 --- a/src/vs/workbench/contrib/output/common/outputLinkComputer.ts +++ b/src/vs/workbench/contrib/output/common/outputLinkComputer.ts @@ -88,7 +88,7 @@ export class OutputLinkComputer { } for (const workspaceFolderVariant of workspaceFolderVariants) { - const validPathCharacterPattern = '[^\\s\\(\\):<>"]'; + const validPathCharacterPattern = '[^\\s\\(\\):<>\'"]'; const validPathCharacterOrSpacePattern = `(?:${validPathCharacterPattern}| ${validPathCharacterPattern})`; const pathPattern = `${validPathCharacterOrSpacePattern}+\\.${validPathCharacterPattern}+`; const strictPathPattern = `${validPathCharacterPattern}+`; diff --git a/src/vs/workbench/contrib/output/test/browser/outputLinkProvider.test.ts b/src/vs/workbench/contrib/output/test/browser/outputLinkProvider.test.ts index 1c92342f5bb4d..2f7988f5236f7 100644 --- a/src/vs/workbench/contrib/output/test/browser/outputLinkProvider.test.ts +++ b/src/vs/workbench/contrib/output/test/browser/outputLinkProvider.test.ts @@ -277,9 +277,9 @@ suite('OutputLinkProvider', () => { line = toOSPath(' at \'C:\\Users\\someone\\AppData\\Local\\Temp\\_monacodata_9888\\workspaces\\mankala\\Game.ts\' in'); result = OutputLinkComputer.detectLinks(line, 1, patterns, contextService); assert.strictEqual(result.length, 1); - assert.strictEqual(result[0].url, contextService.toResource('/Game.ts\'').toString()); + assert.strictEqual(result[0].url, contextService.toResource('/Game.ts').toString()); assert.strictEqual(result[0].range.startColumn, 6); - assert.strictEqual(result[0].range.endColumn, 86); + assert.strictEqual(result[0].range.endColumn, 85); }); test('OutputLinkProvider - #106847', function () { diff --git a/src/vs/workbench/contrib/performance/browser/inputLatencyContrib.ts b/src/vs/workbench/contrib/performance/browser/inputLatencyContrib.ts index 2ea34f84b37e1..6d35f4c07f1a7 100644 --- a/src/vs/workbench/contrib/performance/browser/inputLatencyContrib.ts +++ b/src/vs/workbench/contrib/performance/browser/inputLatencyContrib.ts @@ -30,7 +30,12 @@ export class InputLatencyContrib extends Disposable implements IWorkbenchContrib this._setupListener(); }, 60000)); - this._setupListener(); + + // Only log 1% of users selected randomly to reduce the volume of data + if (Math.random() <= 0.01) { + this._setupListener(); + } + } private _setupListener(): void { @@ -46,9 +51,9 @@ export class InputLatencyContrib extends Disposable implements IWorkbenchContrib type InputLatencyStatisticFragment = { owner: 'tyriar'; comment: 'Represents a set of statistics collected about input latencies'; - average: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The average time it took to execute.'; isMeasurement: true }; - max: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The maximum time it took to execute.'; isMeasurement: true }; - min: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The minimum time it took to execute.'; isMeasurement: true }; + average: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The average time it took to execute.' }; + max: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The maximum time it took to execute.' }; + min: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The minimum time it took to execute.' }; }; type PerformanceInputLatencyClassification = { @@ -58,7 +63,7 @@ export class InputLatencyContrib extends Disposable implements IWorkbenchContrib input: InputLatencyStatisticFragment; render: InputLatencyStatisticFragment; total: InputLatencyStatisticFragment; - sampleCount: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'The number of samples measured.' }; + sampleCount: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The number of samples measured.' }; }; type PerformanceInputLatencyEvent = inputLatency.IInputLatencyMeasurements; diff --git a/src/vs/workbench/contrib/performance/browser/perfviewEditor.ts b/src/vs/workbench/contrib/performance/browser/perfviewEditor.ts index 259ce2da160b5..1057767631851 100644 --- a/src/vs/workbench/contrib/performance/browser/perfviewEditor.ts +++ b/src/vs/workbench/contrib/performance/browser/perfviewEditor.ts @@ -30,6 +30,7 @@ import * as perf from 'vs/base/common/performance'; import { ITextResourceConfigurationService } from 'vs/editor/common/services/textResourceConfiguration'; import { Registry } from 'vs/platform/registry/common/platform'; import { IWorkbenchContributionsRegistry, Extensions as WorkbenchExtensions, getWorkbenchContribution } from 'vs/workbench/common/contributions'; +import { ICustomEditorLabelService } from 'vs/workbench/services/editor/common/customEditorLabelService'; export class PerfviewContrib { @@ -77,7 +78,8 @@ export class PerfviewInput extends TextResourceEditorInput { @IFileService fileService: IFileService, @ILabelService labelService: ILabelService, @IFilesConfigurationService filesConfigurationService: IFilesConfigurationService, - @ITextResourceConfigurationService textResourceConfigurationService: ITextResourceConfigurationService + @ITextResourceConfigurationService textResourceConfigurationService: ITextResourceConfigurationService, + @ICustomEditorLabelService customEditorLabelService: ICustomEditorLabelService ) { super( PerfviewContrib.get().getInputUri(), @@ -91,7 +93,8 @@ export class PerfviewInput extends TextResourceEditorInput { fileService, labelService, filesConfigurationService, - textResourceConfigurationService + textResourceConfigurationService, + customEditorLabelService ); } } diff --git a/src/vs/workbench/contrib/performance/browser/startupTimings.ts b/src/vs/workbench/contrib/performance/browser/startupTimings.ts index 62dc27549f752..c74e721ff8a65 100644 --- a/src/vs/workbench/contrib/performance/browser/startupTimings.ts +++ b/src/vs/workbench/contrib/performance/browser/startupTimings.ts @@ -119,7 +119,7 @@ export class BrowserResourcePerformanceMarks { comment: 'Resource performance numbers'; hosthash: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Hash of the hostname' }; name: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Resource basename' }; - duration: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Resource duration' }; + duration: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Resource duration' }; }; for (const item of performance.getEntriesByType('resource')) { diff --git a/src/vs/workbench/contrib/preferences/browser/keybindingWidgets.ts b/src/vs/workbench/contrib/preferences/browser/keybindingWidgets.ts index 70920e27b2375..14372b3e3bf15 100644 --- a/src/vs/workbench/contrib/preferences/browser/keybindingWidgets.ts +++ b/src/vs/workbench/contrib/preferences/browser/keybindingWidgets.ts @@ -141,6 +141,7 @@ export class DefineKeybindingWidget extends Widget { private _keybindingInputWidget: KeybindingsSearchWidget; private _outputNode: HTMLElement; private _showExistingKeybindingsNode: HTMLElement; + private readonly _keybindingDisposables = this._register(new DisposableStore()); private _chords: ResolvedKeybinding[] | null = null; private _isVisible: boolean = false; @@ -238,17 +239,18 @@ export class DefineKeybindingWidget extends Widget { } private onKeybinding(keybinding: ResolvedKeybinding[] | null): void { + this._keybindingDisposables.clear(); this._chords = keybinding; dom.clearNode(this._outputNode); dom.clearNode(this._showExistingKeybindingsNode); - const firstLabel = new KeybindingLabel(this._outputNode, OS, defaultKeybindingLabelStyles); + const firstLabel = this._keybindingDisposables.add(new KeybindingLabel(this._outputNode, OS, defaultKeybindingLabelStyles)); firstLabel.set(this._chords?.[0] ?? undefined); if (this._chords) { for (let i = 1; i < this._chords.length; i++) { this._outputNode.appendChild(document.createTextNode(nls.localize('defineKeybinding.chordsTo', "chord to"))); - const chordLabel = new KeybindingLabel(this._outputNode, OS, defaultKeybindingLabelStyles); + const chordLabel = this._keybindingDisposables.add(new KeybindingLabel(this._outputNode, OS, defaultKeybindingLabelStyles)); chordLabel.set(this._chords[i]); } } diff --git a/src/vs/workbench/contrib/preferences/browser/keybindingsEditor.ts b/src/vs/workbench/contrib/preferences/browser/keybindingsEditor.ts index 0b0c257a0e523..dcf8e57209393 100644 --- a/src/vs/workbench/contrib/preferences/browser/keybindingsEditor.ts +++ b/src/vs/workbench/contrib/preferences/browser/keybindingsEditor.ts @@ -57,6 +57,11 @@ import { settingsTextInputBorder } from 'vs/workbench/contrib/preferences/common import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; import { AccessibilityVerbositySettingId } from 'vs/workbench/contrib/accessibility/browser/accessibilityConfiguration'; import { registerNavigableContainer } from 'vs/workbench/browser/actions/widgetNavigationCommands'; +import { IActionViewItemOptions } from 'vs/base/browser/ui/actionbar/actionViewItems'; +import { getDefaultHoverDelegate } from 'vs/base/browser/ui/hover/hoverDelegateFactory'; +import { IEditorGroup } from 'vs/workbench/services/editor/common/editorGroupsService'; +import type { IUpdatableHover } from 'vs/base/browser/ui/hover/hover'; +import { IHoverService } from 'vs/platform/hover/browser/hover'; const $ = DOM.$; @@ -105,6 +110,7 @@ export class KeybindingsEditor extends EditorPane implements IKeybindingsEditorP readonly overflowWidgetsDomNode: HTMLElement; constructor( + group: IEditorGroup, @ITelemetryService telemetryService: ITelemetryService, @IThemeService themeService: IThemeService, @IKeybindingService private readonly keybindingsService: IKeybindingService, @@ -118,7 +124,7 @@ export class KeybindingsEditor extends EditorPane implements IKeybindingsEditorP @IStorageService storageService: IStorageService, @IConfigurationService private readonly configurationService: IConfigurationService ) { - super(KeybindingsEditor.ID, telemetryService, themeService, storageService); + super(KeybindingsEditor.ID, group, telemetryService, themeService, storageService); this.delayedFiltering = new Delayer(300); this._register(keybindingsService.onDidUpdateKeybindings(() => this.render(!!this.keybindingFocusContextKey.get()))); @@ -138,6 +144,7 @@ export class KeybindingsEditor extends EditorPane implements IKeybindingsEditorP override create(parent: HTMLElement): void { super.create(parent); this._register(registerNavigableContainer({ + name: 'keybindingsEditor', focusNotifiers: [this], focusNextWidget: () => { if (this.searchWidget.hasFocus()) { @@ -397,9 +404,9 @@ export class KeybindingsEditor extends EditorPane implements IKeybindingsEditorP const actions = [this.recordKeysAction, this.sortByPrecedenceAction, clearInputAction]; const toolBar = this._register(new ToolBar(this.actionsContainer, this.contextMenuService, { - actionViewItemProvider: (action: IAction) => { + actionViewItemProvider: (action: IAction, options: IActionViewItemOptions) => { if (action.id === this.sortByPrecedenceAction.id || action.id === this.recordKeysAction.id) { - return new ToggleActionViewItem(null, action, { keybinding: this.keybindingsService.lookupKeybinding(action.id)?.getLabel(), toggleStyles: defaultToggleStyles }); + return new ToggleActionViewItem(null, action, { ...options, keybinding: this.keybindingsService.lookupKeybinding(action.id)?.getLabel(), toggleStyles: defaultToggleStyles }); } return undefined; }, @@ -851,7 +858,7 @@ class ActionsColumnRenderer implements ITableRenderer(extensionContainer, $('a.extension-label', { tabindex: 0 })); const extensionId = new HighlightedLabel(DOM.append(extensionContainer, $('.extension-id-container.code'))); - return { sourceColumn, sourceLabel, extensionLabel, extensionContainer, extensionId, disposables: new DisposableStore() }; + return { sourceColumn, sourceColumnHover, sourceLabel, extensionLabel, extensionContainer, extensionId, disposables: new DisposableStore() }; } renderElement(keybindingItemEntry: IKeybindingItemEntry, index: number, templateData: ISourceColumnTemplateData, height: number | undefined): void { @@ -1034,14 +1059,14 @@ class SourceColumnRenderer implements ITableRenderer { this.extensionsWorkbenchService.open(extension.identifier.value); @@ -1057,7 +1082,10 @@ class SourceColumnRenderer implements ITableRenderer()); + private readonly _keybindingDecorationRenderer = this._register(new MutableDisposable()); private readonly _defineWidget: DefineKeybindingOverlayWidget; diff --git a/src/vs/workbench/contrib/preferences/browser/media/settingsWidgets.css b/src/vs/workbench/contrib/preferences/browser/media/settingsWidgets.css index a77fedea1d951..89ba7579904be 100644 --- a/src/vs/workbench/contrib/preferences/browser/media/settingsWidgets.css +++ b/src/vs/workbench/contrib/preferences/browser/media/settingsWidgets.css @@ -18,8 +18,9 @@ .settings-editor > .settings-body .settings-tree-container .setting-item.setting-item-list .setting-list-sibling, .settings-editor > .settings-body .settings-tree-container .setting-item.setting-item-list .setting-list-object-widget .setting-list-object-key, .settings-editor > .settings-body .settings-tree-container .setting-item.setting-item-list .setting-list-object-widget .setting-list-object-value { - white-space: normal; - overflow-wrap: normal; + white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; } .settings-editor > .settings-body .settings-tree-container .setting-item-bool .setting-value-checkbox { diff --git a/src/vs/workbench/contrib/preferences/browser/preferences.contribution.ts b/src/vs/workbench/contrib/preferences/browser/preferences.contribution.ts index f843df64aa365..2256c93b8b8c5 100644 --- a/src/vs/workbench/contrib/preferences/browser/preferences.contribution.ts +++ b/src/vs/workbench/contrib/preferences/browser/preferences.contribution.ts @@ -219,7 +219,7 @@ class PreferencesActionsContribution extends Disposable implements IWorkbenchCon return accessor.get(IPreferencesService).openSettings(opts); } })); - registerAction2(class extends Action2 { + this._register(registerAction2(class extends Action2 { constructor() { super({ id: 'workbench.action.openSettings2', @@ -232,13 +232,16 @@ class PreferencesActionsContribution extends Disposable implements IWorkbenchCon args = sanitizeOpenSettingsArgs(args); return accessor.get(IPreferencesService).openSettings({ jsonEditor: false, ...args }); } - }); + })); - registerAction2(class extends Action2 { + this._register(registerAction2(class extends Action2 { constructor() { super({ id: 'workbench.action.openSettingsJson', title: OPEN_USER_SETTINGS_JSON_TITLE, + metadata: { + description: nls.localize2('workbench.action.openSettingsJson.description', "Opens the JSON file containing the current user profile settings") + }, category, f1: true, }); @@ -247,10 +250,10 @@ class PreferencesActionsContribution extends Disposable implements IWorkbenchCon args = sanitizeOpenSettingsArgs(args); return accessor.get(IPreferencesService).openSettings({ jsonEditor: true, ...args }); } - }); + })); const that = this; - registerAction2(class extends Action2 { + this._register(registerAction2(class extends Action2 { constructor() { super({ id: 'workbench.action.openApplicationSettingsJson', @@ -266,10 +269,10 @@ class PreferencesActionsContribution extends Disposable implements IWorkbenchCon args = sanitizeOpenSettingsArgs(args); return accessor.get(IPreferencesService).openApplicationSettings({ jsonEditor: true, ...args }); } - }); + })); // Opens the User tab of the Settings editor - registerAction2(class extends Action2 { + this._register(registerAction2(class extends Action2 { constructor() { super({ id: 'workbench.action.openGlobalSettings', @@ -282,8 +285,8 @@ class PreferencesActionsContribution extends Disposable implements IWorkbenchCon args = sanitizeOpenSettingsArgs(args); return accessor.get(IPreferencesService).openUserSettings(args); } - }); - registerAction2(class extends Action2 { + })); + this._register(registerAction2(class extends Action2 { constructor() { super({ id: 'workbench.action.openRawDefaultSettings', @@ -295,9 +298,9 @@ class PreferencesActionsContribution extends Disposable implements IWorkbenchCon run(accessor: ServicesAccessor) { return accessor.get(IPreferencesService).openRawDefaultSettings(); } - }); + })); - registerAction2(class extends Action2 { + this._register(registerAction2(class extends Action2 { constructor() { super({ id: ConfigureLanguageBasedSettingsAction.ID, @@ -309,8 +312,8 @@ class PreferencesActionsContribution extends Disposable implements IWorkbenchCon run(accessor: ServicesAccessor) { return accessor.get(IInstantiationService).createInstance(ConfigureLanguageBasedSettingsAction, ConfigureLanguageBasedSettingsAction.ID, ConfigureLanguageBasedSettingsAction.LABEL.value).run(); } - }); - registerAction2(class extends Action2 { + })); + this._register(registerAction2(class extends Action2 { constructor() { super({ id: 'workbench.action.openWorkspaceSettings', @@ -327,9 +330,9 @@ class PreferencesActionsContribution extends Disposable implements IWorkbenchCon args = typeof args === 'string' ? { query: args } : sanitizeOpenSettingsArgs(args); return accessor.get(IPreferencesService).openWorkspaceSettings(args); } - }); + })); - registerAction2(class extends Action2 { + this._register(registerAction2(class extends Action2 { constructor() { super({ id: 'workbench.action.openAccessibilitySettings', @@ -344,8 +347,8 @@ class PreferencesActionsContribution extends Disposable implements IWorkbenchCon async run(accessor: ServicesAccessor) { await accessor.get(IPreferencesService).openSettings({ jsonEditor: false, query: '@tag:accessibility' }); } - }); - registerAction2(class extends Action2 { + })); + this._register(registerAction2(class extends Action2 { constructor() { super({ id: 'workbench.action.openWorkspaceSettingsFile', @@ -361,8 +364,8 @@ class PreferencesActionsContribution extends Disposable implements IWorkbenchCon args = sanitizeOpenSettingsArgs(args); return accessor.get(IPreferencesService).openWorkspaceSettings({ jsonEditor: true, ...args }); } - }); - registerAction2(class extends Action2 { + })); + this._register(registerAction2(class extends Action2 { constructor() { super({ id: 'workbench.action.openFolderSettings', @@ -383,8 +386,8 @@ class PreferencesActionsContribution extends Disposable implements IWorkbenchCon await preferencesService.openFolderSettings({ folderUri: workspaceFolder.uri, ...args }); } } - }); - registerAction2(class extends Action2 { + })); + this._register(registerAction2(class extends Action2 { constructor() { super({ id: 'workbench.action.openFolderSettingsFile', @@ -405,8 +408,8 @@ class PreferencesActionsContribution extends Disposable implements IWorkbenchCon await preferencesService.openFolderSettings({ folderUri: workspaceFolder.uri, jsonEditor: true, ...args }); } } - }); - registerAction2(class extends Action2 { + })); + this._register(registerAction2(class extends Action2 { constructor() { super({ id: '_workbench.action.openFolderSettings', @@ -423,8 +426,8 @@ class PreferencesActionsContribution extends Disposable implements IWorkbenchCon run(accessor: ServicesAccessor, resource: URI) { return accessor.get(IPreferencesService).openFolderSettings({ folderUri: resource }); } - }); - registerAction2(class extends Action2 { + })); + this._register(registerAction2(class extends Action2 { constructor() { super({ id: SETTINGS_EDITOR_COMMAND_FILTER_ONLINE, @@ -444,9 +447,9 @@ class PreferencesActionsContribution extends Disposable implements IWorkbenchCon accessor.get(IPreferencesService).openSettings({ jsonEditor: false, query: '@tag:usesOnlineServices' }); } } - }); + })); - registerAction2(class extends Action2 { + this._register(registerAction2(class extends Action2 { constructor() { super({ id: SETTINGS_EDITOR_COMMAND_FILTER_UNTRUSTED, @@ -456,9 +459,9 @@ class PreferencesActionsContribution extends Disposable implements IWorkbenchCon run(accessor: ServicesAccessor) { accessor.get(IPreferencesService).openWorkspaceSettings({ jsonEditor: false, query: `@tag:${REQUIRE_TRUSTED_WORKSPACE_SETTING_TAG}` }); } - }); + })); - registerAction2(class extends Action2 { + this._register(registerAction2(class extends Action2 { constructor() { super({ id: SETTINGS_COMMAND_FILTER_TELEMETRY, @@ -473,7 +476,7 @@ class PreferencesActionsContribution extends Disposable implements IWorkbenchCon accessor.get(IPreferencesService).openSettings({ jsonEditor: false, query: '@tag:telemetry' }); } } - }); + })); this.registerSettingsEditorActions(); @@ -481,7 +484,7 @@ class PreferencesActionsContribution extends Disposable implements IWorkbenchCon .then(() => { const remoteAuthority = this.environmentService.remoteAuthority; const hostLabel = this.labelService.getHostLabel(Schemas.vscodeRemote, remoteAuthority) || remoteAuthority; - registerAction2(class extends Action2 { + this._register(registerAction2(class extends Action2 { constructor() { super({ id: 'workbench.action.openRemoteSettings', @@ -497,8 +500,8 @@ class PreferencesActionsContribution extends Disposable implements IWorkbenchCon args = sanitizeOpenSettingsArgs(args); return accessor.get(IPreferencesService).openRemoteSettings(args); } - }); - registerAction2(class extends Action2 { + })); + this._register(registerAction2(class extends Action2 { constructor() { super({ id: 'workbench.action.openRemoteSettingsFile', @@ -514,7 +517,7 @@ class PreferencesActionsContribution extends Disposable implements IWorkbenchCon args = sanitizeOpenSettingsArgs(args); return accessor.get(IPreferencesService).openRemoteSettings({ jsonEditor: true, ...args }); } - }); + })); }); } @@ -532,7 +535,7 @@ class PreferencesActionsContribution extends Disposable implements IWorkbenchCon preferencesEditor?.focusSearch(); } - registerAction2(class extends Action2 { + this._register(registerAction2(class extends Action2 { constructor() { super({ id: SETTINGS_EDITOR_COMMAND_SEARCH, @@ -549,9 +552,9 @@ class PreferencesActionsContribution extends Disposable implements IWorkbenchCon } run(accessor: ServicesAccessor) { settingsEditorFocusSearch(accessor); } - }); + })); - registerAction2(class extends Action2 { + this._register(registerAction2(class extends Action2 { constructor() { super({ id: SETTINGS_EDITOR_COMMAND_CLEAR_SEARCH_RESULTS, @@ -571,9 +574,9 @@ class PreferencesActionsContribution extends Disposable implements IWorkbenchCon const preferencesEditor = getPreferencesEditor(accessor); preferencesEditor?.clearSearchResults(); } - }); + })); - registerAction2(class extends Action2 { + this._register(registerAction2(class extends Action2 { constructor() { super({ id: SETTINGS_EDITOR_COMMAND_FOCUS_FILE, @@ -591,9 +594,9 @@ class PreferencesActionsContribution extends Disposable implements IWorkbenchCon const preferencesEditor = getPreferencesEditor(accessor); preferencesEditor?.focusSettings(); } - }); + })); - registerAction2(class extends Action2 { + this._register(registerAction2(class extends Action2 { constructor() { super({ id: SETTINGS_EDITOR_COMMAND_FOCUS_SETTINGS_FROM_SEARCH, @@ -611,9 +614,9 @@ class PreferencesActionsContribution extends Disposable implements IWorkbenchCon const preferencesEditor = getPreferencesEditor(accessor); preferencesEditor?.focusSettings(); } - }); + })); - registerAction2(class extends Action2 { + this._register(registerAction2(class extends Action2 { constructor() { super({ id: SETTINGS_EDITOR_COMMAND_FOCUS_SETTINGS_LIST, @@ -633,9 +636,9 @@ class PreferencesActionsContribution extends Disposable implements IWorkbenchCon preferencesEditor.focusSettings(); } } - }); + })); - registerAction2(class extends Action2 { + this._register(registerAction2(class extends Action2 { constructor() { super({ id: SETTINGS_EDITOR_COMMAND_FOCUS_TOC, @@ -660,9 +663,9 @@ class PreferencesActionsContribution extends Disposable implements IWorkbenchCon preferencesEditor.focusTOC(); } - }); + })); - registerAction2(class extends Action2 { + this._register(registerAction2(class extends Action2 { constructor() { super({ id: SETTINGS_EDITOR_COMMAND_FOCUS_CONTROL, @@ -686,9 +689,9 @@ class PreferencesActionsContribution extends Disposable implements IWorkbenchCon preferencesEditor.focusSettings(true); } } - }); + })); - registerAction2(class extends Action2 { + this._register(registerAction2(class extends Action2 { constructor() { super({ id: SETTINGS_EDITOR_COMMAND_SHOW_CONTEXT_MENU, @@ -710,9 +713,9 @@ class PreferencesActionsContribution extends Disposable implements IWorkbenchCon preferencesEditor.showContextMenu(); } } - }); + })); - registerAction2(class extends Action2 { + this._register(registerAction2(class extends Action2 { constructor() { super({ id: SETTINGS_EDITOR_COMMAND_FOCUS_UP, @@ -742,7 +745,7 @@ class PreferencesActionsContribution extends Disposable implements IWorkbenchCon preferencesEditor.focusSearch(); } } - }); + })); } private registerKeybindingsActions() { @@ -791,7 +794,7 @@ class PreferencesActionsContribution extends Disposable implements IWorkbenchCon group: '2_configuration', order: 4 })); - registerAction2(class extends Action2 { + this._register(registerAction2(class extends Action2 { constructor() { super({ id: 'workbench.action.openDefaultKeybindingsFile', @@ -803,8 +806,8 @@ class PreferencesActionsContribution extends Disposable implements IWorkbenchCon run(accessor: ServicesAccessor) { return accessor.get(IPreferencesService).openDefaultKeybindingsFile(); } - }); - registerAction2(class extends Action2 { + })); + this._register(registerAction2(class extends Action2 { constructor() { super({ id: 'workbench.action.openGlobalKeybindingsFile', @@ -824,8 +827,8 @@ class PreferencesActionsContribution extends Disposable implements IWorkbenchCon run(accessor: ServicesAccessor) { return accessor.get(IPreferencesService).openGlobalKeybindingSettings(true); } - }); - registerAction2(class extends Action2 { + })); + this._register(registerAction2(class extends Action2 { constructor() { super({ id: KEYBINDINGS_EDITOR_SHOW_DEFAULT_KEYBINDINGS, @@ -845,8 +848,8 @@ class PreferencesActionsContribution extends Disposable implements IWorkbenchCon editorPane.search('@source:system'); } } - }); - registerAction2(class extends Action2 { + })); + this._register(registerAction2(class extends Action2 { constructor() { super({ id: KEYBINDINGS_EDITOR_SHOW_EXTENSION_KEYBINDINGS, @@ -866,8 +869,8 @@ class PreferencesActionsContribution extends Disposable implements IWorkbenchCon editorPane.search('@source:extension'); } } - }); - registerAction2(class extends Action2 { + })); + this._register(registerAction2(class extends Action2 { constructor() { super({ id: KEYBINDINGS_EDITOR_SHOW_USER_KEYBINDINGS, @@ -887,8 +890,8 @@ class PreferencesActionsContribution extends Disposable implements IWorkbenchCon editorPane.search('@source:user'); } } - }); - registerAction2(class extends Action2 { + })); + this._register(registerAction2(class extends Action2 { constructor() { super({ id: KEYBINDINGS_EDITOR_COMMAND_CLEAR_SEARCH_RESULTS, @@ -906,9 +909,9 @@ class PreferencesActionsContribution extends Disposable implements IWorkbenchCon editorPane.clearSearchResults(); } } - }); + })); - registerAction2(class extends Action2 { + this._register(registerAction2(class extends Action2 { constructor() { super({ id: KEYBINDINGS_EDITOR_COMMAND_CLEAR_SEARCH_HISTORY, @@ -928,7 +931,7 @@ class PreferencesActionsContribution extends Disposable implements IWorkbenchCon editorPane.clearKeyboardShortcutSearchHistory(); } } - }); + })); this.registerKeybindingEditorActions(); } @@ -1261,7 +1264,7 @@ class SettingsEditorTitleContribution extends Disposable implements IWorkbenchCo })); const openSettingsJsonWhen = ContextKeyExpr.and(CONTEXT_SETTINGS_EDITOR, CONTEXT_SETTINGS_JSON_EDITOR.toNegated()); - registerAction2(class extends Action2 { + this._register(registerAction2(class extends Action2 { constructor() { super({ id: SETTINGS_EDITOR_COMMAND_SWITCH_TO_JSON, @@ -1282,7 +1285,7 @@ class SettingsEditorTitleContribution extends Disposable implements IWorkbenchCo } return null; } - }); + })); } } diff --git a/src/vs/workbench/contrib/preferences/browser/preferencesActions.ts b/src/vs/workbench/contrib/preferences/browser/preferencesActions.ts index 341b0b9e673f4..797f056edcf69 100644 --- a/src/vs/workbench/contrib/preferences/browser/preferencesActions.ts +++ b/src/vs/workbench/contrib/preferences/browser/preferencesActions.ts @@ -17,6 +17,7 @@ import { Extensions, IConfigurationRegistry } from 'vs/platform/configuration/co import { EditorExtensionsRegistry } from 'vs/editor/browser/editorExtensions'; import { MenuId, MenuRegistry, isIMenuItem } from 'vs/platform/actions/common/actions'; import { IKeybindingService } from 'vs/platform/keybinding/common/keybinding'; +import { isLocalizedString } from 'vs/platform/action/common/action'; export class ConfigureLanguageBasedSettingsAction extends Action { @@ -83,21 +84,33 @@ CommandsRegistry.registerCommand({ //#region --- Register a command to get all actions from the command palette CommandsRegistry.registerCommand('_getAllCommands', function (accessor) { const keybindingService = accessor.get(IKeybindingService); - const actions: { command: string; label: string; precondition?: string; keybinding: string }[] = []; + const actions: { command: string; label: string; keybinding: string; description?: string; precondition?: string }[] = []; for (const editorAction of EditorExtensionsRegistry.getEditorActions()) { const keybinding = keybindingService.lookupKeybinding(editorAction.id); - actions.push({ command: editorAction.id, label: editorAction.label, precondition: editorAction.precondition?.serialize(), keybinding: keybinding?.getLabel() ?? 'Not set' }); + actions.push({ + command: editorAction.id, + label: editorAction.label, + description: isLocalizedString(editorAction.metadata?.description) ? editorAction.metadata.description.value : editorAction.metadata?.description, + precondition: editorAction.precondition?.serialize(), + keybinding: keybinding?.getLabel() ?? 'Not set' + }); } for (const menuItem of MenuRegistry.getMenuItems(MenuId.CommandPalette)) { if (isIMenuItem(menuItem)) { const title = typeof menuItem.command.title === 'string' ? menuItem.command.title : menuItem.command.title.value; const category = menuItem.command.category ? typeof menuItem.command.category === 'string' ? menuItem.command.category : menuItem.command.category.value : undefined; const label = category ? `${category}: ${title}` : title; + const description = isLocalizedString(menuItem.command.metadata?.description) ? menuItem.command.metadata.description.value : menuItem.command.metadata?.description; const keybinding = keybindingService.lookupKeybinding(menuItem.command.id); - actions.push({ command: menuItem.command.id, label, precondition: menuItem.when?.serialize(), keybinding: keybinding?.getLabel() ?? 'Not set' }); + actions.push({ + command: menuItem.command.id, + label, + description, + precondition: menuItem.when?.serialize(), + keybinding: keybinding?.getLabel() ?? 'Not set' + }); } } return actions; }); //#endregion - diff --git a/src/vs/workbench/contrib/preferences/browser/preferencesRenderers.ts b/src/vs/workbench/contrib/preferences/browser/preferencesRenderers.ts index 19a69d1f1a0f4..078adf7dfa992 100644 --- a/src/vs/workbench/contrib/preferences/browser/preferencesRenderers.ts +++ b/src/vs/workbench/contrib/preferences/browser/preferencesRenderers.ts @@ -7,44 +7,44 @@ import { EventHelper, getDomNodePagePosition } from 'vs/base/browser/dom'; import { IAction, SubmenuAction } from 'vs/base/common/actions'; import { Delayer } from 'vs/base/common/async'; import { CancellationToken } from 'vs/base/common/cancellation'; +import { IStringDictionary } from 'vs/base/common/collections'; import { Emitter, Event } from 'vs/base/common/event'; import { IJSONSchema } from 'vs/base/common/jsonSchema'; import { Disposable, IDisposable } from 'vs/base/common/lifecycle'; import { ResourceMap } from 'vs/base/common/map'; +import { isEqual } from 'vs/base/common/resources'; +import { ThemeIcon } from 'vs/base/common/themables'; import { ICodeEditor, IEditorMouseEvent, MouseTargetType } from 'vs/editor/browser/editorBrowser'; import { EditorOption } from 'vs/editor/common/config/editorOptions'; -import { ICursorPositionChangedEvent } from 'vs/editor/common/cursorEvents'; import { Position } from 'vs/editor/common/core/position'; import { IRange, Range } from 'vs/editor/common/core/range'; import { Selection } from 'vs/editor/common/core/selection'; +import { ICursorPositionChangedEvent } from 'vs/editor/common/cursorEvents'; import * as editorCommon from 'vs/editor/common/editorCommon'; +import * as languages from 'vs/editor/common/languages'; import { IModelDeltaDecoration, ITextModel, TrackedRangeStickiness } from 'vs/editor/common/model'; import { ModelDecorationOptions } from 'vs/editor/common/model/textModel'; -import * as languages from 'vs/editor/common/languages'; +import { ILanguageFeaturesService } from 'vs/editor/common/services/languageFeatures'; import { CodeActionKind } from 'vs/editor/contrib/codeAction/common/types'; import * as nls from 'vs/nls'; import { ConfigurationTarget, IConfigurationService } from 'vs/platform/configuration/common/configuration'; -import { ConfigurationScope, Extensions as ConfigurationExtensions, IConfigurationPropertySchema, IConfigurationRegistry, IRegisteredConfigurationPropertySchema, overrideIdentifiersFromKey, OVERRIDE_PROPERTY_REGEX } from 'vs/platform/configuration/common/configurationRegistry'; +import { Extensions as ConfigurationExtensions, ConfigurationScope, IConfigurationPropertySchema, IConfigurationRegistry, IRegisteredConfigurationPropertySchema, OVERRIDE_PROPERTY_REGEX, overrideIdentifiersFromKey } from 'vs/platform/configuration/common/configurationRegistry'; import { IContextMenuService } from 'vs/platform/contextview/browser/contextView'; import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; import { IMarkerData, IMarkerService, MarkerSeverity, MarkerTag } from 'vs/platform/markers/common/markers'; import { Registry } from 'vs/platform/registry/common/platform'; -import { ThemeIcon } from 'vs/base/common/themables'; +import { IUriIdentityService } from 'vs/platform/uriIdentity/common/uriIdentity'; +import { IUserDataProfilesService } from 'vs/platform/userDataProfile/common/userDataProfile'; import { IWorkspaceContextService, WorkbenchState } from 'vs/platform/workspace/common/workspace'; import { IWorkspaceTrustManagementService } from 'vs/platform/workspace/common/workspaceTrust'; import { RangeHighlightDecorations } from 'vs/workbench/browser/codeeditor'; import { settingsEditIcon } from 'vs/workbench/contrib/preferences/browser/preferencesIcons'; import { EditPreferenceWidget } from 'vs/workbench/contrib/preferences/browser/preferencesWidgets'; +import { APPLY_ALL_PROFILES_SETTING, IWorkbenchConfigurationService } from 'vs/workbench/services/configuration/common/configuration'; import { IWorkbenchEnvironmentService } from 'vs/workbench/services/environment/common/environmentService'; import { IPreferencesEditorModel, IPreferencesService, ISetting, ISettingsEditorModel, ISettingsGroup } from 'vs/workbench/services/preferences/common/preferences'; import { DefaultSettingsEditorModel, SettingsEditorModel, WorkspaceConfigurationEditorModel } from 'vs/workbench/services/preferences/common/preferencesModels'; -import { IUriIdentityService } from 'vs/platform/uriIdentity/common/uriIdentity'; -import { ILanguageFeaturesService } from 'vs/editor/common/services/languageFeatures'; import { IUserDataProfileService } from 'vs/workbench/services/userDataProfile/common/userDataProfile'; -import { isEqual } from 'vs/base/common/resources'; -import { IUserDataProfilesService } from 'vs/platform/userDataProfile/common/userDataProfile'; -import { IStringDictionary } from 'vs/base/common/collections'; -import { APPLY_ALL_PROFILES_SETTING, IWorkbenchConfigurationService } from 'vs/workbench/services/configuration/common/configuration'; export interface IPreferencesRenderer extends IDisposable { render(): void; diff --git a/src/vs/workbench/contrib/preferences/browser/preferencesWidgets.ts b/src/vs/workbench/contrib/preferences/browser/preferencesWidgets.ts index 7383d1b5918bc..7649cc3acca18 100644 --- a/src/vs/workbench/contrib/preferences/browser/preferencesWidgets.ts +++ b/src/vs/workbench/contrib/preferences/browser/preferencesWidgets.ts @@ -6,7 +6,7 @@ import * as DOM from 'vs/base/browser/dom'; import { StandardKeyboardEvent } from 'vs/base/browser/keyboardEvent'; import { ActionBar, ActionsOrientation } from 'vs/base/browser/ui/actionbar/actionbar'; -import { BaseActionViewItem } from 'vs/base/browser/ui/actionbar/actionViewItems'; +import { BaseActionViewItem, IActionViewItemOptions } from 'vs/base/browser/ui/actionbar/actionViewItems'; import { HistoryInputBox, IHistoryInputOptions } from 'vs/base/browser/ui/inputbox/inputBox'; import { Widget } from 'vs/base/browser/ui/widget'; import { Action, IAction } from 'vs/base/common/actions'; @@ -34,6 +34,10 @@ import { isWorkspaceFolder, IWorkspaceContextService, IWorkspaceFolder, Workbenc import { settingsEditIcon, settingsScopeDropDownIcon } from 'vs/workbench/contrib/preferences/browser/preferencesIcons'; import { IWorkbenchEnvironmentService } from 'vs/workbench/services/environment/common/environmentService'; import { ILanguageService } from 'vs/editor/common/languages/language'; +import { getDefaultHoverDelegate } from 'vs/base/browser/ui/hover/hoverDelegateFactory'; +import type { IUpdatableHover } from 'vs/base/browser/ui/hover/hover'; +import { IHoverService } from 'vs/platform/hover/browser/hover'; + export class FolderSettingsActionViewItem extends BaseActionViewItem { private _folder: IWorkspaceFolder | null; @@ -41,6 +45,7 @@ export class FolderSettingsActionViewItem extends BaseActionViewItem { private container!: HTMLElement; private anchorElement!: HTMLElement; + private anchorElementHover!: IUpdatableHover; private labelElement!: HTMLElement; private detailsElement!: HTMLElement; private dropDownElement!: HTMLElement; @@ -49,6 +54,7 @@ export class FolderSettingsActionViewItem extends BaseActionViewItem { action: IAction, @IWorkspaceContextService private readonly contextService: IWorkspaceContextService, @IContextMenuService private readonly contextMenuService: IContextMenuService, + @IHoverService private readonly hoverService: IHoverService, ) { super(null, action); const workspace = this.contextService.getWorkspace(); @@ -87,6 +93,7 @@ export class FolderSettingsActionViewItem extends BaseActionViewItem { 'aria-haspopup': 'true', 'tabindex': '0' }, this.labelElement, this.detailsElement, this.dropDownElement); + this.anchorElementHover = this._register(this.hoverService.setupUpdatableHover(getDefaultHoverDelegate('mouse'), this.anchorElement, '')); this._register(DOM.addDisposableListener(this.anchorElement, DOM.EventType.MOUSE_DOWN, e => DOM.EventHelper.stop(e))); this._register(DOM.addDisposableListener(this.anchorElement, DOM.EventType.CLICK, e => this.onClick(e))); this._register(DOM.addDisposableListener(this.container, DOM.EventType.KEY_UP, e => this.onKeyUp(e))); @@ -145,7 +152,7 @@ export class FolderSettingsActionViewItem extends BaseActionViewItem { const workspace = this.contextService.getWorkspace(); if (this._folder) { this.labelElement.textContent = this._folder.name; - this.anchorElement.title = this._folder.name; + this.anchorElementHover.update(this._folder.name); const detailsText = this.labelWithCount(this._action.label, total); this.detailsElement.textContent = detailsText; this.dropDownElement.classList.toggle('hide', workspace.folders.length === 1 || !this._action.checked); @@ -153,7 +160,7 @@ export class FolderSettingsActionViewItem extends BaseActionViewItem { const labelText = this.labelWithCount(this._action.label, total); this.labelElement.textContent = labelText; this.detailsElement.textContent = ''; - this.anchorElement.title = this._action.label; + this.anchorElementHover.update(this._action.label); this.dropDownElement.classList.remove('hide'); } @@ -252,8 +259,7 @@ export class SettingsTargetsWidget extends Widget { orientation: ActionsOrientation.HORIZONTAL, focusOnlyEnabledItems: true, ariaLabel: localize('settingsSwitcherBarAriaLabel', "Settings Switcher"), - animated: false, - actionViewItemProvider: (action: IAction) => action.id === 'folderSettings' ? this.folderSettings : undefined + actionViewItemProvider: (action: IAction, options: IActionViewItemOptions) => action.id === 'folderSettings' ? this.folderSettings : undefined })); this.userLocalSettings = new Action('userSettings', '', '.settings-tab', true, () => this.updateTarget(ConfigurationTarget.USER_LOCAL)); diff --git a/src/vs/workbench/contrib/preferences/browser/settingsEditor2.ts b/src/vs/workbench/contrib/preferences/browser/settingsEditor2.ts index 9df8e9b70d650..0b0441c93560e 100644 --- a/src/vs/workbench/contrib/preferences/browser/settingsEditor2.ts +++ b/src/vs/workbench/contrib/preferences/browser/settingsEditor2.ts @@ -66,6 +66,7 @@ import { IProductService } from 'vs/platform/product/common/productService'; import { registerNavigableContainer } from 'vs/workbench/browser/actions/widgetNavigationCommands'; import { IEditorProgressService } from 'vs/platform/progress/common/progress'; import { IExtensionManifest } from 'vs/platform/extensions/common/extensions'; +import { CodeWindow } from 'vs/base/browser/window'; export const enum SettingsFocusContext { @@ -155,7 +156,7 @@ export class SettingsEditor2 extends EditorPane { // (!) Lots of props that are set once on the first render private defaultSettingsEditorModel!: Settings2EditorModel; - private modelDisposables: DisposableStore; + private readonly modelDisposables: DisposableStore; private rootElement!: HTMLElement; private headerContainer!: HTMLElement; @@ -219,6 +220,7 @@ export class SettingsEditor2 extends EditorPane { private installedExtensionIds: string[] = []; constructor( + group: IEditorGroup, @ITelemetryService telemetryService: ITelemetryService, @IWorkbenchConfigurationService private readonly configurationService: IWorkbenchConfigurationService, @ITextResourceConfigurationService textResourceConfigurationService: ITextResourceConfigurationService, @@ -240,7 +242,7 @@ export class SettingsEditor2 extends EditorPane { @IExtensionGalleryService private readonly extensionGalleryService: IExtensionGalleryService, @IEditorProgressService private readonly editorProgressService: IEditorProgressService, ) { - super(SettingsEditor2.ID, telemetryService, themeService, storageService); + super(SettingsEditor2.ID, group, telemetryService, themeService, storageService); this.delayedFilterLogging = new Delayer(1000); this.localSearchDelayer = new Delayer(300); this.remoteSearchThrottle = new ThrottledDelayer(200); @@ -334,6 +336,7 @@ export class SettingsEditor2 extends EditorPane { this.updateStyles(); this._register(registerNavigableContainer({ + name: 'settingsEditor2', focusNotifiers: [this], focusNextWidget: () => { if (this.searchWidget.inputWidget.hasWidgetFocus()) { @@ -355,7 +358,7 @@ export class SettingsEditor2 extends EditorPane { return; } - const model = await this.input.resolve(options); + const model = await this.input.resolve(); if (token.isCancellationRequested || !(model instanceof Settings2EditorModel)) { return; } @@ -398,7 +401,7 @@ export class SettingsEditor2 extends EditorPane { } private restoreCachedState(): ISettingsEditor2State | null { - const cachedState = this.group && this.input && this.editorMemento.loadEditorState(this.group, this.input); + const cachedState = this.input && this.editorMemento.loadEditorState(this.group, this.input); if (cachedState && typeof cachedState.target === 'object') { cachedState.target = URI.revive(cachedState.target); } @@ -499,8 +502,8 @@ export class SettingsEditor2 extends EditorPane { } } - protected override setEditorVisible(visible: boolean, group: IEditorGroup | undefined): void { - super.setEditorVisible(visible, group); + protected override setEditorVisible(visible: boolean): void { + super.setEditorVisible(visible); if (!visible) { // Wait for editor to be removed from DOM #106303 @@ -645,7 +648,7 @@ export class SettingsEditor2 extends EditorPane { })); if (this.userDataSyncWorkbenchService.enabled && this.userDataSyncEnablementService.canToggleEnablement()) { - const syncControls = this._register(this.instantiationService.createInstance(SyncControls, headerControlsContainer)); + const syncControls = this._register(this.instantiationService.createInstance(SyncControls, this.window, headerControlsContainer)); this._register(syncControls.onDidChangeLastSyncedLabel(lastSyncedLabel => { this.lastSyncedLabel = lastSyncedLabel; this.updateInputAriaLabel(); @@ -655,10 +658,9 @@ export class SettingsEditor2 extends EditorPane { this.controlsElement = DOM.append(searchContainer, DOM.$('.settings-clear-widget')); const actionBar = this._register(new ActionBar(this.controlsElement, { - animated: false, - actionViewItemProvider: (action) => { + actionViewItemProvider: (action, options) => { if (action.id === filterAction.id) { - return this.instantiationService.createInstance(SettingsSearchFilterDropdownMenuActionViewItem, action, this.actionRunner, this.searchWidget); + return this.instantiationService.createInstance(SettingsSearchFilterDropdownMenuActionViewItem, action, options, this.actionRunner, this.searchWidget); } return undefined; } @@ -1140,8 +1142,8 @@ export class SettingsEditor2 extends EditorPane { type SettingsEditorModifiedSettingClassification = { key: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The setting that is being modified.' }; groupId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether the setting is from the local search or remote search provider, if applicable.' }; - nlpIndex: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'The index of the setting in the remote search provider results, if applicable.' }; - displayIndex: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'The index of the setting in the combined search results, if applicable.' }; + nlpIndex: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The index of the setting in the remote search provider results, if applicable.' }; + displayIndex: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The index of the setting in the combined search results, if applicable.' }; showConfiguredOnly: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether the user is in the modified view, which shows configured settings only.' }; isReset: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Identifies whether a setting was reset to its default value.' }; target: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The scope of the setting, such as user or workspace.' }; @@ -1427,7 +1429,7 @@ export class SettingsEditor2 extends EditorPane { // If the context view is focused, delay rendering settings if (this.contextViewFocused()) { - const element = DOM.getWindow(this.settingsTree.getHTMLElement()).document.querySelector('.context-view'); + const element = this.window.document.querySelector('.context-view'); if (element) { this.scheduleRefresh(element as HTMLElement, key); } @@ -1636,9 +1638,9 @@ export class SettingsEditor2 extends EditorPane { 'counts.uniqueResultsCount': number | undefined; }; type SettingsEditorFilterClassification = { - 'counts.nlpResult': { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; 'comment': 'The number of matches found by the remote search provider, if applicable.' }; - 'counts.filterResult': { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; 'comment': 'The number of matches found by the local search provider, if applicable.' }; - 'counts.uniqueResultsCount': { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; 'comment': 'The number of unique matches over both search providers, if applicable.' }; + 'counts.nlpResult': { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; 'comment': 'The number of matches found by the remote search provider, if applicable.' }; + 'counts.filterResult': { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; 'comment': 'The number of matches found by the local search provider, if applicable.' }; + 'counts.uniqueResultsCount': { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; 'comment': 'The number of unique matches over both search providers, if applicable.' }; owner: 'rzhao271'; comment: 'Tracks the performance of the built-in search providers'; }; @@ -1831,10 +1833,10 @@ export class SettingsEditor2 extends EditorPane { if (this.isVisible()) { const searchQuery = this.searchWidget.getValue().trim(); const target = this.settingsTargetsWidget.settingsTarget as SettingsTarget; - if (this.group && this.input) { + if (this.input) { this.editorMemento.saveEditorState(this.group, this.input, { searchQuery, target }); } - } else if (this.group && this.input) { + } else if (this.input) { this.editorMemento.clearEditorState(this.input, this.group); } @@ -1850,6 +1852,7 @@ class SyncControls extends Disposable { public readonly onDidChangeLastSyncedLabel = this._onDidChangeLastSyncedLabel.event; constructor( + window: CodeWindow, container: HTMLElement, @ICommandService private readonly commandService: ICommandService, @IUserDataSyncService private readonly userDataSyncService: IUserDataSyncService, @@ -1882,7 +1885,7 @@ class SyncControls extends Disposable { })); const updateLastSyncedTimer = this._register(new DOM.WindowIntervalTimer()); - updateLastSyncedTimer.cancelAndSet(() => this.updateLastSyncedTime(), 60 * 1000, DOM.getWindow(container)); + updateLastSyncedTimer.cancelAndSet(() => this.updateLastSyncedTime(), 60 * 1000, window); this.update(); this._register(this.userDataSyncService.onDidChangeStatus(() => { diff --git a/src/vs/workbench/contrib/preferences/browser/settingsEditorSettingIndicators.ts b/src/vs/workbench/contrib/preferences/browser/settingsEditorSettingIndicators.ts index 2f32b9c59328b..6db2e7883d966 100644 --- a/src/vs/workbench/contrib/preferences/browser/settingsEditorSettingIndicators.ts +++ b/src/vs/workbench/contrib/preferences/browser/settingsEditorSettingIndicators.ts @@ -21,8 +21,8 @@ import { IUserDataSyncEnablementService } from 'vs/platform/userDataSync/common/ import { SettingsTreeSettingElement } from 'vs/workbench/contrib/preferences/browser/settingsTreeModels'; import { POLICY_SETTING_TAG } from 'vs/workbench/contrib/preferences/common/preferences'; import { IWorkbenchConfigurationService } from 'vs/workbench/services/configuration/common/configuration'; -import { IHoverOptions, IHoverService } from 'vs/platform/hover/browser/hover'; -import { IHoverWidget } from 'vs/base/browser/ui/iconLabel/iconHoverDelegate'; +import { IHoverService } from 'vs/platform/hover/browser/hover'; +import type { IHoverOptions, IHoverWidget } from 'vs/base/browser/ui/hover/hover'; const $ = DOM.$; @@ -135,12 +135,12 @@ export class SettingsTreeIndicatorsLabel implements IDisposable { } private createWorkspaceTrustIndicator(): SettingIndicator { + const disposables = new DisposableStore(); const workspaceTrustElement = $('span.setting-indicator.setting-item-workspace-trust'); - const workspaceTrustLabel = new SimpleIconLabel(workspaceTrustElement); + const workspaceTrustLabel = disposables.add(new SimpleIconLabel(workspaceTrustElement)); workspaceTrustLabel.text = '$(warning) ' + localize('workspaceUntrustedLabel', "Setting value not applied"); const content = localize('trustLabel', "The setting value can only be applied in a trusted workspace."); - const disposables = new DisposableStore(); const showHover = (focus: boolean) => { return this.hoverService.showHover({ ...this.defaultHoverOptions, @@ -164,23 +164,24 @@ export class SettingsTreeIndicatorsLabel implements IDisposable { } private createScopeOverridesIndicator(): SettingIndicator { + const disposables = new DisposableStore(); // Don't add .setting-indicator class here, because it gets conditionally added later. const otherOverridesElement = $('span.setting-item-overrides'); - const otherOverridesLabel = new SimpleIconLabel(otherOverridesElement); + const otherOverridesLabel = disposables.add(new SimpleIconLabel(otherOverridesElement)); return { element: otherOverridesElement, label: otherOverridesLabel, - disposables: new DisposableStore() + disposables }; } private createSyncIgnoredIndicator(): SettingIndicator { + const disposables = new DisposableStore(); const syncIgnoredElement = $('span.setting-indicator.setting-item-ignored'); - const syncIgnoredLabel = new SimpleIconLabel(syncIgnoredElement); + const syncIgnoredLabel = disposables.add(new SimpleIconLabel(syncIgnoredElement)); syncIgnoredLabel.text = localize('extensionSyncIgnoredLabel', 'Not synced'); const syncIgnoredHoverContent = localize('syncIgnoredTitle', "This setting is ignored during sync"); - const disposables = new DisposableStore(); const showHover = (focus: boolean) => { return this.hoverService.showHover({ ...this.defaultHoverOptions, @@ -193,19 +194,20 @@ export class SettingsTreeIndicatorsLabel implements IDisposable { return { element: syncIgnoredElement, label: syncIgnoredLabel, - disposables: new DisposableStore() + disposables }; } private createDefaultOverrideIndicator(): SettingIndicator { + const disposables = new DisposableStore(); const defaultOverrideIndicator = $('span.setting-indicator.setting-item-default-overridden'); - const defaultOverrideLabel = new SimpleIconLabel(defaultOverrideIndicator); + const defaultOverrideLabel = disposables.add(new SimpleIconLabel(defaultOverrideIndicator)); defaultOverrideLabel.text = localize('defaultOverriddenLabel', "Default value changed"); return { element: defaultOverrideIndicator, label: defaultOverrideLabel, - disposables: new DisposableStore() + disposables }; } diff --git a/src/vs/workbench/contrib/preferences/browser/settingsLayout.ts b/src/vs/workbench/contrib/preferences/browser/settingsLayout.ts index b4b225d7bc80a..6bee52d8257ab 100644 --- a/src/vs/workbench/contrib/preferences/browser/settingsLayout.ts +++ b/src/vs/workbench/contrib/preferences/browser/settingsLayout.ts @@ -146,6 +146,11 @@ export const tocData: ITOCEntry = { id: 'features', label: localize('features', "Features"), children: [ + { + id: 'features/accessibilitySignals', + label: localize('accessibility.signals', 'Accessibility Signals'), + settings: ['accessibility.signals.*', 'audioCues.*'] + }, { id: 'features/accessibility', label: localize('accessibility', "Accessibility"), @@ -221,11 +226,6 @@ export const tocData: ITOCEntry = { label: localize('notebook', 'Notebook'), settings: ['notebook.*', 'interactiveWindow.*'] }, - { - id: 'features/audioCues', - label: localize('audioCues', 'Audio Cues'), - settings: ['audioCues.*'] - }, { id: 'features/mergeEditor', label: localize('mergeEditor', 'Merge Editor'), diff --git a/src/vs/workbench/contrib/preferences/browser/settingsSearchMenu.ts b/src/vs/workbench/contrib/preferences/browser/settingsSearchMenu.ts index 93e13c0234dd4..d119cd97f695a 100644 --- a/src/vs/workbench/contrib/preferences/browser/settingsSearchMenu.ts +++ b/src/vs/workbench/contrib/preferences/browser/settingsSearchMenu.ts @@ -3,6 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { IActionViewItemOptions } from 'vs/base/browser/ui/actionbar/actionViewItems'; import { AnchorAlignment } from 'vs/base/browser/ui/contextview/contextview'; import { DropdownMenuActionViewItem } from 'vs/base/browser/ui/dropdown/dropdownActionViewItem'; import { IAction, IActionRunner } from 'vs/base/common/actions'; @@ -17,6 +18,7 @@ export class SettingsSearchFilterDropdownMenuActionViewItem extends DropdownMenu constructor( action: IAction, + options: IActionViewItemOptions, actionRunner: IActionRunner | undefined, private readonly searchWidget: SuggestEnabledInput, @IContextMenuService contextMenuService: IContextMenuService @@ -25,6 +27,7 @@ export class SettingsSearchFilterDropdownMenuActionViewItem extends DropdownMenu { getActions: () => this.getActions() }, contextMenuService, { + ...options, actionRunner, classNames: action.class, anchorAlignmentProvider: () => AnchorAlignment.RIGHT, diff --git a/src/vs/workbench/contrib/preferences/browser/settingsTree.ts b/src/vs/workbench/contrib/preferences/browser/settingsTree.ts index 8a24222eac302..24ecefc8617ea 100644 --- a/src/vs/workbench/contrib/preferences/browser/settingsTree.ts +++ b/src/vs/workbench/contrib/preferences/browser/settingsTree.ts @@ -69,6 +69,8 @@ import { IWorkbenchEnvironmentService } from 'vs/workbench/services/environment/ import { IExtensionService } from 'vs/workbench/services/extensions/common/extensions'; import { ISetting, ISettingsGroup, SettingValueType } from 'vs/workbench/services/preferences/common/preferences'; import { getInvalidTypeError } from 'vs/workbench/services/preferences/common/preferencesValidation'; +import { getDefaultHoverDelegate } from 'vs/base/browser/ui/hover/hoverDelegateFactory'; +import { IHoverService } from 'vs/platform/hover/browser/hover'; const $ = DOM.$; @@ -768,6 +770,7 @@ export abstract class AbstractSettingRenderer extends Disposable implements ITre @IExtensionsWorkbenchService protected readonly _extensionsWorkbenchService: IExtensionsWorkbenchService, @IProductService protected readonly _productService: IProductService, @ITelemetryService protected readonly _telemetryService: ITelemetryService, + @IHoverService protected readonly _hoverService: IHoverService, ) { super(); @@ -796,13 +799,13 @@ export abstract class AbstractSettingRenderer extends Disposable implements ITre const labelCategoryContainer = DOM.append(titleElement, $('.setting-item-cat-label-container')); const categoryElement = DOM.append(labelCategoryContainer, $('span.setting-item-category')); const labelElementContainer = DOM.append(labelCategoryContainer, $('span.setting-item-label')); - const labelElement = new SimpleIconLabel(labelElementContainer); + const labelElement = toDispose.add(new SimpleIconLabel(labelElementContainer)); const indicatorsLabel = this._instantiationService.createInstance(SettingsTreeIndicatorsLabel, titleElement); toDispose.add(indicatorsLabel); const descriptionElement = DOM.append(container, $('.setting-item-description')); const modifiedIndicatorElement = DOM.append(container, $('.setting-item-modified-indicator')); - modifiedIndicatorElement.title = localize('modified', "The setting has been configured in the current scope."); + toDispose.add(this._hoverService.setupUpdatableHover(getDefaultHoverDelegate('mouse'), modifiedIndicatorElement, () => localize('modified', "The setting has been configured in the current scope."))); const valueElement = DOM.append(container, $('.setting-item-value')); const controlElement = DOM.append(valueElement, $('div.setting-item-control')); @@ -889,7 +892,7 @@ export abstract class AbstractSettingRenderer extends Disposable implements ITre const titleTooltip = setting.key + (element.isConfigured ? ' - Modified' : ''); template.categoryElement.textContent = element.displayCategory ? (element.displayCategory + ': ') : ''; - template.categoryElement.title = titleTooltip; + template.elementDisposables.add(this._hoverService.setupUpdatableHover(getDefaultHoverDelegate('mouse'), template.categoryElement, titleTooltip)); template.labelElement.text = element.displayLabel; template.labelElement.title = titleTooltip; @@ -1817,24 +1820,25 @@ export class SettingBoolRenderer extends AbstractSettingRenderer implements ITre _container.classList.add('setting-item'); _container.classList.add('setting-item-bool'); + const toDispose = new DisposableStore(); + const container = DOM.append(_container, $(AbstractSettingRenderer.CONTENTS_SELECTOR)); container.classList.add('settings-row-inner-container'); const titleElement = DOM.append(container, $('.setting-item-title')); const categoryElement = DOM.append(titleElement, $('span.setting-item-category')); const labelElementContainer = DOM.append(titleElement, $('span.setting-item-label')); - const labelElement = new SimpleIconLabel(labelElementContainer); + const labelElement = toDispose.add(new SimpleIconLabel(labelElementContainer)); const indicatorsLabel = this._instantiationService.createInstance(SettingsTreeIndicatorsLabel, titleElement); const descriptionAndValueElement = DOM.append(container, $('.setting-item-value-description')); const controlElement = DOM.append(descriptionAndValueElement, $('.setting-item-bool-control')); const descriptionElement = DOM.append(descriptionAndValueElement, $('.setting-item-description')); const modifiedIndicatorElement = DOM.append(container, $('.setting-item-modified-indicator')); - modifiedIndicatorElement.title = localize('modified', "The setting has been configured in the current scope."); + toDispose.add(this._hoverService.setupUpdatableHover(getDefaultHoverDelegate('mouse'), modifiedIndicatorElement, localize('modified', "The setting has been configured in the current scope."))); const deprecationWarningElement = DOM.append(container, $('.setting-item-deprecation-message')); - const toDispose = new DisposableStore(); const checkbox = new Toggle({ icon: Codicon.check, actionClassName: 'setting-value-checkbox', isChecked: true, title: '', ...unthemedToggleStyles }); controlElement.appendChild(checkbox.domNode); toDispose.add(checkbox); diff --git a/src/vs/workbench/contrib/preferences/browser/settingsWidgets.ts b/src/vs/workbench/contrib/preferences/browser/settingsWidgets.ts index 6675881177f4e..80736dc0e3bf1 100644 --- a/src/vs/workbench/contrib/preferences/browser/settingsWidgets.ts +++ b/src/vs/workbench/contrib/preferences/browser/settingsWidgets.ts @@ -27,6 +27,8 @@ import { ThemeIcon } from 'vs/base/common/themables'; import { settingsDiscardIcon, settingsEditIcon, settingsRemoveIcon } from 'vs/workbench/contrib/preferences/browser/preferencesIcons'; import { settingsSelectBackground, settingsSelectBorder, settingsSelectForeground, settingsSelectListBorder, settingsTextInputBackground, settingsTextInputBorder, settingsTextInputForeground } from 'vs/workbench/contrib/preferences/common/settingsEditorColorRegistry'; import { defaultButtonStyles, getInputBoxStyle, getSelectBoxStyles } from 'vs/platform/theme/browser/defaultStyles'; +import { getDefaultHoverDelegate } from 'vs/base/browser/ui/hover/hoverDelegateFactory'; +import { IHoverService } from 'vs/platform/hover/browser/hover'; const $ = DOM.$; @@ -410,6 +412,15 @@ export class ListSettingWidget extends AbstractListSettingWidget super.setValue(listData); } + constructor( + container: HTMLElement, + @IThemeService themeService: IThemeService, + @IContextViewService contextViewService: IContextViewService, + @IHoverService protected readonly hoverService: IHoverService + ) { + super(container, themeService, contextViewService); + } + protected getEmptyItem(): IListDataItem { return { value: { @@ -673,8 +684,8 @@ export class ListSettingWidget extends AbstractListSettingWidget : localize('listSiblingHintLabel', "List item `{0}` with sibling `${1}`", value.data, sibling); const { rowElement } = rowElementGroup; - rowElement.title = title; - rowElement.setAttribute('aria-label', rowElement.title); + this.listDisposables.add(this.hoverService.setupUpdatableHover(getDefaultHoverDelegate('mouse'), rowElement, title)); + rowElement.setAttribute('aria-label', title); } protected getLocalizedStrings() { @@ -733,8 +744,8 @@ export class ExcludeSettingWidget extends ListSettingWidget { : localize('excludeSiblingHintLabel', "Exclude files matching `{0}`, only when a file matching `{1}` is present", value.data, sibling); const { rowElement } = rowElementGroup; - rowElement.title = title; - rowElement.setAttribute('aria-label', rowElement.title); + this.listDisposables.add(this.hoverService.setupUpdatableHover(getDefaultHoverDelegate('mouse'), rowElement, title)); + rowElement.setAttribute('aria-label', title); } protected override getLocalizedStrings() { @@ -763,8 +774,8 @@ export class IncludeSettingWidget extends ListSettingWidget { : localize('includeSiblingHintLabel', "Include files matching `{0}`, only when a file matching `{1}` is present", value.data, sibling); const { rowElement } = rowElementGroup; - rowElement.title = title; - rowElement.setAttribute('aria-label', rowElement.title); + this.listDisposables.add(this.hoverService.setupUpdatableHover(getDefaultHoverDelegate('mouse'), rowElement, title)); + rowElement.setAttribute('aria-label', title); } protected override getLocalizedStrings() { @@ -839,6 +850,15 @@ export class ObjectSettingDropdownWidget extends AbstractListSettingWidget undefined; private valueSuggester: IObjectValueSuggester = () => undefined; + constructor( + container: HTMLElement, + @IThemeService themeService: IThemeService, + @IContextViewService contextViewService: IContextViewService, + @IHoverService private readonly hoverService: IHoverService, + ) { + super(container, themeService, contextViewService); + } + override setValue(listData: IObjectDataItem[], options?: IObjectSetValueOptions): void { this.showAddButton = options?.showAddButton ?? this.showAddButton; this.keySuggester = options?.keySuggester ?? this.keySuggester; @@ -1161,10 +1181,10 @@ export class ObjectSettingDropdownWidget extends AbstractListSettingWidget { private currentSettingKey: string = ''; + constructor( + container: HTMLElement, + @IThemeService themeService: IThemeService, + @IContextViewService contextViewService: IContextViewService, + @IHoverService private readonly hoverService: IHoverService, + ) { + super(container, themeService, contextViewService); + } + override setValue(listData: IObjectDataItem[], options?: IBoolObjectSetValueOptions): void { if (isDefined(options) && options.settingKey !== this.currentSettingKey) { this.model.setEditKey('none'); @@ -1315,7 +1344,7 @@ export class ObjectSettingCheckboxWidget extends AbstractListSettingWidget { templateId = TOC_ENTRY_TEMPLATE_ID; + constructor(private readonly _hoverService: IHoverService) { + } + renderTemplate(container: HTMLElement): ITOCEntryTemplate { return { labelElement: DOM.append(container, $('.settings-toc-entry')), - countElement: DOM.append(container, $('.settings-toc-count')) + countElement: DOM.append(container, $('.settings-toc-count')), + elementDisposables: new DisposableStore() }; } renderElement(node: ITreeNode, index: number, template: ITOCEntryTemplate): void { + template.elementDisposables.clear(); + const element = node.element; const count = element.count; const label = element.label; template.labelElement.textContent = label; - template.labelElement.title = label; + template.elementDisposables.add(this._hoverService.setupUpdatableHover(getDefaultHoverDelegate('mouse'), template.labelElement, label)); if (count) { template.countElement.textContent = ` (${count})`; @@ -131,6 +141,7 @@ export class TOCRenderer implements ITreeRenderer { @IContextKeyService contextKeyService: IContextKeyService, @IListService listService: IListService, @IConfigurationService configurationService: IConfigurationService, + @IHoverService hoverService: IHoverService, @IInstantiationService instantiationService: IInstantiationService, ) { // test open mode @@ -225,7 +237,7 @@ export class TOCTree extends WorkbenchObjectTree { 'SettingsTOC', container, new TOCTreeDelegate(), - [new TOCRenderer()], + [new TOCRenderer(hoverService)], options, instantiationService, contextKeyService, diff --git a/src/vs/workbench/contrib/quickaccess/browser/commandsQuickAccess.ts b/src/vs/workbench/contrib/quickaccess/browser/commandsQuickAccess.ts index 3d492fd2e020d..59ec31d81d675 100644 --- a/src/vs/workbench/contrib/quickaccess/browser/commandsQuickAccess.ts +++ b/src/vs/workbench/contrib/quickaccess/browser/commandsQuickAccess.ts @@ -32,11 +32,12 @@ import { ITelemetryService } from 'vs/platform/telemetry/common/telemetry'; import { IWorkbenchQuickAccessConfiguration } from 'vs/workbench/browser/quickaccess'; import { CHAT_OPEN_ACTION_ID } from 'vs/workbench/contrib/chat/browser/actions/chatActions'; import { ASK_QUICK_QUESTION_ACTION_ID } from 'vs/workbench/contrib/chat/browser/actions/chatQuickInputActions'; -import { IChatAgentService } from 'vs/workbench/contrib/chat/common/chatAgents'; +import { ChatAgentLocation, IChatAgentService } from 'vs/workbench/contrib/chat/common/chatAgents'; import { CommandInformationResult, IAiRelatedInformationService, RelatedInformationType } from 'vs/workbench/services/aiRelatedInformation/common/aiRelatedInformation'; import { IEditorGroupsService } from 'vs/workbench/services/editor/common/editorGroupsService'; import { IEditorService } from 'vs/workbench/services/editor/common/editorService'; import { IExtensionService } from 'vs/workbench/services/extensions/common/extensions'; +import { createKeybindingCommandQuery } from 'vs/workbench/services/preferences/browser/keybindingsEditorModel'; import { IPreferencesService } from 'vs/workbench/services/preferences/common/preferences'; export class CommandsQuickAccessProvider extends AbstractEditorCommandsQuickAccessProvider { @@ -132,7 +133,7 @@ export class CommandsQuickAccessProvider extends AbstractEditorCommandsQuickAcce tooltip: localize('configure keybinding', "Configure Keybinding"), }], trigger: (): TriggerAction => { - this.preferencesService.openGlobalKeybindingSettings(false, { query: `@command:${picks.commandId}` }); + this.preferencesService.openGlobalKeybindingSettings(false, { query: createKeybindingCommandQuery(picks.commandId, picks.commandWhen) }); return TriggerAction.CLOSE_PICKER; }, })); @@ -172,7 +173,7 @@ export class CommandsQuickAccessProvider extends AbstractEditorCommandsQuickAcce }); } - const defaultAgent = this.chatAgentService.getDefaultAgent(); + const defaultAgent = this.chatAgentService.getDefaultAgent(ChatAgentLocation.Panel); if (defaultAgent) { additionalPicks.push({ label: localize('askXInChat', "Ask {0}: {1}", defaultAgent.metadata.fullName, filter), @@ -243,6 +244,7 @@ export class CommandsQuickAccessProvider extends AbstractEditorCommandsQuickAcce : { value: metadataDescription, original: metadataDescription }; globalCommandPicks.push({ commandId: action.item.id, + commandWhen: action.item.precondition?.serialize(), commandAlias, label: stripIcons(label), commandDescription, diff --git a/src/vs/workbench/contrib/remote/browser/remote.ts b/src/vs/workbench/contrib/remote/browser/remote.ts index 3aedafa13aa51..13ee310b79c2d 100644 --- a/src/vs/workbench/contrib/remote/browser/remote.ts +++ b/src/vs/workbench/contrib/remote/browser/remote.ts @@ -54,6 +54,7 @@ import { getVirtualWorkspaceLocation } from 'vs/platform/workspace/common/virtua import { IWalkthroughsService } from 'vs/workbench/contrib/welcomeGettingStarted/browser/gettingStartedService'; import { Schemas } from 'vs/base/common/network'; import { mainWindow } from 'vs/base/browser/window'; +import { IHoverService } from 'vs/platform/hover/browser/hover'; interface IViewModel { onDidChangeHelpInformation: Event; @@ -460,10 +461,11 @@ class HelpPanel extends ViewPane { @IWorkbenchEnvironmentService protected readonly environmentService: IWorkbenchEnvironmentService, @IThemeService themeService: IThemeService, @ITelemetryService telemetryService: ITelemetryService, + @IHoverService hoverService: IHoverService, @IWorkspaceContextService private readonly workspaceContextService: IWorkspaceContextService, @IWalkthroughsService private readonly walkthroughsService: IWalkthroughsService, ) { - super(options, keybindingService, contextMenuService, configurationService, contextKeyService, viewDescriptorService, instantiationService, openerService, themeService, telemetryService); + super(options, keybindingService, contextMenuService, configurationService, contextKeyService, viewDescriptorService, instantiationService, openerService, themeService, telemetryService, hoverService); } protected override renderBody(container: HTMLElement): void { diff --git a/src/vs/workbench/contrib/remote/browser/remoteConnectionHealth.ts b/src/vs/workbench/contrib/remote/browser/remoteConnectionHealth.ts index e75900e7fa7ad..337ed1d256045 100644 --- a/src/vs/workbench/contrib/remote/browser/remoteConnectionHealth.ts +++ b/src/vs/workbench/contrib/remote/browser/remoteConnectionHealth.ts @@ -21,6 +21,7 @@ import Severity from 'vs/base/common/severity'; const REMOTE_UNSUPPORTED_CONNECTION_CHOICE_KEY = 'remote.unsupportedConnectionChoice'; +const BANNER_REMOTE_UNSUPPORTED_CONNECTION_DISMISSED_KEY = 'workbench.banner.remote.unsupportedConnection.dismissed'; export class InitialRemoteConnectionHealthContribution implements IWorkbenchContribution { @@ -90,19 +91,27 @@ export class InitialRemoteConnectionHealthContribution implements IWorkbenchCont allowed = await this._confirmConnection(); } if (allowed) { - const actions = [ - { - label: localize('unsupportedGlibcBannerLearnMore', "Learn More"), - href: 'https://aka.ms/vscode-remote/faq/old-linux' - } - ]; - this.bannerService.show({ - id: 'unsupportedGlibcWarning.banner', - message: localize('unsupportedGlibcWarning.banner', "You are connected to an OS version that is unsupported by {0}.", this.productService.nameLong), - actions, - icon: Codicon.warning, - disableCloseAction: true - }); + const bannerDismissedVersion = this.storageService.get(`${BANNER_REMOTE_UNSUPPORTED_CONNECTION_DISMISSED_KEY}`, StorageScope.PROFILE) ?? ''; + // Ignore patch versions and dismiss the banner if the major and minor versions match. + const shouldShowBanner = bannerDismissedVersion.slice(0, bannerDismissedVersion.lastIndexOf('.')) !== this.productService.version.slice(0, this.productService.version.lastIndexOf('.')); + if (shouldShowBanner) { + const actions = [ + { + label: localize('unsupportedGlibcBannerLearnMore', "Learn More"), + href: 'https://aka.ms/vscode-remote/faq/old-linux' + } + ]; + this.bannerService.show({ + id: 'unsupportedGlibcWarning.banner', + message: localize('unsupportedGlibcWarning.banner', "You are connected to an OS version that is unsupported by {0}.", this.productService.nameLong), + actions, + icon: Codicon.warning, + closeLabel: `Do not show again in v${this.productService.version}`, + onClose: () => { + this.storageService.store(`${BANNER_REMOTE_UNSUPPORTED_CONNECTION_DISMISSED_KEY}`, this.productService.version, StorageScope.PROFILE, StorageTarget.MACHINE); + } + }); + } } else { this.hostService.openWindow({ forceReuseWindow: true, remoteAuthority: null }); return; @@ -113,7 +122,7 @@ export class InitialRemoteConnectionHealthContribution implements IWorkbenchCont owner: 'alexdima'; comment: 'The initial connection succeeded'; web: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Is web ui.' }; - connectionTimeMs: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Time, in ms, until connected'; isMeasurement: true }; + connectionTimeMs: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Time, in ms, until connected' }; remoteName: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The name of the resolver.' }; }; type RemoteConnectionSuccessEvent = { @@ -136,7 +145,7 @@ export class InitialRemoteConnectionHealthContribution implements IWorkbenchCont comment: 'The initial connection failed'; web: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Is web ui.' }; remoteName: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The name of the resolver.' }; - connectionTimeMs: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Time, in ms, until connection failure'; isMeasurement: true }; + connectionTimeMs: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Time, in ms, until connection failure' }; message: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Error message' }; }; type RemoteConnectionFailureEvent = { @@ -166,7 +175,7 @@ export class InitialRemoteConnectionHealthContribution implements IWorkbenchCont comment: 'The latency to the remote extension host'; web: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Whether this is running on web' }; remoteName: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Anonymized remote name' }; - latencyMs: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Latency to the remote, in milliseconds'; isMeasurement: true }; + latencyMs: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Latency to the remote, in milliseconds' }; }; type RemoteConnectionLatencyEvent = { web: boolean; diff --git a/src/vs/workbench/contrib/remote/browser/remoteExplorer.ts b/src/vs/workbench/contrib/remote/browser/remoteExplorer.ts index 880ccebd8e040..39636afde8919 100644 --- a/src/vs/workbench/contrib/remote/browser/remoteExplorer.ts +++ b/src/vs/workbench/contrib/remote/browser/remoteExplorer.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ import * as nls from 'vs/nls'; -import { Disposable, IDisposable } from 'vs/base/common/lifecycle'; +import { Disposable, IDisposable, MutableDisposable } from 'vs/base/common/lifecycle'; import { IWorkbenchContribution } from 'vs/workbench/common/contributions'; import { Extensions, IViewContainersRegistry, IViewsRegistry, ViewContainer, ViewContainerLocation } from 'vs/workbench/common/views'; import { IRemoteExplorerService, PORT_AUTO_FALLBACK_SETTING, PORT_AUTO_FORWARD_SETTING, PORT_AUTO_SOURCE_SETTING, PORT_AUTO_SOURCE_SETTING_HYBRID, PORT_AUTO_SOURCE_SETTING_OUTPUT, PORT_AUTO_SOURCE_SETTING_PROCESS, TUNNEL_VIEW_CONTAINER_ID, TUNNEL_VIEW_ID } from 'vs/workbench/services/remote/common/remoteExplorerService'; @@ -41,8 +41,8 @@ import { IStorageService, StorageScope } from 'vs/platform/storage/common/storag export const VIEWLET_ID = 'workbench.view.remote'; export class ForwardedPortsView extends Disposable implements IWorkbenchContribution { - private contextKeyListener?: IDisposable; - private _activityBadge?: IDisposable; + private readonly contextKeyListener = this._register(new MutableDisposable()); + private readonly activityBadge = this._register(new MutableDisposable()); private entryAccessor: IStatusbarEntryAccessor | undefined; constructor( @@ -75,10 +75,7 @@ export class ForwardedPortsView extends Disposable implements IWorkbenchContribu } private async enableForwardedPortsView() { - if (this.contextKeyListener) { - this.contextKeyListener.dispose(); - this.contextKeyListener = undefined; - } + this.contextKeyListener.clear(); const viewEnabled: boolean = !!forwardedPortsViewEnabled.getValue(this.contextKeyService); @@ -91,7 +88,7 @@ export class ForwardedPortsView extends Disposable implements IWorkbenchContribu viewsRegistry.registerViews([tunnelPanelDescriptor], viewContainer); } } else { - this.contextKeyListener = this.contextKeyService.onDidChangeContext(e => { + this.contextKeyListener.value = this.contextKeyService.onDidChangeContext(e => { if (e.affectsSome(new Set(forwardedPortsViewEnabled.keys()))) { this.enableForwardedPortsView(); } @@ -119,9 +116,8 @@ export class ForwardedPortsView extends Disposable implements IWorkbenchContribu } private async updateActivityBadge() { - this._activityBadge?.dispose(); if (this.remoteExplorerService.tunnelModel.forwarded.size > 0) { - this._activityBadge = this.activityService.showViewActivity(TUNNEL_VIEW_ID, { + this.activityBadge.value = this.activityService.showViewActivity(TUNNEL_VIEW_ID, { badge: new NumberBadge(this.remoteExplorerService.tunnelModel.forwarded.size, n => n === 1 ? nls.localize('1forwardedPort', "1 forwarded port") : nls.localize('nForwardedPorts', "{0} forwarded ports", n)) }); } @@ -189,11 +185,11 @@ export class AutomaticPortForwarding extends Disposable implements IWorkbenchCon @IOpenerService private readonly openerService: IOpenerService, @IExternalUriOpenerService private readonly externalOpenerService: IExternalUriOpenerService, @IRemoteExplorerService private readonly remoteExplorerService: IRemoteExplorerService, - @IWorkbenchEnvironmentService readonly environmentService: IWorkbenchEnvironmentService, + @IWorkbenchEnvironmentService environmentService: IWorkbenchEnvironmentService, @IContextKeyService private readonly contextKeyService: IContextKeyService, @IWorkbenchConfigurationService private readonly configurationService: IWorkbenchConfigurationService, @IDebugService private readonly debugService: IDebugService, - @IRemoteAgentService readonly remoteAgentService: IRemoteAgentService, + @IRemoteAgentService remoteAgentService: IRemoteAgentService, @ITunnelService private readonly tunnelService: ITunnelService, @IHostService private readonly hostService: IHostService, @ILogService private readonly logService: ILogService, @@ -221,8 +217,25 @@ export class AutomaticPortForwarding extends Disposable implements IWorkbenchCon } } + private getPortAutoFallbackNumber(): number { + const fallbackAt = this.configurationService.inspect(PORT_AUTO_FALLBACK_SETTING); + if ((fallbackAt.value !== undefined) && (fallbackAt.value === 0 || (fallbackAt.value !== fallbackAt.defaultValue))) { + return fallbackAt.value; + } + const inspectSource = this.configurationService.inspect(PORT_AUTO_SOURCE_SETTING); + if (inspectSource.applicationValue === PORT_AUTO_SOURCE_SETTING_PROCESS || + inspectSource.userValue === PORT_AUTO_SOURCE_SETTING_PROCESS || + inspectSource.userLocalValue === PORT_AUTO_SOURCE_SETTING_PROCESS || + inspectSource.userRemoteValue === PORT_AUTO_SOURCE_SETTING_PROCESS || + inspectSource.workspaceFolderValue === PORT_AUTO_SOURCE_SETTING_PROCESS || + inspectSource.workspaceValue === PORT_AUTO_SOURCE_SETTING_PROCESS) { + return 0; + } + return fallbackAt.value ?? 20; + } + private listenForPorts() { - let fallbackAt = this.configurationService.getValue(PORT_AUTO_FALLBACK_SETTING); + let fallbackAt = this.getPortAutoFallbackNumber(); if (fallbackAt === 0) { this.portListener?.dispose(); return; @@ -230,7 +243,7 @@ export class AutomaticPortForwarding extends Disposable implements IWorkbenchCon if (this.procForwarder && !this.portListener && (this.configurationService.getValue(PORT_AUTO_SOURCE_SETTING) === PORT_AUTO_SOURCE_SETTING_PROCESS)) { this.portListener = this._register(this.remoteExplorerService.tunnelModel.onForwardPort(async () => { - fallbackAt = this.configurationService.getValue(PORT_AUTO_FALLBACK_SETTING); + fallbackAt = this.getPortAutoFallbackNumber(); if (fallbackAt === 0) { this.portListener?.dispose(); return; @@ -273,8 +286,10 @@ export class AutomaticPortForwarding extends Disposable implements IWorkbenchCon this.outputForwarder?.dispose(); this.outputForwarder = undefined; if (environment?.os !== OperatingSystem.Linux) { - Registry.as(ConfigurationExtensions.Configuration) - .registerDefaultConfigurations([{ overrides: { 'remote.autoForwardPortsSource': PORT_AUTO_SOURCE_SETTING_OUTPUT } }]); + if (this.configurationService.inspect(PORT_AUTO_SOURCE_SETTING).default?.value !== PORT_AUTO_SOURCE_SETTING_OUTPUT) { + Registry.as(ConfigurationExtensions.Configuration) + .registerDefaultConfigurations([{ overrides: { 'remote.autoForwardPortsSource': PORT_AUTO_SOURCE_SETTING_OUTPUT } }]); + } this.outputForwarder = this._register(new OutputAutomaticPortForwarding(this.terminalService, this.notificationService, this.openerService, this.externalOpenerService, this.remoteExplorerService, this.configurationService, this.debugService, this.tunnelService, this.hostService, this.logService, this.contextKeyService, () => false)); } else { diff --git a/src/vs/workbench/contrib/remote/browser/remoteIndicator.ts b/src/vs/workbench/contrib/remote/browser/remoteIndicator.ts index f362b61aa1b6d..08dd88b0fae7c 100644 --- a/src/vs/workbench/contrib/remote/browser/remoteIndicator.ts +++ b/src/vs/workbench/contrib/remote/browser/remoteIndicator.ts @@ -49,6 +49,10 @@ import { infoIcon } from 'vs/workbench/contrib/extensions/browser/extensionsIcon import { IOpenerService } from 'vs/platform/opener/common/opener'; import { URI } from 'vs/base/common/uri'; import { mainWindow } from 'vs/base/browser/window'; +import { Registry } from 'vs/platform/registry/common/platform'; +import { IConfigurationRegistry, Extensions as ConfigurationExtensions } from 'vs/platform/configuration/common/configurationRegistry'; +import { workbenchConfigurationNodeBase } from 'vs/workbench/common/configuration'; +import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; type ActionGroup = [string, Array]; @@ -97,7 +101,32 @@ export class RemoteStatusIndicator extends Disposable implements IWorkbenchContr private measureNetworkConnectionLatencyScheduler: RunOnceScheduler | undefined = undefined; private loggedInvalidGroupNames: { [group: string]: boolean } = Object.create(null); - private readonly remoteExtensionMetadata: RemoteExtensionMetadata[]; + + private _remoteExtensionMetadata: RemoteExtensionMetadata[] | undefined = undefined; + private get remoteExtensionMetadata(): RemoteExtensionMetadata[] { + if (!this._remoteExtensionMetadata) { + const remoteExtensionTips = { ...this.productService.remoteExtensionTips, ...this.productService.virtualWorkspaceExtensionTips }; + this._remoteExtensionMetadata = Object.values(remoteExtensionTips).filter(value => value.startEntry !== undefined).map(value => { + return { + id: value.extensionId, + installed: false, + friendlyName: value.friendlyName, + isPlatformCompatible: false, + dependencies: [], + helpLink: value.startEntry?.helpLink ?? '', + startConnectLabel: value.startEntry?.startConnectLabel ?? '', + startCommand: value.startEntry?.startCommand ?? '', + priority: value.startEntry?.priority ?? 10, + supportedPlatforms: value.supportedPlatforms + }; + }); + + this.remoteExtensionMetadata.sort((ext1, ext2) => ext1.priority - ext2.priority); + } + + return this._remoteExtensionMetadata; + } + private remoteMetadataInitialized: boolean = false; private readonly _onDidChangeEntries = this._register(new Emitter()); private readonly onDidChangeEntries: Event = this._onDidChangeEntries.event; @@ -121,27 +150,10 @@ export class RemoteStatusIndicator extends Disposable implements IWorkbenchContr @IProductService private readonly productService: IProductService, @IExtensionManagementService private readonly extensionManagementService: IExtensionManagementService, @IOpenerService private readonly openerService: IOpenerService, + @IConfigurationService private readonly configurationService: IConfigurationService, ) { super(); - const remoteExtensionTips = { ...this.productService.remoteExtensionTips, ...this.productService.virtualWorkspaceExtensionTips }; - this.remoteExtensionMetadata = Object.values(remoteExtensionTips).filter(value => value.startEntry !== undefined).map(value => { - return { - id: value.extensionId, - installed: false, - friendlyName: value.friendlyName, - isPlatformCompatible: false, - dependencies: [], - helpLink: value.startEntry?.helpLink ?? '', - startConnectLabel: value.startEntry?.startConnectLabel ?? '', - startCommand: value.startEntry?.startCommand ?? '', - priority: value.startEntry?.priority ?? 10, - supportedPlatforms: value.supportedPlatforms - }; - }); - - this.remoteExtensionMetadata.sort((ext1, ext2) => ext1.priority - ext2.priority); - // Set initial connection state if (this.remoteAuthority) { this.connectionState = 'initializing'; @@ -162,7 +174,7 @@ export class RemoteStatusIndicator extends Disposable implements IWorkbenchContr // Show Remote Menu const that = this; - registerAction2(class extends Action2 { + this._register(registerAction2(class extends Action2 { constructor() { super({ id: RemoteStatusIndicator.REMOTE_ACTIONS_COMMAND_ID, @@ -176,11 +188,11 @@ export class RemoteStatusIndicator extends Disposable implements IWorkbenchContr }); } run = () => that.showRemoteMenu(); - }); + })); // Close Remote Connection if (RemoteStatusIndicator.SHOW_CLOSE_REMOTE_COMMAND_ID) { - registerAction2(class extends Action2 { + this._register(registerAction2(class extends Action2 { constructor() { super({ id: RemoteStatusIndicator.CLOSE_REMOTE_COMMAND_ID, @@ -191,7 +203,7 @@ export class RemoteStatusIndicator extends Disposable implements IWorkbenchContr }); } run = () => that.hostService.openWindow({ forceReuseWindow: true, remoteAuthority: null }); - }); + })); if (this.remoteAuthority) { MenuRegistry.appendMenuItem(MenuId.MenubarFileMenu, { group: '6_close', @@ -205,7 +217,7 @@ export class RemoteStatusIndicator extends Disposable implements IWorkbenchContr } if (this.extensionGalleryService.isEnabled()) { - registerAction2(class extends Action2 { + this._register(registerAction2(class extends Action2 { constructor() { super({ id: RemoteStatusIndicator.INSTALL_REMOTE_EXTENSIONS_ID, @@ -223,7 +235,7 @@ export class RemoteStatusIndicator extends Disposable implements IWorkbenchContr } }); }; - }); + })); } } @@ -707,7 +719,8 @@ export class RemoteStatusIndicator extends Disposable implements IWorkbenchContr } } - if (this.extensionGalleryService.isEnabled() && this.remoteMetadataInitialized) { + const showExtensionRecommendations = this.configurationService.getValue('workbench.remoteIndicator.showExtensionRecommendations'); + if (showExtensionRecommendations && this.extensionGalleryService.isEnabled() && this.remoteMetadataInitialized) { const notInstalledItems: QuickPickItem[] = []; for (const metadata of this.remoteExtensionMetadata) { @@ -821,3 +834,15 @@ export class RemoteStatusIndicator extends Disposable implements IWorkbenchContr quickPick.show(); } } + +Registry.as(ConfigurationExtensions.Configuration) + .registerConfiguration({ + ...workbenchConfigurationNodeBase, + properties: { + 'workbench.remoteIndicator.showExtensionRecommendations': { + type: 'boolean', + markdownDescription: nls.localize('remote.showExtensionRecommendations', "When enabled, remote extensions recommendations will be shown in the Remote Indicator menu."), + default: true + }, + } + }); diff --git a/src/vs/workbench/contrib/remote/browser/remoteStartEntry.ts b/src/vs/workbench/contrib/remote/browser/remoteStartEntry.ts index e4e2f80af4b8d..551a983f173e3 100644 --- a/src/vs/workbench/contrib/remote/browser/remoteStartEntry.ts +++ b/src/vs/workbench/contrib/remote/browser/remoteStartEntry.ts @@ -48,7 +48,7 @@ export class RemoteStartEntry extends Disposable implements IWorkbenchContributi // Show Remote Start Action const startEntry = this; - registerAction2(class extends Action2 { + this._register(registerAction2(class extends Action2 { constructor() { super({ id: RemoteStartEntry.REMOTE_WEB_START_ENTRY_ACTIONS_COMMAND_ID, @@ -61,7 +61,7 @@ export class RemoteStartEntry extends Disposable implements IWorkbenchContributi async run(): Promise { await startEntry.showWebRemoteStartActions(); } - }); + })); } private registerListeners(): void { diff --git a/src/vs/workbench/contrib/remote/browser/tunnelView.ts b/src/vs/workbench/contrib/remote/browser/tunnelView.ts index 0753f9429c5e1..0ba86ba97b8aa 100644 --- a/src/vs/workbench/contrib/remote/browser/tunnelView.ts +++ b/src/vs/workbench/contrib/remote/browser/tunnelView.ts @@ -44,19 +44,20 @@ import { ITelemetryService } from 'vs/platform/telemetry/common/telemetry'; import { ActionViewItem } from 'vs/base/browser/ui/actionbar/actionViewItems'; import { copyAddressIcon, forwardedPortWithoutProcessIcon, forwardedPortWithProcessIcon, forwardPortIcon, labelPortIcon, openBrowserIcon, openPreviewIcon, portsViewIcon, privatePortIcon, stopForwardIcon } from 'vs/workbench/contrib/remote/browser/remoteIcons'; import { IExternalUriOpenerService } from 'vs/workbench/contrib/externalUriOpener/common/externalUriOpenerService'; -import { CancellationTokenSource } from 'vs/base/common/cancellation'; +import { CancellationToken } from 'vs/base/common/cancellation'; import { isMacintosh } from 'vs/base/common/platform'; import { ITableColumn, ITableContextMenuEvent, ITableEvent, ITableMouseEvent, ITableRenderer, ITableVirtualDelegate } from 'vs/base/browser/ui/table/table'; import { WorkbenchTable } from 'vs/platform/list/browser/listService'; import { Button } from 'vs/base/browser/ui/button/button'; import { registerColor } from 'vs/platform/theme/common/colorRegistry'; import { IMarkdownString, MarkdownString } from 'vs/base/common/htmlContent'; -import { IHoverDelegateOptions } from 'vs/base/browser/ui/iconLabel/iconHoverDelegate'; -import { IHoverService } from 'vs/platform/hover/browser/hover'; +import { IHoverDelegate } from 'vs/base/browser/ui/hover/hoverDelegate'; import { STATUS_BAR_REMOTE_ITEM_BACKGROUND } from 'vs/workbench/common/theme'; import { Codicon } from 'vs/base/common/codicons'; import { defaultButtonStyles, defaultInputBoxStyles } from 'vs/platform/theme/browser/defaultStyles'; import { Attributes, CandidatePort, Tunnel, TunnelCloseReason, TunnelModel, TunnelSource, forwardedPortsViewEnabled, makeAddress, mapHasAddressLocalhostOrAllInterfaces, parseAddress } from 'vs/workbench/services/remote/common/tunnelModel'; +import { getDefaultHoverDelegate } from 'vs/base/browser/ui/hover/hoverDelegateFactory'; +import { IHoverService } from 'vs/platform/hover/browser/hover'; export const openPreviewEnabledContext = new RawContextKey('openPreviewEnabled', false); @@ -342,6 +343,7 @@ class ActionBarRenderer extends Disposable implements ITableRenderer void; private _actionRunner: ActionRunner | undefined; + private readonly _hoverDelegate: IHoverDelegate; constructor( @IInstantiationService private readonly instantiationService: IInstantiationService, @@ -351,8 +353,11 @@ class ActionBarRenderer extends Disposable implements ITableRenderer this.hoverService.showHover(options), - delay: this.configurationService.getValue('workbench.hover.delay') - } + hoverDelegate: this._hoverDelegate }); const actionsContainer = dom.append(cell, dom.$('.actions')); const actionBar = new ActionBar(actionsContainer, { - actionViewItemProvider: createActionViewItem.bind(undefined, this.instantiationService) + actionViewItemProvider: createActionViewItem.bind(undefined, this.instantiationService), + hoverDelegate: this._hoverDelegate }); return { label, icon, actionBar, container: cell, elementDisposable: Disposable.None }; } @@ -748,7 +751,7 @@ export class TunnelPanel extends ViewPane { private panelContainer: HTMLElement | undefined; private table!: WorkbenchTable; - private tableDisposables: DisposableStore = this._register(new DisposableStore()); + private readonly tableDisposables: DisposableStore = this._register(new DisposableStore()); private tunnelTypeContext: IContextKey; private tunnelCloseableContext: IContextKey; private tunnelPrivacyContext: IContextKey; @@ -779,11 +782,11 @@ export class TunnelPanel extends ViewPane { @IThemeService themeService: IThemeService, @IRemoteExplorerService private readonly remoteExplorerService: IRemoteExplorerService, @ITelemetryService telemetryService: ITelemetryService, + @IHoverService hoverService: IHoverService, @ITunnelService private readonly tunnelService: ITunnelService, @IContextViewService private readonly contextViewService: IContextViewService, - @IHoverService private readonly hoverService: IHoverService ) { - super(options, keybindingService, contextMenuService, configurationService, contextKeyService, viewDescriptorService, instantiationService, openerService, themeService, telemetryService); + super(options, keybindingService, contextMenuService, configurationService, contextKeyService, viewDescriptorService, instantiationService, openerService, themeService, telemetryService, hoverService); this.tunnelTypeContext = TunnelTypeContextKey.bindTo(contextKeyService); this.tunnelCloseableContext = TunnelCloseableContextKey.bindTo(contextKeyService); this.tunnelPrivacyContext = TunnelPrivacyContextKey.bindTo(contextKeyService); @@ -865,7 +868,7 @@ export class TunnelPanel extends ViewPane { const actionBarRenderer = new ActionBarRenderer(this.instantiationService, this.contextKeyService, this.menuService, this.contextViewService, this.remoteExplorerService, this.commandService, - this.configurationService, this.hoverService); + this.configurationService); const columns = [new IconColumn(), new PortColumn(), new LocalAddressColumn(), new RunningProcessColumn()]; if (this.tunnelService.canChangePrivacy) { columns.push(new PrivacyColumn()); @@ -1371,9 +1374,9 @@ export namespace OpenPortInPreviewAction { if (tunnel) { const remoteHost = tunnel.remoteHost.includes(':') ? `[${tunnel.remoteHost}]` : tunnel.remoteHost; const sourceUri = URI.parse(`http://${remoteHost}:${tunnel.remotePort}`); - const opener = await externalOpenerService.getOpener(tunnel.localUri, { sourceUri }, new CancellationTokenSource().token); + const opener = await externalOpenerService.getOpener(tunnel.localUri, { sourceUri }, CancellationToken.None); if (opener) { - return opener.openExternalUri(tunnel.localUri, { sourceUri }, new CancellationTokenSource().token); + return opener.openExternalUri(tunnel.localUri, { sourceUri }, CancellationToken.None); } return openerService.open(tunnel.localUri); } diff --git a/src/vs/workbench/contrib/remote/common/remote.contribution.ts b/src/vs/workbench/contrib/remote/common/remote.contribution.ts index c66080cacec69..446aef1fc3653 100644 --- a/src/vs/workbench/contrib/remote/common/remote.contribution.ts +++ b/src/vs/workbench/contrib/remote/common/remote.contribution.ts @@ -239,7 +239,7 @@ Registry.as(ConfigurationExtensions.Configuration) 'remote.autoForwardPortsFallback': { type: 'number', default: 20, - markdownDescription: localize('remote.autoForwardPortFallback', "The number of auto forwarded ports that will trigger the switch from `process` to `hybrid` when automatically forwarding ports and `remote.autoForwardPortsSource` is set to `process`. Set to `0` to disable the fallback.") + markdownDescription: localize('remote.autoForwardPortFallback', "The number of auto forwarded ports that will trigger the switch from `process` to `hybrid` when automatically forwarding ports and `remote.autoForwardPortsSource` is set to `process` by default. Set to `0` to disable the fallback. When `remote.autoForwardPortsFallback` hasn't been configured, but `remote.autoForwardPortsSource` has, `remote.autoForwardPortsFallback` will be treated as though it's set to `0`.") }, 'remote.forwardOnOpen': { type: 'boolean', diff --git a/src/vs/workbench/contrib/remoteTunnel/electron-sandbox/remoteTunnel.contribution.ts b/src/vs/workbench/contrib/remoteTunnel/electron-sandbox/remoteTunnel.contribution.ts index 305f9bae1c5c9..0f75017045786 100644 --- a/src/vs/workbench/contrib/remoteTunnel/electron-sandbox/remoteTunnel.contribution.ts +++ b/src/vs/workbench/contrib/remoteTunnel/electron-sandbox/remoteTunnel.contribution.ts @@ -395,7 +395,7 @@ export class RemoteTunnelWorkbenchContribution extends Disposable implements IWo private createExistingSessionItem(session: AuthenticationSession, providerId: string): ExistingSessionItem { return { label: session.account.label, - description: this.authenticationService.getLabel(providerId), + description: this.authenticationService.getProvider(providerId).label, session, providerId }; @@ -412,9 +412,9 @@ export class RemoteTunnelWorkbenchContribution extends Disposable implements IWo for (const authenticationProvider of (await this.getAuthenticationProviders())) { const signedInForProvider = sessions.some(account => account.providerId === authenticationProvider.id); - if (!signedInForProvider || this.authenticationService.supportsMultipleAccounts(authenticationProvider.id)) { - const providerName = this.authenticationService.getLabel(authenticationProvider.id); - options.push({ label: localize({ key: 'sign in using account', comment: ['{0} will be a auth provider (e.g. Github)'] }, "Sign in with {0}", providerName), provider: authenticationProvider }); + const provider = this.authenticationService.getProvider(authenticationProvider.id); + if (!signedInForProvider || provider.supportsMultipleAccounts) { + options.push({ label: localize({ key: 'sign in using account', comment: ['{0} will be a auth provider (e.g. Github)'] }, "Sign in with {0}", provider.label), provider: authenticationProvider }); } } @@ -797,6 +797,7 @@ Registry.as(ConfigurationExtensions.Configuration).regis description: localize('remoteTunnelAccess.machineName', "The name under which the remote tunnel access is registered. If not set, the host name is used."), type: 'string', scope: ConfigurationScope.APPLICATION, + ignoreSync: true, pattern: '^(\\w[\\w-]*)?$', patternErrorMessage: localize('remoteTunnelAccess.machineNameRegex', "The name must only consist of letters, numbers, underscore and dash. It must not start with a dash."), maxLength: 20, diff --git a/src/vs/workbench/contrib/scm/browser/activity.ts b/src/vs/workbench/contrib/scm/browser/activity.ts index 68a280a56bca3..088eec24d7cb0 100644 --- a/src/vs/workbench/contrib/scm/browser/activity.ts +++ b/src/vs/workbench/contrib/scm/browser/activity.ts @@ -10,7 +10,7 @@ import { Event } from 'vs/base/common/event'; import { VIEW_PANE_ID, ISCMService, ISCMRepository, ISCMViewService } from 'vs/workbench/contrib/scm/common/scm'; import { IActivityService, NumberBadge } from 'vs/workbench/services/activity/common/activity'; import { IWorkbenchContribution } from 'vs/workbench/common/contributions'; -import { IContextKey, IContextKeyService } from 'vs/platform/contextkey/common/contextkey'; +import { IContextKey, IContextKeyService, RawContextKey } from 'vs/platform/contextkey/common/contextkey'; import { IStatusbarEntry, IStatusbarService, StatusbarAlignment as MainThreadStatusBarAlignment } from 'vs/workbench/services/statusbar/browser/statusbar'; import { IEditorService } from 'vs/workbench/services/editor/common/editorService'; import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; @@ -18,6 +18,7 @@ import { EditorResourceAccessor } from 'vs/workbench/common/editor'; import { IUriIdentityService } from 'vs/platform/uriIdentity/common/uriIdentity'; import { Schemas } from 'vs/base/common/network'; import { Iterable } from 'vs/base/common/iterator'; +import { ITitleService } from 'vs/workbench/services/title/browser/titleService'; function getCount(repository: ISCMRepository): number { if (typeof repository.provider.count === 'number') { @@ -213,6 +214,81 @@ export class SCMStatusController implements IWorkbenchContribution { } } +const ActiveRepositoryContextKeys = { + ActiveRepositoryName: new RawContextKey('scmActiveRepositoryName', ''), + ActiveRepositoryBranchName: new RawContextKey('scmActiveRepositoryBranchName', ''), +}; + +export class SCMActiveRepositoryContextKeyController implements IWorkbenchContribution { + + private activeRepositoryNameContextKey: IContextKey; + private activeRepositoryBranchNameContextKey: IContextKey; + + private focusedRepository: ISCMRepository | undefined = undefined; + private focusDisposable: IDisposable = Disposable.None; + private readonly disposables = new DisposableStore(); + + constructor( + @IContextKeyService contextKeyService: IContextKeyService, + @IEditorService private readonly editorService: IEditorService, + @ISCMViewService private readonly scmViewService: ISCMViewService, + @ITitleService titleService: ITitleService, + @IUriIdentityService private readonly uriIdentityService: IUriIdentityService + ) { + this.activeRepositoryNameContextKey = ActiveRepositoryContextKeys.ActiveRepositoryName.bindTo(contextKeyService); + this.activeRepositoryBranchNameContextKey = ActiveRepositoryContextKeys.ActiveRepositoryBranchName.bindTo(contextKeyService); + + titleService.registerVariables([ + { name: 'activeRepositoryName', contextKey: ActiveRepositoryContextKeys.ActiveRepositoryName.key }, + { name: 'activeRepositoryBranchName', contextKey: ActiveRepositoryContextKeys.ActiveRepositoryBranchName.key, } + ]); + + editorService.onDidActiveEditorChange(this.onDidActiveEditorChange, this, this.disposables); + scmViewService.onDidFocusRepository(this.onDidFocusRepository, this, this.disposables); + this.onDidFocusRepository(scmViewService.focusedRepository); + } + + private onDidActiveEditorChange(): void { + const activeResource = EditorResourceAccessor.getOriginalUri(this.editorService.activeEditor); + + if (activeResource?.scheme !== Schemas.file && activeResource?.scheme !== Schemas.vscodeRemote) { + return; + } + + const repository = Iterable.find( + this.scmViewService.repositories, + r => Boolean(r.provider.rootUri && this.uriIdentityService.extUri.isEqualOrParent(activeResource, r.provider.rootUri)) + ); + + this.onDidFocusRepository(repository); + } + + private onDidFocusRepository(repository: ISCMRepository | undefined): void { + if (!repository || this.focusedRepository === repository) { + return; + } + + this.focusDisposable.dispose(); + this.focusedRepository = repository; + + if (repository && repository.provider.onDidChangeStatusBarCommands) { + this.focusDisposable = repository.provider.onDidChangeStatusBarCommands(() => this.updateContextKeys(repository)); + } + + this.updateContextKeys(repository); + } + + private updateContextKeys(repository: ISCMRepository | undefined): void { + this.activeRepositoryNameContextKey.set(repository?.provider.name ?? ''); + this.activeRepositoryBranchNameContextKey.set(repository?.provider.historyProvider?.currentHistoryItemGroup?.name ?? ''); + } + + dispose(): void { + this.focusDisposable.dispose(); + this.disposables.dispose(); + } +} + export class SCMActiveResourceContextKeyController implements IWorkbenchContribution { private activeResourceHasChangesContextKey: IContextKey; diff --git a/src/vs/workbench/contrib/scm/browser/dirtydiffDecorator.ts b/src/vs/workbench/contrib/scm/browser/dirtydiffDecorator.ts index e99121e3d6d51..7537f80f534cd 100644 --- a/src/vs/workbench/contrib/scm/browser/dirtydiffDecorator.ts +++ b/src/vs/workbench/contrib/scm/browser/dirtydiffDecorator.ts @@ -29,7 +29,7 @@ import { Position } from 'vs/editor/common/core/position'; import { Range } from 'vs/editor/common/core/range'; import { rot } from 'vs/base/common/numbers'; import { KeybindingsRegistry, KeybindingWeight } from 'vs/platform/keybinding/common/keybindingsRegistry'; -import { EmbeddedDiffEditorWidget } from 'vs/editor/browser/widget/embeddedCodeEditorWidget'; +import { EmbeddedDiffEditorWidget } from 'vs/editor/browser/widget/diffEditor/embeddedDiffEditorWidget'; import { IDiffEditorOptions, EditorOption } from 'vs/editor/common/config/editorOptions'; import { Action, IAction, ActionRunner } from 'vs/base/common/actions'; import { IActionBarOptions } from 'vs/base/browser/ui/actionbar/actionbar'; @@ -54,7 +54,7 @@ import { IChange } from 'vs/editor/common/diff/legacyLinesDiffComputer'; import { Color } from 'vs/base/common/color'; import { ResourceMap } from 'vs/base/common/map'; import { IEditorService } from 'vs/workbench/services/editor/common/editorService'; -import { AudioCue, IAudioCueService } from 'vs/platform/audioCues/browser/audioCueService'; +import { AccessibilitySignal, IAccessibilitySignalService } from 'vs/platform/accessibilitySignal/browser/accessibilitySignalService'; import { IAccessibilityService } from 'vs/platform/accessibility/common/accessibility'; import { IQuickDiffService, QuickDiff } from 'vs/workbench/contrib/scm/common/quickDiff'; import { IQuickDiffSelectItem, SwitchQuickDiffBaseAction, SwitchQuickDiffViewItem } from 'vs/workbench/contrib/scm/browser/dirtyDiffSwitcher'; @@ -280,9 +280,9 @@ class DirtyDiffWidget extends PeekViewWidget { this._actionbarWidget!.context = [diffEditorModel.modified.uri, providerSpecificChanges, contextIndex]; if (usePosition) { this.show(position, height); + this.editor.setPosition(position); + this.editor.focus(); } - this.editor.setPosition(position); - this.editor.focus(); } private renderTitle(label: string): void { @@ -578,7 +578,7 @@ export class GotoPreviousChangeAction extends EditorAction { async run(accessor: ServicesAccessor): Promise { const outerEditor = getOuterEditorFromDiffEditor(accessor); - const audioCueService = accessor.get(IAudioCueService); + const accessibilitySignalService = accessor.get(IAccessibilitySignalService); const accessibilityService = accessor.get(IAccessibilityService); const codeEditorService = accessor.get(ICodeEditorService); @@ -600,7 +600,7 @@ export class GotoPreviousChangeAction extends EditorAction { const index = model.findPreviousClosestChange(lineNumber, false); const change = model.changes[index]; - await playAudioCueForChange(change.change, audioCueService); + await playAccessibilitySymbolForChange(change.change, accessibilitySignalService); setPositionAndSelection(change.change, outerEditor, accessibilityService, codeEditorService); } } @@ -619,7 +619,7 @@ export class GotoNextChangeAction extends EditorAction { } async run(accessor: ServicesAccessor): Promise { - const audioCueService = accessor.get(IAudioCueService); + const accessibilitySignalService = accessor.get(IAccessibilitySignalService); const outerEditor = getOuterEditorFromDiffEditor(accessor); const accessibilityService = accessor.get(IAccessibilityService); const codeEditorService = accessor.get(ICodeEditorService); @@ -643,7 +643,7 @@ export class GotoNextChangeAction extends EditorAction { const index = model.findNextClosestChange(lineNumber, false); const change = model.changes[index].change; - await playAudioCueForChange(change, audioCueService); + await playAccessibilitySymbolForChange(change, accessibilitySignalService); setPositionAndSelection(change, outerEditor, accessibilityService, codeEditorService); } } @@ -658,17 +658,17 @@ function setPositionAndSelection(change: IChange, editor: ICodeEditor, accessibi } } -async function playAudioCueForChange(change: IChange, audioCueService: IAudioCueService) { +async function playAccessibilitySymbolForChange(change: IChange, accessibilitySignalService: IAccessibilitySignalService) { const changeType = getChangeType(change); switch (changeType) { case ChangeType.Add: - audioCueService.playAudioCue(AudioCue.diffLineInserted, { allowManyInParallel: true, source: 'dirtyDiffDecoration' }); + accessibilitySignalService.playSignal(AccessibilitySignal.diffLineInserted, { allowManyInParallel: true, source: 'dirtyDiffDecoration' }); break; case ChangeType.Delete: - audioCueService.playAudioCue(AudioCue.diffLineDeleted, { allowManyInParallel: true, source: 'dirtyDiffDecoration' }); + accessibilitySignalService.playSignal(AccessibilitySignal.diffLineDeleted, { allowManyInParallel: true, source: 'dirtyDiffDecoration' }); break; case ChangeType.Modify: - audioCueService.playAudioCue(AudioCue.diffLineModified, { allowManyInParallel: true, source: 'dirtyDiffDecoration' }); + accessibilitySignalService.playSignal(AccessibilitySignal.diffLineModified, { allowManyInParallel: true, source: 'dirtyDiffDecoration' }); break; } } @@ -1568,12 +1568,12 @@ export class DirtyDiffWorkbenchController extends Disposable implements ext.IWor this.onDidChangeConfiguration(); const onDidChangeDiffWidthConfiguration = Event.filter(configurationService.onDidChangeConfiguration, e => e.affectsConfiguration('scm.diffDecorationsGutterWidth')); - onDidChangeDiffWidthConfiguration(this.onDidChangeDiffWidthConfiguration, this); + this._register(onDidChangeDiffWidthConfiguration(this.onDidChangeDiffWidthConfiguration, this)); this.onDidChangeDiffWidthConfiguration(); const onDidChangeDiffVisibilityConfiguration = Event.filter(configurationService.onDidChangeConfiguration, e => e.affectsConfiguration('scm.diffDecorationsGutterVisibility')); - onDidChangeDiffVisibilityConfiguration(this.onDidChangeDiffVisibiltiyConfiguration, this); - this.onDidChangeDiffVisibiltiyConfiguration(); + this._register(onDidChangeDiffVisibilityConfiguration(this.onDidChangeDiffVisibilityConfiguration, this)); + this.onDidChangeDiffVisibilityConfiguration(); } private onDidChangeConfiguration(): void { @@ -1596,7 +1596,7 @@ export class DirtyDiffWorkbenchController extends Disposable implements ext.IWor this.setViewState({ ...this.viewState, width }); } - private onDidChangeDiffVisibiltiyConfiguration(): void { + private onDidChangeDiffVisibilityConfiguration(): void { const visibility = this.configurationService.getValue<'always' | 'hover'>('scm.diffDecorationsGutterVisibility'); this.setViewState({ ...this.viewState, visibility }); } diff --git a/src/vs/workbench/contrib/scm/browser/media/scm.css b/src/vs/workbench/contrib/scm/browser/media/scm.css index 13eb48d7df4fc..fee45b4e002a3 100644 --- a/src/vs/workbench/contrib/scm/browser/media/scm.css +++ b/src/vs/workbench/contrib/scm/browser/media/scm.css @@ -210,6 +210,10 @@ border-top: 1px solid var(--vscode-sideBar-border); } +.scm-view .monaco-list-row .separator-container .actions { + padding-left: 6px; +} + .scm-view .monaco-list-row .history > .name, .scm-view .monaco-list-row .history-item-group > .name, .scm-view .monaco-list-row .resource-group > .name { @@ -255,6 +259,7 @@ .scm-view .monaco-list .monaco-list-row .resource-group > .actions, .scm-view .monaco-list .monaco-list-row .resource > .name > .monaco-icon-label > .actions, +.scm-view .monaco-list .monaco-list-row .separator-container > .actions, .scm-view .monaco-list .monaco-list-row .history-item-group > .actions, .scm-view .monaco-list .monaco-list-row .history-item > .actions { display: none; @@ -262,17 +267,15 @@ } .scm-view .monaco-list .monaco-list-row:hover .resource-group > .actions, -.scm-view .monaco-list .monaco-list-row.selected .resource-group > .actions, .scm-view .monaco-list .monaco-list-row.focused .resource-group > .actions, .scm-view .monaco-list .monaco-list-row:hover .resource > .name > .monaco-icon-label > .actions, -.scm-view .monaco-list .monaco-list-row.selected .resource > .name > .monaco-icon-label > .actions, .scm-view .monaco-list .monaco-list-row.focused .resource > .name > .monaco-icon-label > .actions, .scm-view .monaco-list:not(.selection-multiple) .monaco-list-row .resource:hover > .actions, +.scm-view .monaco-list .monaco-list-row:hover .separator-container > .actions, +.scm-view .monaco-list .monaco-list-row.focused .separator-container > .actions, .scm-view .monaco-list .monaco-list-row:hover .history-item-group > .actions, -.scm-view .monaco-list .monaco-list-row.selected .history-item-group > .actions, .scm-view .monaco-list .monaco-list-row.focused .history-item-group > .actions, .scm-view .monaco-list .monaco-list-row:hover .history-item > .actions, -.scm-view .monaco-list .monaco-list-row.selected .history-item > .actions, .scm-view .monaco-list .monaco-list-row.focused .history-item > .actions { display: block; } @@ -292,6 +295,7 @@ .scm-view.show-actions > .monaco-list .monaco-list-row .scm-input > .scm-editor > .actions, .scm-view.show-actions > .monaco-list .monaco-list-row .resource-group > .actions, .scm-view.show-actions > .monaco-list .monaco-list-row .resource > .name > .monaco-icon-label > .actions, +.scm-view.show-actions > .monaco-list .monaco-list-row .separator-container > .actions, .scm-view.show-actions > .monaco-list .monaco-list-row .history-item-group > .actions, .scm-view.show-actions > .monaco-list .monaco-list-row .history-item > .actions { display: block; diff --git a/src/vs/workbench/contrib/scm/browser/scm.contribution.ts b/src/vs/workbench/contrib/scm/browser/scm.contribution.ts index 8fae0d843b8a1..9cd686878977e 100644 --- a/src/vs/workbench/contrib/scm/browser/scm.contribution.ts +++ b/src/vs/workbench/contrib/scm/browser/scm.contribution.ts @@ -10,7 +10,7 @@ import { DirtyDiffWorkbenchController } from './dirtydiffDecorator'; import { VIEWLET_ID, ISCMService, VIEW_PANE_ID, ISCMProvider, ISCMViewService, REPOSITORIES_VIEW_PANE_ID } from 'vs/workbench/contrib/scm/common/scm'; import { KeyMod, KeyCode } from 'vs/base/common/keyCodes'; import { MenuRegistry, MenuId } from 'vs/platform/actions/common/actions'; -import { SCMActiveResourceContextKeyController, SCMStatusController } from './activity'; +import { SCMActiveRepositoryContextKeyController, SCMActiveResourceContextKeyController, SCMStatusController } from './activity'; import { LifecyclePhase } from 'vs/workbench/services/lifecycle/common/lifecycle'; import { IConfigurationRegistry, Extensions as ConfigurationExtensions, ConfigurationScope } from 'vs/platform/configuration/common/configurationRegistry'; import { IContextKeyService, ContextKeyExpr } from 'vs/platform/contextkey/common/contextkey'; @@ -113,6 +113,9 @@ viewsRegistry.registerViews([{ Registry.as(WorkbenchExtensions.Workbench) .registerWorkbenchContribution(SCMActiveResourceContextKeyController, LifecyclePhase.Restored); +Registry.as(WorkbenchExtensions.Workbench) + .registerWorkbenchContribution(SCMActiveRepositoryContextKeyController, LifecyclePhase.Restored); + Registry.as(WorkbenchExtensions.Workbench) .registerWorkbenchContribution(SCMStatusController, LifecyclePhase.Restored); diff --git a/src/vs/workbench/contrib/scm/browser/scmRepositoriesViewPane.ts b/src/vs/workbench/contrib/scm/browser/scmRepositoriesViewPane.ts index 7ec52b6aabd39..890e39b459f00 100644 --- a/src/vs/workbench/contrib/scm/browser/scmRepositoriesViewPane.ts +++ b/src/vs/workbench/contrib/scm/browser/scmRepositoriesViewPane.ts @@ -27,6 +27,7 @@ import { Orientation } from 'vs/base/browser/ui/sash/sash'; import { Iterable } from 'vs/base/common/iterator'; import { DisposableStore } from 'vs/base/common/lifecycle'; import { MenuId } from 'vs/platform/actions/common/actions'; +import { IHoverService } from 'vs/platform/hover/browser/hover'; class ListDelegate implements IListVirtualDelegate { @@ -55,9 +56,10 @@ export class SCMRepositoriesViewPane extends ViewPane { @IConfigurationService configurationService: IConfigurationService, @IOpenerService openerService: IOpenerService, @IThemeService themeService: IThemeService, - @ITelemetryService telemetryService: ITelemetryService + @ITelemetryService telemetryService: ITelemetryService, + @IHoverService hoverService: IHoverService ) { - super({ ...options, titleMenuId: MenuId.SCMSourceControlTitle }, keybindingService, contextMenuService, configurationService, contextKeyService, viewDescriptorService, instantiationService, openerService, themeService, telemetryService); + super({ ...options, titleMenuId: MenuId.SCMSourceControlTitle }, keybindingService, contextMenuService, configurationService, contextKeyService, viewDescriptorService, instantiationService, openerService, themeService, telemetryService, hoverService); } protected override renderBody(container: HTMLElement): void { diff --git a/src/vs/workbench/contrib/scm/browser/scmViewPane.ts b/src/vs/workbench/contrib/scm/browser/scmViewPane.ts index 57f3428b3dcba..54cd24fa32d66 100644 --- a/src/vs/workbench/contrib/scm/browser/scmViewPane.ts +++ b/src/vs/workbench/contrib/scm/browser/scmViewPane.ts @@ -43,7 +43,7 @@ import { flatten } from 'vs/base/common/arrays'; import { IStorageService, StorageScope, StorageTarget } from 'vs/platform/storage/common/storage'; import { EditorResourceAccessor, SideBySideEditor } from 'vs/workbench/common/editor'; import { SIDE_BAR_BACKGROUND, PANEL_BACKGROUND } from 'vs/workbench/common/theme'; -import { CodeEditorWidget, ICodeEditorWidgetOptions } from 'vs/editor/browser/widget/codeEditorWidget'; +import { CodeEditorWidget, ICodeEditorWidgetOptions } from 'vs/editor/browser/widget/codeEditor/codeEditorWidget'; import { ITextModel } from 'vs/editor/common/model'; import { IEditorConstructionOptions } from 'vs/editor/browser/config/editorConfiguration'; import { getSimpleEditorOptions } from 'vs/workbench/contrib/codeEditor/browser/simpleEditorOptions'; @@ -51,6 +51,7 @@ import { IModelService } from 'vs/editor/common/services/model'; import { EditorExtensionsRegistry } from 'vs/editor/browser/editorExtensions'; import { MenuPreventer } from 'vs/workbench/contrib/codeEditor/browser/menuPreventer'; import { SelectionClipboardContributionID } from 'vs/workbench/contrib/codeEditor/browser/selectionClipboard'; +import { EditorDictation } from 'vs/workbench/contrib/codeEditor/browser/dictation/editorDictation'; import { ContextMenuController } from 'vs/editor/contrib/contextmenu/browser/contextmenu'; import * as platform from 'vs/base/common/platform'; import { compare, format } from 'vs/base/common/strings'; @@ -66,7 +67,7 @@ import { IListAccessibilityProvider } from 'vs/base/browser/ui/list/listWidget'; import { ILanguageService } from 'vs/editor/common/languages/language'; import { ILabelService } from 'vs/platform/label/common/label'; import { KeyCode } from 'vs/base/common/keyCodes'; -import { DEFAULT_FONT_FAMILY } from 'vs/workbench/browser/style'; +import { DEFAULT_FONT_FAMILY } from 'vs/base/browser/fonts'; import { Codicon } from 'vs/base/common/codicons'; import { ThemeIcon } from 'vs/base/common/themables'; import { AnchorAlignment } from 'vs/base/browser/ui/contextview/contextview'; @@ -81,6 +82,7 @@ import { Button, ButtonWithDescription, ButtonWithDropdown } from 'vs/base/brows import { INotificationService } from 'vs/platform/notification/common/notification'; import { RepositoryContextKeys } from 'vs/workbench/contrib/scm/browser/scmViewService'; import { DragAndDropController } from 'vs/editor/contrib/dnd/browser/dnd'; +import { CopyPasteController } from 'vs/editor/contrib/dropOrPasteInto/browser/copyPasteController'; import { DropIntoEditorController } from 'vs/editor/contrib/dropOrPasteInto/browser/dropIntoEditorController'; import { MessageController } from 'vs/editor/contrib/message/browser/messageController'; import { defaultButtonStyles, defaultCountBadgeStyles } from 'vs/platform/theme/browser/defaultStyles'; @@ -100,10 +102,15 @@ import { EditOperation } from 'vs/editor/common/core/editOperation'; import { stripIcons } from 'vs/base/common/iconLabels'; import { IconLabel } from 'vs/base/browser/ui/iconLabel/iconLabel'; import { foreground, listActiveSelectionForeground, registerColor, transparent } from 'vs/platform/theme/common/colorRegistry'; -import { IMenuWorkbenchToolBarOptions, WorkbenchToolBar } from 'vs/platform/actions/browser/toolbar'; +import { IMenuWorkbenchToolBarOptions, MenuWorkbenchToolBar, WorkbenchToolBar } from 'vs/platform/actions/browser/toolbar'; import { CancellationTokenSource } from 'vs/base/common/cancellation'; import { DropdownWithPrimaryActionViewItem } from 'vs/platform/actions/browser/dropdownWithPrimaryActionViewItem'; import { clamp } from 'vs/base/common/numbers'; +import { ILogService } from 'vs/platform/log/common/log'; +import { getDefaultHoverDelegate } from 'vs/base/browser/ui/hover/hoverDelegateFactory'; +import { MarkdownString } from 'vs/base/common/htmlContent'; +import type { IUpdatableHover, IUpdatableHoverTooltipMarkdownString } from 'vs/base/browser/ui/hover/hover'; +import { IHoverService } from 'vs/platform/hover/browser/hover'; // type SCMResourceTreeNode = IResourceNode; // type SCMHistoryItemChangeResourceTreeNode = IResourceNode; @@ -150,6 +157,56 @@ registerColor('scm.historyItemSelectedStatisticsBorder', { hcLight: transparent(listActiveSelectionForeground, 0.2) }, localize('scm.historyItemSelectedStatisticsBorder', "History item selected statistics border color.")); +function processResourceFilterData(uri: URI, filterData: FuzzyScore | LabelFuzzyScore | undefined): [IMatch[] | undefined, IMatch[] | undefined] { + if (!filterData) { + return [undefined, undefined]; + } + + if (!(filterData as LabelFuzzyScore).label) { + const matches = createMatches(filterData as FuzzyScore); + return [matches, undefined]; + } + + const fileName = basename(uri); + const label = (filterData as LabelFuzzyScore).label; + const pathLength = label.length - fileName.length; + const matches = createMatches((filterData as LabelFuzzyScore).score); + + // FileName match + if (label === fileName) { + return [matches, undefined]; + } + + // FilePath match + const labelMatches: IMatch[] = []; + const descriptionMatches: IMatch[] = []; + + for (const match of matches) { + if (match.start > pathLength) { + // Label match + labelMatches.push({ + start: match.start - pathLength, + end: match.end - pathLength + }); + } else if (match.end < pathLength) { + // Description match + descriptionMatches.push(match); + } else { + // Spanning match + labelMatches.push({ + start: 0, + end: match.end - pathLength + }); + descriptionMatches.push({ + start: match.start, + end: pathLength + }); + } + } + + return [labelMatches, descriptionMatches]; +} + interface ISCMLayout { height: number | undefined; width: number | undefined; @@ -370,6 +427,9 @@ class InputRenderer implements ICompressibleTreeRenderer { const contentHeight = templateData.inputWidget.getContentHeight(); @@ -600,7 +660,7 @@ class ResourceRenderer implements ICompressibleTreeRenderer pathLength) { - // Label match - labelMatches.push({ - start: match.start - pathLength, - end: match.end - pathLength - }); - } else if (match.end < pathLength) { - // Description match - descriptionMatches.push(match); - } else { - // Spanning match - labelMatches.push({ - start: 0, - end: match.end - pathLength - }); - descriptionMatches.push({ - start: match.start, - end: pathLength - }); - } - } - - return [labelMatches, descriptionMatches]; - } - private onDidColorThemeChange(): void { for (const [template, data] of this.renderedResources) { this.renderIcon(template, data); @@ -789,12 +799,12 @@ class HistoryItemGroupRenderer implements ICompressibleTreeRenderer { +class HistoryItemRenderer implements ICompressibleTreeRenderer { static readonly TEMPLATE_ID = 'history-item'; get templateId(): string { return HistoryItemRenderer.TEMPLATE_ID; } @@ -902,7 +913,9 @@ class HistoryItemRenderer implements ICompressibleTreeRenderer, index: number, templateData: HistoryItemTemplate, height: number | undefined): void { + renderElement(node: ITreeNode, index: number, templateData: HistoryItemTemplate, height: number | undefined): void { const historyItem = node.element; templateData.iconContainer.className = 'icon-container'; @@ -934,7 +950,9 @@ class HistoryItemRenderer implements ICompressibleTreeRenderer, void>, index: number, templateData: HistoryItemTemplate, height: number | undefined): void { + renderCompressedElements(node: ITreeNode, LabelFuzzyScore>, index: number, templateData: HistoryItemTemplate, height: number | undefined): void { throw new Error('Should never happen since node is incompressible'); } - private renderStatistics(node: ITreeNode, index: number, templateData: HistoryItemTemplate, height: number | undefined): void { + private getTooltip(historyItem: SCMHistoryItemTreeElement): IUpdatableHoverTooltipMarkdownString { + const markdown = new MarkdownString('', { isTrusted: true, supportThemeIcons: true }); + + if (historyItem.author) { + markdown.appendMarkdown(`$(account) **${historyItem.author}**\n\n`); + } + + if (historyItem.timestamp) { + const dateFormatter = new Intl.DateTimeFormat(platform.language, { year: 'numeric', month: 'long', day: 'numeric', hour: 'numeric', minute: 'numeric' }); + markdown.appendMarkdown(`$(history) ${dateFormatter.format(historyItem.timestamp)}\n\n`); + } + + markdown.appendMarkdown(historyItem.message); + + return { markdown, markdownNotSupportedFallback: historyItem.message }; + } + + private processMatches(historyItem: SCMHistoryItemTreeElement, filterData: LabelFuzzyScore | undefined): [IMatch[] | undefined, IMatch[] | undefined] { + if (!filterData) { + return [undefined, undefined]; + } + + return [ + historyItem.message === filterData.label ? createMatches(filterData.score) : undefined, + historyItem.author === filterData.label ? createMatches(filterData.score) : undefined + ]; + } + + private renderStatistics(node: ITreeNode, index: number, templateData: HistoryItemTemplate, height: number | undefined): void { const historyItem = node.element; if (historyItem.statistics) { @@ -966,10 +1012,9 @@ class HistoryItemRenderer implements ICompressibleTreeRenderer 1 ? localize('deletions', "{0} deletions{1}", historyItem.statistics.deletions, '(-)') : '' ]; - const statsTitle = statsAriaLabel - .filter(l => l !== '').join(', '); - templateData.statsContainer.title = statsTitle; + const statsTitle = statsAriaLabel.filter(l => l !== '').join(', '); templateData.statsContainer.setAttribute('aria-label', statsTitle); + templateData.statsCustomHover.update(statsTitle); templateData.filesLabel.textContent = historyItem.statistics.files.toString(); @@ -983,9 +1028,10 @@ class HistoryItemRenderer implements ICompressibleTreeRenderer, index: number, templateData: HistoryItemTemplate, height: number | undefined): void { + disposeElement(element: ITreeNode, index: number, templateData: HistoryItemTemplate, height: number | undefined): void { templateData.elementDisposables.clear(); } + disposeTemplate(templateData: HistoryItemTemplate): void { templateData.disposables.dispose(); } @@ -999,7 +1045,7 @@ interface HistoryItemChangeTemplate { readonly disposables: IDisposable; } -class HistoryItemChangeRenderer implements ICompressibleTreeRenderer, void, HistoryItemChangeTemplate> { +class HistoryItemChangeRenderer implements ICompressibleTreeRenderer, FuzzyScore | LabelFuzzyScore, HistoryItemChangeTemplate> { static readonly TEMPLATE_ID = 'historyItemChange'; get templateId(): string { return HistoryItemChangeRenderer.TEMPLATE_ID; } @@ -1018,24 +1064,37 @@ class HistoryItemChangeRenderer implements ICompressibleTreeRenderer, void>, index: number, templateData: HistoryItemChangeTemplate, height: number | undefined): void { + renderElement(node: ITreeNode, FuzzyScore | LabelFuzzyScore>, index: number, templateData: HistoryItemChangeTemplate, height: number | undefined): void { const historyItemChangeOrFolder = node.element; const uri = ResourceTree.isResourceNode(historyItemChangeOrFolder) ? historyItemChangeOrFolder.element?.uri ?? historyItemChangeOrFolder.uri : historyItemChangeOrFolder.uri; const fileKind = ResourceTree.isResourceNode(historyItemChangeOrFolder) ? FileKind.FOLDER : FileKind.FILE; const hidePath = this.viewMode() === ViewMode.Tree; - templateData.fileLabel.setFile(uri, { fileDecorations: { colors: false, badges: true }, fileKind, hidePath, }); + let matches: IMatch[] | undefined; + let descriptionMatches: IMatch[] | undefined; + + if (ResourceTree.isResourceNode(historyItemChangeOrFolder)) { + if (!historyItemChangeOrFolder.element) { + matches = createMatches(node.filterData as FuzzyScore | undefined); + } + } else { + [matches, descriptionMatches] = processResourceFilterData(uri, node.filterData); + } + + templateData.fileLabel.setFile(uri, { fileDecorations: { colors: false, badges: true }, fileKind, hidePath, matches, descriptionMatches }); } - renderCompressedElements(node: ITreeNode>, void>, index: number, templateData: HistoryItemChangeTemplate, height: number | undefined): void { + renderCompressedElements(node: ITreeNode>, FuzzyScore | LabelFuzzyScore>, index: number, templateData: HistoryItemChangeTemplate, height: number | undefined): void { const compressed = node.element as ICompressedTreeNode>; const folder = compressed.elements[compressed.elements.length - 1]; const label = compressed.elements.map(e => e.name); + const matches = createMatches(node.filterData as FuzzyScore | undefined); templateData.fileLabel.setResource({ resource: folder.uri, name: label }, { fileDecorations: { colors: false, badges: true }, fileKind: FileKind.FOLDER, + matches, separator: this.labelService.getSeparator(folder.uri.scheme) }); } @@ -1047,7 +1106,7 @@ class HistoryItemChangeRenderer implements ICompressibleTreeRenderer { @@ -1055,6 +1114,14 @@ class SeparatorRenderer implements ICompressibleTreeRenderer, index: number, templateData: SeparatorTemplate, height: number | undefined): void { templateData.label.setLabel(element.element.label, undefined, { title: element.element.ariaLabel }); @@ -1077,7 +1149,7 @@ class SeparatorRenderer implements ICompressibleTreeRenderer) { + super({ + ...desc, + f1: false, + toggled: ContextKeyExpr.equals(`config.${settingKey}`, settingValue), + }); + } + + override run(accessor: ServicesAccessor): void { + const configurationService = accessor.get(IConfigurationService); + configurationService.updateValue(this.settingKey, this.settingValue); + } +} + +MenuRegistry.appendMenuItem(MenuId.SCMChangesSeparator, { + title: localize('incomingChanges', "Show Incoming Changes"), + submenu: MenuId.SCMIncomingChangesSetting, + group: '1_incoming&outgoing', + order: 1 +}); + +MenuRegistry.appendMenuItem(Menus.ChangesSettings, { + title: localize('incomingChanges', "Show Incoming Changes"), + submenu: MenuId.SCMIncomingChangesSetting, + group: '1_incoming&outgoing', + order: 1 +}); + +registerAction2(class extends SCMChangesSettingAction { + constructor() { + super('scm.showIncomingChanges', 'always', + { + id: 'workbench.scm.action.showIncomingChanges.always', + title: localize('always', "Always"), + menu: { id: MenuId.SCMIncomingChangesSetting }, + }); + } +}); + +registerAction2(class extends SCMChangesSettingAction { + constructor() { + super('scm.showIncomingChanges', 'auto', + { + id: 'workbench.scm.action.showIncomingChanges.auto', + title: localize('auto', "Auto"), + menu: { + id: MenuId.SCMIncomingChangesSetting, + } + }); + } +}); + +registerAction2(class extends SCMChangesSettingAction { + constructor() { + super('scm.showIncomingChanges', 'never', + { + id: 'workbench.scm.action.showIncomingChanges.never', + title: localize('never', "Never"), + menu: { + id: MenuId.SCMIncomingChangesSetting, + } + }); + } +}); + +MenuRegistry.appendMenuItem(MenuId.SCMChangesSeparator, { + title: localize('outgoingChanges', "Show Outgoing Changes"), + submenu: MenuId.SCMOutgoingChangesSetting, + group: '1_incoming&outgoing', + order: 2 +}); + +MenuRegistry.appendMenuItem(Menus.ChangesSettings, { + title: localize('outgoingChanges', "Show Outgoing Changes"), + submenu: MenuId.SCMOutgoingChangesSetting, + group: '1_incoming&outgoing', + order: 2 +}); + +registerAction2(class extends SCMChangesSettingAction { + constructor() { + super('scm.showOutgoingChanges', 'always', + { + id: 'workbench.scm.action.showOutgoingChanges.always', + title: localize('always', "Always"), + menu: { + id: MenuId.SCMOutgoingChangesSetting, + + } + }); + } +}); + +registerAction2(class extends SCMChangesSettingAction { + constructor() { + super('scm.showOutgoingChanges', 'auto', + { + id: 'workbench.scm.action.showOutgoingChanges.auto', + title: localize('auto', "Auto"), + menu: { + id: MenuId.SCMOutgoingChangesSetting, + } + }); + } +}); + +registerAction2(class extends SCMChangesSettingAction { + constructor() { + super('scm.showOutgoingChanges', 'never', + { + id: 'workbench.scm.action.showOutgoingChanges.never', + title: localize('never', "Never"), + menu: { + id: MenuId.SCMOutgoingChangesSetting, + } + }); + } +}); + +registerAction2(class extends Action2 { + constructor() { + super({ + id: 'workbench.scm.action.scm.showChangesSummary', + title: localize('showChangesSummary', "Show Changes Summary"), + f1: false, + toggled: ContextKeyExpr.equals('config.scm.showChangesSummary', true), + menu: [ + { id: MenuId.SCMChangesSeparator, order: 3 }, + { id: Menus.ChangesSettings, order: 3 }, + ] + }); + } + + override run(accessor: ServicesAccessor) { + const configurationService = accessor.get(IConfigurationService); + const configValue = configurationService.getValue('scm.showChangesSummary') === true; + configurationService.updateValue('scm.showChangesSummary', !configValue); + } +}); + class RepositoryVisibilityAction extends Action2 { private repository: ISCMRepository; @@ -1857,7 +2086,7 @@ class SCMInputWidgetToolbar extends WorkbenchToolBar { id: SCMInputWidgetCommandId.CancelAction, title: localize('scmInputCancelAction', "Cancel"), icon: Codicon.debugStop, - }, undefined, undefined, undefined, contextKeyService, commandService); + }, undefined, undefined, undefined, undefined, contextKeyService, commandService); } public setInput(input: ISCMInput): void { @@ -2266,6 +2495,7 @@ class SCMInputWidget { ColorDetector.ID, ContextMenuController.ID, DragAndDropController.ID, + CopyPasteController.ID, DropIntoEditorController.ID, LinkDetector.ID, MenuPreventer.ID, @@ -2276,7 +2506,8 @@ class SCMInputWidget { SuggestController.ID, InlineCompletionsController.ID, CodeActionController.ID, - FormatOnType.ID + FormatOnType.ID, + EditorDictation.ID, ]) }; @@ -2303,6 +2534,11 @@ class SCMInputWidget { }, 0); })); + this.disposables.add(this.inputEditor.onDidBlurEditorWidget(() => { + CopyPasteController.get(this.inputEditor)?.clearWidgets(); + DropIntoEditorController.get(this.inputEditor)?.clearWidgets(); + })); + const firstLineKey = this.contextKeyService.createKey('scmInputIsInFirstPosition', false); const lastLineKey = this.contextKeyService.createKey('scmInputIsInLastPosition', false); @@ -2324,12 +2560,12 @@ class SCMInputWidget { // Toolbar this.toolbar = instantiationService2.createInstance(SCMInputWidgetToolbar, this.toolbarContainer, { - actionViewItemProvider: action => { + actionViewItemProvider: (action, options) => { if (action instanceof MenuItemAction && this.toolbar.dropdownActions.length > 1) { - return instantiationService.createInstance(DropdownWithPrimaryActionViewItem, action, this.toolbar.dropdownAction, this.toolbar.dropdownActions, '', this.contextMenuService, { actionRunner: this.toolbar.actionRunner }); + return instantiationService.createInstance(DropdownWithPrimaryActionViewItem, action, this.toolbar.dropdownAction, this.toolbar.dropdownActions, '', this.contextMenuService, { actionRunner: this.toolbar.actionRunner, hoverDelegate: options.hoverDelegate }); } - return createActionViewItem(instantiationService, action); + return createActionViewItem(instantiationService, action, options); }, menuOptions: { shouldForwardArgs: true @@ -2586,6 +2822,7 @@ export class SCMViewPane extends ViewPane { options: IViewPaneOptions, @ICommandService private readonly commandService: ICommandService, @IEditorService private readonly editorService: IEditorService, + @ILogService private readonly logService: ILogService, @IMenuService private readonly menuService: IMenuService, @ISCMService private readonly scmService: ISCMService, @ISCMViewService private readonly scmViewService: ISCMViewService, @@ -2600,8 +2837,9 @@ export class SCMViewPane extends ViewPane { @IContextKeyService contextKeyService: IContextKeyService, @IOpenerService openerService: IOpenerService, @ITelemetryService telemetryService: ITelemetryService, + @IHoverService hoverService: IHoverService, ) { - super({ ...options, titleMenuId: MenuId.SCMTitle }, keybindingService, contextMenuService, configurationService, contextKeyService, viewDescriptorService, instantiationService, openerService, themeService, telemetryService); + super({ ...options, titleMenuId: MenuId.SCMTitle }, keybindingService, contextMenuService, configurationService, contextKeyService, viewDescriptorService, instantiationService, openerService, themeService, telemetryService, hoverService); // View mode and sort key this._viewMode = this.getViewMode(); @@ -2950,9 +3188,19 @@ export class SCMViewPane extends ViewPane { repositoryDisposables.add(repository.input.onDidChangeVisibility(() => this.updateChildren(repository))); repositoryDisposables.add(repository.provider.onDidChangeResourceGroups(() => this.updateChildren(repository))); - if (repository.provider.historyProvider) { - repositoryDisposables.add(repository.provider.historyProvider.onDidChangeCurrentHistoryItemGroup(() => this.updateChildren(repository))); - } + repositoryDisposables.add(Event.runAndSubscribe(repository.provider.onDidChangeHistoryProvider, () => { + if (!repository.provider.historyProvider) { + this.logService.debug('SCMViewPane:onDidChangeVisibleRepositories - no history provider present'); + return; + } + + repositoryDisposables.add(repository.provider.historyProvider.onDidChangeCurrentHistoryItemGroup(() => { + this.updateChildren(repository); + this.logService.debug('SCMViewPane:onDidChangeCurrentHistoryItemGroup - update children'); + })); + + this.logService.debug('SCMViewPane:onDidChangeVisibleRepositories - onDidChangeCurrentHistoryItemGroup listener added'); + })); const resourceGroupDisposables = repositoryDisposables.add(new DisposableMap()); @@ -3043,6 +3291,13 @@ export class SCMViewPane extends ViewPane { actionRunner = new HistoryItemGroupActionRunner(); createAndFillInContextMenuActions(menu, { shouldForwardArgs: true }, actions); } + } else if (isSCMHistoryItemTreeElement(element)) { + const menus = this.scmViewService.menus.getRepositoryMenus(element.historyItemGroup.repository.provider); + const menu = menus.historyProviderMenu?.getHistoryItemMenu(element); + if (menu) { + actionRunner = new HistoryItemActionRunner(); + actions = collectContextMenuActions(menu); + } } actionRunner.onWillRun(() => this.tree.domFocus()); @@ -3244,6 +3499,7 @@ class SCMTreeDataSource implements IAsyncDataSource ViewMode, @IConfigurationService private readonly configurationService: IConfigurationService, + @ILogService private readonly logService: ILogService, @ISCMViewService private readonly scmViewService: ISCMViewService, @IUriIdentityService private uriIdentityService: IUriIdentityService, ) { @@ -3392,7 +3648,7 @@ class SCMTreeDataSource implements IAsyncDataSource this.historyProviderCache.delete(repository))); - } + repositoryDisposables.add(Event.runAndSubscribe(repository.provider.onDidChangeHistoryProvider, () => { + if (!repository.provider.historyProvider) { + this.logService.debug('SCMTreeDataSource:onDidChangeVisibleRepositories - no history provider present'); + return; + } + + repositoryDisposables.add(repository.provider.historyProvider.onDidChangeCurrentHistoryItemGroup(() => { + this.historyProviderCache.delete(repository); + this.logService.debug('SCMTreeDataSource:onDidChangeCurrentHistoryItemGroup - cache cleared'); + })); + + this.logService.debug('SCMTreeDataSource:onDidChangeVisibleRepositories - onDidChangeCurrentHistoryItemGroup listener added'); + })); this.repositoryDisposables.set(repository, repositoryDisposables); } diff --git a/src/vs/workbench/contrib/scm/browser/util.ts b/src/vs/workbench/contrib/scm/browser/util.ts index be5afbb292f93..6f20e40b24cc0 100644 --- a/src/vs/workbench/contrib/scm/browser/util.ts +++ b/src/vs/workbench/contrib/scm/browser/util.ts @@ -12,7 +12,7 @@ import { IDisposable } from 'vs/base/common/lifecycle'; import { Action, IAction } from 'vs/base/common/actions'; import { createActionViewItem, createAndFillInActionBarActions, createAndFillInContextMenuActions } from 'vs/platform/actions/browser/menuEntryActionViewItem'; import { equals } from 'vs/base/common/arrays'; -import { ActionViewItem } from 'vs/base/browser/ui/actionbar/actionViewItems'; +import { ActionViewItem, IBaseActionViewItemOptions } from 'vs/base/browser/ui/actionbar/actionViewItems'; import { renderLabelWithIcons } from 'vs/base/browser/ui/iconLabel/iconLabels'; import { ICommandService } from 'vs/platform/commands/common/commands'; import { Command } from 'vs/editor/common/languages'; @@ -149,8 +149,8 @@ export class StatusBarAction extends Action { class StatusBarActionViewItem extends ActionViewItem { - constructor(action: StatusBarAction) { - super(null, action, {}); + constructor(action: StatusBarAction, options: IBaseActionViewItemOptions) { + super(null, action, { ...options, icon: false, label: true }); } protected override updateLabel(): void { @@ -161,11 +161,11 @@ class StatusBarActionViewItem extends ActionViewItem { } export function getActionViewItemProvider(instaService: IInstantiationService): IActionViewItemProvider { - return action => { + return (action, options) => { if (action instanceof StatusBarAction) { - return new StatusBarActionViewItem(action); + return new StatusBarActionViewItem(action, options); } - return createActionViewItem(instaService, action); + return createActionViewItem(instaService, action, options); }; } diff --git a/src/vs/workbench/contrib/scm/common/history.ts b/src/vs/workbench/contrib/scm/common/history.ts index 70ab5a8f8bb9e..2cb81effd9130 100644 --- a/src/vs/workbench/contrib/scm/common/history.ts +++ b/src/vs/workbench/contrib/scm/common/history.ts @@ -43,7 +43,7 @@ export interface ISCMHistoryOptions { export interface ISCMHistoryItemGroup { readonly id: string; - readonly label: string; + readonly name: string; readonly base?: Omit; } @@ -69,8 +69,8 @@ export interface ISCMHistoryItemStatistics { export interface ISCMHistoryItem { readonly id: string; readonly parentIds: string[]; - readonly label: string; - readonly description?: string; + readonly message: string; + readonly author?: string; readonly icon?: URI | { light: URI; dark: URI } | ThemeIcon; readonly timestamp?: number; readonly statistics?: ISCMHistoryItemStatistics; diff --git a/src/vs/workbench/contrib/scrollLocking/browser/scrollLocking.contribution.ts b/src/vs/workbench/contrib/scrollLocking/browser/scrollLocking.contribution.ts new file mode 100644 index 0000000000000..ad8bc56733bc7 --- /dev/null +++ b/src/vs/workbench/contrib/scrollLocking/browser/scrollLocking.contribution.ts @@ -0,0 +1,13 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { WorkbenchPhase, registerWorkbenchContribution2 } from 'vs/workbench/common/contributions'; +import { SyncScroll as ScrollLocking } from 'vs/workbench/contrib/scrollLocking/browser/scrollLocking'; + +registerWorkbenchContribution2( + ScrollLocking.ID, + ScrollLocking, + WorkbenchPhase.Eventually // registration only +); diff --git a/src/vs/workbench/contrib/scrollLocking/browser/scrollLocking.ts b/src/vs/workbench/contrib/scrollLocking/browser/scrollLocking.ts new file mode 100644 index 0000000000000..c8e01cc2ef15a --- /dev/null +++ b/src/vs/workbench/contrib/scrollLocking/browser/scrollLocking.ts @@ -0,0 +1,240 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable, DisposableStore, MutableDisposable } from 'vs/base/common/lifecycle'; +import { ServicesAccessor } from 'vs/editor/browser/editorExtensions'; +import { localize, localize2 } from 'vs/nls'; +import { Categories } from 'vs/platform/action/common/actionCommonCategories'; +import { Action2, registerAction2 } from 'vs/platform/actions/common/actions'; +import { IKeybindingService } from 'vs/platform/keybinding/common/keybinding'; +import { SideBySideEditor } from 'vs/workbench/browser/parts/editor/sideBySideEditor'; +import { IWorkbenchContribution } from 'vs/workbench/common/contributions'; +import { IEditorPane, IEditorPaneScrollPosition, isEditorPaneWithScrolling } from 'vs/workbench/common/editor'; +import { ReentrancyBarrier } from 'vs/base/common/controlFlow'; +import { IEditorService } from 'vs/workbench/services/editor/common/editorService'; +import { IStatusbarEntryAccessor, IStatusbarService, StatusbarAlignment } from 'vs/workbench/services/statusbar/browser/statusbar'; + +export class SyncScroll extends Disposable implements IWorkbenchContribution { + + static readonly ID = 'workbench.contrib.syncScrolling'; + + private readonly paneInitialScrollTop = new Map(); + + private readonly syncScrollDispoasbles = this._register(new DisposableStore()); + private readonly paneDisposables = new DisposableStore(); + + private readonly statusBarEntry = this._register(new MutableDisposable()); + + private isActive: boolean = false; + + constructor( + @IEditorService private readonly editorService: IEditorService, + @IStatusbarService private readonly statusbarService: IStatusbarService + ) { + super(); + + this.registerActions(); + } + + private registerActiveListeners(): void { + this.syncScrollDispoasbles.add(this.editorService.onDidVisibleEditorsChange(() => this.trackVisiblePanes())); + } + + private activate(): void { + this.registerActiveListeners(); + + this.trackVisiblePanes(); + } + + toggle(): void { + if (this.isActive) { + this.deactivate(); + } else { + this.activate(); + } + + this.isActive = !this.isActive; + + this.toggleStatusbarItem(this.isActive); + } + + // makes sure that the onDidEditorPaneScroll is not called multiple times for the same event + private _reentrancyBarrier = new ReentrancyBarrier(); + + private trackVisiblePanes(): void { + this.paneDisposables.clear(); + this.paneInitialScrollTop.clear(); + + for (const pane of this.getAllVisiblePanes()) { + + if (!isEditorPaneWithScrolling(pane)) { + continue; + } + + this.paneInitialScrollTop.set(pane, pane.getScrollPosition()); + this.paneDisposables.add(pane.onDidChangeScroll(() => + this._reentrancyBarrier.runExclusivelyOrSkip(() => { + this.onDidEditorPaneScroll(pane); + }) + )); + } + } + + private onDidEditorPaneScroll(scrolledPane: IEditorPane) { + + const scrolledPaneInitialOffset = this.paneInitialScrollTop.get(scrolledPane); + if (scrolledPaneInitialOffset === undefined) { + throw new Error('Scrolled pane not tracked'); + } + + if (!isEditorPaneWithScrolling(scrolledPane)) { + throw new Error('Scrolled pane does not support scrolling'); + } + + const scrolledPaneCurrentPosition = scrolledPane.getScrollPosition(); + const scrolledFromInitial = { + scrollTop: scrolledPaneCurrentPosition.scrollTop - scrolledPaneInitialOffset.scrollTop, + scrollLeft: scrolledPaneCurrentPosition.scrollLeft !== undefined && scrolledPaneInitialOffset.scrollLeft !== undefined ? scrolledPaneCurrentPosition.scrollLeft - scrolledPaneInitialOffset.scrollLeft : undefined, + }; + + for (const pane of this.getAllVisiblePanes()) { + if (pane === scrolledPane) { + continue; + } + + if (!isEditorPaneWithScrolling(pane)) { + continue; + } + + const initialOffset = this.paneInitialScrollTop.get(pane); + if (initialOffset === undefined) { + throw new Error('Could not find initial offset for pane'); + } + + const currentPanePosition = pane.getScrollPosition(); + const newPaneScrollPosition = { + scrollTop: initialOffset.scrollTop + scrolledFromInitial.scrollTop, + scrollLeft: initialOffset.scrollLeft !== undefined && scrolledFromInitial.scrollLeft !== undefined ? initialOffset.scrollLeft + scrolledFromInitial.scrollLeft : undefined, + }; + + if (currentPanePosition.scrollTop === newPaneScrollPosition.scrollTop && currentPanePosition.scrollLeft === newPaneScrollPosition.scrollLeft) { + continue; + } + + pane.setScrollPosition(newPaneScrollPosition); + } + } + + private getAllVisiblePanes(): IEditorPane[] { + const panes: IEditorPane[] = []; + + for (const pane of this.editorService.visibleEditorPanes) { + + if (pane instanceof SideBySideEditor) { + const primaryPane = pane.getPrimaryEditorPane(); + const secondaryPane = pane.getSecondaryEditorPane(); + if (primaryPane) { + panes.push(primaryPane); + } + if (secondaryPane) { + panes.push(secondaryPane); + } + continue; + } + + panes.push(pane); + } + + return panes; + } + + private deactivate(): void { + this.paneDisposables.clear(); + this.syncScrollDispoasbles.clear(); + this.paneInitialScrollTop.clear(); + } + + // Actions & Commands + + private toggleStatusbarItem(active: boolean): void { + if (active) { + if (!this.statusBarEntry.value) { + const text = localize('mouseScrolllingLocked', 'Scrolling Locked'); + const tooltip = localize('mouseLockScrollingEnabled', 'Lock Scrolling Enabled'); + this.statusBarEntry.value = this.statusbarService.addEntry({ + name: text, + text, + tooltip, + ariaLabel: text, + command: { + id: 'workbench.action.toggleLockedScrolling', + title: '' + }, + kind: 'prominent', + showInAllWindows: true + }, 'status.scrollLockingEnabled', StatusbarAlignment.RIGHT, 102); + } + } else { + this.statusBarEntry.clear(); + } + } + + private registerActions() { + const $this = this; + this._register(registerAction2(class extends Action2 { + constructor() { + super({ + id: 'workbench.action.toggleLockedScrolling', + title: { + ...localize2('toggleLockedScrolling', "Toggle Locked Scrolling Across Editors"), + mnemonicTitle: localize({ key: 'miToggleLockedScrolling', comment: ['&& denotes a mnemonic'] }, "Locked Scrolling"), + }, + category: Categories.View, + f1: true, + metadata: { + description: localize('synchronizeScrolling', "Synchronize Scrolling Editors"), + } + }); + } + + run(): void { + $this.toggle(); + } + })); + this._register(registerAction2(class extends Action2 { + constructor() { + super({ + id: 'workbench.action.holdLockedScrolling', + title: { + ...localize2('holdLockedScrolling', "Hold Locked Scrolling Across Editors"), + mnemonicTitle: localize({ key: 'miHoldLockedScrolling', comment: ['&& denotes a mnemonic'] }, "Locked Scrolling"), + }, + category: Categories.View, + }); + } + + run(accessor: ServicesAccessor): void { + const keybindingService = accessor.get(IKeybindingService); + + // Enable Sync Scrolling while pressed + $this.toggle(); + + const holdMode = keybindingService.enableKeybindingHoldMode('workbench.action.holdLockedScrolling'); + if (!holdMode) { + return; + } + + holdMode.finally(() => { + $this.toggle(); + }); + } + })); + } + + override dispose(): void { + this.deactivate(); + super.dispose(); + } +} diff --git a/src/vs/workbench/contrib/search/browser/anythingQuickAccess.ts b/src/vs/workbench/contrib/search/browser/anythingQuickAccess.ts index a85e27af8330c..489a46c55441e 100644 --- a/src/vs/workbench/contrib/search/browser/anythingQuickAccess.ts +++ b/src/vs/workbench/contrib/search/browser/anythingQuickAccess.ts @@ -41,7 +41,7 @@ import { IFilesConfigurationService } from 'vs/workbench/services/filesConfigura import { ResourceMap } from 'vs/base/common/map'; import { SymbolsQuickAccessProvider } from 'vs/workbench/contrib/search/browser/symbolsQuickAccess'; import { AnythingQuickAccessProviderRunOptions, DefaultQuickAccessFilterValue, Extensions, IQuickAccessRegistry } from 'vs/platform/quickinput/common/quickAccess'; -import { EditorViewState, IWorkbenchQuickAccessConfiguration } from 'vs/workbench/browser/quickaccess'; +import { PickerEditorState, IWorkbenchQuickAccessConfiguration } from 'vs/workbench/browser/quickaccess'; import { GotoSymbolQuickAccessProvider } from 'vs/workbench/contrib/codeEditor/browser/quickaccess/gotoSymbolQuickAccess'; import { ITextModelService } from 'vs/editor/common/services/resolverService'; import { ScrollType, IEditor } from 'vs/editor/common/editorCommon'; @@ -55,6 +55,7 @@ import { IKeybindingService } from 'vs/platform/keybinding/common/keybinding'; import { Registry } from 'vs/platform/registry/common/platform'; import { ASK_QUICK_QUESTION_ACTION_ID } from 'vs/workbench/contrib/chat/browser/actions/chatQuickInputActions'; import { IQuickChatService } from 'vs/workbench/contrib/chat/browser/chat'; +import { ILogService } from 'vs/platform/log/common/log'; interface IAnythingQuickPickItem extends IPickerQuickAccessItem, IQuickPickItemWithResource { } @@ -83,11 +84,11 @@ export class AnythingQuickAccessProvider extends PickerQuickAccessProvider | undefined = undefined; - editorViewState: EditorViewState; + editorViewState = this._register(this.instantiationService.createInstance(PickerEditorState)); scorerCache: FuzzyScorerCache = Object.create(null); fileQueryCache: FileQueryCacheState | undefined = undefined; @@ -100,8 +101,11 @@ export class AnythingQuickAccessProvider extends PickerQuickAccessProvider): void { @@ -129,7 +133,7 @@ export class AnythingQuickAccessProvider extends PickerQuickAccessProvider; + let picks = new Array(); + if (options.additionPicks) { + picks.push(...options.additionPicks); + } if (this.pickState.isQuickNavigating) { + if (picks.length > 0) { + picks.push({ type: 'separator', label: localize('recentlyOpenedSeparator', "recently opened") } as IQuickPickSeparator); + } picks = historyEditorPicks; } else { - picks = []; if (options.includeHelp) { picks.push(...this.getHelpPicks(query, token, options)); } @@ -628,6 +646,7 @@ export class AnythingQuickAccessProvider extends PickerQuickAccessProvider { + const start = Date.now(); return this.searchService.fileSearch( this.fileQueryBuilder.file( this.contextService.getWorkspace().folders, @@ -636,7 +655,9 @@ export class AnythingQuickAccessProvider extends PickerQuickAccessProvider { + this.logService.trace(`QuickAccess fileSearch ${Date.now() - start}ms`); + }); } private getFileQueryOptions(input: { filePattern?: string; cacheKey?: string; maxResults?: number }): IFileQueryBuilderOptions { @@ -844,7 +865,7 @@ export class AnythingQuickAccessProvider extends PickerQuickAccessProvider { @@ -271,6 +273,7 @@ export class ExcludePatternInputWidget extends PatternInputWidget { actionClassName: 'useExcludesAndIgnoreFiles', title: nls.localize('useExcludesAndIgnoreFilesDescription', "Use Exclude Settings and Ignore Files"), isChecked: true, + hoverDelegate: getDefaultHoverDelegate('element'), ...defaultToggleStyles })); this._register(this.useExcludesAndIgnoreFilesBox.onChange(viaKeyboard => { diff --git a/src/vs/workbench/contrib/search/browser/quickTextSearch/textSearchQuickAccess.ts b/src/vs/workbench/contrib/search/browser/quickTextSearch/textSearchQuickAccess.ts index b1ba725a1b3a5..3172a47ff342a 100644 --- a/src/vs/workbench/contrib/search/browser/quickTextSearch/textSearchQuickAccess.ts +++ b/src/vs/workbench/contrib/search/browser/quickTextSearch/textSearchQuickAccess.ts @@ -15,9 +15,9 @@ import { ITextEditorSelection } from 'vs/platform/editor/common/editor'; import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; import { ILabelService } from 'vs/platform/label/common/label'; import { WorkbenchCompressibleObjectTree, getSelectionKeyboardEvent } from 'vs/platform/list/browser/listService'; -import { FastAndSlowPicks, IPickerQuickAccessItem, PickerQuickAccessProvider, Picks, TriggerAction } from 'vs/platform/quickinput/browser/pickerQuickAccess'; +import { FastAndSlowPicks, IPickerQuickAccessItem, IPickerQuickAccessSeparator, PickerQuickAccessProvider, Picks, TriggerAction } from 'vs/platform/quickinput/browser/pickerQuickAccess'; import { DefaultQuickAccessFilterValue, IQuickAccessProviderRunOptions } from 'vs/platform/quickinput/common/quickAccess'; -import { IKeyMods, IQuickPick, IQuickPickItem, IQuickPickSeparator, QuickInputHideReason } from 'vs/platform/quickinput/common/quickInput'; +import { IKeyMods, IQuickPick, IQuickPickItem, QuickInputHideReason } from 'vs/platform/quickinput/common/quickInput'; import { IWorkspaceContextService, IWorkspaceFolder } from 'vs/platform/workspace/common/workspace'; import { IWorkbenchEditorConfiguration } from 'vs/workbench/common/editor'; import { searchDetailsIcon, searchOpenInFileIcon, searchActivityBarIcon } from 'vs/workbench/contrib/search/browser/searchIcons'; @@ -28,8 +28,9 @@ import { ACTIVE_GROUP, IEditorService, SIDE_GROUP } from 'vs/workbench/services/ import { ITextQueryBuilderOptions, QueryBuilder } from 'vs/workbench/services/search/common/queryBuilder'; import { IPatternInfo, ISearchComplete, ITextQuery, VIEW_ID } from 'vs/workbench/services/search/common/search'; import { Event } from 'vs/base/common/event'; -import { EditorViewState } from 'vs/workbench/browser/quickaccess'; +import { PickerEditorState } from 'vs/workbench/browser/quickaccess'; import { IViewsService } from 'vs/workbench/services/views/common/viewsService'; +import { Sequencer } from 'vs/base/common/async'; export const TEXT_SEARCH_QUICK_ACCESS_PREFIX = '%'; @@ -43,20 +44,21 @@ const DEFAULT_TEXT_QUERY_BUILDER_OPTIONS: ITextQueryBuilderOptions = { const MAX_FILES_SHOWN = 30; const MAX_RESULTS_PER_FILE = 10; +const DEBOUNCE_DELAY = 75; interface ITextSearchQuickAccessItem extends IPickerQuickAccessItem { match?: Match; } export class TextSearchQuickAccess extends PickerQuickAccessProvider { + + private editorSequencer: Sequencer; private queryBuilder: QueryBuilder; private searchModel: SearchModel; private currentAsyncSearch: Promise = Promise.resolve({ results: [], messages: [] }); - private readonly editorViewState = new EditorViewState( - this._editorService - ); + private readonly editorViewState: PickerEditorState; private _getTextQueryBuilderOptions(charsPerLine: number): ITextQueryBuilderOptions { return { @@ -85,8 +87,10 @@ export class TextSearchQuickAccess extends PickerQuickAccessProvider { + + const onDidChangeActive = () => { const [item] = picker.activeItems; if (item?.match) { // we must remember our curret view state to be able to restore (will automatically track if there is already stored state) this.editorViewState.set(); - // open it - this._editorService.openEditor({ - resource: item.match.parent().resource, - options: { preserveFocus: true, revealIfOpened: true, ignoreError: true, selection: item.match.range() } + const itemMatch = item.match; + this.editorSequencer.queue(async () => { + await this.editorViewState.openTransientEditor({ + resource: itemMatch.parent().resource, + options: { preserveFocus: true, revealIfOpened: true, ignoreError: true, selection: itemMatch.range() } + }); }); } - })); + }; - disposables.add(Event.once(picker.onDidHide)(({ reason }) => { + disposables.add(Event.debounce(picker.onDidChangeActive, (last, event) => event, DEBOUNCE_DELAY, true)(onDidChangeActive)); + disposables.add(Event.once(picker.onWillHide)(({ reason }) => { // Restore view state upon cancellation if we changed it // but only when the picker was closed via explicit user // gesture and not e.g. when focus was lost because that @@ -132,6 +140,9 @@ export class TextSearchQuickAccess extends PickerQuickAccessProvider { this.searchModel.searchResult.toggleHighlights(false); })); @@ -211,11 +222,11 @@ export class TextSearchQuickAccess extends PickerQuickAccessProvider limit ? matches.slice(0, limit) : matches; - const picks: Array = []; + const picks: Array = []; for (let fileIndex = 0; fileIndex < matches.length; fileIndex++) { if (fileIndex === limit) { @@ -243,11 +254,15 @@ export class TextSearchQuickAccess extends PickerQuickAccessProvider => { + await this.handleAccept(fileMatch, {}); + return TriggerAction.CLOSE_PICKER; + }, }); const results: Match[] = fileMatch.matches() ?? []; diff --git a/src/vs/workbench/contrib/search/browser/search.contribution.ts b/src/vs/workbench/contrib/search/browser/search.contribution.ts index 0531e3a3776b4..4a31def5cbb9d 100644 --- a/src/vs/workbench/contrib/search/browser/search.contribution.ts +++ b/src/vs/workbench/contrib/search/browser/search.contribution.ts @@ -128,7 +128,7 @@ configurationRegistry.registerConfiguration({ properties: { [SEARCH_EXCLUDE_CONFIG]: { type: 'object', - markdownDescription: nls.localize('exclude', "Configure [glob patterns](https://code.visualstudio.com/docs/editor/codebasics#_advanced-search-options) for excluding files and folders in fulltext searches and quick open. Inherits all glob patterns from the `#files.exclude#` setting."), + markdownDescription: nls.localize('exclude', "Configure [glob patterns](https://code.visualstudio.com/docs/editor/codebasics#_advanced-search-options) for excluding files and folders in fulltext searches and file search in quick open. To exclude files from the recently opened list in quick open, patterns must be absolute (for example `**/node_modules/**`). Inherits all glob patterns from the `#files.exclude#` setting."), default: { '**/node_modules': true, '**/bower_components': true, '**/*.code-search': true }, additionalProperties: { anyOf: [ @@ -308,6 +308,16 @@ configurationRegistry.registerConfiguration({ ], markdownDescription: nls.localize('search.searchEditor.doubleClickBehaviour', "Configure effect of double-clicking a result in a search editor.") }, + 'search.searchEditor.singleClickBehaviour': { + type: 'string', + enum: ['default', 'peekDefinition',], + default: 'default', + enumDescriptions: [ + nls.localize('search.searchEditor.singleClickBehaviour.default', "Single-clicking does nothing."), + nls.localize('search.searchEditor.singleClickBehaviour.peekDefinition', "Single-clicking opens a Peek Definition window."), + ], + markdownDescription: nls.localize('search.searchEditor.singleClickBehaviour', "Configure effect of single-clicking a result in a search editor.") + }, 'search.searchEditor.reusePriorSearchConfiguration': { type: 'boolean', default: false, diff --git a/src/vs/workbench/contrib/search/browser/searchActionsFind.ts b/src/vs/workbench/contrib/search/browser/searchActionsFind.ts index c2e313827bf39..2051b1d13741b 100644 --- a/src/vs/workbench/contrib/search/browser/searchActionsFind.ts +++ b/src/vs/workbench/contrib/search/browser/searchActionsFind.ts @@ -77,6 +77,31 @@ registerAction2(class RestrictSearchToFolderAction extends Action2 { } }); + +registerAction2(class ExpandSelectedTreeCommandAction extends Action2 { + constructor( + ) { + super({ + id: Constants.SearchCommandIds.ExpandRecursivelyCommandId, + title: nls.localize('search.expandRecursively', "Expand Recursively"), + category, + menu: [{ + id: MenuId.SearchContext, + when: ContextKeyExpr.and( + ContextKeyExpr.or(Constants.SearchContext.FileFocusKey, Constants.SearchContext.FolderFocusKey), + Constants.SearchContext.HasSearchResults + ), + group: 'search', + order: 4 + }] + }); + } + + override async run(accessor: any): Promise { + await expandSelectSubtree(accessor); + } +}); + registerAction2(class ExcludeFolderFromSearchAction extends Action2 { constructor() { super({ @@ -270,6 +295,16 @@ registerAction2(class FindInWorkspaceAction extends Action2 { }); //#region Helpers +function expandSelectSubtree(accessor: ServicesAccessor) { + const viewsService = accessor.get(IViewsService); + const searchView = getSearchView(viewsService); + if (searchView) { + const viewer = searchView.getControl(); + const selected = viewer.getFocus()[0]; + viewer.expand(selected, true); + } +} + async function searchWithFolderCommand(accessor: ServicesAccessor, isFromExplorer: boolean, isIncludes: boolean, resource?: URI, folderMatch?: FolderMatchWithResource) { const listService = accessor.get(IListService); const fileService = accessor.get(IFileService); diff --git a/src/vs/workbench/contrib/search/browser/searchActionsTopBar.ts b/src/vs/workbench/contrib/search/browser/searchActionsTopBar.ts index e7dbb128b7be8..a253cc2738b9b 100644 --- a/src/vs/workbench/contrib/search/browser/searchActionsTopBar.ts +++ b/src/vs/workbench/contrib/search/browser/searchActionsTopBar.ts @@ -100,7 +100,7 @@ registerAction2(class CollapseDeepestExpandedLevelAction extends Action2 { menu: [{ id: MenuId.ViewTitle, group: 'navigation', - order: 3, + order: 4, when: ContextKeyExpr.and(ContextKeyExpr.equals('view', VIEW_ID), ContextKeyExpr.or(Constants.SearchContext.HasSearchResults.negate(), Constants.SearchContext.ViewHasSomeCollapsibleKey)), }] }); @@ -122,7 +122,7 @@ registerAction2(class ExpandAllAction extends Action2 { menu: [{ id: MenuId.ViewTitle, group: 'navigation', - order: 3, + order: 4, when: ContextKeyExpr.and(ContextKeyExpr.equals('view', VIEW_ID), Constants.SearchContext.HasSearchResults, Constants.SearchContext.ViewHasSomeCollapsibleKey.toNegated()), }] }); @@ -205,6 +205,7 @@ registerAction2(class ViewAsListAction extends Action2 { } }); + //#endregion //#region Helpers diff --git a/src/vs/workbench/contrib/search/browser/searchFindInput.ts b/src/vs/workbench/contrib/search/browser/searchFindInput.ts index 0d8db8e91d3f1..8490a3b3ee865 100644 --- a/src/vs/workbench/contrib/search/browser/searchFindInput.ts +++ b/src/vs/workbench/contrib/search/browser/searchFindInput.ts @@ -12,11 +12,20 @@ import { IInstantiationService } from 'vs/platform/instantiation/common/instanti import { NotebookFindFilters } from 'vs/workbench/contrib/notebook/browser/contrib/find/findFilters'; import { NotebookFindInputFilterButton } from 'vs/workbench/contrib/notebook/browser/contrib/find/notebookFindReplaceWidget'; import * as nls from 'vs/nls'; +import { IFindInputToggleOpts } from 'vs/base/browser/ui/findinput/findInputToggles'; +import { Codicon } from 'vs/base/common/codicons'; +import { getDefaultHoverDelegate } from 'vs/base/browser/ui/hover/hoverDelegateFactory'; +import { Toggle } from 'vs/base/browser/ui/toggle/toggle'; +import { Emitter } from 'vs/base/common/event'; + +const NLS_AI_TOGGLE_LABEL = nls.localize('aiDescription', "Use AI"); export class SearchFindInput extends ContextScopedFindInput { private _findFilter: NotebookFindInputFilterButton; + private _aiButton: AIToggle; private _filterChecked: boolean = false; - private _visible: boolean = false; + private readonly _onDidChangeAIToggle = this._register(new Emitter()); + public readonly onDidChangeAIToggle = this._onDidChangeAIToggle.event; constructor( container: HTMLElement | null, @@ -26,6 +35,7 @@ export class SearchFindInput extends ContextScopedFindInput { readonly contextMenuService: IContextMenuService, readonly instantiationService: IInstantiationService, readonly filters: NotebookFindFilters, + shouldShowAIButton: boolean, // caller responsible for updating this when it changes, filterStartVisiblitity: boolean ) { super(container, contextViewProvider, options, contextKeyService); @@ -37,29 +47,69 @@ export class SearchFindInput extends ContextScopedFindInput { options, nls.localize('searchFindInputNotebookFilter.label', "Notebook Find Filters") )); - this.inputBox.paddingRight = (this.caseSensitive?.width() ?? 0) + (this.wholeWords?.width() ?? 0) + (this.regex?.width() ?? 0) + this._findFilter.width; + + this._aiButton = this._register( + new AIToggle({ + appendTitle: '', + isChecked: false, + ...options.toggleStyles + })); + + this.setAdditionalToggles([this._aiButton]); + + this._updatePadding(); + this.controls.appendChild(this._findFilter.container); this._findFilter.container.classList.add('monaco-custom-toggle'); - this.filterVisible = filterStartVisiblitity; + // ensure that ai button is visible if it should be + this.sparkleVisible = shouldShowAIButton; + + this._register(this._aiButton.onChange(() => { + if (this._aiButton.checked) { + this.regex?.disable(); + this.wholeWords?.disable(); + this.caseSensitive?.disable(); + this._findFilter.disable(); + } else { + this.regex?.enable(); + this.wholeWords?.enable(); + this.caseSensitive?.enable(); + this._findFilter.enable(); + } + })); + } + + private _updatePadding() { + this.inputBox.paddingRight = + (this.caseSensitive?.width() ?? 0) + + (this.wholeWords?.width() ?? 0) + + (this.regex?.width() ?? 0) + + (this._findFilter.visible ? this._findFilter.width() : 0) + + (this._aiButton.visible ? this._aiButton.width() : 0); + } + + set sparkleVisible(visible: boolean) { + this._aiButton.visible = visible; + this._updatePadding(); } - set filterVisible(show: boolean) { - this._findFilter.container.style.display = show ? '' : 'none'; - this._visible = show; - this.updateStyles(); + set filterVisible(visible: boolean) { + this._findFilter.visible = visible; + this.updateFilterStyles(); + this._updatePadding(); } override setEnabled(enabled: boolean) { super.setEnabled(enabled); - if (enabled && (!this._filterChecked || !this._visible)) { + if (enabled && (!this._filterChecked || !this._findFilter.visible)) { this.regex?.enable(); } else { this.regex?.disable(); } } - updateStyles() { + updateFilterStyles() { // filter is checked if it's in a non-default state this._filterChecked = !this.filters.markupInput || @@ -68,7 +118,32 @@ export class SearchFindInput extends ContextScopedFindInput { !this.filters.codeOutput; // TODO: find a way to express that searching notebook output and markdown preview don't support regex. - this._findFilter.applyStyles(this._filterChecked); } + + get isAIEnabled() { + return this._aiButton.checked; + } +} + +class AIToggle extends Toggle { + constructor(opts: IFindInputToggleOpts) { + super({ + icon: Codicon.sparkle, + title: NLS_AI_TOGGLE_LABEL + opts.appendTitle, + isChecked: opts.isChecked, + hoverDelegate: opts.hoverDelegate ?? getDefaultHoverDelegate('element'), + inputActiveOptionBorder: opts.inputActiveOptionBorder, + inputActiveOptionForeground: opts.inputActiveOptionForeground, + inputActiveOptionBackground: opts.inputActiveOptionBackground + }); + } + + set visible(visible: boolean) { + this.domNode.style.display = visible ? '' : 'none'; + } + + get visible() { + return this.domNode.style.display !== 'none'; + } } diff --git a/src/vs/workbench/contrib/search/browser/searchIcons.ts b/src/vs/workbench/contrib/search/browser/searchIcons.ts index f81dc87d39444..066fbb8c83660 100644 --- a/src/vs/workbench/contrib/search/browser/searchIcons.ts +++ b/src/vs/workbench/contrib/search/browser/searchIcons.ts @@ -29,3 +29,6 @@ export const searchViewIcon = registerIcon('search-view-icon', Codicon.search, l export const searchNewEditorIcon = registerIcon('search-new-editor', Codicon.newFile, localize('searchNewEditorIcon', 'Icon for the action to open a new search editor.')); export const searchOpenInFileIcon = registerIcon('search-open-in-file', Codicon.goToFile, localize('searchOpenInFile', 'Icon for the action to go to the file of the current search result.')); + +export const searchSparkleFilled = registerIcon('search-sparkle-filled', Codicon.sparkleFilled, localize('searchSparkleFilled', 'Icon to show AI results in search.')); +export const searchSparkleEmpty = registerIcon('search-sparkle-empty', Codicon.sparkle, localize('searchSparkleEmpty', 'Icon to hide AI results in search.')); diff --git a/src/vs/workbench/contrib/search/browser/searchModel.ts b/src/vs/workbench/contrib/search/browser/searchModel.ts index 0f280b7f1d074..97a018558d74d 100644 --- a/src/vs/workbench/contrib/search/browser/searchModel.ts +++ b/src/vs/workbench/contrib/search/browser/searchModel.ts @@ -26,7 +26,7 @@ import { IFileService, IFileStatWithPartialMetadata } from 'vs/platform/files/co import { createDecorator, IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; import { ILabelService } from 'vs/platform/label/common/label'; import { ILogService } from 'vs/platform/log/common/log'; -import { IProgress, IProgressStep } from 'vs/platform/progress/common/progress'; +import { IProgress, IProgressService, IProgressStep, ProgressLocation } from 'vs/platform/progress/common/progress'; import { ITelemetryService } from 'vs/platform/telemetry/common/telemetry'; import { minimapFindMatch, overviewRulerFindMatchForeground } from 'vs/platform/theme/common/colorRegistry'; import { themeColorFromId } from 'vs/platform/theme/common/themeService'; @@ -41,7 +41,7 @@ import { contentMatchesToTextSearchMatches, webviewMatchesToTextSearchMatches, I import { INotebookSearchService } from 'vs/workbench/contrib/search/common/notebookSearch'; import { rawCellPrefix, INotebookCellMatchNoModel, isINotebookFileMatchNoModel } from 'vs/workbench/contrib/search/common/searchNotebookHelpers'; import { ReplacePattern } from 'vs/workbench/services/search/common/replace'; -import { IFileMatch, IPatternInfo, ISearchComplete, ISearchConfigurationProperties, ISearchProgressItem, ISearchRange, ISearchService, ITextQuery, ITextSearchContext, ITextSearchMatch, ITextSearchPreviewOptions, ITextSearchResult, ITextSearchStats, OneLineRange, resultIsMatch, SearchCompletionExitCode, SearchSortOrder } from 'vs/workbench/services/search/common/search'; +import { IAITextQuery, IFileMatch, IPatternInfo, ISearchComplete, ISearchConfigurationProperties, ISearchProgressItem, ISearchRange, ISearchService, ITextQuery, ITextSearchContext, ITextSearchMatch, ITextSearchPreviewOptions, ITextSearchResult, ITextSearchStats, OneLineRange, QueryType, resultIsMatch, SearchCompletionExitCode, SearchSortOrder } from 'vs/workbench/services/search/common/search'; import { getTextSearchMatchWithModelContext, editorMatchesToTextSearchResults } from 'vs/workbench/services/search/common/searchHelpers'; import { CellSearchModel } from 'vs/workbench/contrib/search/common/cellSearchModel'; @@ -55,7 +55,7 @@ export class Match { // For replace private _fullPreviewRange: ISearchRange; - constructor(protected _parent: FileMatch, private _fullPreviewLines: string[], _fullPreviewRange: ISearchRange, _documentRange: ISearchRange) { + constructor(protected _parent: FileMatch, private _fullPreviewLines: string[], _fullPreviewRange: ISearchRange, _documentRange: ISearchRange, public readonly aiContributed: boolean) { this._oneLinePreviewText = _fullPreviewLines[_fullPreviewRange.startLineNumber]; const adjustedEndCol = _fullPreviewRange.startLineNumber === _fullPreviewRange.endLineNumber ? _fullPreviewRange.endColumn : @@ -289,7 +289,7 @@ export class MatchInNotebook extends Match { private _webviewIndex: number | undefined; constructor(private readonly _cellParent: CellMatch, _fullPreviewLines: string[], _fullPreviewRange: ISearchRange, _documentRange: ISearchRange, webviewIndex?: number) { - super(_cellParent.parent, _fullPreviewLines, _fullPreviewRange, _documentRange); + super(_cellParent.parent, _fullPreviewLines, _fullPreviewRange, _documentRange, false); this._id = this._parent.id() + '>' + this._cellParent.cellIndex + (webviewIndex ? '_' + webviewIndex : '') + '_' + this.notebookMatchTypeString() + this._range + this.getMatchString(); this._webviewIndex = webviewIndex; } @@ -415,7 +415,7 @@ export class FileMatch extends Disposable implements IFileMatch { private readonly searchInstanceID: string, @IModelService private readonly modelService: IModelService, @IReplaceService private readonly replaceService: IReplaceService, - @ILabelService readonly labelService: ILabelService, + @ILabelService labelService: ILabelService, @INotebookEditorService private readonly notebookEditorService: INotebookEditorService, ) { super(); @@ -426,7 +426,6 @@ export class FileMatch extends Disposable implements IFileMatch { this._name = new Lazy(() => labelService.getUriBasenameLabel(this.resource)); this._cellMatches = new Map(); this._notebookUpdateScheduler = new RunOnceScheduler(this.updateMatchesForEditorWidget.bind(this), 250); - this.createMatches(); } addWebviewMatchesToCell(cellID: string, webviewMatches: ITextSearchMatch[]) { @@ -462,9 +461,10 @@ export class FileMatch extends Disposable implements IFileMatch { return this.matches().some(m => m instanceof MatchInNotebook && m.isReadonly()); } - createMatches(): void { + createMatches(isAiContributed: boolean): void { const model = this.modelService.getModel(this._resource); - if (model) { + if (model && !isAiContributed) { + // todo: handle better when ai contributed results has model, currently, createMatches does not work for this this.bindModel(model); this.updateMatchesForModel(); } else { @@ -477,7 +477,7 @@ export class FileMatch extends Disposable implements IFileMatch { this.rawMatch.results .filter(resultIsMatch) .forEach(rawMatch => { - textSearchResultToMatches(rawMatch, this) + textSearchResultToMatches(rawMatch, this, isAiContributed) .forEach(m => this.add(m)); }); } @@ -529,7 +529,7 @@ export class FileMatch extends Disposable implements IFileMatch { const matches = this._model .findMatches(this._query.pattern, this._model.getFullModelRange(), !!this._query.isRegExp, !!this._query.isCaseSensitive, wordSeparators, false, this._maxResults ?? Number.MAX_SAFE_INTEGER); - this.updateMatches(matches, true, this._model); + this.updateMatches(matches, true, this._model, false); } @@ -549,17 +549,17 @@ export class FileMatch extends Disposable implements IFileMatch { const wordSeparators = this._query.isWordMatch && this._query.wordSeparators ? this._query.wordSeparators : null; const matches = this._model.findMatches(this._query.pattern, range, !!this._query.isRegExp, !!this._query.isCaseSensitive, wordSeparators, false, this._maxResults ?? Number.MAX_SAFE_INTEGER); - this.updateMatches(matches, modelChange, this._model); + this.updateMatches(matches, modelChange, this._model, false); // await this.updateMatchesForEditorWidget(); } - private updateMatches(matches: FindMatch[], modelChange: boolean, model: ITextModel): void { + private updateMatches(matches: FindMatch[], modelChange: boolean, model: ITextModel, isAiContributed: boolean): void { const textSearchResults = editorMatchesToTextSearchResults(matches, model, this._previewOptions); textSearchResults.forEach(textSearchResult => { - textSearchResultToMatches(textSearchResult, this).forEach(match => { + textSearchResultToMatches(textSearchResult, this, isAiContributed).forEach(match => { if (!this._removedTextMatches.has(match.id())) { this.add(match); if (this.isMatchSelected(match)) { @@ -1142,7 +1142,7 @@ export class FolderMatch extends Disposable { return this._query; } - addFileMatch(raw: IFileMatch[], silent: boolean, searchInstanceID: string): void { + addFileMatch(raw: IFileMatch[], silent: boolean, searchInstanceID: string, isAiContributed: boolean): void { // when adding a fileMatch that has intermediate directories const added: FileMatch[] = []; const updated: FileMatch[] = []; @@ -1156,7 +1156,7 @@ export class FolderMatch extends Disposable { .results .filter(resultIsMatch) .forEach(m => { - textSearchResultToMatches(m, existingFileMatch) + textSearchResultToMatches(m, existingFileMatch, isAiContributed) .forEach(m => existingFileMatch.add(m)); }); } @@ -1350,7 +1350,7 @@ export class FolderMatchWithResource extends FolderMatch { * FolderMatchWorkspaceRoot => folder for workspace root */ export class FolderMatchWorkspaceRoot extends FolderMatchWithResource { - constructor(_resource: URI, _id: string, _index: number, _query: ITextQuery, _parent: SearchResult, + constructor(_resource: URI, _id: string, _index: number, _query: ITextQuery, _parent: SearchResult, private readonly _ai: boolean, @IReplaceService replaceService: IReplaceService, @IInstantiationService instantiationService: IInstantiationService, @ILabelService labelService: ILabelService, @@ -1379,6 +1379,7 @@ export class FolderMatchWorkspaceRoot extends FolderMatchWithResource { closestRoot, searchInstanceID ); + fileMatch.createMatches(this._ai); parent.doAddFile(fileMatch); const disposable = fileMatch.onChange(({ didRemove }) => parent.onFileChange(fileMatch, didRemove)); this._register(fileMatch.onDispose(() => disposable.dispose())); @@ -1441,6 +1442,7 @@ export class FolderMatchNoRoot extends FolderMatch { this, rawFileMatch, null, searchInstanceID)); + fileMatch.createMatches(false); // currently, no support for AI results in out-of-workspace files this.doAddFile(fileMatch); const disposable = fileMatch.onChange(({ didRemove }) => this.onFileChange(fileMatch, didRemove)); this._register(fileMatch.onDispose(() => disposable.dispose())); @@ -1588,8 +1590,10 @@ export class SearchResult extends Disposable { })); readonly onChange: Event = this._onChange.event; private _folderMatches: FolderMatchWorkspaceRoot[] = []; + private _aiFolderMatches: FolderMatchWorkspaceRoot[] = []; private _otherFilesMatch: FolderMatch | null = null; private _folderMatchesMap: TernarySearchTree = TernarySearchTree.forUris(key => this.uriIdentityService.extUri.ignorePathCasing(key)); + private _aiFolderMatchesMap: TernarySearchTree = TernarySearchTree.forUris(key => this.uriIdentityService.extUri.ignorePathCasing(key)); private _showHighlights: boolean = false; private _query: ITextQuery | null = null; private _rangeHighlightDecorations: RangeHighlightDecorations; @@ -1598,6 +1602,9 @@ export class SearchResult extends Disposable { private _onWillChangeModelListener: IDisposable | undefined; private _onDidChangeModelListener: IDisposable | undefined; + private _cachedSearchComplete: ISearchComplete | undefined; + private _aiCachedSearchComplete: ISearchComplete | undefined; + constructor( public readonly searchModel: SearchModel, @IReplaceService private readonly replaceService: IReplaceService, @@ -1619,7 +1626,7 @@ export class SearchResult extends Disposable { this._register(this.onChange(e => { if (e.removed) { - this._isDirty = !this.isEmpty(); + this._isDirty = !this.isEmpty() || !this.isEmpty(true); } })); } @@ -1683,8 +1690,12 @@ export class SearchResult extends Disposable { this._isDirty = false; }; + this._cachedSearchComplete = undefined; + this._aiCachedSearchComplete = undefined; + this._rangeHighlightDecorations.removeHighlightRange(); this._folderMatchesMap = TernarySearchTree.forUris(key => this.uriIdentityService.extUri.ignorePathCasing(key)); + this._aiFolderMatchesMap = TernarySearchTree.forUris(key => this.uriIdentityService.extUri.ignorePathCasing(key)); if (!query) { return; @@ -1692,14 +1703,33 @@ export class SearchResult extends Disposable { this._folderMatches = (query && query.folderQueries || []) .map(fq => fq.folder) - .map((resource, index) => this._createBaseFolderMatch(resource, resource.toString(), index, query)); + .map((resource, index) => this._createBaseFolderMatch(resource, resource.toString(), index, query, false)); this._folderMatches.forEach(fm => this._folderMatchesMap.set(fm.resource, fm)); - this._otherFilesMatch = this._createBaseFolderMatch(null, 'otherFiles', this._folderMatches.length + 1, query); + + this._aiFolderMatches = (query && query.folderQueries || []) + .map(fq => fq.folder) + .map((resource, index) => this._createBaseFolderMatch(resource, resource.toString(), index, query, true)); + + this._aiFolderMatches.forEach(fm => this._aiFolderMatchesMap.set(fm.resource, fm)); + + this._otherFilesMatch = this._createBaseFolderMatch(null, 'otherFiles', this._folderMatches.length + this._aiFolderMatches.length + 1, query, false); this._query = query; } + setCachedSearchComplete(cachedSearchComplete: ISearchComplete | undefined, ai: boolean) { + if (ai) { + this._aiCachedSearchComplete = cachedSearchComplete; + } else { + this._cachedSearchComplete = cachedSearchComplete; + } + } + + getCachedSearchComplete(ai: boolean) { + return ai ? this._aiCachedSearchComplete : this._cachedSearchComplete; + } + private onDidAddNotebookEditorWidget(widget: NotebookEditorWidget): void { this._onWillChangeModelListener?.dispose(); @@ -1737,10 +1767,10 @@ export class SearchResult extends Disposable { folderMatch?.unbindNotebookEditorWidget(editor, resource); } - private _createBaseFolderMatch(resource: URI | null, id: string, index: number, query: ITextQuery): FolderMatch { + private _createBaseFolderMatch(resource: URI | null, id: string, index: number, query: ITextQuery, ai: boolean): FolderMatch { let folderMatch: FolderMatch; if (resource) { - folderMatch = this._register(this.instantiationService.createInstance(FolderMatchWorkspaceRoot, resource, id, index, query, this)); + folderMatch = this._register(this.instantiationService.createInstance(FolderMatchWorkspaceRoot, resource, id, index, query, this, ai)); } else { folderMatch = this._register(this.instantiationService.createInstance(FolderMatchNoRoot, id, index, query, this)); } @@ -1750,31 +1780,36 @@ export class SearchResult extends Disposable { } - add(allRaw: IFileMatch[], searchInstanceID: string, silent: boolean = false): void { + add(allRaw: IFileMatch[], searchInstanceID: string, ai: boolean, silent: boolean = false): void { // Split up raw into a list per folder so we can do a batch add per folder. - const { byFolder, other } = this.groupFilesByFolder(allRaw); + const { byFolder, other } = this.groupFilesByFolder(allRaw, ai); byFolder.forEach(raw => { if (!raw.length) { return; } - const folderMatch = this.getFolderMatch(raw[0].resource); - folderMatch?.addFileMatch(raw, silent, searchInstanceID); + // ai results go into the respective folder + const folderMatch = ai ? this.getAIFolderMatch(raw[0].resource) : this.getFolderMatch(raw[0].resource); + folderMatch?.addFileMatch(raw, silent, searchInstanceID, ai); }); - this._otherFilesMatch?.addFileMatch(other, silent, searchInstanceID); + if (!ai) { + this._otherFilesMatch?.addFileMatch(other, silent, searchInstanceID, false); + } this.disposePastResults(); } clear(): void { this.folderMatches().forEach((folderMatch) => folderMatch.clear(true)); + this.folderMatches(true); this.disposeMatches(); this._folderMatches = []; + this._aiFolderMatches = []; this._otherFilesMatch = null; } - remove(matches: FileMatch | FolderMatch | (FileMatch | FolderMatch)[]): void { + remove(matches: FileMatch | FolderMatch | (FileMatch | FolderMatch)[], ai = false): void { if (!Array.isArray(matches)) { matches = [matches]; } @@ -1787,7 +1822,7 @@ export class SearchResult extends Disposable { const fileMatches: FileMatch[] = matches.filter(m => m instanceof FileMatch) as FileMatch[]; - const { byFolder, other } = this.groupFilesByFolder(fileMatches); + const { byFolder, other } = this.groupFilesByFolder(fileMatches, ai); byFolder.forEach(matches => { if (!matches.length) { return; @@ -1818,7 +1853,10 @@ export class SearchResult extends Disposable { }); } - folderMatches(): FolderMatch[] { + folderMatches(ai = false): FolderMatch[] { + if (ai) { + return this._aiFolderMatches; + } return this._otherFilesMatch ? [ ...this._folderMatches, @@ -1829,25 +1867,25 @@ export class SearchResult extends Disposable { ]; } - matches(): FileMatch[] { + matches(ai = false): FileMatch[] { const matches: FileMatch[][] = []; - this.folderMatches().forEach(folderMatch => { + this.folderMatches(ai).forEach(folderMatch => { matches.push(folderMatch.allDownstreamFileMatches()); }); return ([]).concat(...matches); } - isEmpty(): boolean { - return this.folderMatches().every((folderMatch) => folderMatch.isEmpty()); + isEmpty(ai = false): boolean { + return this.folderMatches(ai).every((folderMatch) => folderMatch.isEmpty()); } - fileCount(): number { - return this.folderMatches().reduce((prev, match) => prev + match.recursiveFileCount(), 0); + fileCount(ai = false): number { + return this.folderMatches(ai).reduce((prev, match) => prev + match.recursiveFileCount(), 0); } - count(): number { - return this.matches().reduce((prev, match) => prev + match.count(), 0); + count(ai = false): number { + return this.matches(ai).reduce((prev, match) => prev + match.count(), 0); } get showHighlights(): boolean { @@ -1887,19 +1925,24 @@ export class SearchResult extends Disposable { return folderMatch ? folderMatch : this._otherFilesMatch!; } + private getAIFolderMatch(resource: URI): FolderMatchWorkspaceRoot | FolderMatch | undefined { + const folderMatch = this._aiFolderMatchesMap.findSubstr(resource); + return folderMatch; + } + private set replacingAll(running: boolean) { this.folderMatches().forEach((folderMatch) => { folderMatch.replacingAll = running; }); } - private groupFilesByFolder(fileMatches: IFileMatch[]): { byFolder: ResourceMap; other: IFileMatch[] } { + private groupFilesByFolder(fileMatches: IFileMatch[], ai: boolean): { byFolder: ResourceMap; other: IFileMatch[] } { const rawPerFolder = new ResourceMap(); const otherFileMatches: IFileMatch[] = []; - this._folderMatches.forEach(fm => rawPerFolder.set(fm.resource, [])); + (ai ? this._aiFolderMatches : this._folderMatches).forEach(fm => rawPerFolder.set(fm.resource, [])); fileMatches.forEach(rawFileMatch => { - const folderMatch = this.getFolderMatch(rawFileMatch.resource); + const folderMatch = ai ? this.getAIFolderMatch(rawFileMatch.resource) : this.getFolderMatch(rawFileMatch.resource); if (!folderMatch) { // foldermatch was previously removed by user or disposed for some reason return; @@ -1921,8 +1964,14 @@ export class SearchResult extends Disposable { private disposeMatches(): void { this.folderMatches().forEach(folderMatch => folderMatch.dispose()); + this.folderMatches(true).forEach(folderMatch => folderMatch.dispose()); + this._folderMatches = []; + this._aiFolderMatches = []; + this._folderMatchesMap = TernarySearchTree.forUris(key => this.uriIdentityService.extUri.ignorePathCasing(key)); + this._aiFolderMatchesMap = TernarySearchTree.forUris(key => this.uriIdentityService.extUri.ignorePathCasing(key)); + this._rangeHighlightDecorations.removeHighlightRange(); } @@ -1951,6 +2000,7 @@ export class SearchModel extends Disposable { private _preserveCase: boolean = false; private _startStreamDelay: Promise = Promise.resolve(); private readonly _resultQueue: IFileMatch[] = []; + private readonly _aiResultQueue: IFileMatch[] = []; private readonly _onReplaceTermChanged: Emitter = this._register(new Emitter()); readonly onReplaceTermChanged: Event = this._onReplaceTermChanged.event; @@ -1961,7 +2011,9 @@ export class SearchModel extends Disposable { readonly onSearchResultChanged: Event = this._onSearchResultChanged.event; private currentCancelTokenSource: CancellationTokenSource | null = null; + private currentAICancelTokenSource: CancellationTokenSource | null = null; private searchCancelledForNewSearch: boolean = false; + private aiSearchCancelledForNewSearch: boolean = false; public location: SearchModelLocation = SearchModelLocation.PANEL; constructor( @@ -1971,6 +2023,7 @@ export class SearchModel extends Disposable { @IInstantiationService private readonly instantiationService: IInstantiationService, @ILogService private readonly logService: ILogService, @INotebookSearchService private readonly notebookSearchService: INotebookSearchService, + @IProgressService private readonly progressService: IProgressService, ) { super(); this._searchResult = this.instantiationService.createInstance(SearchResult, this); @@ -2013,6 +2066,57 @@ export class SearchModel extends Disposable { return this._searchResult; } + async addAIResults(onProgress?: (result: ISearchProgressItem) => void) { + if (this.searchResult.count(true)) { + // already has matches + return; + } else { + if (this._searchQuery) { + await this.aiSearch( + { ...this._searchQuery, contentPattern: this._searchQuery.contentPattern.pattern, type: QueryType.aiText }, + onProgress, + this.currentCancelTokenSource?.token, + ); + } + } + } + + private async doAISearchWithModal(searchQuery: IAITextQuery, searchInstanceID: string, token?: CancellationToken, onProgress?: (result: ISearchProgressItem) => void): Promise { + const promise = this.searchService.aiTextSearch( + searchQuery, + token, async (p: ISearchProgressItem) => { + this.onSearchProgress(p, searchInstanceID, false, true); + onProgress?.(p); + }); + return this.progressService.withProgress({ + location: ProgressLocation.Notification, + type: 'syncing', + title: 'Searching for AI results...', + }, async (_) => promise); + } + + aiSearch(query: IAITextQuery, onProgress?: (result: ISearchProgressItem) => void, callerToken?: CancellationToken): Promise { + + const searchInstanceID = Date.now().toString(); + const tokenSource = this.currentAICancelTokenSource = new CancellationTokenSource(callerToken); + const start = Date.now(); + const asyncAIResults = this.doAISearchWithModal(query, + searchInstanceID, + this.currentAICancelTokenSource.token, async (p: ISearchProgressItem) => { + this.onSearchProgress(p, searchInstanceID, false, true); + onProgress?.(p); + }) + .then( + value => { + this.onSearchCompleted(value, Date.now() - start, searchInstanceID, true); + return value; + }, + e => { + this.onSearchError(e, Date.now() - start, true); + throw e; + }).finally(() => tokenSource.dispose()); + return asyncAIResults; + } private doSearch(query: ITextQuery, progressEmitter: Emitter, searchQuery: ITextQuery, searchInstanceID: string, onProgress?: (result: ISearchProgressItem) => void, callerToken?: CancellationToken): { asyncResults: Promise; @@ -2020,7 +2124,7 @@ export class SearchModel extends Disposable { } { const asyncGenerateOnProgress = async (p: ISearchProgressItem) => { progressEmitter.fire(); - this.onSearchProgress(p, searchInstanceID, false); + this.onSearchProgress(p, searchInstanceID, false, false); onProgress?.(p); }; @@ -2121,11 +2225,11 @@ export class SearchModel extends Disposable { return { asyncResults: asyncResults.then( value => { - this.onSearchCompleted(value, Date.now() - start, searchInstanceID); + this.onSearchCompleted(value, Date.now() - start, searchInstanceID, false); return value; }, e => { - this.onSearchError(e, Date.now() - start); + this.onSearchError(e, Date.now() - start, false); throw e; }), syncResults @@ -2141,13 +2245,20 @@ export class SearchModel extends Disposable { } } - private onSearchCompleted(completed: ISearchComplete | undefined, duration: number, searchInstanceID: string): ISearchComplete | undefined { + private onSearchCompleted(completed: ISearchComplete | undefined, duration: number, searchInstanceID: string, ai: boolean): ISearchComplete | undefined { if (!this._searchQuery) { throw new Error('onSearchCompleted must be called after a search is started'); } - this._searchResult.add(this._resultQueue, searchInstanceID); - this._resultQueue.length = 0; + if (ai) { + this._searchResult.add(this._aiResultQueue, searchInstanceID, true); + this._aiResultQueue.length = 0; + } else { + this._searchResult.add(this._resultQueue, searchInstanceID, false); + this._resultQueue.length = 0; + } + + this.searchResult.setCachedSearchComplete(completed, ai); const options: IPatternInfo = Object.assign({}, this._searchQuery.contentPattern); delete (options as any).pattern; @@ -2184,30 +2295,35 @@ export class SearchModel extends Disposable { return completed; } - private onSearchError(e: any, duration: number): void { + private onSearchError(e: any, duration: number, ai: boolean): void { if (errors.isCancellationError(e)) { this.onSearchCompleted( - this.searchCancelledForNewSearch + (ai ? this.aiSearchCancelledForNewSearch : this.searchCancelledForNewSearch) ? { exit: SearchCompletionExitCode.NewSearchStarted, results: [], messages: [] } : undefined, - duration, ''); - this.searchCancelledForNewSearch = false; + duration, '', ai); + if (ai) { + this.aiSearchCancelledForNewSearch = false; + } else { + this.searchCancelledForNewSearch = false; + } } } - private onSearchProgress(p: ISearchProgressItem, searchInstanceID: string, sync = true) { + private onSearchProgress(p: ISearchProgressItem, searchInstanceID: string, sync = true, ai: boolean = false) { + const targetQueue = ai ? this._aiResultQueue : this._resultQueue; if ((p).resource) { - this._resultQueue.push(p); + targetQueue.push(p); if (sync) { - if (this._resultQueue.length) { - this._searchResult.add(this._resultQueue, searchInstanceID, true); - this._resultQueue.length = 0; + if (targetQueue.length) { + this._searchResult.add(targetQueue, searchInstanceID, false, true); + targetQueue.length = 0; } } else { this._startStreamDelay.then(() => { - if (this._resultQueue.length) { - this._searchResult.add(this._resultQueue, searchInstanceID, true); - this._resultQueue.length = 0; + if (targetQueue.length) { + this._searchResult.add(targetQueue, searchInstanceID, ai, true); + targetQueue.length = 0; } }); } @@ -2227,9 +2343,17 @@ export class SearchModel extends Disposable { } return false; } - + cancelAISearch(cancelledForNewSearch = false): boolean { + if (this.currentAICancelTokenSource) { + this.aiSearchCancelledForNewSearch = cancelledForNewSearch; + this.currentAICancelTokenSource.cancel(); + return true; + } + return false; + } override dispose(): void { this.cancelSearch(); + this.cancelAISearch(); this.searchResult.dispose(); super.dispose(); } @@ -2354,16 +2478,16 @@ export class RangeHighlightDecorations implements IDisposable { -function textSearchResultToMatches(rawMatch: ITextSearchMatch, fileMatch: FileMatch): Match[] { +function textSearchResultToMatches(rawMatch: ITextSearchMatch, fileMatch: FileMatch, isAiContributed: boolean): Match[] { const previewLines = rawMatch.preview.text.split('\n'); if (Array.isArray(rawMatch.ranges)) { return rawMatch.ranges.map((r, i) => { const previewRange: ISearchRange = (rawMatch.preview.matches)[i]; - return new Match(fileMatch, previewLines, previewRange, r); + return new Match(fileMatch, previewLines, previewRange, r, isAiContributed); }); } else { const previewRange = rawMatch.preview.matches; - const match = new Match(fileMatch, previewLines, previewRange, rawMatch.ranges); + const match = new Match(fileMatch, previewLines, previewRange, rawMatch.ranges, isAiContributed); return [match]; } } diff --git a/src/vs/workbench/contrib/search/browser/searchResultsView.ts b/src/vs/workbench/contrib/search/browser/searchResultsView.ts index 3f71827f12c8f..f3c64a6e7b0db 100644 --- a/src/vs/workbench/contrib/search/browser/searchResultsView.ts +++ b/src/vs/workbench/contrib/search/browser/searchResultsView.ts @@ -30,6 +30,8 @@ import { IContextKeyService } from 'vs/platform/contextkey/common/contextkey'; import { ServiceCollection } from 'vs/platform/instantiation/common/serviceCollection'; import { defaultCountBadgeStyles } from 'vs/platform/theme/browser/defaultStyles'; import { SearchContext } from 'vs/workbench/contrib/search/common/constants'; +import { getDefaultHoverDelegate } from 'vs/base/browser/ui/hover/hoverDelegateFactory'; +import { IHoverService } from 'vs/platform/hover/browser/hover'; interface IFolderMatchTemplate { label: IResourceLabel; @@ -299,6 +301,7 @@ export class MatchRenderer extends Disposable implements ICompressibleTreeRender @IConfigurationService private readonly configurationService: IConfigurationService, @IInstantiationService private readonly instantiationService: IInstantiationService, @IContextKeyService private readonly contextKeyService: IContextKeyService, + @IHoverService private readonly hoverService: IHoverService ) { super(); } @@ -360,7 +363,9 @@ export class MatchRenderer extends Disposable implements ICompressibleTreeRender templateData.match.classList.toggle('replace', replace); templateData.replace.textContent = replace ? match.replaceString : ''; templateData.after.textContent = preview.after; - templateData.parent.title = (preview.fullBefore + (replace ? match.replaceString : preview.inside) + preview.after).trim().substr(0, 999); + + const title = (preview.fullBefore + (replace ? match.replaceString : preview.inside) + preview.after).trim().substr(0, 999); + templateData.disposables.add(this.hoverService.setupUpdatableHover(getDefaultHoverDelegate('mouse'), templateData.parent, title)); SearchContext.IsEditableItemKey.bindTo(templateData.contextKeyService).set(!(match instanceof MatchInNotebook && match.isReadonly())); @@ -372,7 +377,7 @@ export class MatchRenderer extends Disposable implements ICompressibleTreeRender templateData.lineNumber.classList.toggle('show', (numLines > 0) || showLineNumbers); templateData.lineNumber.textContent = lineNumberStr + extraLinesStr; - templateData.lineNumber.setAttribute('title', this.getMatchTitle(match, showLineNumbers)); + templateData.disposables.add(this.hoverService.setupUpdatableHover(getDefaultHoverDelegate('mouse'), templateData.lineNumber, this.getMatchTitle(match, showLineNumbers))); templateData.actions.context = { viewer: this.searchView.getControl(), element: match }; diff --git a/src/vs/workbench/contrib/search/browser/searchView.ts b/src/vs/workbench/contrib/search/browser/searchView.ts index fc89084618298..74c80973c4255 100644 --- a/src/vs/workbench/contrib/search/browser/searchView.ts +++ b/src/vs/workbench/contrib/search/browser/searchView.ts @@ -23,7 +23,7 @@ import * as network from 'vs/base/common/network'; import 'vs/css!./media/searchview'; import { getCodeEditor, isCodeEditor, isDiffEditor } from 'vs/editor/browser/editorBrowser'; import { ICodeEditorService } from 'vs/editor/browser/services/codeEditorService'; -import { EmbeddedCodeEditorWidget } from 'vs/editor/browser/widget/embeddedCodeEditorWidget'; +import { EmbeddedCodeEditorWidget } from 'vs/editor/browser/widget/codeEditor/embeddedCodeEditorWidget'; import { IEditorOptions } from 'vs/editor/common/config/editorOptions'; import { Selection } from 'vs/editor/common/core/selection'; import { IEditor } from 'vs/editor/common/editorCommon'; @@ -75,12 +75,14 @@ import { createEditorFromSearchResult } from 'vs/workbench/contrib/searchEditor/ import { ACTIVE_GROUP, IEditorService, SIDE_GROUP } from 'vs/workbench/services/editor/common/editorService'; import { IPreferencesService, ISettingsEditorOptions } from 'vs/workbench/services/preferences/common/preferences'; import { ITextQueryBuilderOptions, QueryBuilder } from 'vs/workbench/services/search/common/queryBuilder'; -import { IPatternInfo, ISearchComplete, ISearchConfiguration, ISearchConfigurationProperties, ITextQuery, SearchCompletionExitCode, SearchSortOrder, TextSearchCompleteMessageType, ViewMode } from 'vs/workbench/services/search/common/search'; +import { IPatternInfo, ISearchComplete, ISearchConfiguration, ISearchConfigurationProperties, ITextQuery, QueryType, SearchCompletionExitCode, SearchSortOrder, TextSearchCompleteMessageType, ViewMode } from 'vs/workbench/services/search/common/search'; import { TextSearchCompleteMessage } from 'vs/workbench/services/search/common/searchExtTypes'; import { ITextFileService } from 'vs/workbench/services/textfile/common/textfiles'; import { INotebookService } from 'vs/workbench/contrib/notebook/common/notebookService'; import { ILogService } from 'vs/platform/log/common/log'; -import { AudioCue, IAudioCueService } from 'vs/platform/audioCues/browser/audioCueService'; +import { AccessibilitySignal, IAccessibilitySignalService } from 'vs/platform/accessibilitySignal/browser/accessibilitySignalService'; +import { getDefaultHoverDelegate } from 'vs/base/browser/ui/hover/hoverDelegateFactory'; +import { IHoverService } from 'vs/platform/hover/browser/hover'; const $ = dom.$; @@ -156,6 +158,7 @@ export class SearchView extends ViewPane { private treeAccessibilityProvider: SearchAccessibilityProvider; private treeViewKey: IContextKey; + private aiResultsVisibleKey: IContextKey; private _visibleMatches: number = 0; @@ -190,12 +193,13 @@ export class SearchView extends ViewPane { @IStorageService private readonly storageService: IStorageService, @IOpenerService openerService: IOpenerService, @ITelemetryService telemetryService: ITelemetryService, + @IHoverService hoverService: IHoverService, @INotebookService private readonly notebookService: INotebookService, @ILogService private readonly logService: ILogService, - @IAudioCueService private readonly audioCueService: IAudioCueService + @IAccessibilitySignalService private readonly accessibilitySignalService: IAccessibilitySignalService ) { - super(options, keybindingService, contextMenuService, configurationService, contextKeyService, viewDescriptorService, instantiationService, openerService, themeService, telemetryService); + super(options, keybindingService, contextMenuService, configurationService, contextKeyService, viewDescriptorService, instantiationService, openerService, themeService, telemetryService, hoverService); this.container = dom.$('.search-view'); @@ -216,6 +220,14 @@ export class SearchView extends ViewPane { this.hasFilePatternKey = Constants.SearchContext.ViewHasFilePatternKey.bindTo(this.contextKeyService); this.hasSomeCollapsibleResultKey = Constants.SearchContext.ViewHasSomeCollapsibleKey.bindTo(this.contextKeyService); this.treeViewKey = Constants.SearchContext.InTreeViewKey.bindTo(this.contextKeyService); + this.aiResultsVisibleKey = Constants.SearchContext.AIResultsVisibleKey.bindTo(this.contextKeyService); + + this._register(this.contextKeyService.onDidChangeContext(e => { + const keys = Constants.SearchContext.hasAIResultProvider.keys(); + if (e.affectsSome(new Set(keys))) { + this.refreshHasAISetting(); + } + })); // scoped this.contextKeyService = this._register(this.contextKeyService.createScoped(this.container)); @@ -228,7 +240,7 @@ export class SearchView extends ViewPane { this.instantiationService = this.instantiationService.createChild( new ServiceCollection([IContextKeyService, this.contextKeyService])); - this.configurationService.onDidChangeConfiguration(e => { + this._register(this.configurationService.onDidChangeConfiguration(e => { if (e.affectsConfiguration('search.sortOrder')) { if (this.searchConfig.sortOrder === SearchSortOrder.Modified) { // If changing away from modified, remove all fileStats @@ -236,8 +248,10 @@ export class SearchView extends ViewPane { this.removeFileStats(); } this.refreshTree(); + } else if (e.affectsConfiguration('search.aiResults')) { + this.refreshHasAISetting(); } - }); + })); this.viewModel = this._register(this.searchViewModelWorkbenchService.searchModel); this.queryBuilder = this.instantiationService.createInstance(QueryBuilder); @@ -292,6 +306,14 @@ export class SearchView extends ViewPane { this.treeViewKey.set(visible); } + get aiResultsVisible(): boolean { + return this.aiResultsVisibleKey.get() ?? false; + } + + private set aiResultsVisible(visible: boolean) { + this.aiResultsVisibleKey.set(visible); + } + setTreeView(visible: boolean): void { if (visible === this.isTreeLayoutViewVisible) { return; @@ -301,6 +323,26 @@ export class SearchView extends ViewPane { this.refreshTree(); } + async setAIResultsVisible(visible: boolean): Promise { + if (visible === this.aiResultsVisible) { + return; + } + this.aiResultsVisible = visible; + if (this.viewModel.searchResult.isEmpty()) { + return; + } + + // in each case, we want to cancel our current AI search because it is no longer valid + this.model.cancelAISearch(); + if (visible) { + await this.model.addAIResults(); + } else { + this.searchWidget.toggleReplace(false); + } + this.onSearchResultsChanged(); + this.onSearchComplete(() => { }, undefined, undefined, this.viewModel.searchResult.getCachedSearchComplete(visible)); + } + private get state(): SearchUIState { return this.searchStateKey.get() ?? SearchUIState.Idle; } @@ -321,6 +363,12 @@ export class SearchView extends ViewPane { return this.viewModel; } + private refreshHasAISetting() { + const val = this.shouldShowAIButton(); + if (val && this.searchWidget.searchInput) { + this.searchWidget.searchInput.sparkleVisible = val; + } + } private onDidChangeWorkbenchState(): void { if (this.contextService.getWorkbenchState() !== WorkbenchState.EMPTY && this.searchWithoutFolderMessageElement) { dom.hide(this.searchWithoutFolderMessageElement); @@ -374,8 +422,8 @@ export class SearchView extends ViewPane { }); const collapseResults = this.searchConfig.collapseResults; - if (collapseResults !== 'alwaysCollapse' && this.viewModel.searchResult.matches().length === 1) { - const onlyMatch = this.viewModel.searchResult.matches()[0]; + if (collapseResults !== 'alwaysCollapse' && this.viewModel.searchResult.matches(this.aiResultsVisible).length === 1) { + const onlyMatch = this.viewModel.searchResult.matches(this.aiResultsVisible)[0]; if (onlyMatch.count() < 50) { this.tree.expand(onlyMatch); } @@ -388,6 +436,7 @@ export class SearchView extends ViewPane { this.searchWidgetsContainerElement = dom.append(this.container, $('.search-widgets-container')); this.createSearchWidget(this.searchWidgetsContainerElement); + this.refreshHasAISetting(); const history = this.searchHistoryService.load(); const filePatterns = this.viewletState['query.filePatterns'] || ''; @@ -405,7 +454,8 @@ export class SearchView extends ViewPane { // Toggle query details button this.toggleQueryDetailsButton = dom.append(this.queryDetails, - $('.more' + ThemeIcon.asCSSSelector(searchDetailsIcon), { tabindex: 0, role: 'button', title: nls.localize('moreSearch', "Toggle Search Details") })); + $('.more' + ThemeIcon.asCSSSelector(searchDetailsIcon), { tabindex: 0, role: 'button' })); + this._register(this.hoverService.setupUpdatableHover(getDefaultHoverDelegate('element'), this.toggleQueryDetailsButton, nls.localize('moreSearch', "Toggle Search Details"))); this._register(dom.addDisposableListener(this.toggleQueryDetailsButton, dom.EventType.CLICK, e => { dom.EventHelper.stop(e); @@ -569,7 +619,8 @@ export class SearchView extends ViewPane { isInNotebookMarkdownPreview, isInNotebookCellInput, isInNotebookCellOutput, - } + }, + initialAIButtonVisibility: this.shouldShowAIButton() })); if (!this.searchWidget.searchInput || !this.searchWidget.replaceInput) { @@ -583,7 +634,14 @@ export class SearchView extends ViewPane { this._register(this.searchWidget.onSearchSubmit(options => this.triggerQueryChange(options))); this._register(this.searchWidget.onSearchCancel(({ focus }) => this.cancelSearch(focus))); - this._register(this.searchWidget.searchInput.onDidOptionChange(() => this.triggerQueryChange())); + this._register(this.searchWidget.searchInput.onDidOptionChange(() => { + if (this.searchWidget.searchInput && this.searchWidget.searchInput.isAIEnabled !== this.aiResultsVisible) { + this.setAIResultsVisible(this.searchWidget.searchInput.isAIEnabled); + } else { + this.triggerQueryChange(); + } + })); + this._register(this.searchWidget.getNotebookFilters().onDidChange(() => this.triggerQueryChange())); const updateHasPatternKey = () => this.hasSearchPatternKey.set(this.searchWidget.searchInput ? (this.searchWidget.searchInput.getValue().length > 0) : false); @@ -622,7 +680,10 @@ export class SearchView extends ViewPane { this.trackInputBox(this.searchWidget.replaceInputFocusTracker); } - + private shouldShowAIButton(): boolean { + const hasProvider = Constants.SearchContext.hasAIResultProvider.getValue(this.contextKeyService); + return !!(this.configurationService.getValue('search.aiResults') && hasProvider); + } private onConfigurationUpdated(event?: IConfigurationChangeEvent): void { if (event && (event.affectsConfiguration('search.decorations.colors') || event.affectsConfiguration('search.decorations.badges'))) { this.refreshTree(); @@ -657,7 +718,7 @@ export class SearchView extends ViewPane { } private refreshAndUpdateCount(event?: IChangeEvent): void { - this.searchWidget.setReplaceAllActionState(!this.viewModel.searchResult.isEmpty()); + this.searchWidget.setReplaceAllActionState(!this.viewModel.searchResult.isEmpty(this.aiResultsVisible)); this.updateSearchResultCount(this.viewModel.searchResult.query!.userDisabledExcludesAndIgnoreFiles, this.viewModel.searchResult.query?.onlyOpenEditors, event?.clearingAll); return this.refreshTree(event); } @@ -689,7 +750,7 @@ export class SearchView extends ViewPane { } private createResultIterator(collapseResults: ISearchConfigurationProperties['collapseResults']): Iterable> { - const folderMatches = this.searchResult.folderMatches() + const folderMatches = this.searchResult.folderMatches(this.aiResultsVisible) .filter(fm => !fm.isEmpty()) .sort(searchMatchComparer); @@ -724,7 +785,11 @@ export class SearchView extends ViewPane { } private createFileIterator(fileMatch: FileMatch): Iterable> { - const matches = fileMatch.matches().sort(searchMatchComparer); + let matches = fileMatch.matches().sort(searchMatchComparer); + + if (!this.aiResultsVisible) { + matches = matches.filter(e => !e.aiContributed); + } return Iterable.map(matches, r => (>{ element: r, incompressible: true })); } @@ -892,15 +957,14 @@ export class SearchView extends ViewPane { }), multipleSelectionSupport: true, selectionNavigation: true, - overrideStyles: { - listBackground: this.getBackgroundColor() - }, + overrideStyles: this.getLocationBasedColors().listOverrideStyles, paddingBottom: SearchDelegate.ITEM_HEIGHT })); this._register(this.tree.onContextMenu(e => this.onContextMenu(e))); const updateHasSomeCollapsible = () => this.toggleCollapseStateDelayer.trigger(() => this.hasSomeCollapsibleResultKey.set(this.hasSomeCollapsible())); updateHasSomeCollapsible(); this._register(this.tree.onDidChangeCollapseState(() => updateHasSomeCollapsible())); + this._register(this.tree.onDidChangeModel(() => updateHasSomeCollapsible())); this._register(Event.debounce(this.tree.onDidOpen, (last, event) => event, DEBOUNCE_DELAY, true)(options => { if (options.element instanceof Match) { @@ -1261,7 +1325,7 @@ export class SearchView extends ViewPane { } hasSearchResults(): boolean { - return !this.viewModel.searchResult.isEmpty(); + return !this.viewModel.searchResult.isEmpty(this.aiResultsVisible); } clearSearchResults(clearInput = true): void { @@ -1279,7 +1343,7 @@ export class SearchView extends ViewPane { this.viewModel.cancelSearch(); this.tree.ariaLabel = nls.localize('emptySearch', "Empty Search"); - this.audioCueService.playAudioCue(AudioCue.clear); + this.accessibilitySignalService.playSignal(AccessibilitySignal.clear); this.reLayout(); } @@ -1572,6 +1636,7 @@ export class SearchView extends ViewPane { }); this.viewModel.cancelSearch(true); + this.viewModel.cancelAISearch(true); this.currentSearchQ = this.currentSearchQ .then(() => this.doSearch(query, excludePatternText, includePatternText, triggeredOnType)) @@ -1585,7 +1650,7 @@ export class SearchView extends ViewPane { } try { // Search result tree update - const fileCount = this.viewModel.searchResult.fileCount(); + const fileCount = this.viewModel.searchResult.fileCount(this.aiResultsVisible); if (this._visibleMatches !== fileCount) { this._visibleMatches = fileCount; this.refreshAndUpdateCount(); @@ -1607,14 +1672,14 @@ export class SearchView extends ViewPane { this.onSearchResultsChanged(); const collapseResults = this.searchConfig.collapseResults; - if (collapseResults !== 'alwaysCollapse' && this.viewModel.searchResult.matches().length === 1) { - const onlyMatch = this.viewModel.searchResult.matches()[0]; + if (collapseResults !== 'alwaysCollapse' && this.viewModel.searchResult.matches(this.aiResultsVisible).length === 1) { + const onlyMatch = this.viewModel.searchResult.matches(this.aiResultsVisible)[0]; if (onlyMatch.count() < 50) { this.tree.expand(onlyMatch); } } - const hasResults = !this.viewModel.searchResult.isEmpty(); + const hasResults = !this.viewModel.searchResult.isEmpty(this.aiResultsVisible); if (completed?.exit === SearchCompletionExitCode.NewSearchStarted) { return; } @@ -1657,20 +1722,20 @@ export class SearchView extends ViewPane { if (!completed) { const searchAgainButton = this.messageDisposables.add(new SearchLinkButton( nls.localize('rerunSearch.message', "Search again"), - () => this.triggerQueryChange({ preserveFocus: false }))); + () => this.triggerQueryChange({ preserveFocus: false }), this.hoverService)); dom.append(messageEl, searchAgainButton.element); } else if (hasIncludes || hasExcludes) { - const searchAgainButton = this.messageDisposables.add(new SearchLinkButton(nls.localize('rerunSearchInAll.message', "Search again in all files"), this.onSearchAgain.bind(this))); + const searchAgainButton = this.messageDisposables.add(new SearchLinkButton(nls.localize('rerunSearchInAll.message', "Search again in all files"), this.onSearchAgain.bind(this), this.hoverService)); dom.append(messageEl, searchAgainButton.element); } else { - const openSettingsButton = this.messageDisposables.add(new SearchLinkButton(nls.localize('openSettings.message', "Open Settings"), this.onOpenSettings.bind(this))); + const openSettingsButton = this.messageDisposables.add(new SearchLinkButton(nls.localize('openSettings.message', "Open Settings"), this.onOpenSettings.bind(this), this.hoverService)); dom.append(messageEl, openSettingsButton.element); } if (completed) { dom.append(messageEl, $('span', undefined, ' - ')); - const learnMoreButton = this.messageDisposables.add(new SearchLinkButton(nls.localize('openSettings.learnMore', "Learn More"), this.onLearnMore.bind(this))); + const learnMoreButton = this.messageDisposables.add(new SearchLinkButton(nls.localize('openSettings.learnMore', "Learn More"), this.onLearnMore.bind(this), this.hoverService)); dom.append(messageEl, learnMoreButton.element); } @@ -1682,7 +1747,7 @@ export class SearchView extends ViewPane { this.viewModel.searchResult.toggleHighlights(this.isVisible()); // show highlights // Indicate final search result count for ARIA - aria.status(nls.localize('ariaSearchResultsStatus', "Search returned {0} results in {1} files", this.viewModel.searchResult.count(), this.viewModel.searchResult.fileCount())); + aria.status(nls.localize('ariaSearchResultsStatus', "Search returned {0} results in {1} files", this.viewModel.searchResult.count(this.aiResultsVisible), this.viewModel.searchResult.fileCount())); } @@ -1737,6 +1802,20 @@ export class SearchView extends ViewPane { this.viewModel.replaceString = this.searchWidget.getReplaceValue(); const result = this.viewModel.search(query); + if (this.aiResultsVisible) { + const aiResult = this.viewModel.aiSearch({ ...query, contentPattern: query.contentPattern.pattern, type: QueryType.aiText }); + return result.asyncResults.then( + () => aiResult.then( + (complete) => { + clearTimeout(slowTimer); + this.onSearchComplete(progressComplete, excludePatternText, includePatternText, complete); + }, (e) => { + clearTimeout(slowTimer); + this.onSearchError(e, progressComplete, excludePatternText, includePatternText); + } + ) + ); + } return result.asyncResults.then((complete) => { clearTimeout(slowTimer); this.onSearchComplete(progressComplete, excludePatternText, includePatternText, complete); @@ -1781,26 +1860,27 @@ export class SearchView extends ViewPane { } private updateSearchResultCount(disregardExcludesAndIgnores?: boolean, onlyOpenEditors?: boolean, clear: boolean = false): void { - const fileCount = this.viewModel.searchResult.fileCount(); + const fileCount = this.viewModel.searchResult.fileCount(this.aiResultsVisible); + const resultCount = this.viewModel.searchResult.count(this.aiResultsVisible); this.hasSearchResultsKey.set(fileCount > 0); const msgWasHidden = this.messagesElement.style.display === 'none'; const messageEl = this.clearMessage(); - const resultMsg = clear ? '' : this.buildResultCountMessage(this.viewModel.searchResult.count(), fileCount); + const resultMsg = clear ? '' : this.buildResultCountMessage(resultCount, fileCount); this.tree.ariaLabel = resultMsg + nls.localize('forTerm', " - Search: {0}", this.searchResult.query?.contentPattern.pattern ?? ''); dom.append(messageEl, resultMsg); if (fileCount > 0) { if (disregardExcludesAndIgnores) { const excludesDisabledMessage = ' - ' + nls.localize('useIgnoresAndExcludesDisabled', "exclude settings and ignore files are disabled") + ' '; - const enableExcludesButton = this.messageDisposables.add(new SearchLinkButton(nls.localize('excludes.enable', "enable"), this.onEnableExcludes.bind(this), nls.localize('useExcludesAndIgnoreFilesDescription', "Use Exclude Settings and Ignore Files"))); + const enableExcludesButton = this.messageDisposables.add(new SearchLinkButton(nls.localize('excludes.enable', "enable"), this.onEnableExcludes.bind(this), this.hoverService, nls.localize('useExcludesAndIgnoreFilesDescription', "Use Exclude Settings and Ignore Files"))); dom.append(messageEl, $('span', undefined, excludesDisabledMessage, '(', enableExcludesButton.element, ')')); } if (onlyOpenEditors) { const searchingInOpenMessage = ' - ' + nls.localize('onlyOpenEditors', "searching only in open files") + ' '; - const disableOpenEditorsButton = this.messageDisposables.add(new SearchLinkButton(nls.localize('openEditors.disable', "disable"), this.onDisableSearchInOpenEditors.bind(this), nls.localize('disableOpenEditors', "Search in entire workspace"))); + const disableOpenEditorsButton = this.messageDisposables.add(new SearchLinkButton(nls.localize('openEditors.disable', "disable"), this.onDisableSearchInOpenEditors.bind(this), this.hoverService, nls.localize('disableOpenEditors', "Search in entire workspace"))); dom.append(messageEl, $('span', undefined, searchingInOpenMessage, '(', disableOpenEditorsButton.element, ')')); } @@ -1811,7 +1891,7 @@ export class SearchView extends ViewPane { this.keybindingService.lookupKeybinding(Constants.SearchCommandIds.OpenInEditorCommandId)); const openInEditorButton = this.messageDisposables.add(new SearchLinkButton( nls.localize('openInEditor.message', "Open in editor"), - () => this.instantiationService.invokeFunction(createEditorFromSearchResult, this.searchResult, this.searchIncludePattern.getValue(), this.searchExcludePattern.getValue(), this.searchIncludePattern.onlySearchInOpenEditors()), + () => this.instantiationService.invokeFunction(createEditorFromSearchResult, this.searchResult, this.searchIncludePattern.getValue(), this.searchExcludePattern.getValue(), this.searchIncludePattern.onlySearchInOpenEditors()), this.hoverService, openInEditorTooltip)); dom.append(messageEl, openInEditorButton.element); @@ -1849,7 +1929,7 @@ export class SearchView extends ViewPane { nls.localize('openFolder', "Open Folder"), () => { this.commandService.executeCommand(env.isMacintosh && env.isNative ? OpenFileFolderAction.ID : OpenFolderAction.ID).catch(err => errors.onUnexpectedError(err)); - })); + }, this.hoverService)); dom.append(textEl, openFolderButton.element); } @@ -1983,7 +2063,13 @@ export class SearchView extends ViewPane { } // remove search results from this resource as it got disposed - const matches = this.viewModel.searchResult.matches(); + let matches = this.viewModel.searchResult.matches(); + for (let i = 0, len = matches.length; i < len; i++) { + if (resource.toString() === matches[i].resource.toString()) { + this.viewModel.searchResult.remove(matches[i]); + } + } + matches = this.viewModel.searchResult.matches(true); for (let i = 0, len = matches.length; i < len; i++) { if (resource.toString() === matches[i].resource.toString()) { this.viewModel.searchResult.remove(matches[i]); @@ -2104,7 +2190,7 @@ export class SearchView extends ViewPane { } private async retrieveFileStats(): Promise { - const files = this.searchResult.matches().filter(f => !f.fileStat).map(f => f.resolveFileStat(this.fileService)); + const files = this.searchResult.matches(this.aiResultsVisible).filter(f => !f.fileStat).map(f => f.resolveFileStat(this.fileService)); await Promise.all(files); } @@ -2117,6 +2203,9 @@ export class SearchView extends ViewPane { for (const fileMatch of this.searchResult.matches()) { fileMatch.fileStat = undefined; } + for (const fileMatch of this.searchResult.matches(true)) { + fileMatch.fileStat = undefined; + } } override dispose(): void { @@ -2130,9 +2219,10 @@ export class SearchView extends ViewPane { class SearchLinkButton extends Disposable { public readonly element: HTMLElement; - constructor(label: string, handler: (e: dom.EventLike) => unknown, tooltip?: string) { + constructor(label: string, handler: (e: dom.EventLike) => unknown, hoverService: IHoverService, tooltip?: string) { super(); - this.element = $('a.pointer', { tabindex: 0, title: tooltip }, label); + this.element = $('a.pointer', { tabindex: 0 }, label); + this._register(hoverService.setupUpdatableHover(getDefaultHoverDelegate('mouse'), this.element, tooltip)); this.addEventHandlers(handler); } diff --git a/src/vs/workbench/contrib/search/browser/searchWidget.ts b/src/vs/workbench/contrib/search/browser/searchWidget.ts index 26acd42d459f6..766990702f80d 100644 --- a/src/vs/workbench/contrib/search/browser/searchWidget.ts +++ b/src/vs/workbench/contrib/search/browser/searchWidget.ts @@ -7,7 +7,7 @@ import * as dom from 'vs/base/browser/dom'; import { IKeyboardEvent } from 'vs/base/browser/keyboardEvent'; import { ActionBar } from 'vs/base/browser/ui/actionbar/actionbar'; import { Button, IButtonOptions } from 'vs/base/browser/ui/button/button'; -import { FindInput, IFindInputOptions } from 'vs/base/browser/ui/findinput/findInput'; +import { IFindInputOptions } from 'vs/base/browser/ui/findinput/findInput'; import { ReplaceInput } from 'vs/base/browser/ui/findinput/replaceInput'; import { IInputBoxStyles, IMessage, InputBox } from 'vs/base/browser/ui/inputbox/inputBox'; import { Widget } from 'vs/base/browser/ui/widget'; @@ -42,6 +42,8 @@ import { IEditorService } from 'vs/workbench/services/editor/common/editorServic import { NotebookEditorInput } from 'vs/workbench/contrib/notebook/common/notebookEditorInput'; import { GroupModelChangeKind } from 'vs/workbench/common/editor'; import { SearchFindInput } from 'vs/workbench/contrib/search/browser/searchFindInput'; +import { getDefaultHoverDelegate } from 'vs/base/browser/ui/hover/hoverDelegateFactory'; +import { IDisposable, MutableDisposable } from 'vs/base/common/lifecycle'; /** Specified in searchview.css */ const SingleLineInputHeight = 26; @@ -60,6 +62,7 @@ export interface ISearchWidgetOptions { inputBoxStyles: IInputBoxStyles; toggleStyles: IToggleStyles; notebookOptions?: NotebookToggleState; + initialAIButtonVisibility?: boolean; } interface NotebookToggleState { @@ -119,7 +122,7 @@ export class SearchWidget extends Widget { domNode: HTMLElement | undefined; - searchInput: FindInput | undefined; + searchInput: SearchFindInput | undefined; searchInputFocusTracker: dom.IFocusTracker | undefined; private searchInputBoxFocused: IContextKey; @@ -169,6 +172,7 @@ export class SearchWidget extends Widget { public contextLinesInput!: InputBox; private _notebookFilters: NotebookFindFilters; + private readonly _toggleReplaceButtonListener: MutableDisposable; constructor( container: HTMLElement, @@ -205,12 +209,12 @@ export class SearchWidget extends Widget { this._register( this._notebookFilters.onDidChange(() => { - if (this.searchInput instanceof SearchFindInput) { - this.searchInput.updateStyles(); + if (this.searchInput) { + this.searchInput.updateFilterStyles(); } })); this._register(this.editorService.onDidEditorsChange((e) => { - if (this.searchInput instanceof SearchFindInput && + if (this.searchInput && e.event.editor instanceof NotebookEditorInput && (e.event.kind === GroupModelChangeKind.EDITOR_OPEN || e.event.kind === GroupModelChangeKind.EDITOR_CLOSE)) { this.searchInput.filterVisible = this._hasNotebookOpen(); @@ -218,6 +222,7 @@ export class SearchWidget extends Widget { })); this._replaceHistoryDelayer = new Delayer(500); + this._toggleReplaceButtonListener = this._register(new MutableDisposable()); this.render(container, options); @@ -370,15 +375,15 @@ export class SearchWidget extends Widget { buttonSecondaryBackground: undefined, buttonSecondaryForeground: undefined, buttonSecondaryHoverBackground: undefined, - buttonSeparator: undefined + buttonSeparator: undefined, + title: nls.localize('search.replace.toggle.button.title', "Toggle Replace"), + hoverDelegate: getDefaultHoverDelegate('element'), }; this.toggleReplaceButton = this._register(new Button(parent, opts)); this.toggleReplaceButton.element.setAttribute('aria-expanded', 'false'); this.toggleReplaceButton.element.classList.add('toggle-replace-button'); this.toggleReplaceButton.icon = searchHideReplaceIcon; - // TODO@joao need to dispose this listener eventually - this.toggleReplaceButton.onDidClick(() => this.onToggleReplaceButton()); - this.toggleReplaceButton.element.title = nls.localize('search.replace.toggle.button.title', "Toggle Replace"); + this._toggleReplaceButtonListener.value = this.toggleReplaceButton.onDidClick(() => this.onToggleReplaceButton()); } private renderSearchInput(parent: HTMLElement, options: ISearchWidgetOptions): void { @@ -400,7 +405,19 @@ export class SearchWidget extends Widget { const searchInputContainer = dom.append(parent, dom.$('.search-container.input-box')); - this.searchInput = this._register(new SearchFindInput(searchInputContainer, this.contextViewService, inputOptions, this.contextKeyService, this.contextMenuService, this.instantiationService, this._notebookFilters, this._hasNotebookOpen())); + this.searchInput = this._register( + new SearchFindInput( + searchInputContainer, + this.contextViewService, + inputOptions, + this.contextKeyService, + this.contextMenuService, + this.instantiationService, + this._notebookFilters, + options.initialAIButtonVisibility ?? false, + this._hasNotebookOpen() + ) + ); this.searchInput.onKeyDown((keyboardEvent: IKeyboardEvent) => this.onSearchInputKeyDown(keyboardEvent)); this.searchInput.setValue(options.value || ''); @@ -441,6 +458,7 @@ export class SearchWidget extends Widget { isChecked: false, title: appendKeyBindingLabel(nls.localize('showContext', "Toggle Context Lines"), this.keybindingService.lookupKeybinding(ToggleSearchEditorContextLinesCommandId)), icon: searchShowContextIcon, + hoverDelegate: getDefaultHoverDelegate('element'), ...defaultToggleStyles }); this._register(this.showContextToggle.onChange(() => this.onContextLinesChanged())); @@ -582,6 +600,7 @@ export class SearchWidget extends Widget { this.setReplaceAllActionState(false); if (this.searchConfiguration.searchOnType) { + const delayMultiplierFromAISearch = (this.searchInput && this.searchInput.isAIEnabled) ? 5 : 1; // expand debounce period to multiple by 5 if AI is enabled if (this.searchInput?.getRegex()) { try { const regex = new RegExp(this.searchInput.getValue(), 'ug'); @@ -600,12 +619,13 @@ export class SearchWidget extends Widget { matchienessHeuristic < 100 ? 5 : // expressions like `.` or `\w` 10; // only things matching empty string - this.submitSearch(true, this.searchConfiguration.searchOnTypeDebouncePeriod * delayMultiplier); + + this.submitSearch(true, this.searchConfiguration.searchOnTypeDebouncePeriod * delayMultiplier * delayMultiplierFromAISearch); } catch { // pass } } else { - this.submitSearch(true, this.searchConfiguration.searchOnTypeDebouncePeriod); + this.submitSearch(true, this.searchConfiguration.searchOnTypeDebouncePeriod * delayMultiplierFromAISearch); } } } diff --git a/src/vs/workbench/contrib/search/common/constants.ts b/src/vs/workbench/contrib/search/common/constants.ts index a8f1bc8431143..aa4e615b115b2 100644 --- a/src/vs/workbench/contrib/search/common/constants.ts +++ b/src/vs/workbench/contrib/search/common/constants.ts @@ -38,9 +38,12 @@ export const enum SearchCommandIds { ToggleSearchOnTypeActionId = 'workbench.action.toggleSearchOnType', CollapseSearchResultsActionId = 'search.action.collapseSearchResults', ExpandSearchResultsActionId = 'search.action.expandSearchResults', + ExpandRecursivelyCommandId = 'search.action.expandRecursively', ClearSearchResultsActionId = 'search.action.clearSearchResults', ViewAsTreeActionId = 'search.action.viewAsTree', ViewAsListActionId = 'search.action.viewAsList', + ShowAIResultsActionId = 'search.action.showAIResults', + HideAIResultsActionId = 'search.action.hideAIResults', ToggleQueryDetailsActionId = 'workbench.action.search.toggleQueryDetails', ExcludeFolderFromSearchId = 'search.action.excludeFromSearch', FocusNextInputActionId = 'search.focus.nextInputBox', @@ -74,4 +77,6 @@ export const SearchContext = { ViewHasFilePatternKey: new RawContextKey('viewHasFilePattern', false), ViewHasSomeCollapsibleKey: new RawContextKey('viewHasSomeCollapsibleResult', false), InTreeViewKey: new RawContextKey('inTreeView', false), + AIResultsVisibleKey: new RawContextKey('AIResultsVisibleKey', false), + hasAIResultProvider: new RawContextKey('hasAIResultProviderKey', false), }; diff --git a/src/vs/workbench/contrib/search/test/browser/searchActions.test.ts b/src/vs/workbench/contrib/search/test/browser/searchActions.test.ts index 5e39773a0cc0e..9b3b9e4f4ddd6 100644 --- a/src/vs/workbench/contrib/search/test/browser/searchActions.test.ts +++ b/src/vs/workbench/contrib/search/test/browser/searchActions.test.ts @@ -125,6 +125,7 @@ suite('Search Actions', () => { const fileMatch = instantiationService.createInstance(FileMatch, { pattern: '' }, undefined, undefined, folderMatch, rawMatch, null, ''); + fileMatch.createMatches(false); store.add(fileMatch); return fileMatch; } @@ -145,7 +146,8 @@ suite('Search Actions', () => { startColumn: 0, endLineNumber: line, endColumn: 2 - } + }, + false ); fileMatch.add(match); return match; diff --git a/src/vs/workbench/contrib/search/test/browser/searchModel.test.ts b/src/vs/workbench/contrib/search/test/browser/searchModel.test.ts index 24e2448a9b3cb..17f9e88b338f4 100644 --- a/src/vs/workbench/contrib/search/test/browser/searchModel.test.ts +++ b/src/vs/workbench/contrib/search/test/browser/searchModel.test.ts @@ -14,7 +14,7 @@ import { ModelService } from 'vs/editor/common/services/modelService'; import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; import { TestConfigurationService } from 'vs/platform/configuration/test/common/testConfigurationService'; import { TestInstantiationService } from 'vs/platform/instantiation/test/common/instantiationServiceMock'; -import { IFileMatch, IFileQuery, IFileSearchStats, IFolderQuery, ISearchComplete, ISearchProgressItem, ISearchQuery, ISearchService, ITextQuery, ITextSearchMatch, OneLineRange, QueryType, TextSearchMatch } from 'vs/workbench/services/search/common/search'; +import { IAITextQuery, IFileMatch, IFileQuery, IFileSearchStats, IFolderQuery, ISearchComplete, ISearchProgressItem, ISearchQuery, ISearchService, ITextQuery, ITextSearchMatch, OneLineRange, QueryType, TextSearchMatch } from 'vs/workbench/services/search/common/search'; import { ITelemetryService } from 'vs/platform/telemetry/common/telemetry'; import { NullTelemetryService } from 'vs/platform/telemetry/common/telemetryUtils'; import { CellMatch, MatchInNotebook, SearchModel } from 'vs/workbench/contrib/search/browser/searchModel'; @@ -122,6 +122,14 @@ suite('SearchModel', () => { }); }, + aiTextSearch(query: ISearchQuery, token?: CancellationToken, onProgress?: (result: ISearchProgressItem) => void, notebookURIs?: ResourceSet): Promise { + return new Promise(resolve => { + queueMicrotask(() => { + results.forEach(onProgress!); + resolve(complete!); + }); + }); + }, textSearchSplitSyncAsync(query: ITextQuery, token?: CancellationToken | undefined, onProgress?: ((result: ISearchProgressItem) => void) | undefined): { syncResults: ISearchComplete; asyncResults: Promise } { return { syncResults: { @@ -153,6 +161,11 @@ suite('SearchModel', () => { }); }); }, + aiTextSearch(query: ISearchQuery, token?: CancellationToken, onProgress?: (result: ISearchProgressItem) => void, notebookURIs?: ResourceSet): Promise { + return new Promise((resolve, reject) => { + reject(error); + }); + }, textSearchSplitSyncAsync(query: ITextQuery, token?: CancellationToken | undefined, onProgress?: ((result: ISearchProgressItem) => void) | undefined): { syncResults: ISearchComplete; asyncResults: Promise } { return { syncResults: { @@ -188,6 +201,17 @@ suite('SearchModel', () => { }); }); }, + aiTextSearch(query: IAITextQuery, token?: CancellationToken, onProgress?: (result: ISearchProgressItem) => void, notebookURIs?: ResourceSet): Promise { + const disposable = token?.onCancellationRequested(() => tokenSource.cancel()); + if (disposable) { + store.add(disposable); + } + + return Promise.resolve({ + results: [], + messages: [] + }); + }, textSearchSplitSyncAsync(query: ITextQuery, token?: CancellationToken | undefined, onProgress?: ((result: ISearchProgressItem) => void) | undefined): { syncResults: ISearchComplete; asyncResults: Promise } { const disposable = token?.onCancellationRequested(() => tokenSource.cancel()); if (disposable) { diff --git a/src/vs/workbench/contrib/search/test/browser/searchNotebookHelpers.test.ts b/src/vs/workbench/contrib/search/test/browser/searchNotebookHelpers.test.ts index 0a591e2145792..a90875c1384ff 100644 --- a/src/vs/workbench/contrib/search/test/browser/searchNotebookHelpers.test.ts +++ b/src/vs/workbench/contrib/search/test/browser/searchNotebookHelpers.test.ts @@ -217,6 +217,7 @@ suite('searchNotebookHelpers', () => { const fileMatch = instantiationService.createInstance(FileMatch, { pattern: '' }, undefined, undefined, folderMatch, rawMatch, null, ''); + fileMatch.createMatches(false); store.add(folderMatch); store.add(fileMatch); diff --git a/src/vs/workbench/contrib/search/test/browser/searchResult.test.ts b/src/vs/workbench/contrib/search/test/browser/searchResult.test.ts index c6d94a0ebf3a5..e71fab4b13164 100644 --- a/src/vs/workbench/contrib/search/test/browser/searchResult.test.ts +++ b/src/vs/workbench/contrib/search/test/browser/searchResult.test.ts @@ -66,7 +66,7 @@ suite('SearchResult', () => { test('Line Match', function () { const fileMatch = aFileMatch('folder/file.txt', null!); - const lineMatch = new Match(fileMatch, ['0 foo bar'], new OneLineRange(0, 2, 5), new OneLineRange(1, 0, 5)); + const lineMatch = new Match(fileMatch, ['0 foo bar'], new OneLineRange(0, 2, 5), new OneLineRange(1, 0, 5), false); assert.strictEqual(lineMatch.text(), '0 foo bar'); assert.strictEqual(lineMatch.range().startLineNumber, 2); assert.strictEqual(lineMatch.range().endLineNumber, 2); @@ -174,7 +174,7 @@ suite('SearchResult', () => { const searchResult = instantiationService.createInstance(SearchResult, searchModel); store.add(searchResult); const fileMatch = aFileMatch('far/boo', searchResult); - const lineMatch = new Match(fileMatch, ['foo bar'], new OneLineRange(0, 0, 3), new OneLineRange(1, 0, 3)); + const lineMatch = new Match(fileMatch, ['foo bar'], new OneLineRange(0, 0, 3), new OneLineRange(1, 0, 3), false); assert(lineMatch.parent() === fileMatch); assert(fileMatch.parent() === searchResult.folderMatches()[0]); @@ -532,6 +532,7 @@ suite('SearchResult', () => { const fileMatch = instantiationService.createInstance(FileMatch, { pattern: '' }, undefined, undefined, root, rawMatch, null, ''); + fileMatch.createMatches(false); store.add(fileMatch); return fileMatch; diff --git a/src/vs/workbench/contrib/search/test/browser/searchTestCommon.ts b/src/vs/workbench/contrib/search/test/browser/searchTestCommon.ts index 5c5fcd10aab88..b6e7dc04bbbb7 100644 --- a/src/vs/workbench/contrib/search/test/browser/searchTestCommon.ts +++ b/src/vs/workbench/contrib/search/test/browser/searchTestCommon.ts @@ -66,5 +66,5 @@ export function stubNotebookEditorService(instantiationService: TestInstantiatio } export function addToSearchResult(searchResult: SearchResult, allRaw: IFileMatch[], searchInstanceID = '') { - searchResult.add(allRaw, searchInstanceID); + searchResult.add(allRaw, searchInstanceID, false); } diff --git a/src/vs/workbench/contrib/search/test/browser/searchViewlet.test.ts b/src/vs/workbench/contrib/search/test/browser/searchViewlet.test.ts index 76b49f5f2bf2d..4feb3d343ed7c 100644 --- a/src/vs/workbench/contrib/search/test/browser/searchViewlet.test.ts +++ b/src/vs/workbench/contrib/search/test/browser/searchViewlet.test.ts @@ -76,7 +76,7 @@ suite('Search - Viewlet', () => { endColumn: 1 } }] - }], ''); + }], '', false); const fileMatch = result.matches()[0]; const lineMatch = fileMatch.matches()[0]; @@ -89,9 +89,9 @@ suite('Search - Viewlet', () => { const fileMatch1 = aFileMatch('/foo'); const fileMatch2 = aFileMatch('/with/path'); const fileMatch3 = aFileMatch('/with/path/foo'); - const lineMatch1 = new Match(fileMatch1, ['bar'], new OneLineRange(0, 1, 1), new OneLineRange(0, 1, 1)); - const lineMatch2 = new Match(fileMatch1, ['bar'], new OneLineRange(0, 1, 1), new OneLineRange(2, 1, 1)); - const lineMatch3 = new Match(fileMatch1, ['bar'], new OneLineRange(0, 1, 1), new OneLineRange(2, 1, 1)); + const lineMatch1 = new Match(fileMatch1, ['bar'], new OneLineRange(0, 1, 1), new OneLineRange(0, 1, 1), false); + const lineMatch2 = new Match(fileMatch1, ['bar'], new OneLineRange(0, 1, 1), new OneLineRange(2, 1, 1), false); + const lineMatch3 = new Match(fileMatch1, ['bar'], new OneLineRange(0, 1, 1), new OneLineRange(2, 1, 1), false); assert(searchMatchComparer(fileMatch1, fileMatch2) < 0); assert(searchMatchComparer(fileMatch2, fileMatch1) > 0); @@ -127,13 +127,13 @@ suite('Search - Viewlet', () => { const fileMatch2 = aFileMatch('/with/path.c', folderMatch2); const fileMatch3 = aFileMatch('/with/path/bar.b', folderMatch2); - const lineMatch1 = new Match(fileMatch1, ['bar'], new OneLineRange(0, 1, 1), new OneLineRange(0, 1, 1)); - const lineMatch2 = new Match(fileMatch1, ['bar'], new OneLineRange(0, 1, 1), new OneLineRange(2, 1, 1)); + const lineMatch1 = new Match(fileMatch1, ['bar'], new OneLineRange(0, 1, 1), new OneLineRange(0, 1, 1), false); + const lineMatch2 = new Match(fileMatch1, ['bar'], new OneLineRange(0, 1, 1), new OneLineRange(2, 1, 1), false); - const lineMatch3 = new Match(fileMatch2, ['barfoo'], new OneLineRange(0, 1, 1), new OneLineRange(0, 1, 1)); - const lineMatch4 = new Match(fileMatch2, ['fooooo'], new OneLineRange(0, 1, 1), new OneLineRange(2, 1, 1)); + const lineMatch3 = new Match(fileMatch2, ['barfoo'], new OneLineRange(0, 1, 1), new OneLineRange(0, 1, 1), false); + const lineMatch4 = new Match(fileMatch2, ['fooooo'], new OneLineRange(0, 1, 1), new OneLineRange(2, 1, 1), false); - const lineMatch5 = new Match(fileMatch3, ['foobar'], new OneLineRange(0, 1, 1), new OneLineRange(2, 1, 1)); + const lineMatch5 = new Match(fileMatch3, ['foobar'], new OneLineRange(0, 1, 1), new OneLineRange(2, 1, 1), false); /*** * Structure would take the following form: @@ -180,6 +180,7 @@ suite('Search - Viewlet', () => { const fileMatch = instantiation.createInstance(FileMatch, { pattern: '' }, undefined, undefined, parentFolder ?? aFolderMatch('', 0), rawMatch, null, ''); + fileMatch.createMatches(false); store.add(fileMatch); return fileMatch; } diff --git a/src/vs/workbench/contrib/searchEditor/browser/searchEditor.contribution.ts b/src/vs/workbench/contrib/searchEditor/browser/searchEditor.contribution.ts index 020ae109fd0e9..be7cad0444498 100644 --- a/src/vs/workbench/contrib/searchEditor/browser/searchEditor.contribution.ts +++ b/src/vs/workbench/contrib/searchEditor/browser/searchEditor.contribution.ts @@ -113,6 +113,10 @@ class SearchEditorInputSerializer implements IEditorSerializer { } serialize(input: SearchEditorInput) { + if (!this.canSerialize(input)) { + return undefined; + } + if (input.isDisposed()) { return JSON.stringify({ modelUri: undefined, dirty: false, config: input.tryReadConfigSync(), name: input.getName(), matchRanges: [], backingUri: input.backingUri?.toString() } as SerializedSearchEditor); } diff --git a/src/vs/workbench/contrib/searchEditor/browser/searchEditor.ts b/src/vs/workbench/contrib/searchEditor/browser/searchEditor.ts index 5f36473df1593..c0f0f27dbfa18 100644 --- a/src/vs/workbench/contrib/searchEditor/browser/searchEditor.ts +++ b/src/vs/workbench/contrib/searchEditor/browser/searchEditor.ts @@ -13,7 +13,7 @@ import { DisposableStore } from 'vs/base/common/lifecycle'; import { assertIsDefined } from 'vs/base/common/types'; import { URI } from 'vs/base/common/uri'; import 'vs/css!./media/searchEditor'; -import { ICodeEditorWidgetOptions } from 'vs/editor/browser/widget/codeEditorWidget'; +import { ICodeEditorWidgetOptions } from 'vs/editor/browser/widget/codeEditor/codeEditorWidget'; import { Position } from 'vs/editor/common/core/position'; import { Range } from 'vs/editor/common/core/range'; import { Selection } from 'vs/editor/common/core/selection'; @@ -47,7 +47,7 @@ import { SearchModel, SearchResult } from 'vs/workbench/contrib/search/browser/s import { InSearchEditor, SearchEditorID, SearchEditorInputTypeId } from 'vs/workbench/contrib/searchEditor/browser/constants'; import type { SearchConfiguration, SearchEditorInput } from 'vs/workbench/contrib/searchEditor/browser/searchEditorInput'; import { serializeSearchResultForEditor } from 'vs/workbench/contrib/searchEditor/browser/searchEditorSerialization'; -import { IEditorGroupsService } from 'vs/workbench/services/editor/common/editorGroupsService'; +import { IEditorGroup, IEditorGroupsService } from 'vs/workbench/services/editor/common/editorGroupsService'; import { IEditorService } from 'vs/workbench/services/editor/common/editorService'; import { IPatternInfo, ISearchComplete, ISearchConfigurationProperties, ITextQuery, SearchSortOrder } from 'vs/workbench/services/search/common/search'; import { searchDetailsIcon } from 'vs/workbench/contrib/search/browser/searchIcons'; @@ -62,6 +62,8 @@ import { UnusualLineTerminatorsDetector } from 'vs/editor/contrib/unusualLineTer import { defaultToggleStyles, getInputBoxStyle } from 'vs/platform/theme/browser/defaultStyles'; import { ILogService } from 'vs/platform/log/common/log'; import { SearchContext } from 'vs/workbench/contrib/search/common/constants'; +import { getDefaultHoverDelegate } from 'vs/base/browser/ui/hover/hoverDelegateFactory'; +import { IHoverService } from 'vs/platform/hover/browser/hover'; const RESULT_LINE_REGEX = /^(\s+)(\d+)(: | )(\s*)(.*)$/; const FILE_LINE_REGEX = /^(\S.*):$/; @@ -88,13 +90,14 @@ export class SearchEditor extends AbstractTextCodeEditor private showingIncludesExcludes: boolean = false; private searchOperation: LongRunningOperation; private searchHistoryDelayer: Delayer; - private messageDisposables: DisposableStore; + private readonly messageDisposables: DisposableStore; private container: HTMLElement; private searchModel: SearchModel; private ongoingOperations: number = 0; private updatingModelForSearch: boolean = false; constructor( + group: IEditorGroup, @ITelemetryService telemetryService: ITelemetryService, @IThemeService themeService: IThemeService, @IStorageService storageService: IStorageService, @@ -112,9 +115,10 @@ export class SearchEditor extends AbstractTextCodeEditor @IEditorService editorService: IEditorService, @IConfigurationService protected configurationService: IConfigurationService, @IFileService fileService: IFileService, - @ILogService private readonly logService: ILogService + @ILogService private readonly logService: ILogService, + @IHoverService private readonly hoverService: IHoverService ) { - super(SearchEditor.ID, telemetryService, instantiationService, storageService, textResourceService, themeService, editorService, editorGroupService, fileService); + super(SearchEditor.ID, group, telemetryService, instantiationService, storageService, textResourceService, themeService, editorService, editorGroupService, fileService); this.container = DOM.$('.search-editor'); this.searchOperation = this._register(new LongRunningOperation(progressService)); @@ -161,7 +165,8 @@ export class SearchEditor extends AbstractTextCodeEditor this.includesExcludesContainer = DOM.append(container, DOM.$('.includes-excludes')); // Toggle query details button - this.toggleQueryDetailsButton = DOM.append(this.includesExcludesContainer, DOM.$('.expand' + ThemeIcon.asCSSSelector(searchDetailsIcon), { tabindex: 0, role: 'button', title: localize('moreSearch', "Toggle Search Details") })); + this.toggleQueryDetailsButton = DOM.append(this.includesExcludesContainer, DOM.$('.expand' + ThemeIcon.asCSSSelector(searchDetailsIcon), { tabindex: 0, role: 'button' })); + this._register(this.hoverService.setupUpdatableHover(getDefaultHoverDelegate('element'), this.toggleQueryDetailsButton, localize('moreSearch', "Toggle Search Details"))); this._register(DOM.addDisposableListener(this.toggleQueryDetailsButton, DOM.EventType.CLICK, e => { DOM.EventHelper.stop(e); this.toggleIncludesExcludes(); @@ -245,7 +250,17 @@ export class SearchEditor extends AbstractTextCodeEditor private registerEditorListeners() { this.searchResultEditor.onMouseUp(e => { - if (e.event.detail === 2) { + if (e.event.detail === 1) { + const behaviour = this.searchConfig.searchEditor.singleClickBehaviour; + const position = e.target.position; + if (position && behaviour === 'peekDefinition') { + const line = this.searchResultEditor.getModel()?.getLineContent(position.lineNumber) ?? ''; + if (line.match(FILE_LINE_REGEX) || line.match(RESULT_LINE_REGEX)) { + this.searchResultEditor.setSelection(Range.fromPositions(position)); + this.commandService.executeCommand('editor.action.peekDefinition'); + } + } + } else if (e.event.detail === 2) { const behaviour = this.searchConfig.searchEditor.doubleClickBehaviour; const position = e.target.position; if (position && behaviour !== 'selectWord') { @@ -655,7 +670,7 @@ export class SearchEditor extends AbstractTextCodeEditor } private getInput(): SearchEditorInput | undefined { - return this._input as SearchEditorInput; + return this.input as SearchEditorInput; } private priorConfig: Partial> | undefined; diff --git a/src/vs/workbench/contrib/share/browser/share.contribution.ts b/src/vs/workbench/contrib/share/browser/share.contribution.ts index ae07114161026..5bdf93d7236b7 100644 --- a/src/vs/workbench/contrib/share/browser/share.contribution.ts +++ b/src/vs/workbench/contrib/share/browser/share.contribution.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import 'vs/css!./share'; -import { CancellationTokenSource } from 'vs/base/common/cancellation'; +import { CancellationToken } from 'vs/base/common/cancellation'; import { Codicon } from 'vs/base/common/codicons'; import { MarkdownString } from 'vs/base/common/htmlContent'; import { KeyCode, KeyMod } from 'vs/base/common/keyCodes'; @@ -110,7 +110,7 @@ class ShareWorkbenchContribution { const result = await progressService.withProgress({ location: ProgressLocation.Window, detail: localize('generating link', 'Generating link...') - }, async () => shareService.provideShare({ resourceUri, selection }, new CancellationTokenSource().token)); + }, async () => shareService.provideShare({ resourceUri, selection }, CancellationToken.None)); if (result) { const uriText = result.toString(); diff --git a/src/vs/workbench/contrib/snippets/browser/snippetsFile.ts b/src/vs/workbench/contrib/snippets/browser/snippetsFile.ts index e734b0150046b..7d123c059eb93 100644 --- a/src/vs/workbench/contrib/snippets/browser/snippetsFile.ts +++ b/src/vs/workbench/contrib/snippets/browser/snippetsFile.ts @@ -14,7 +14,6 @@ import { ExtensionIdentifier, IExtensionDescription } from 'vs/platform/extensio import { IExtensionResourceLoaderService } from 'vs/platform/extensionResourceLoader/common/extensionResourceLoader'; import { relativePath } from 'vs/base/common/resources'; import { isObject } from 'vs/base/common/types'; -import { tail } from 'vs/base/common/arrays'; import { Iterable } from 'vs/base/common/iterator'; import { WindowIdleValue, getActiveWindow } from 'vs/base/browser/dom'; @@ -54,7 +53,7 @@ class SnippetBodyInsights { if (textmateSnippet.placeholders.length === 0) { this.isTrivial = true; } else if (placeholderMax === 0) { - const last = tail(textmateSnippet.children); + const last = textmateSnippet.children.at(-1); this.isTrivial = last instanceof Placeholder && last.isFinalTabstop; } diff --git a/src/vs/workbench/contrib/speech/common/speech.contribution.ts b/src/vs/workbench/contrib/speech/browser/speech.contribution.ts similarity index 71% rename from src/vs/workbench/contrib/speech/common/speech.contribution.ts rename to src/vs/workbench/contrib/speech/browser/speech.contribution.ts index 6a093cd32e7a6..7184018cd98eb 100644 --- a/src/vs/workbench/contrib/speech/common/speech.contribution.ts +++ b/src/vs/workbench/contrib/speech/browser/speech.contribution.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { InstantiationType, registerSingleton } from 'vs/platform/instantiation/common/extensions'; -import { ISpeechService, SpeechService } from 'vs/workbench/contrib/speech/common/speechService'; +import { ISpeechService } from 'vs/workbench/contrib/speech/common/speechService'; +import { SpeechService } from 'vs/workbench/contrib/speech/browser/speechService'; -registerSingleton(ISpeechService, SpeechService, InstantiationType.Delayed); +registerSingleton(ISpeechService, SpeechService, InstantiationType.Eager /* Reads Extension Points */); diff --git a/src/vs/workbench/contrib/speech/browser/speechAccessibilitySignal.ts b/src/vs/workbench/contrib/speech/browser/speechAccessibilitySignal.ts new file mode 100644 index 0000000000000..5df6a7005924f --- /dev/null +++ b/src/vs/workbench/contrib/speech/browser/speechAccessibilitySignal.ts @@ -0,0 +1,23 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable } from 'vs/base/common/lifecycle'; +import { AccessibilitySignal, IAccessibilitySignalService } from 'vs/platform/accessibilitySignal/browser/accessibilitySignalService'; +import { IWorkbenchContribution } from 'vs/workbench/common/contributions'; +import { ISpeechService } from 'vs/workbench/contrib/speech/common/speechService'; + +export class SpeechAccessibilitySignalContribution extends Disposable implements IWorkbenchContribution { + + static readonly ID = 'workbench.contrib.speechAccessibilitySignal'; + + constructor( + @IAccessibilitySignalService private readonly _accessibilitySignalService: IAccessibilitySignalService, + @ISpeechService private readonly _speechService: ISpeechService, + ) { + super(); + this._register(this._speechService.onDidStartSpeechToTextSession(() => this._accessibilitySignalService.playSignal(AccessibilitySignal.voiceRecordingStarted))); + this._register(this._speechService.onDidEndSpeechToTextSession(() => this._accessibilitySignalService.playSignal(AccessibilitySignal.voiceRecordingStopped))); + } +} diff --git a/src/vs/workbench/contrib/speech/browser/speechService.ts b/src/vs/workbench/contrib/speech/browser/speechService.ts new file mode 100644 index 0000000000000..94a45671ea3e1 --- /dev/null +++ b/src/vs/workbench/contrib/speech/browser/speechService.ts @@ -0,0 +1,347 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { localize } from 'vs/nls'; +import { firstOrDefault } from 'vs/base/common/arrays'; +import { CancellationToken, CancellationTokenSource } from 'vs/base/common/cancellation'; +import { Emitter, Event } from 'vs/base/common/event'; +import { Disposable, DisposableStore, IDisposable, toDisposable } from 'vs/base/common/lifecycle'; +import { IContextKeyService } from 'vs/platform/contextkey/common/contextkey'; +import { ILogService } from 'vs/platform/log/common/log'; +import { IHostService } from 'vs/workbench/services/host/browser/host'; +import { DeferredPromise } from 'vs/base/common/async'; +import { ISpeechService, ISpeechProvider, HasSpeechProvider, ISpeechToTextSession, SpeechToTextInProgress, IKeywordRecognitionSession, KeywordRecognitionStatus, SpeechToTextStatus, speechLanguageConfigToLanguage, SPEECH_LANGUAGE_CONFIG } from 'vs/workbench/contrib/speech/common/speechService'; +import { ITelemetryService } from 'vs/platform/telemetry/common/telemetry'; +import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; +import { ExtensionsRegistry } from 'vs/workbench/services/extensions/common/extensionsRegistry'; +import { IExtensionService } from 'vs/workbench/services/extensions/common/extensions'; + +export interface ISpeechProviderDescriptor { + readonly name: string; + readonly description?: string; +} + +const speechProvidersExtensionPoint = ExtensionsRegistry.registerExtensionPoint({ + extensionPoint: 'speechProviders', + jsonSchema: { + description: localize('vscode.extension.contributes.speechProvider', 'Contributes a Speech Provider'), + type: 'array', + items: { + additionalProperties: false, + type: 'object', + defaultSnippets: [{ body: { name: '', description: '' } }], + required: ['name'], + properties: { + name: { + description: localize('speechProviderName', "Unique name for this Speech Provider."), + type: 'string' + }, + description: { + description: localize('speechProviderDescription', "A description of this Speech Provider, shown in the UI."), + type: 'string' + } + } + } + } +}); + +export class SpeechService extends Disposable implements ISpeechService { + + readonly _serviceBrand: undefined; + + private readonly _onDidChangeHasSpeechProvider = this._register(new Emitter()); + readonly onDidChangeHasSpeechProvider = this._onDidChangeHasSpeechProvider.event; + + get hasSpeechProvider() { return this.providerDescriptors.size > 0 || this.providers.size > 0; } + + private readonly providers = new Map(); + private readonly providerDescriptors = new Map(); + + private readonly hasSpeechProviderContext = HasSpeechProvider.bindTo(this.contextKeyService); + + constructor( + @ILogService private readonly logService: ILogService, + @IContextKeyService private readonly contextKeyService: IContextKeyService, + @IHostService private readonly hostService: IHostService, + @ITelemetryService private readonly telemetryService: ITelemetryService, + @IConfigurationService private readonly configurationService: IConfigurationService, + @IExtensionService private readonly extensionService: IExtensionService + ) { + super(); + + this.handleAndRegisterSpeechExtensions(); + } + + private handleAndRegisterSpeechExtensions(): void { + speechProvidersExtensionPoint.setHandler((extensions, delta) => { + const oldHasSpeechProvider = this.hasSpeechProvider; + + for (const extension of delta.removed) { + for (const descriptor of extension.value) { + this.providerDescriptors.delete(descriptor.name); + } + } + + for (const extension of delta.added) { + for (const descriptor of extension.value) { + this.providerDescriptors.set(descriptor.name, descriptor); + } + } + + if (oldHasSpeechProvider !== this.hasSpeechProvider) { + this.handleHasSpeechProviderChange(); + } + }); + } + + registerSpeechProvider(identifier: string, provider: ISpeechProvider): IDisposable { + if (this.providers.has(identifier)) { + throw new Error(`Speech provider with identifier ${identifier} is already registered.`); + } + + const oldHasSpeechProvider = this.hasSpeechProvider; + + this.providers.set(identifier, provider); + + if (oldHasSpeechProvider !== this.hasSpeechProvider) { + this.handleHasSpeechProviderChange(); + } + + return toDisposable(() => { + const oldHasSpeechProvider = this.hasSpeechProvider; + + this.providers.delete(identifier); + + if (oldHasSpeechProvider !== this.hasSpeechProvider) { + this.handleHasSpeechProviderChange(); + } + }); + } + + private handleHasSpeechProviderChange(): void { + this.hasSpeechProviderContext.set(this.hasSpeechProvider); + + this._onDidChangeHasSpeechProvider.fire(); + } + + private readonly _onDidStartSpeechToTextSession = this._register(new Emitter()); + readonly onDidStartSpeechToTextSession = this._onDidStartSpeechToTextSession.event; + + private readonly _onDidEndSpeechToTextSession = this._register(new Emitter()); + readonly onDidEndSpeechToTextSession = this._onDidEndSpeechToTextSession.event; + + private _activeSpeechToTextSession: ISpeechToTextSession | undefined = undefined; + get hasActiveSpeechToTextSession() { return !!this._activeSpeechToTextSession; } + + private readonly speechToTextInProgress = SpeechToTextInProgress.bindTo(this.contextKeyService); + + async createSpeechToTextSession(token: CancellationToken, context: string = 'speech'): Promise { + const provider = await this.getProvider(); + + const language = speechLanguageConfigToLanguage(this.configurationService.getValue(SPEECH_LANGUAGE_CONFIG)); + const session = this._activeSpeechToTextSession = provider.createSpeechToTextSession(token, typeof language === 'string' ? { language } : undefined); + + const sessionStart = Date.now(); + let sessionRecognized = false; + let sessionError = false; + let sessionContentLength = 0; + + const disposables = new DisposableStore(); + + const onSessionStoppedOrCanceled = () => { + if (session === this._activeSpeechToTextSession) { + this._activeSpeechToTextSession = undefined; + this.speechToTextInProgress.reset(); + this._onDidEndSpeechToTextSession.fire(); + + type SpeechToTextSessionClassification = { + owner: 'bpasero'; + comment: 'An event that fires when a speech to text session is created'; + context: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Context of the session.' }; + sessionDuration: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Duration of the session.' }; + sessionRecognized: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'If speech was recognized.' }; + sessionError: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'If speech resulted in error.' }; + sessionContentLength: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Length of the recognized text.' }; + sessionLanguage: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Configured language for the session.' }; + }; + type SpeechToTextSessionEvent = { + context: string; + sessionDuration: number; + sessionRecognized: boolean; + sessionError: boolean; + sessionContentLength: number; + sessionLanguage: string; + }; + this.telemetryService.publicLog2('speechToTextSession', { + context, + sessionDuration: Date.now() - sessionStart, + sessionRecognized, + sessionError, + sessionContentLength, + sessionLanguage: language + }); + } + + disposables.dispose(); + }; + + disposables.add(token.onCancellationRequested(() => onSessionStoppedOrCanceled())); + if (token.isCancellationRequested) { + onSessionStoppedOrCanceled(); + } + + disposables.add(session.onDidChange(e => { + switch (e.status) { + case SpeechToTextStatus.Started: + if (session === this._activeSpeechToTextSession) { + this.speechToTextInProgress.set(true); + this._onDidStartSpeechToTextSession.fire(); + } + break; + case SpeechToTextStatus.Recognizing: + sessionRecognized = true; + break; + case SpeechToTextStatus.Recognized: + if (typeof e.text === 'string') { + sessionContentLength += e.text.length; + } + break; + case SpeechToTextStatus.Stopped: + onSessionStoppedOrCanceled(); + break; + case SpeechToTextStatus.Error: + this.logService.error(`Speech provider error in speech to text session: ${e.text}`); + sessionError = true; + break; + } + })); + + return session; + } + + private async getProvider(): Promise { + + // Send out extension activation to ensure providers can register + await this.extensionService.activateByEvent('onSpeech'); + + const provider = firstOrDefault(Array.from(this.providers.values())); + if (!provider) { + throw new Error(`No Speech provider is registered.`); + } else if (this.providers.size > 1) { + this.logService.warn(`Multiple speech providers registered. Picking first one: ${provider.metadata.displayName}`); + } + + return provider; + } + + private readonly _onDidStartKeywordRecognition = this._register(new Emitter()); + readonly onDidStartKeywordRecognition = this._onDidStartKeywordRecognition.event; + + private readonly _onDidEndKeywordRecognition = this._register(new Emitter()); + readonly onDidEndKeywordRecognition = this._onDidEndKeywordRecognition.event; + + private _activeKeywordRecognitionSession: IKeywordRecognitionSession | undefined = undefined; + get hasActiveKeywordRecognition() { return !!this._activeKeywordRecognitionSession; } + + async recognizeKeyword(token: CancellationToken): Promise { + const result = new DeferredPromise(); + + // Send out extension activation to ensure providers can register + await this.extensionService.activateByEvent('onSpeech'); + + const disposables = new DisposableStore(); + disposables.add(token.onCancellationRequested(() => { + disposables.dispose(); + result.complete(KeywordRecognitionStatus.Canceled); + })); + + const recognizeKeywordDisposables = disposables.add(new DisposableStore()); + let activeRecognizeKeywordSession: Promise | undefined = undefined; + const recognizeKeyword = () => { + recognizeKeywordDisposables.clear(); + + const cts = new CancellationTokenSource(token); + recognizeKeywordDisposables.add(toDisposable(() => cts.dispose(true))); + const currentRecognizeKeywordSession = activeRecognizeKeywordSession = this.doRecognizeKeyword(cts.token).then(status => { + if (currentRecognizeKeywordSession === activeRecognizeKeywordSession) { + result.complete(status); + } + }, error => { + if (currentRecognizeKeywordSession === activeRecognizeKeywordSession) { + result.error(error); + } + }); + }; + + disposables.add(this.hostService.onDidChangeFocus(focused => { + if (!focused && activeRecognizeKeywordSession) { + recognizeKeywordDisposables.clear(); + activeRecognizeKeywordSession = undefined; + } else if (!activeRecognizeKeywordSession) { + recognizeKeyword(); + } + })); + + if (this.hostService.hasFocus) { + recognizeKeyword(); + } + + let status: KeywordRecognitionStatus; + try { + status = await result.p; + } finally { + disposables.dispose(); + } + + type KeywordRecognitionClassification = { + owner: 'bpasero'; + comment: 'An event that fires when a speech keyword detection is started'; + keywordRecognized: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'If the keyword was recognized.' }; + }; + type KeywordRecognitionEvent = { + keywordRecognized: boolean; + }; + this.telemetryService.publicLog2('keywordRecognition', { + keywordRecognized: status === KeywordRecognitionStatus.Recognized + }); + + return status; + } + + private async doRecognizeKeyword(token: CancellationToken): Promise { + const provider = await this.getProvider(); + + const session = this._activeKeywordRecognitionSession = provider.createKeywordRecognitionSession(token); + this._onDidStartKeywordRecognition.fire(); + + const disposables = new DisposableStore(); + + const onSessionStoppedOrCanceled = () => { + if (session === this._activeKeywordRecognitionSession) { + this._activeKeywordRecognitionSession = undefined; + this._onDidEndKeywordRecognition.fire(); + } + + disposables.dispose(); + }; + + disposables.add(token.onCancellationRequested(() => onSessionStoppedOrCanceled())); + if (token.isCancellationRequested) { + onSessionStoppedOrCanceled(); + } + + disposables.add(session.onDidChange(e => { + if (e.status === KeywordRecognitionStatus.Stopped) { + onSessionStoppedOrCanceled(); + } + })); + + try { + return (await Event.toPromise(session.onDidChange)).status; + } finally { + onSessionStoppedOrCanceled(); + } + } +} diff --git a/src/vs/workbench/contrib/speech/common/speechService.ts b/src/vs/workbench/contrib/speech/common/speechService.ts index 48f21fb8ac134..4f260469983c3 100644 --- a/src/vs/workbench/contrib/speech/common/speechService.ts +++ b/src/vs/workbench/contrib/speech/common/speechService.ts @@ -4,14 +4,13 @@ *--------------------------------------------------------------------------------------------*/ import { localize } from 'vs/nls'; -import { firstOrDefault } from 'vs/base/common/arrays'; import { CancellationToken } from 'vs/base/common/cancellation'; -import { Emitter, Event } from 'vs/base/common/event'; -import { Disposable, DisposableStore, IDisposable, toDisposable } from 'vs/base/common/lifecycle'; -import { IContextKeyService, RawContextKey } from 'vs/platform/contextkey/common/contextkey'; +import { Event } from 'vs/base/common/event'; +import { IDisposable } from 'vs/base/common/lifecycle'; +import { RawContextKey } from 'vs/platform/contextkey/common/contextkey'; import { ExtensionIdentifier } from 'vs/platform/extensions/common/extensions'; import { createDecorator } from 'vs/platform/instantiation/common/instantiation'; -import { ILogService } from 'vs/platform/log/common/log'; +import { language } from 'vs/base/common/platform'; export const ISpeechService = createDecorator('speechService'); @@ -27,7 +26,8 @@ export enum SpeechToTextStatus { Started = 1, Recognizing = 2, Recognized = 3, - Stopped = 4 + Stopped = 4, + Error = 5 } export interface ISpeechToTextEvent { @@ -35,13 +35,14 @@ export interface ISpeechToTextEvent { readonly text?: string; } -export interface ISpeechToTextSession extends IDisposable { +export interface ISpeechToTextSession { readonly onDidChange: Event; } export enum KeywordRecognitionStatus { Recognized = 1, - Stopped = 2 + Stopped = 2, + Canceled = 3 } export interface IKeywordRecognitionEvent { @@ -49,14 +50,18 @@ export interface IKeywordRecognitionEvent { readonly text?: string; } -export interface IKeywordRecognitionSession extends IDisposable { +export interface IKeywordRecognitionSession { readonly onDidChange: Event; } +export interface ISpeechToTextSessionOptions { + readonly language?: string; +} + export interface ISpeechProvider { readonly metadata: ISpeechProviderMetadata; - createSpeechToTextSession(token: CancellationToken): ISpeechToTextSession; + createSpeechToTextSession(token: CancellationToken, options?: ISpeechToTextSessionOptions): ISpeechToTextSession; createKeywordRecognitionSession(token: CancellationToken): IKeywordRecognitionSession; } @@ -64,8 +69,7 @@ export interface ISpeechService { readonly _serviceBrand: undefined; - readonly onDidRegisterSpeechProvider: Event; - readonly onDidUnregisterSpeechProvider: Event; + readonly onDidChangeHasSpeechProvider: Event; readonly hasSpeechProvider: boolean; @@ -80,7 +84,7 @@ export interface ISpeechService { * Starts to transcribe speech from the default microphone. The returned * session object provides an event to subscribe for transcribed text. */ - createSpeechToTextSession(token: CancellationToken): ISpeechToTextSession; + createSpeechToTextSession(token: CancellationToken, context?: string): Promise; readonly onDidStartKeywordRecognition: Event; readonly onDidEndKeywordRecognition: Event; @@ -95,150 +99,104 @@ export interface ISpeechService { recognizeKeyword(token: CancellationToken): Promise; } -export class SpeechService extends Disposable implements ISpeechService { - - readonly _serviceBrand: undefined; - - private readonly _onDidRegisterSpeechProvider = this._register(new Emitter()); - readonly onDidRegisterSpeechProvider = this._onDidRegisterSpeechProvider.event; - - private readonly _onDidUnregisterSpeechProvider = this._register(new Emitter()); - readonly onDidUnregisterSpeechProvider = this._onDidUnregisterSpeechProvider.event; - - get hasSpeechProvider() { return this.providers.size > 0; } - - private readonly providers = new Map(); - - private readonly hasSpeechProviderContext = HasSpeechProvider.bindTo(this.contextKeyService); - - constructor( - @ILogService private readonly logService: ILogService, - @IContextKeyService private readonly contextKeyService: IContextKeyService - ) { - super(); +export const SPEECH_LANGUAGE_CONFIG = 'accessibility.voice.speechLanguage'; + +export const SPEECH_LANGUAGES = { + ['da-DK']: { + name: localize('speechLanguage.da-DK', "Danish (Denmark)") + }, + ['de-DE']: { + name: localize('speechLanguage.de-DE', "German (Germany)") + }, + ['en-AU']: { + name: localize('speechLanguage.en-AU', "English (Australia)") + }, + ['en-CA']: { + name: localize('speechLanguage.en-CA', "English (Canada)") + }, + ['en-GB']: { + name: localize('speechLanguage.en-GB', "English (United Kingdom)") + }, + ['en-IE']: { + name: localize('speechLanguage.en-IE', "English (Ireland)") + }, + ['en-IN']: { + name: localize('speechLanguage.en-IN', "English (India)") + }, + ['en-NZ']: { + name: localize('speechLanguage.en-NZ', "English (New Zealand)") + }, + ['en-US']: { + name: localize('speechLanguage.en-US', "English (United States)") + }, + ['es-ES']: { + name: localize('speechLanguage.es-ES', "Spanish (Spain)") + }, + ['es-MX']: { + name: localize('speechLanguage.es-MX', "Spanish (Mexico)") + }, + ['fr-CA']: { + name: localize('speechLanguage.fr-CA', "French (Canada)") + }, + ['fr-FR']: { + name: localize('speechLanguage.fr-FR', "French (France)") + }, + ['hi-IN']: { + name: localize('speechLanguage.hi-IN', "Hindi (India)") + }, + ['it-IT']: { + name: localize('speechLanguage.it-IT', "Italian (Italy)") + }, + ['ja-JP']: { + name: localize('speechLanguage.ja-JP', "Japanese (Japan)") + }, + ['ko-KR']: { + name: localize('speechLanguage.ko-KR', "Korean (South Korea)") + }, + ['nl-NL']: { + name: localize('speechLanguage.nl-NL', "Dutch (Netherlands)") + }, + ['pt-PT']: { + name: localize('speechLanguage.pt-PT', "Portuguese (Portugal)") + }, + ['pt-BR']: { + name: localize('speechLanguage.pt-BR', "Portuguese (Brazil)") + }, + ['ru-RU']: { + name: localize('speechLanguage.ru-RU', "Russian (Russia)") + }, + ['sv-SE']: { + name: localize('speechLanguage.sv-SE', "Swedish (Sweden)") + }, + ['tr-TR']: { + // allow-any-unicode-next-line + name: localize('speechLanguage.tr-TR', "Turkish (Türkiye)") + }, + ['zh-CN']: { + name: localize('speechLanguage.zh-CN', "Chinese (Simplified, China)") + }, + ['zh-HK']: { + name: localize('speechLanguage.zh-HK', "Chinese (Traditional, Hong Kong)") + }, + ['zh-TW']: { + name: localize('speechLanguage.zh-TW', "Chinese (Traditional, Taiwan)") } +}; - registerSpeechProvider(identifier: string, provider: ISpeechProvider): IDisposable { - if (this.providers.has(identifier)) { - throw new Error(`Speech provider with identifier ${identifier} is already registered.`); - } - - this.providers.set(identifier, provider); - this.hasSpeechProviderContext.set(true); +export function speechLanguageConfigToLanguage(config: unknown, lang = language): string { + if (typeof config === 'string') { + if (config === 'auto') { + if (lang !== 'en') { + const langParts = lang.split('-'); - this._onDidRegisterSpeechProvider.fire(provider); - - return toDisposable(() => { - this.providers.delete(identifier); - this._onDidUnregisterSpeechProvider.fire(provider); - - if (this.providers.size === 0) { - this.hasSpeechProviderContext.set(false); + return speechLanguageConfigToLanguage(`${langParts[0]}-${(langParts[1] ?? langParts[0]).toUpperCase()}`); } - }); - } - - private readonly _onDidStartSpeechToTextSession = this._register(new Emitter()); - readonly onDidStartSpeechToTextSession = this._onDidStartSpeechToTextSession.event; - - private readonly _onDidEndSpeechToTextSession = this._register(new Emitter()); - readonly onDidEndSpeechToTextSession = this._onDidEndSpeechToTextSession.event; - - private _activeSpeechToTextSession: ISpeechToTextSession | undefined = undefined; - get hasActiveSpeechToTextSession() { return !!this._activeSpeechToTextSession; } - - private readonly speechToTextInProgress = SpeechToTextInProgress.bindTo(this.contextKeyService); - - createSpeechToTextSession(token: CancellationToken): ISpeechToTextSession { - const provider = firstOrDefault(Array.from(this.providers.values())); - if (!provider) { - throw new Error(`No Speech provider is registered.`); - } else if (this.providers.size > 1) { - this.logService.warn(`Multiple speech providers registered. Picking first one: ${provider.metadata.displayName}`); - } - - const session = this._activeSpeechToTextSession = provider.createSpeechToTextSession(token); - - const disposables = new DisposableStore(); - - const onSessionStoppedOrCanceled = () => { - if (session === this._activeSpeechToTextSession) { - this._activeSpeechToTextSession = undefined; - this.speechToTextInProgress.reset(); - this._onDidEndSpeechToTextSession.fire(); + } else { + if (SPEECH_LANGUAGES[config as keyof typeof SPEECH_LANGUAGES]) { + return config; } - - disposables.dispose(); - }; - - disposables.add(token.onCancellationRequested(() => onSessionStoppedOrCanceled())); - if (token.isCancellationRequested) { - onSessionStoppedOrCanceled(); } - - disposables.add(session.onDidChange(e => { - switch (e.status) { - case SpeechToTextStatus.Started: - if (session === this._activeSpeechToTextSession) { - this.speechToTextInProgress.set(true); - this._onDidStartSpeechToTextSession.fire(); - } - break; - case SpeechToTextStatus.Stopped: - onSessionStoppedOrCanceled(); - break; - } - })); - - return session; } - private readonly _onDidStartKeywordRecognition = this._register(new Emitter()); - readonly onDidStartKeywordRecognition = this._onDidStartKeywordRecognition.event; - - private readonly _onDidEndKeywordRecognition = this._register(new Emitter()); - readonly onDidEndKeywordRecognition = this._onDidEndKeywordRecognition.event; - - private _activeKeywordRecognitionSession: IKeywordRecognitionSession | undefined = undefined; - get hasActiveKeywordRecognition() { return !!this._activeKeywordRecognitionSession; } - - async recognizeKeyword(token: CancellationToken): Promise { - const provider = firstOrDefault(Array.from(this.providers.values())); - if (!provider) { - throw new Error(`No Speech provider is registered.`); - } else if (this.providers.size > 1) { - this.logService.warn(`Multiple speech providers registered. Picking first one: ${provider.metadata.displayName}`); - } - - const session = this._activeKeywordRecognitionSession = provider.createKeywordRecognitionSession(token); - this._onDidStartKeywordRecognition.fire(); - - const disposables = new DisposableStore(); - - const onSessionStoppedOrCanceled = () => { - if (session === this._activeKeywordRecognitionSession) { - this._activeKeywordRecognitionSession = undefined; - this._onDidEndKeywordRecognition.fire(); - } - - disposables.dispose(); - }; - - disposables.add(token.onCancellationRequested(() => onSessionStoppedOrCanceled())); - if (token.isCancellationRequested) { - onSessionStoppedOrCanceled(); - } - - disposables.add(session.onDidChange(e => { - if (e.status === KeywordRecognitionStatus.Stopped) { - onSessionStoppedOrCanceled(); - } - })); - - try { - return (await Event.toPromise(session.onDidChange)).status; - } finally { - onSessionStoppedOrCanceled(); - } - } + return 'en-US'; } diff --git a/src/vs/workbench/contrib/speech/test/common/speechService.test.ts b/src/vs/workbench/contrib/speech/test/common/speechService.test.ts new file mode 100644 index 0000000000000..d757eace7e08a --- /dev/null +++ b/src/vs/workbench/contrib/speech/test/common/speechService.test.ts @@ -0,0 +1,27 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as assert from 'assert'; +import { ensureNoDisposablesAreLeakedInTestSuite } from 'vs/base/test/common/utils'; +import { speechLanguageConfigToLanguage } from 'vs/workbench/contrib/speech/common/speechService'; + +suite('SpeechService', () => { + + test('resolve language', async () => { + assert.strictEqual(speechLanguageConfigToLanguage(undefined), 'en-US'); + assert.strictEqual(speechLanguageConfigToLanguage(3), 'en-US'); + assert.strictEqual(speechLanguageConfigToLanguage('foo'), 'en-US'); + assert.strictEqual(speechLanguageConfigToLanguage('foo-bar'), 'en-US'); + + assert.strictEqual(speechLanguageConfigToLanguage('tr-TR'), 'tr-TR'); + assert.strictEqual(speechLanguageConfigToLanguage('zh-TW'), 'zh-TW'); + + assert.strictEqual(speechLanguageConfigToLanguage('auto', 'en'), 'en-US'); + assert.strictEqual(speechLanguageConfigToLanguage('auto', 'tr'), 'tr-TR'); + assert.strictEqual(speechLanguageConfigToLanguage('auto', 'zh-tw'), 'zh-TW'); + }); + + ensureNoDisposablesAreLeakedInTestSuite(); +}); diff --git a/src/vs/workbench/contrib/tasks/browser/abstractTaskService.ts b/src/vs/workbench/contrib/tasks/browser/abstractTaskService.ts index 43246667cfeba..981c0bf013621 100644 --- a/src/vs/workbench/contrib/tasks/browser/abstractTaskService.ts +++ b/src/vs/workbench/contrib/tasks/browser/abstractTaskService.ts @@ -337,9 +337,9 @@ export abstract class AbstractTaskService extends Disposable implements ITaskSer } return task._label; }); - this._lifecycleService.onBeforeShutdown(e => { + this._register(this._lifecycleService.onBeforeShutdown(e => { this._willRestart = e.reason !== ShutdownReason.RELOAD; - }); + })); this._register(this.onDidStateChange(e => { this._log(nls.localize('taskEvent', 'Task Event kind: {0}', e.kind), true); if (e.kind === TaskEventKind.Changed) { @@ -1865,7 +1865,7 @@ export abstract class AbstractTaskService extends Disposable implements ITaskSer message: nls.localize('TaskSystem.saveBeforeRun.prompt.title', "Save all editors?"), detail: nls.localize('detail', "Do you want to save all editors before running the task?"), primaryButton: nls.localize({ key: 'saveBeforeRun.save', comment: ['&& denotes a mnemonic'] }, '&&Save'), - cancelButton: nls.localize('saveBeforeRun.dontSave', 'Don\'t save'), + cancelButton: nls.localize({ key: 'saveBeforeRun.dontSave', comment: ['&& denotes a mnemonic'] }, "Do&&n't Save"), }); if (!confirmed) { @@ -2417,6 +2417,9 @@ export abstract class AbstractTaskService extends Disposable implements ITaskSer private async _computeTasksForSingleConfig(workspaceFolder: IWorkspaceFolder, config: TaskConfig.IExternalTaskRunnerConfiguration | undefined, runSource: TaskRunSource, custom: CustomTask[], customized: IStringDictionary, source: TaskConfig.TaskConfigSource, isRecentTask: boolean = false): Promise { if (!config) { return false; + } else if (!workspaceFolder) { + this._logService.trace('TaskService.computeTasksForSingleConfig: no workspace folder for worskspace', this._workspace?.id); + return false; } const taskSystemInfo: ITaskSystemInfo | undefined = this._getTaskSystemInfo(workspaceFolder.uri.scheme); const problemReporter = new ProblemReporter(this._outputChannel); @@ -2945,7 +2948,7 @@ export abstract class AbstractTaskService extends Disposable implements ITaskSer */ private _getDefaultTasks(tasks: Task[], taskGlobsInList: boolean = false): Task[] { const defaults: Task[] = []; - for (const task of tasks) { + for (const task of tasks.filter(t => !!t.configurationProperties.group)) { // At this point (assuming taskGlobsInList is true) there are tasks with matching globs, so only put those in defaults if (taskGlobsInList && typeof (task.configurationProperties.group as TaskGroup).isDefault === 'string') { defaults.push(task); @@ -2970,7 +2973,6 @@ export abstract class AbstractTaskService extends Disposable implements ITaskSer title: strings.fetching }; const promise = (async () => { - let groupTasks: (Task | ConfiguringTask)[] = []; async function runSingleTask(task: Task | undefined, problemMatcherOptions: IProblemMatcherRunOptions | undefined, that: AbstractTaskService) { that.run(task, problemMatcherOptions, TaskRunSource.User).then(undefined, reason => { @@ -2998,33 +3000,9 @@ export abstract class AbstractTaskService extends Disposable implements ITaskSer }); }); }; - let globTasksDetected = false; - // First check for globs before checking for the default tasks of the task group - const absoluteURI = EditorResourceAccessor.getOriginalUri(this._editorService.activeEditor); - if (absoluteURI) { - const workspaceFolder = this._contextService.getWorkspaceFolder(absoluteURI); - if (workspaceFolder) { - const configuredTasks = this._getConfiguration(workspaceFolder)?.config?.tasks; - if (configuredTasks) { - globTasksDetected = configuredTasks.filter(task => task.group && typeof task.group !== 'string' && typeof task.group.isDefault === 'string').length > 0; - // This will activate extensions, so only do so if necessary #185960 - if (globTasksDetected) { - // Fallback to absolute path of the file if it is not in a workspace or relative path cannot be found - const relativePath = workspaceFolder?.uri ? (resources.relativePath(workspaceFolder.uri, absoluteURI) ?? absoluteURI.path) : absoluteURI.path; - - groupTasks = await this._findWorkspaceTasks((task) => { - const currentTaskGroup = task.configurationProperties.group; - if (currentTaskGroup && typeof currentTaskGroup !== 'string' && typeof currentTaskGroup.isDefault === 'string') { - return (currentTaskGroup._id === taskGroup._id && glob.match(currentTaskGroup.isDefault, relativePath)); - } - - return false; - }); - } - } - } - } - + let groupTasks: (Task | ConfiguringTask)[] = []; + const { globGroupTasks, globTasksDetected } = await this._getGlobTasks(taskGroup._id); + groupTasks = [...globGroupTasks]; if (!globTasksDetected && groupTasks.length === 0) { groupTasks = await this._findWorkspaceTasksInGroup(taskGroup, true); } @@ -3072,20 +3050,58 @@ export abstract class AbstractTaskService extends Disposable implements ITaskSer // If no globs are found or matched fallback to checking for default tasks of the task group if (!groupTasks.length) { - groupTasks = await this._findWorkspaceTasksInGroup(taskGroup, false); + groupTasks = await this._findWorkspaceTasksInGroup(taskGroup, true); } - // A single default task was returned, just run it directly - if (groupTasks.length === 1) { - return resolveTaskAndRun(groupTasks[0]); + switch (groupTasks.length) { + case 0: + // No tasks found, prompt to configure + configure.apply(this); + break; + case 1: + // A single default task was returned, just run it directly + return resolveTaskAndRun(groupTasks[0]); + default: + // Multiple default tasks returned, show the quickPicker + return handleMultipleTasks(false); } - - // Multiple default tasks returned, show the quickPicker - return handleMultipleTasks(false); })(); this._progressService.withProgress(options, () => promise); } + private async _getGlobTasks(taskGroupId: string): Promise<{ globGroupTasks: (Task | ConfiguringTask)[]; globTasksDetected: boolean }> { + let globTasksDetected = false; + // First check for globs before checking for the default tasks of the task group + const absoluteURI = EditorResourceAccessor.getOriginalUri(this._editorService.activeEditor); + if (absoluteURI) { + const workspaceFolder = this._contextService.getWorkspaceFolder(absoluteURI); + if (workspaceFolder) { + const configuredTasks = this._getConfiguration(workspaceFolder)?.config?.tasks; + if (configuredTasks) { + globTasksDetected = configuredTasks.filter(task => task.group && typeof task.group !== 'string' && typeof task.group.isDefault === 'string').length > 0; + // This will activate extensions, so only do so if necessary #185960 + if (globTasksDetected) { + // Fallback to absolute path of the file if it is not in a workspace or relative path cannot be found + const relativePath = workspaceFolder?.uri ? (resources.relativePath(workspaceFolder.uri, absoluteURI) ?? absoluteURI.path) : absoluteURI.path; + + const globGroupTasks = await this._findWorkspaceTasks((task) => { + const currentTaskGroup = task.configurationProperties.group; + if (currentTaskGroup && typeof currentTaskGroup !== 'string' && typeof currentTaskGroup.isDefault === 'string') { + return (currentTaskGroup._id === taskGroupId && glob.match(currentTaskGroup.isDefault, relativePath)); + } + + globTasksDetected = false; + return false; + }); + return { globGroupTasks, globTasksDetected }; + } + } + } + } + return { globGroupTasks: [], globTasksDetected }; + + } + private _runBuildCommand(): void { if (!this._tasksReconnected) { return; @@ -3429,10 +3445,25 @@ export abstract class AbstractTaskService extends Disposable implements ITaskSer const entries: QuickPickInput[] = []; let selectedTask: Task | undefined; let selectedEntry: TaskQuickPickEntryType | undefined; - this._showIgnoredFoldersMessage().then(() => { + this._showIgnoredFoldersMessage().then(async () => { + const { globGroupTasks } = await this._getGlobTasks(TaskGroup.Build._id); + let defaultTasks = globGroupTasks; + if (!defaultTasks?.length) { + defaultTasks = this._getDefaultTasks(tasks, false); + } + let defaultBuildTask; + if (defaultTasks.length === 1) { + const group: string | TaskGroup | undefined = defaultTasks[0].configurationProperties.group; + if (group) { + if (typeof group === 'string' && group === TaskGroup.Build._id) { + defaultBuildTask = defaultTasks[0]; + } else { + defaultBuildTask = defaultTasks[0]; + } + } + } for (const task of tasks) { - const taskGroup: TaskGroup | undefined = TaskGroup.from(task.configurationProperties.group); - if (taskGroup && taskGroup.isDefault && taskGroup._id === TaskGroup.Build._id) { + if (task === defaultBuildTask) { const label = nls.localize('TaskService.defaultBuildTaskExists', '{0} is already marked as the default build task', TaskQuickPick.getTaskLabelWithIcon(task, task.getQualifiedLabel())); selectedTask = task; selectedEntry = { label, task, description: this.getTaskDescription(task), detail: this._showDetail() ? task.configurationProperties.detail : undefined }; diff --git a/src/vs/workbench/contrib/tasks/browser/task.contribution.ts b/src/vs/workbench/contrib/tasks/browser/task.contribution.ts index a0d2bb523f6f0..08e8292b60c3b 100644 --- a/src/vs/workbench/contrib/tasks/browser/task.contribution.ts +++ b/src/vs/workbench/contrib/tasks/browser/task.contribution.ts @@ -70,7 +70,7 @@ export class TaskStatusBarContributions extends Disposable implements IWorkbench private _registerListeners(): void { let promise: Promise | undefined = undefined; let resolve: (value?: void | Thenable) => void; - this._taskService.onDidStateChange(event => { + this._register(this._taskService.onDidStateChange(event => { if (event.kind === TaskEventKind.Changed) { this._updateRunningTasksStatus(); } @@ -116,7 +116,7 @@ export class TaskStatusBarContributions extends Disposable implements IWorkbench promise = undefined; }); } - }); + })); } private async _updateRunningTasksStatus(): Promise { diff --git a/src/vs/workbench/contrib/tasks/browser/taskQuickPick.ts b/src/vs/workbench/contrib/tasks/browser/taskQuickPick.ts index 6dde509a9d4e2..fe0e1641dc185 100644 --- a/src/vs/workbench/contrib/tasks/browser/taskQuickPick.ts +++ b/src/vs/workbench/contrib/tasks/browser/taskQuickPick.ts @@ -11,7 +11,7 @@ import * as Types from 'vs/base/common/types'; import { ITaskService, IWorkspaceFolderTaskResult } from 'vs/workbench/contrib/tasks/common/taskService'; import { IQuickPickItem, QuickPickInput, IQuickPick, IQuickInputButton, IQuickInputService } from 'vs/platform/quickinput/common/quickInput'; import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; -import { Disposable } from 'vs/base/common/lifecycle'; +import { Disposable, IDisposable } from 'vs/base/common/lifecycle'; import { Event } from 'vs/base/common/event'; import { INotificationService, Severity } from 'vs/platform/notification/common/notification'; import { Codicon } from 'vs/base/common/codicons'; @@ -90,12 +90,14 @@ export class TaskQuickPick extends Disposable { return icon.id ? `$(${icon.id}) ${label}` : `$(${Codicon.tools.id}) ${label}`; } - public static applyColorStyles(task: Task | ConfiguringTask, entry: TaskQuickPickEntryType | ITaskTwoLevelQuickPickEntry, themeService: IThemeService): void { + public static applyColorStyles(task: Task | ConfiguringTask, entry: TaskQuickPickEntryType | ITaskTwoLevelQuickPickEntry, themeService: IThemeService): IDisposable | undefined { if (task.configurationProperties.icon?.color) { const colorTheme = themeService.getColorTheme(); - createColorStyleElement(colorTheme); + const disposable = createColorStyleElement(colorTheme); entry.iconClasses = [getColorClass(task.configurationProperties.icon.color)]; + return disposable; } + return; } private _createTaskEntry(task: Task | ConfiguringTask, extraButtons: IQuickInputButton[] = []): ITaskTwoLevelQuickPickEntry { @@ -104,7 +106,10 @@ export class TaskQuickPick extends Disposable { ...extraButtons ]; const entry: ITaskTwoLevelQuickPickEntry = { label: TaskQuickPick.getTaskLabelWithIcon(task, this._guessTaskLabel(task)), description: this._taskService.getTaskDescription(task), task, detail: this._showDetail() ? task.configurationProperties.detail : undefined, buttons }; - TaskQuickPick.applyColorStyles(task, entry, this._themeService); + const disposable = TaskQuickPick.applyColorStyles(task, entry, this._themeService); + if (disposable) { + this._register(disposable); + } return entry; } diff --git a/src/vs/workbench/contrib/tasks/browser/taskTerminalStatus.ts b/src/vs/workbench/contrib/tasks/browser/taskTerminalStatus.ts index df71476712482..12767f599ae08 100644 --- a/src/vs/workbench/contrib/tasks/browser/taskTerminalStatus.ts +++ b/src/vs/workbench/contrib/tasks/browser/taskTerminalStatus.ts @@ -14,7 +14,7 @@ import { ITerminalInstance } from 'vs/workbench/contrib/terminal/browser/termina import { MarkerSeverity } from 'vs/platform/markers/common/markers'; import { spinningLoading } from 'vs/platform/theme/common/iconRegistry'; import { IMarker } from 'vs/platform/terminal/common/capabilities/capabilities'; -import { AudioCue, IAudioCueService } from 'vs/platform/audioCues/browser/audioCueService'; +import { AccessibilitySignal, IAccessibilitySignalService } from 'vs/platform/accessibilitySignal/browser/accessibilitySignalService'; import { ITerminalStatus } from 'vs/workbench/contrib/terminal/common/terminal'; interface ITerminalData { @@ -40,7 +40,7 @@ const INFO_INACTIVE_TASK_STATUS: ITerminalStatus = { id: TASK_TERMINAL_STATUS_ID export class TaskTerminalStatus extends Disposable { private terminalMap: Map = new Map(); private _marker: IMarker | undefined; - constructor(@ITaskService taskService: ITaskService, @IAudioCueService private readonly _audioCueService: IAudioCueService) { + constructor(@ITaskService taskService: ITaskService, @IAccessibilitySignalService private readonly _accessibilitySignalService: IAccessibilitySignalService) { super(); this._register(taskService.onDidStateChange((event) => { switch (event.kind) { @@ -95,7 +95,7 @@ export class TaskTerminalStatus extends Disposable { terminalData.taskRunEnded = true; terminalData.terminal.statusList.remove(terminalData.status); if ((event.exitCode === 0) && (terminalData.problemMatcher.numberOfMatches === 0)) { - this._audioCueService.playAudioCue(AudioCue.taskCompleted); + this._accessibilitySignalService.playSignal(AccessibilitySignal.taskCompleted); if (terminalData.task.configurationProperties.isBackground) { for (const status of terminalData.terminal.statusList.statuses) { terminalData.terminal.statusList.remove(status); @@ -104,7 +104,7 @@ export class TaskTerminalStatus extends Disposable { terminalData.terminal.statusList.add(SUCCEEDED_TASK_STATUS); } } else if (event.exitCode || terminalData.problemMatcher.maxMarkerSeverity === MarkerSeverity.Error) { - this._audioCueService.playAudioCue(AudioCue.taskFailed); + this._accessibilitySignalService.playSignal(AccessibilitySignal.taskFailed); terminalData.terminal.statusList.add(FAILED_TASK_STATUS); } else if (terminalData.problemMatcher.maxMarkerSeverity === MarkerSeverity.Warning) { terminalData.terminal.statusList.add(WARNING_TASK_STATUS); @@ -120,10 +120,10 @@ export class TaskTerminalStatus extends Disposable { } terminalData.terminal.statusList.remove(terminalData.status); if (terminalData.problemMatcher.numberOfMatches === 0) { - this._audioCueService.playAudioCue(AudioCue.taskCompleted); + this._accessibilitySignalService.playSignal(AccessibilitySignal.taskCompleted); terminalData.terminal.statusList.add(SUCCEEDED_INACTIVE_TASK_STATUS); } else if (terminalData.problemMatcher.maxMarkerSeverity === MarkerSeverity.Error) { - this._audioCueService.playAudioCue(AudioCue.taskFailed); + this._accessibilitySignalService.playSignal(AccessibilitySignal.taskFailed); terminalData.terminal.statusList.add(FAILED_INACTIVE_TASK_STATUS); } else if (terminalData.problemMatcher.maxMarkerSeverity === MarkerSeverity.Warning) { terminalData.terminal.statusList.add(WARNING_INACTIVE_TASK_STATUS); diff --git a/src/vs/workbench/contrib/tasks/common/problemCollectors.ts b/src/vs/workbench/contrib/tasks/common/problemCollectors.ts index d07edd2679cb0..9f47ca8564e1a 100644 --- a/src/vs/workbench/contrib/tasks/common/problemCollectors.ts +++ b/src/vs/workbench/contrib/tasks/common/problemCollectors.ts @@ -441,7 +441,7 @@ export class WatchingProblemCollector extends AbstractProblemCollector implement }, 500, false, true)(async (markerEvent) => { markerChanged?.dispose(); markerChanged = undefined; - if (!markerEvent.includes(modelEvent.uri) || (this.markerService.read({ resource: modelEvent.uri }).length !== 0)) { + if (!markerEvent || !markerEvent.includes(modelEvent.uri) || (this.markerService.read({ resource: modelEvent.uri }).length !== 0)) { return; } const oldLines = Array.from(this.lines); diff --git a/src/vs/workbench/contrib/tasks/electron-sandbox/taskService.ts b/src/vs/workbench/contrib/tasks/electron-sandbox/taskService.ts index 5eeee32c2642c..9efbc2ac32da1 100644 --- a/src/vs/workbench/contrib/tasks/electron-sandbox/taskService.ts +++ b/src/vs/workbench/contrib/tasks/electron-sandbox/taskService.ts @@ -47,7 +47,7 @@ import { IPaneCompositePartService } from 'vs/workbench/services/panecomposite/b import { IThemeService } from 'vs/platform/theme/common/themeService'; import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; import { IRemoteAgentService } from 'vs/workbench/services/remote/common/remoteAgentService'; -import { IAudioCueService } from 'vs/platform/audioCues/browser/audioCueService'; +import { IAccessibilitySignalService } from 'vs/platform/accessibilitySignal/browser/accessibilitySignalService'; interface IWorkspaceFolderConfigurationResult { workspaceFolder: IWorkspaceFolder; @@ -92,7 +92,7 @@ export class TaskService extends AbstractTaskService { @IThemeService themeService: IThemeService, @IInstantiationService instantiationService: IInstantiationService, @IRemoteAgentService remoteAgentService: IRemoteAgentService, - @IAudioCueService audioCueService: IAudioCueService + @IAccessibilitySignalService accessibilitySignalService: IAccessibilitySignalService ) { super(configurationService, markerService, diff --git a/src/vs/workbench/contrib/tasks/test/browser/taskTerminalStatus.test.ts b/src/vs/workbench/contrib/tasks/test/browser/taskTerminalStatus.test.ts index 5e413afc9c1a9..c3bf26252fda5 100644 --- a/src/vs/workbench/contrib/tasks/test/browser/taskTerminalStatus.test.ts +++ b/src/vs/workbench/contrib/tasks/test/browser/taskTerminalStatus.test.ts @@ -7,7 +7,7 @@ import { ok } from 'assert'; import { Emitter, Event } from 'vs/base/common/event'; import { Disposable } from 'vs/base/common/lifecycle'; import { ensureNoDisposablesAreLeakedInTestSuite } from 'vs/base/test/common/utils'; -import { AudioCue, IAudioCueService } from 'vs/platform/audioCues/browser/audioCueService'; +import { AccessibilitySignal, IAccessibilitySignalService } from 'vs/platform/accessibilitySignal/browser/accessibilitySignalService'; import { TestConfigurationService } from 'vs/platform/configuration/test/common/testConfigurationService'; import { TestInstantiationService } from 'vs/platform/instantiation/test/common/instantiationServiceMock'; import { ACTIVE_TASK_STATUS, FAILED_TASK_STATUS, SUCCEEDED_TASK_STATUS, TaskTerminalStatus } from 'vs/workbench/contrib/tasks/browser/taskTerminalStatus'; @@ -28,8 +28,8 @@ class TestTaskService implements Partial { } } -class TestAudioCueService implements Partial { - async playAudioCue(cue: AudioCue): Promise { +class TestaccessibilitySignalService implements Partial { + async playSignal(cue: AccessibilitySignal): Promise { return; } } @@ -74,13 +74,13 @@ suite('Task Terminal Status', () => { let testTerminal: ITerminalInstance; let testTask: Task; let problemCollector: AbstractProblemCollector; - let audioCueService: TestAudioCueService; + let accessibilitySignalService: TestaccessibilitySignalService; const store = ensureNoDisposablesAreLeakedInTestSuite(); setup(() => { instantiationService = store.add(new TestInstantiationService()); taskService = new TestTaskService(); - audioCueService = new TestAudioCueService(); - taskTerminalStatus = store.add(new TaskTerminalStatus(taskService as any, audioCueService as any)); + accessibilitySignalService = new TestaccessibilitySignalService(); + taskTerminalStatus = store.add(new TaskTerminalStatus(taskService as any, accessibilitySignalService as any)); testTerminal = store.add(instantiationService.createInstance(TestTerminal) as any); testTask = instantiationService.createInstance(TestTask) as unknown as Task; problemCollector = store.add(instantiationService.createInstance(TestProblemCollector) as any); diff --git a/src/vs/workbench/contrib/telemetry/browser/telemetry.contribution.ts b/src/vs/workbench/contrib/telemetry/browser/telemetry.contribution.ts index 68275f3ea2002..b3941a6fea4d6 100644 --- a/src/vs/workbench/contrib/telemetry/browser/telemetry.contribution.ts +++ b/src/vs/workbench/contrib/telemetry/browser/telemetry.contribution.ts @@ -46,7 +46,7 @@ type FileTelemetryDataFragment = { mimeType: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The language type of the file (for example XML).' }; ext: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The file extension of the file (for example xml).' }; path: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The path of the file as a hash.' }; - reason?: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'The reason why a file is read or written. Allows to e.g. distinguish auto save from normal save.' }; + reason?: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The reason why a file is read or written. Allows to e.g. distinguish auto save from normal save.' }; allowlistedjson?: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The name of the file but only if it matches some well known file names such as package.json or tsconfig.json.' }; }; @@ -73,28 +73,28 @@ export class TelemetryContribution extends Disposable implements IWorkbenchContr const activeViewlet = paneCompositeService.getActivePaneComposite(ViewContainerLocation.Sidebar); type WindowSizeFragment = { - innerHeight: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'The height of the current window.' }; - innerWidth: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'The width of the current window.' }; - outerHeight: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'The height of the current window with all decoration removed.' }; - outerWidth: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'The width of the current window with all decoration removed.' }; + innerHeight: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The height of the current window.' }; + innerWidth: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The width of the current window.' }; + outerHeight: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The height of the current window with all decoration removed.' }; + outerWidth: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The width of the current window with all decoration removed.' }; owner: 'bpasero'; comment: 'The size of the window.'; }; type WorkspaceLoadClassification = { owner: 'bpasero'; - emptyWorkbench: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Whether a folder or workspace is opened or not.' }; + emptyWorkbench: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether a folder or workspace is opened or not.' }; windowSize: WindowSizeFragment; - 'workbench.filesToOpenOrCreate': { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of files that should open or be created.' }; - 'workbench.filesToDiff': { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of files that should be compared.' }; - 'workbench.filesToMerge': { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of files that should be merged.' }; - customKeybindingsCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of custom keybindings' }; + 'workbench.filesToOpenOrCreate': { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Number of files that should open or be created.' }; + 'workbench.filesToDiff': { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Number of files that should be compared.' }; + 'workbench.filesToMerge': { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Number of files that should be merged.' }; + customKeybindingsCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Number of custom keybindings' }; theme: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The current theme of the window.' }; language: { classification: 'SystemMetaData'; purpose: 'BusinessInsight'; comment: 'The display language of the window.' }; pinnedViewlets: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The identifiers of views that are pinned.' }; restoredViewlet?: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The identifier of the view that is restored.' }; - restoredEditors: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'The number of editors that restored.' }; - startupKind: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'How the window was opened, e.g via reload or not.' }; + restoredEditors: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The number of editors that restored.' }; + startupKind: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'How the window was opened, e.g via reload or not.' }; comment: 'Metadata around the workspace that is being loaded into a window.'; }; @@ -250,7 +250,7 @@ class ConfigurationTelemetryContribution extends Disposable implements IWorkbenc return { ...cur, affectedKeys: newAffectedKeys }; }, 1000, true); - debouncedConfigService(event => { + this._register(debouncedConfigService(event => { if (event.source !== ConfigurationTarget.DEFAULT) { type UpdateConfigurationClassification = { owner: 'sandy081'; @@ -267,7 +267,7 @@ class ConfigurationTelemetryContribution extends Disposable implements IWorkbenc configurationKeys: Array.from(event.affectedKeys) }); } - }); + })); const { user, workspace } = configurationService.keys(); for (const setting of user) { @@ -282,12 +282,13 @@ class ConfigurationTelemetryContribution extends Disposable implements IWorkbenc * Report value of a setting only if it is an enum, boolean, or number or an array of those. */ private getValueToReport(key: string, target: ConfigurationTarget.USER_LOCAL | ConfigurationTarget.WORKSPACE): string | undefined { - const schema = this.configurationRegistry.getConfigurationProperties()[key]; const inpsectData = this.configurationService.inspect(key); const value = target === ConfigurationTarget.USER_LOCAL ? inpsectData.user?.value : inpsectData.workspace?.value; if (isNumber(value) || isBoolean(value)) { return value.toString(); } + + const schema = this.configurationRegistry.getConfigurationProperties()[key]; if (isString(value)) { if (schema?.enum?.includes(value)) { return value; @@ -304,7 +305,7 @@ class ConfigurationTelemetryContribution extends Disposable implements IWorkbenc private reportTelemetry(key: string, target: ConfigurationTarget.USER_LOCAL | ConfigurationTarget.WORKSPACE): void { type UpdatedSettingEvent = { - value: string | undefined; + settingValue: string | undefined; source: string; }; const source = ConfigurationTargetToString(target); @@ -315,90 +316,99 @@ class ConfigurationTelemetryContribution extends Disposable implements IWorkbenc this.telemetryService.publicLog2('workbench.activityBar.location', { value: this.getValueToReport(key, target), source }); + }>('workbench.activityBar.location', { settingValue: this.getValueToReport(key, target), source }); return; case AutoUpdateConfigurationKey: this.telemetryService.publicLog2('extensions.autoUpdate', { value: this.getValueToReport(key, target), source }); + }>('extensions.autoUpdate', { settingValue: this.getValueToReport(key, target), source }); return; case 'files.autoSave': this.telemetryService.publicLog2('files.autoSave', { value: this.getValueToReport(key, target), source }); + }>('files.autoSave', { settingValue: this.getValueToReport(key, target), source }); return; case 'editor.stickyScroll.enabled': this.telemetryService.publicLog2('editor.stickyScroll.enabled', { value: this.getValueToReport(key, target), source }); + }>('editor.stickyScroll.enabled', { settingValue: this.getValueToReport(key, target), source }); return; case KEYWORD_ACTIVIATION_SETTING_ID: this.telemetryService.publicLog2('accessibility.voice.keywordActivation', { value: this.getValueToReport(key, target), source }); + }>('accessibility.voice.keywordActivation', { settingValue: this.getValueToReport(key, target), source }); return; case 'window.zoomLevel': this.telemetryService.publicLog2('window.zoomLevel', { value: this.getValueToReport(key, target), source }); + }>('window.zoomLevel', { settingValue: this.getValueToReport(key, target), source }); return; case 'window.zoomPerWindow': this.telemetryService.publicLog2('window.zoomPerWindow', { value: this.getValueToReport(key, target), source }); + }>('window.zoomPerWindow', { settingValue: this.getValueToReport(key, target), source }); return; case 'window.titleBarStyle': this.telemetryService.publicLog2('window.titleBarStyle', { value: this.getValueToReport(key, target), source }); + }>('window.titleBarStyle', { settingValue: this.getValueToReport(key, target), source }); return; case 'window.customTitleBarVisibility': this.telemetryService.publicLog2('window.customTitleBarVisibility', { value: this.getValueToReport(key, target), source }); + }>('window.customTitleBarVisibility', { settingValue: this.getValueToReport(key, target), source }); return; case 'window.nativeTabs': this.telemetryService.publicLog2('window.nativeTabs', { settingValue: this.getValueToReport(key, target), source }); + return; + + case 'extensions.verifySignature': + this.telemetryService.publicLog2('window.nativeTabs', { value: this.getValueToReport(key, target), source }); + }>('extensions.verifySignature', { settingValue: this.getValueToReport(key, target), source }); return; } } diff --git a/src/vs/workbench/contrib/terminal/browser/baseTerminalBackend.ts b/src/vs/workbench/contrib/terminal/browser/baseTerminalBackend.ts index 7710088cf645d..12b5058d65a98 100644 --- a/src/vs/workbench/contrib/terminal/browser/baseTerminalBackend.ts +++ b/src/vs/workbench/contrib/terminal/browser/baseTerminalBackend.ts @@ -46,7 +46,7 @@ export abstract class BaseTerminalBackend extends Disposable { this._register(this._ptyHostController.onPtyHostExit(() => { this._logService.error(`The terminal's pty host process exited, the connection to all terminal processes was lost`); })); - this.onPtyHostConnected(() => hasStarted = true); + this._register(this.onPtyHostConnected(() => hasStarted = true)); this._register(this._ptyHostController.onPtyHostStart(() => { this._logService.debug(`The terminal's pty host process is starting`); // Only fire the _restart_ event after it has started diff --git a/src/vs/workbench/contrib/terminal/browser/media/shellIntegration-bash.sh b/src/vs/workbench/contrib/terminal/browser/media/shellIntegration-bash.sh index e8c7ac8bada21..db9031b73002e 100755 --- a/src/vs/workbench/contrib/terminal/browser/media/shellIntegration-bash.sh +++ b/src/vs/workbench/contrib/terminal/browser/media/shellIntegration-bash.sh @@ -50,7 +50,7 @@ if [ -n "${VSCODE_ENV_REPLACE:-}" ]; then IFS=':' read -ra ADDR <<< "$VSCODE_ENV_REPLACE" for ITEM in "${ADDR[@]}"; do VARNAME="$(echo $ITEM | cut -d "=" -f 1)" - VALUE="$(echo -e "$ITEM" | cut -d "=" -f 2)" + VALUE="$(echo -e "$ITEM" | cut -d "=" -f 2-)" export $VARNAME="$VALUE" done builtin unset VSCODE_ENV_REPLACE @@ -59,7 +59,7 @@ if [ -n "${VSCODE_ENV_PREPEND:-}" ]; then IFS=':' read -ra ADDR <<< "$VSCODE_ENV_PREPEND" for ITEM in "${ADDR[@]}"; do VARNAME="$(echo $ITEM | cut -d "=" -f 1)" - VALUE="$(echo -e "$ITEM" | cut -d "=" -f 2)" + VALUE="$(echo -e "$ITEM" | cut -d "=" -f 2-)" export $VARNAME="$VALUE${!VARNAME}" done builtin unset VSCODE_ENV_PREPEND @@ -68,7 +68,7 @@ if [ -n "${VSCODE_ENV_APPEND:-}" ]; then IFS=':' read -ra ADDR <<< "$VSCODE_ENV_APPEND" for ITEM in "${ADDR[@]}"; do VARNAME="$(echo $ITEM | cut -d "=" -f 1)" - VALUE="$(echo -e "$ITEM" | cut -d "=" -f 2)" + VALUE="$(echo -e "$ITEM" | cut -d "=" -f 2-)" export $VARNAME="${!VARNAME}$VALUE" done builtin unset VSCODE_ENV_APPEND @@ -183,6 +183,9 @@ __vsc_continuation_end() { } __vsc_command_complete() { + if [[ -z "$__vsc_first_prompt" ]]; then + builtin return + fi if [ "$__vsc_current_command" = "" ]; then builtin printf '\e]633;D\a' else @@ -213,6 +216,7 @@ __vsc_precmd() { __vsc_command_complete "$__vsc_status" __vsc_current_command="" __vsc_update_prompt + __vsc_first_prompt=1 } __vsc_preexec() { @@ -275,6 +279,7 @@ __vsc_prompt_cmd_original() { __vsc_restore_exit_code "${__vsc_status}" # Evaluate the original PROMPT_COMMAND similarly to how bash would normally # See https://unix.stackexchange.com/a/672843 for technique + local cmd for cmd in "${__vsc_original_prompt_command[@]}"; do eval "${cmd:-}" done diff --git a/src/vs/workbench/contrib/terminal/browser/media/shellIntegration-rc.zsh b/src/vs/workbench/contrib/terminal/browser/media/shellIntegration-rc.zsh index cc2cb83e0d2b4..d54b124e69a05 100644 --- a/src/vs/workbench/contrib/terminal/browser/media/shellIntegration-rc.zsh +++ b/src/vs/workbench/contrib/terminal/browser/media/shellIntegration-rc.zsh @@ -32,12 +32,6 @@ if [[ "$VSCODE_INJECTION" == "1" ]]; then fi fi -# Shell integration was disabled by the shell, exit without warning assuming either the shell has -# explicitly disabled shell integration as it's incompatible or it implements the protocol. -if [ -z "$VSCODE_SHELL_INTEGRATION" ]; then - builtin return -fi - # Apply EnvironmentVariableCollections if needed if [ -n "${VSCODE_ENV_REPLACE:-}" ]; then IFS=':' read -rA ADDR <<< "$VSCODE_ENV_REPLACE" @@ -64,6 +58,12 @@ if [ -n "${VSCODE_ENV_APPEND:-}" ]; then unset VSCODE_ENV_APPEND fi +# Shell integration was disabled by the shell, exit without warning assuming either the shell has +# explicitly disabled shell integration as it's incompatible or it implements the protocol. +if [ -z "$VSCODE_SHELL_INTEGRATION" ]; then + builtin return +fi + # The property (P) and command (E) codes embed values which require escaping. # Backslashes are doubled. Non-alphanumeric characters are converted to escaped hex. __vsc_escape_value() { diff --git a/src/vs/workbench/contrib/terminal/browser/media/shellIntegration.ps1 b/src/vs/workbench/contrib/terminal/browser/media/shellIntegration.ps1 index 53e9bd306021f..69b5bea7875e6 100644 --- a/src/vs/workbench/contrib/terminal/browser/media/shellIntegration.ps1 +++ b/src/vs/workbench/contrib/terminal/browser/media/shellIntegration.ps1 @@ -24,7 +24,7 @@ $env:VSCODE_NONCE = $null if ($env:VSCODE_ENV_REPLACE) { $Split = $env:VSCODE_ENV_REPLACE.Split(":") foreach ($Item in $Split) { - $Inner = $Item.Split('=') + $Inner = $Item.Split('=', 2) [Environment]::SetEnvironmentVariable($Inner[0], $Inner[1].Replace('\x3a', ':')) } $env:VSCODE_ENV_REPLACE = $null @@ -32,7 +32,7 @@ if ($env:VSCODE_ENV_REPLACE) { if ($env:VSCODE_ENV_PREPEND) { $Split = $env:VSCODE_ENV_PREPEND.Split(":") foreach ($Item in $Split) { - $Inner = $Item.Split('=') + $Inner = $Item.Split('=', 2) [Environment]::SetEnvironmentVariable($Inner[0], $Inner[1].Replace('\x3a', ':') + [Environment]::GetEnvironmentVariable($Inner[0])) } $env:VSCODE_ENV_PREPEND = $null @@ -40,7 +40,7 @@ if ($env:VSCODE_ENV_PREPEND) { if ($env:VSCODE_ENV_APPEND) { $Split = $env:VSCODE_ENV_APPEND.Split(":") foreach ($Item in $Split) { - $Inner = $Item.Split('=') + $Inner = $Item.Split('=', 2) [Environment]::SetEnvironmentVariable($Inner[0], [Environment]::GetEnvironmentVariable($Inner[0]) + $Inner[1].Replace('\x3a', ':')) } $env:VSCODE_ENV_APPEND = $null @@ -63,29 +63,14 @@ function Global:Prompt() { # error when $LastHistoryEntry is null, and is not otherwise useful. Set-StrictMode -Off $LastHistoryEntry = Get-History -Count 1 + $Result = "" # Skip finishing the command if the first command has not yet started if ($Global:__LastHistoryId -ne -1) { if ($LastHistoryEntry.Id -eq $Global:__LastHistoryId) { # Don't provide a command line or exit code if there was no history entry (eg. ctrl+c, enter on no command) - $Result = "$([char]0x1b)]633;E`a" $Result += "$([char]0x1b)]633;D`a" } else { - # Command finished command line - # OSC 633 ; E ; ; ST - $Result = "$([char]0x1b)]633;E;" - # Sanitize the command line to ensure it can get transferred to the terminal and can be parsed - # correctly. This isn't entirely safe but good for most cases, it's important for the Pt parameter - # to only be composed of _printable_ characters as per the spec. - if ($LastHistoryEntry.CommandLine) { - $CommandLine = $LastHistoryEntry.CommandLine - } - else { - $CommandLine = "" - } - $Result += $(__VSCode-Escape-Value $CommandLine) - $Result += ";$Nonce" - $Result += "`a" # Command finished exit code # OSC 633 ; D [; ] ST $Result += "$([char]0x1b)]633;D;$FakeCode`a" @@ -114,10 +99,24 @@ function Global:Prompt() { if (Get-Module -Name PSReadLine) { $__VSCodeOriginalPSConsoleHostReadLine = $function:PSConsoleHostReadLine function Global:PSConsoleHostReadLine { - $tmp = $__VSCodeOriginalPSConsoleHostReadLine.Invoke() + $CommandLine = $__VSCodeOriginalPSConsoleHostReadLine.Invoke() + + # Command line + # OSC 633 ; E ; ; ST + $Result = "$([char]0x1b)]633;E;" + $Result += $(__VSCode-Escape-Value $CommandLine) + $Result += ";$Nonce" + $Result += "`a" + [Console]::Write($Result) + + # Command executed + # OSC 633 ; C ST + $Result += "$([char]0x1b)]633;C`a" + # Write command executed sequence directly to Console to avoid the new line from Write-Host - [Console]::Write("$([char]0x1b)]633;C`a") - $tmp + [Console]::Write($Result) + + $CommandLine } } diff --git a/src/vs/workbench/contrib/terminal/browser/media/terminal.css b/src/vs/workbench/contrib/terminal/browser/media/terminal.css index 488815cf2589b..e30f5dd92d942 100644 --- a/src/vs/workbench/contrib/terminal/browser/media/terminal.css +++ b/src/vs/workbench/contrib/terminal/browser/media/terminal.css @@ -474,6 +474,11 @@ pointer-events: none; } +.terminal-range-highlight { + outline: 1px solid var(--vscode-focusBorder); + pointer-events: none; +} + .terminal-command-guide { left: 0; border: 1.5px solid #ffffff; diff --git a/src/vs/workbench/contrib/terminal/browser/remoteTerminalBackend.ts b/src/vs/workbench/contrib/terminal/browser/remoteTerminalBackend.ts index 524a251532a1c..7841d76d3299e 100644 --- a/src/vs/workbench/contrib/terminal/browser/remoteTerminalBackend.ts +++ b/src/vs/workbench/contrib/terminal/browser/remoteTerminalBackend.ts @@ -32,6 +32,8 @@ import { IRemoteAgentService } from 'vs/workbench/services/remote/common/remoteA import { IStatusbarService } from 'vs/workbench/services/statusbar/browser/statusbar'; export class RemoteTerminalBackendContribution implements IWorkbenchContribution { + static ID = 'remoteTerminalBackend'; + constructor( @IInstantiationService instantiationService: IInstantiationService, @IRemoteAgentService remoteAgentService: IRemoteAgentService, diff --git a/src/vs/workbench/contrib/terminal/browser/terminal.contribution.ts b/src/vs/workbench/contrib/terminal/browser/terminal.contribution.ts index fba9c78e785e3..b9f06d1546f00 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminal.contribution.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminal.contribution.ts @@ -4,59 +4,61 @@ *--------------------------------------------------------------------------------------------*/ import { KeyCode, KeyMod } from 'vs/base/common/keyCodes'; +import { Schemas } from 'vs/base/common/network'; +import { isIOS, isWindows } from 'vs/base/common/platform'; +import { URI } from 'vs/base/common/uri'; import 'vs/css!./media/scrollbar'; -import 'vs/css!./media/widgets'; -import 'vs/css!./media/xterm'; import 'vs/css!./media/terminal'; import 'vs/css!./media/terminalVoice'; +import 'vs/css!./media/widgets'; +import 'vs/css!./media/xterm'; import * as nls from 'vs/nls'; -import { URI } from 'vs/base/common/uri'; +import { CONTEXT_ACCESSIBILITY_MODE_ENABLED } from 'vs/platform/accessibility/common/accessibility'; import { CommandsRegistry } from 'vs/platform/commands/common/commands'; import { ContextKeyExpr, ContextKeyExpression } from 'vs/platform/contextkey/common/contextkey'; -import { KeybindingWeight, KeybindingsRegistry, IKeybindings } from 'vs/platform/keybinding/common/keybindingsRegistry'; -import { Registry } from 'vs/platform/registry/common/platform'; -import { getQuickNavigateHandler } from 'vs/workbench/browser/quickaccess'; -import { Extensions as ViewContainerExtensions, IViewContainersRegistry, ViewContainerLocation, IViewsRegistry } from 'vs/workbench/common/views'; import { Extensions as DragAndDropExtensions, IDragAndDropContributionRegistry, IDraggedResourceEditorInput } from 'vs/platform/dnd/browser/dnd'; -import { registerTerminalActions, terminalSendSequenceCommand } from 'vs/workbench/contrib/terminal/browser/terminalActions'; -import { TerminalViewPane } from 'vs/workbench/contrib/terminal/browser/terminalView'; -import { TERMINAL_VIEW_ID, TerminalCommandId, ITerminalProfileService } from 'vs/workbench/contrib/terminal/common/terminal'; -import { registerColors } from 'vs/workbench/contrib/terminal/common/terminalColorRegistry'; -import { setupTerminalCommands } from 'vs/workbench/contrib/terminal/browser/terminalCommands'; -import { TerminalService } from 'vs/workbench/contrib/terminal/browser/terminalService'; -import { InstantiationType, registerSingleton } from 'vs/platform/instantiation/common/extensions'; -import { ITerminalEditorService, ITerminalGroupService, ITerminalInstanceService, ITerminalService, TerminalDataTransfers, terminalEditorId } from 'vs/workbench/contrib/terminal/browser/terminal'; import { SyncDescriptor } from 'vs/platform/instantiation/common/descriptors'; -import { ViewPaneContainer } from 'vs/workbench/browser/parts/views/viewPaneContainer'; +import { InstantiationType, registerSingleton } from 'vs/platform/instantiation/common/extensions'; +import { IKeybindings, KeybindingWeight, KeybindingsRegistry } from 'vs/platform/keybinding/common/keybindingsRegistry'; import { IQuickAccessRegistry, Extensions as QuickAccessExtensions } from 'vs/platform/quickinput/common/quickAccess'; -import { TerminalQuickAccessProvider } from 'vs/workbench/contrib/terminal/browser/terminalQuickAccess'; -import { registerTerminalConfiguration } from 'vs/workbench/contrib/terminal/common/terminalConfiguration'; -import { CONTEXT_ACCESSIBILITY_MODE_ENABLED } from 'vs/platform/accessibility/common/accessibility'; -import { terminalViewIcon } from 'vs/workbench/contrib/terminal/browser/terminalIcons'; +import { Registry } from 'vs/platform/registry/common/platform'; import { ITerminalLogService, TerminalSettingId, WindowsShellType } from 'vs/platform/terminal/common/terminal'; -import { isIOS, isWindows } from 'vs/base/common/platform'; -import { setupTerminalMenus } from 'vs/workbench/contrib/terminal/browser/terminalMenus'; -import { TerminalInstanceService } from 'vs/workbench/contrib/terminal/browser/terminalInstanceService'; +import { TerminalLogService } from 'vs/platform/terminal/common/terminalLogService'; import { registerTerminalPlatformConfiguration } from 'vs/platform/terminal/common/terminalPlatformConfiguration'; -import { EditorExtensions, IEditorFactoryRegistry } from 'vs/workbench/common/editor'; import { EditorPaneDescriptor, IEditorPaneRegistry } from 'vs/workbench/browser/editor'; +import { ViewPaneContainer } from 'vs/workbench/browser/parts/views/viewPaneContainer'; +import { getQuickNavigateHandler } from 'vs/workbench/browser/quickaccess'; +import { WorkbenchPhase, registerWorkbenchContribution2 } from 'vs/workbench/common/contributions'; +import { EditorExtensions, IEditorFactoryRegistry } from 'vs/workbench/common/editor'; +import { IViewContainersRegistry, IViewsRegistry, Extensions as ViewContainerExtensions, ViewContainerLocation } from 'vs/workbench/common/views'; +import { RemoteTerminalBackendContribution } from 'vs/workbench/contrib/terminal/browser/remoteTerminalBackend'; +import { ITerminalConfigurationService, ITerminalEditorService, ITerminalGroupService, ITerminalInstanceService, ITerminalService, TerminalDataTransfers, terminalEditorId } from 'vs/workbench/contrib/terminal/browser/terminal'; +import { registerTerminalActions, terminalSendSequenceCommand } from 'vs/workbench/contrib/terminal/browser/terminalActions'; +import { setupTerminalCommands } from 'vs/workbench/contrib/terminal/browser/terminalCommands'; +import { TerminalConfigurationService } from 'vs/workbench/contrib/terminal/browser/terminalConfigurationService'; import { TerminalEditor } from 'vs/workbench/contrib/terminal/browser/terminalEditor'; import { TerminalEditorInput } from 'vs/workbench/contrib/terminal/browser/terminalEditorInput'; -import { terminalStrings } from 'vs/workbench/contrib/terminal/common/terminalStrings'; -import { TerminalEditorService } from 'vs/workbench/contrib/terminal/browser/terminalEditorService'; import { TerminalInputSerializer } from 'vs/workbench/contrib/terminal/browser/terminalEditorSerializer'; +import { TerminalEditorService } from 'vs/workbench/contrib/terminal/browser/terminalEditorService'; import { TerminalGroupService } from 'vs/workbench/contrib/terminal/browser/terminalGroupService'; -import { TerminalContextKeys, TerminalContextKeyStrings } from 'vs/workbench/contrib/terminal/common/terminalContextKey'; -import { TerminalProfileService } from 'vs/workbench/contrib/terminal/browser/terminalProfileService'; -import { IWorkbenchContributionsRegistry, Extensions as WorkbenchExtensions } from 'vs/workbench/common/contributions'; -import { RemoteTerminalBackendContribution } from 'vs/workbench/contrib/terminal/browser/remoteTerminalBackend'; -import { LifecyclePhase } from 'vs/workbench/services/lifecycle/common/lifecycle'; +import { terminalViewIcon } from 'vs/workbench/contrib/terminal/browser/terminalIcons'; +import { TerminalInstanceService } from 'vs/workbench/contrib/terminal/browser/terminalInstanceService'; import { TerminalMainContribution } from 'vs/workbench/contrib/terminal/browser/terminalMainContribution'; -import { Schemas } from 'vs/base/common/network'; -import { TerminalLogService } from 'vs/platform/terminal/common/terminalLogService'; +import { setupTerminalMenus } from 'vs/workbench/contrib/terminal/browser/terminalMenus'; +import { TerminalProfileService } from 'vs/workbench/contrib/terminal/browser/terminalProfileService'; +import { TerminalQuickAccessProvider } from 'vs/workbench/contrib/terminal/browser/terminalQuickAccess'; +import { TerminalService } from 'vs/workbench/contrib/terminal/browser/terminalService'; +import { TerminalViewPane } from 'vs/workbench/contrib/terminal/browser/terminalView'; +import { TerminalWslRecommendationContribution } from 'vs/workbench/contrib/terminal/browser/terminalWslRecommendationContribution'; +import { ITerminalProfileService, TERMINAL_VIEW_ID, TerminalCommandId } from 'vs/workbench/contrib/terminal/common/terminal'; +import { registerColors } from 'vs/workbench/contrib/terminal/common/terminalColorRegistry'; +import { registerTerminalConfiguration } from 'vs/workbench/contrib/terminal/common/terminalConfiguration'; +import { TerminalContextKeyStrings, TerminalContextKeys } from 'vs/workbench/contrib/terminal/common/terminalContextKey'; +import { terminalStrings } from 'vs/workbench/contrib/terminal/common/terminalStrings'; // Register services registerSingleton(ITerminalLogService, TerminalLogService, InstantiationType.Delayed); +registerSingleton(ITerminalConfigurationService, TerminalConfigurationService, InstantiationType.Delayed); registerSingleton(ITerminalService, TerminalService, InstantiationType.Delayed); registerSingleton(ITerminalEditorService, TerminalEditorService, InstantiationType.Delayed); registerSingleton(ITerminalGroupService, TerminalGroupService, InstantiationType.Delayed); @@ -79,9 +81,10 @@ const quickAccessNavigatePreviousInTerminalPickerId = 'workbench.action.quickOpe CommandsRegistry.registerCommand({ id: quickAccessNavigatePreviousInTerminalPickerId, handler: getQuickNavigateHandler(quickAccessNavigatePreviousInTerminalPickerId, false) }); // Register workbench contributions -const workbenchRegistry = Registry.as(WorkbenchExtensions.Workbench); -workbenchRegistry.registerWorkbenchContribution(TerminalMainContribution, LifecyclePhase.Restored); -workbenchRegistry.registerWorkbenchContribution(RemoteTerminalBackendContribution, LifecyclePhase.Restored); +// This contribution blocks startup as it's critical to enable the web embedder window.createTerminal API +registerWorkbenchContribution2(TerminalMainContribution.ID, TerminalMainContribution, WorkbenchPhase.BlockStartup); +registerWorkbenchContribution2(RemoteTerminalBackendContribution.ID, RemoteTerminalBackendContribution, WorkbenchPhase.AfterRestored); +registerWorkbenchContribution2(TerminalWslRecommendationContribution.ID, TerminalWslRecommendationContribution, WorkbenchPhase.Eventually); // Register configurations registerTerminalPlatformConfiguration(); diff --git a/src/vs/workbench/contrib/terminal/browser/terminal.ts b/src/vs/workbench/contrib/terminal/browser/terminal.ts index 9a1bd1ab1b77c..699f97f9bea8f 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminal.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminal.ts @@ -6,7 +6,7 @@ import { IDimension } from 'vs/base/browser/dom'; import { Orientation } from 'vs/base/browser/ui/splitview/splitview'; import { Color } from 'vs/base/common/color'; -import { Event, IDynamicListEventMultiplexer } from 'vs/base/common/event'; +import { Event, IDynamicListEventMultiplexer, type DynamicListEventMultiplexer } from 'vs/base/common/event'; import { DisposableStore, IDisposable } from 'vs/base/common/lifecycle'; import { OperatingSystem } from 'vs/base/common/platform'; import { URI } from 'vs/base/common/uri'; @@ -23,13 +23,16 @@ import { ITerminalStatusList } from 'vs/workbench/contrib/terminal/browser/termi import { XtermTerminal } from 'vs/workbench/contrib/terminal/browser/xterm/xtermTerminal'; import { IRegisterContributedProfileArgs, IRemoteTerminalAttachTarget, IStartExtensionTerminalRequest, ITerminalConfiguration, ITerminalFont, ITerminalProcessExtHostProxy, ITerminalProcessInfo } from 'vs/workbench/contrib/terminal/common/terminal'; import { ISimpleSelectedSuggestion } from 'vs/workbench/services/suggest/browser/simpleSuggestWidget'; -import type { IMarker, ITheme, Terminal as RawXtermTerminal } from '@xterm/xterm'; +import type { IMarker, ITheme, Terminal as RawXtermTerminal, IBufferRange } from '@xterm/xterm'; import { ScrollPosition } from 'vs/workbench/contrib/terminal/browser/xterm/markNavigationAddon'; import { IContextKeyService } from 'vs/platform/contextkey/common/contextkey'; import { GroupIdentifier } from 'vs/workbench/common/editor'; import { ACTIVE_GROUP_TYPE, AUX_WINDOW_GROUP_TYPE, SIDE_GROUP_TYPE } from 'vs/workbench/services/editor/common/editorService'; +import type { ICurrentPartialCommand } from 'vs/platform/terminal/common/capabilities/commandDetection/terminalCommand'; +import type { IXtermCore } from 'vs/workbench/contrib/terminal/browser/xterm-private'; export const ITerminalService = createDecorator('terminalService'); +export const ITerminalConfigurationService = createDecorator('terminalConfigurationService'); export const ITerminalEditorService = createDecorator('terminalEditorService'); export const ITerminalGroupService = createDecorator('terminalGroupService'); export const ITerminalInstanceService = createDecorator('terminalInstanceService'); @@ -85,15 +88,6 @@ export interface ITerminalInstanceService { didRegisterBackend(remoteAuthority?: string): void; } -export interface ITerminalConfigHelper { - config: ITerminalConfiguration; - panelContainer: HTMLElement | undefined; - - configFontIsMonospace(): boolean; - getFont(w: Window): ITerminalFont; - showRecommendations(shellLaunchConfig: IShellLaunchConfig): void; -} - export const enum Direction { Left = 0, Right = 1, @@ -113,13 +107,17 @@ export interface IMarkTracker { selectToNextMark(): void; selectToPreviousLine(): void; selectToNextLine(): void; - clearMarker(): void; + clear(): void; scrollToClosestMarker(startMarkerId: string, endMarkerId?: string, highlight?: boolean | undefined): void; scrollToLine(line: number, position: ScrollPosition): void; - revealCommand(command: ITerminalCommand, position?: ScrollPosition): void; + revealCommand(command: ITerminalCommand | ICurrentPartialCommand, position?: ScrollPosition): void; + revealRange(range: IBufferRange): void; registerTemporaryDecoration(marker: IMarker, endMarker: IMarker | undefined, showOutline: boolean): void; showCommandGuide(command: ITerminalCommand | undefined): void; + + saveScrollState(): void; + restoreScrollState(): void; } export interface ITerminalGroup { @@ -243,7 +241,6 @@ export interface ITerminalService extends ITerminalInstanceHost { readonly instances: readonly ITerminalInstance[]; /** Gets detached terminal instances created via {@link createDetachedXterm}. */ readonly detachedInstances: Iterable; - readonly configHelper: ITerminalConfigHelper; readonly defaultLocation: TerminalLocation; readonly isProcessSupportRegistered: boolean; @@ -262,6 +259,7 @@ export interface ITerminalService extends ITerminalInstanceHost { readonly onDidChangeActiveGroup: Event; // Multiplexed events + readonly onAnyInstanceData: Event<{ instance: ITerminalInstance; data: string }>; readonly onAnyInstanceDataInput: Event; readonly onAnyInstanceIconChange: Event<{ instance: ITerminalInstance; userInitiated: boolean }>; readonly onAnyInstanceMaximumDimensionsChange: Event; @@ -336,7 +334,7 @@ export interface ITerminalService extends ITerminalInstanceHost { * instances and removing old instances as needed. * @param getEvent Maps the instance to the event. */ - createOnInstanceEvent(getEvent: (instance: ITerminalInstance) => Event): Event; + createOnInstanceEvent(getEvent: (instance: ITerminalInstance) => Event): DynamicListEventMultiplexer; /** * Creates a capability event listener that listens to capabilities on all instances, @@ -346,6 +344,28 @@ export interface ITerminalService extends ITerminalInstanceHost { */ createOnInstanceCapabilityEvent(capabilityId: T, getEvent: (capability: ITerminalCapabilityImplMap[T]) => Event): IDynamicListEventMultiplexer<{ instance: ITerminalInstance; data: K }>; } + +/** + * A service that provides convenient access to the terminal configuration and derived values. + */ +export interface ITerminalConfigurationService { + readonly _serviceBrand: undefined; + + /** + * A typed and partially validated representation of the terminal configuration. + */ + readonly config: Readonly; + + /** + * Fires when something within the terminal configuration changes. + */ + readonly onConfigChanged: Event; + + setPanelContainer(panelContainer: HTMLElement): void; + configFontIsMonospace(): boolean; + getFont(w: Window, xtermCore?: IXtermCore, excludeDimensions?: boolean): ITerminalFont; +} + export class TerminalLinkQuickPickEvent extends MouseEvent { } diff --git a/src/vs/workbench/contrib/terminal/browser/terminalActions.ts b/src/vs/workbench/contrib/terminal/browser/terminalActions.ts index 50a7fc56a299c..e2bb68af8c4a2 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminalActions.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminalActions.ts @@ -31,7 +31,7 @@ import { ITerminalProfile, TerminalExitReason, TerminalIcon, TerminalLocation, T import { IWorkspaceContextService, IWorkspaceFolder } from 'vs/platform/workspace/common/workspace'; import { PICK_WORKSPACE_FOLDER_COMMAND_ID } from 'vs/workbench/browser/actions/workspaceCommands'; import { CLOSE_EDITOR_COMMAND_ID } from 'vs/workbench/browser/parts/editor/editorCommands'; -import { Direction, ICreateTerminalOptions, IDetachedTerminalInstance, ITerminalConfigHelper, ITerminalEditorService, ITerminalGroupService, ITerminalInstance, ITerminalInstanceService, ITerminalService, IXtermTerminal } from 'vs/workbench/contrib/terminal/browser/terminal'; +import { Direction, ICreateTerminalOptions, IDetachedTerminalInstance, ITerminalConfigurationService, ITerminalEditorService, ITerminalGroupService, ITerminalInstance, ITerminalInstanceService, ITerminalService, IXtermTerminal } from 'vs/workbench/contrib/terminal/browser/terminal'; import { TerminalQuickAccessProvider } from 'vs/workbench/contrib/terminal/browser/terminalQuickAccess'; import { IRemoteTerminalAttachTarget, ITerminalProfileResolverService, ITerminalProfileService, TERMINAL_VIEW_ID, TerminalCommandId } from 'vs/workbench/contrib/terminal/common/terminal'; import { TerminalContextKeys } from 'vs/workbench/contrib/terminal/common/terminalContextKey'; @@ -92,8 +92,13 @@ export interface WorkspaceFolderCwdPair { isOverridden: boolean; } -export async function getCwdForSplit(configHelper: ITerminalConfigHelper, instance: ITerminalInstance, folders?: IWorkspaceFolder[], commandService?: ICommandService): Promise { - switch (configHelper.config.splitCwd) { +export async function getCwdForSplit( + instance: ITerminalInstance, + folders: IWorkspaceFolder[] | undefined, + commandService: ICommandService, + configService: ITerminalConfigurationService +): Promise { + switch (configService.config.splitCwd) { case 'workspaceRoot': if (folders !== undefined && commandService !== undefined) { if (folders.length === 1) { @@ -286,6 +291,7 @@ export function registerActiveXtermAction( export interface ITerminalServicesCollection { service: ITerminalService; + configService: ITerminalConfigurationService; groupService: ITerminalGroupService; instanceService: ITerminalInstanceService; editorService: ITerminalEditorService; @@ -296,6 +302,7 @@ export interface ITerminalServicesCollection { function getTerminalServices(accessor: ServicesAccessor): ITerminalServicesCollection { return { service: accessor.get(ITerminalService), + configService: accessor.get(ITerminalConfigurationService), groupService: accessor.get(ITerminalGroupService), instanceService: accessor.get(ITerminalInstanceService), editorService: accessor.get(ITerminalEditorService), @@ -519,6 +526,9 @@ export function registerTerminalActions() { registerActiveInstanceAction({ id: TerminalCommandId.GoToRecentDirectory, title: localize2('workbench.action.terminal.goToRecentDirectory', 'Go to Recent Directory...'), + metadata: { + description: localize2('goToRecentDirectory.metadata', 'Goes to a recent folder'), + }, precondition: sharedWhenClause.terminalAvailable, keybinding: { primary: KeyMod.CtrlCmd | KeyCode.KeyG, @@ -1180,7 +1190,7 @@ export function registerTerminalActions() { if (!activeInstance) { return; } - const cwd = await getCwdForSplit(c.service.configHelper, activeInstance, workspaceContextService.getWorkspace().folders, commandService); + const cwd = await getCwdForSplit(activeInstance, workspaceContextService.getWorkspace().folders, commandService, c.configService); if (cwd === undefined) { return; } @@ -1243,7 +1253,7 @@ export function registerTerminalActions() { registerTerminalAction({ id: TerminalCommandId.Join, - title: localize2('workbench.action.terminal.join', 'Join Terminals'), + title: localize2('workbench.action.terminal.join', 'Join Terminals...'), precondition: sharedWhenClause.terminalAvailable, run: async (c, accessor) => { const themeService = accessor.get(IThemeService); @@ -1654,7 +1664,7 @@ export function registerTerminalActions() { registerActiveInstanceAction({ id: TerminalCommandId.StartVoice, - title: localize2('workbench.action.terminal.startVoice', "Start Terminal Voice"), + title: localize2('workbench.action.terminal.startDictation', "Start Dictation in Terminal"), precondition: ContextKeyExpr.and(HasSpeechProvider, sharedWhenClause.terminalAvailable), f1: true, run: (activeInstance, c, accessor) => { @@ -1665,7 +1675,7 @@ export function registerTerminalActions() { registerActiveInstanceAction({ id: TerminalCommandId.StopVoice, - title: localize2('workbench.action.terminal.stopVoice', "Stop Terminal Voice"), + title: localize2('workbench.action.terminal.stopDictation', "Stop Dictation in Terminal"), precondition: ContextKeyExpr.and(HasSpeechProvider, sharedWhenClause.terminalAvailable), f1: true, run: (activeInstance, c, accessor) => { @@ -1776,6 +1786,15 @@ export function refreshTerminalActions(detectedProfiles: ITerminalProfile[]) { type: 'string', enum: profileEnum.values, markdownEnumDescriptions: profileEnum.markdownDescriptions + }, + location: { + description: localize('newWithProfile.location', "Where to create the terminal"), + type: 'string', + enum: ['view', 'editor'], + enumDescriptions: [ + localize('newWithProfile.location.view', 'Create the terminal in the terminal view'), + localize('newWithProfile.location.editor', 'Create the terminal in the editor'), + ] } } } @@ -1783,7 +1802,11 @@ export function refreshTerminalActions(detectedProfiles: ITerminalProfile[]) { }, }); } - async run(accessor: ServicesAccessor, eventOrOptionsOrProfile: MouseEvent | ICreateTerminalOptions | ITerminalProfile | { profileName: string } | undefined, profile?: ITerminalProfile) { + async run( + accessor: ServicesAccessor, + eventOrOptionsOrProfile: MouseEvent | ICreateTerminalOptions | ITerminalProfile | { profileName: string; location?: 'view' | 'editor' | unknown } | undefined, + profile?: ITerminalProfile + ) { const c = getTerminalServices(accessor); const workspaceContextService = accessor.get(IWorkspaceContextService); const commandService = accessor.get(ICommandService); @@ -1799,6 +1822,12 @@ export function refreshTerminalActions(detectedProfiles: ITerminalProfile[]) { throw new Error(`Could not find terminal profile "${eventOrOptionsOrProfile.profileName}"`); } options = { config }; + if ('location' in eventOrOptionsOrProfile) { + switch (eventOrOptionsOrProfile.location) { + case 'editor': options.location = TerminalLocation.Editor; break; + case 'view': options.location = TerminalLocation.Panel; break; + } + } } else if (isMouseEvent(eventOrOptionsOrProfile) || isPointerEvent(eventOrOptionsOrProfile) || isKeyboardEvent(eventOrOptionsOrProfile)) { event = eventOrOptionsOrProfile; options = profile ? { config: profile } : undefined; diff --git a/src/vs/workbench/contrib/terminal/browser/terminalConfigHelper.ts b/src/vs/workbench/contrib/terminal/browser/terminalConfigHelper.ts deleted file mode 100644 index dc0b3a04ef20d..0000000000000 --- a/src/vs/workbench/contrib/terminal/browser/terminalConfigHelper.ts +++ /dev/null @@ -1,274 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import * as nls from 'vs/nls'; -import { EDITOR_FONT_DEFAULTS, IEditorOptions } from 'vs/editor/common/config/editorOptions'; -import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; -import { ITerminalConfiguration, TERMINAL_CONFIG_SECTION, DEFAULT_LETTER_SPACING, DEFAULT_LINE_HEIGHT, MINIMUM_LETTER_SPACING, MINIMUM_FONT_WEIGHT, MAXIMUM_FONT_WEIGHT, DEFAULT_FONT_WEIGHT, DEFAULT_BOLD_FONT_WEIGHT, FontWeight, ITerminalFont } from 'vs/workbench/contrib/terminal/common/terminal'; -import Severity from 'vs/base/common/severity'; -import { INotificationService, NeverShowAgainScope } from 'vs/platform/notification/common/notification'; -import { ITerminalConfigHelper, LinuxDistro } from 'vs/workbench/contrib/terminal/browser/terminal'; -import { Emitter, Event } from 'vs/base/common/event'; -import { basename } from 'vs/base/common/path'; -import { IExtensionManagementService } from 'vs/platform/extensionManagement/common/extensionManagement'; -import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; -import { InstallRecommendedExtensionAction } from 'vs/workbench/contrib/extensions/browser/extensionsActions'; -import { IProductService } from 'vs/platform/product/common/productService'; -import { IXtermCore } from 'vs/workbench/contrib/terminal/browser/xterm-private'; -import { IShellLaunchConfig } from 'vs/platform/terminal/common/terminal'; -import { isLinux, isWindows } from 'vs/base/common/platform'; -import { Disposable } from 'vs/base/common/lifecycle'; - -const enum FontConstants { - MinimumFontSize = 6, - MaximumFontSize = 100, -} - -/** - * Encapsulates terminal configuration logic, the primary purpose of this file is so that platform - * specific test cases can be written. - */ -export class TerminalConfigHelper extends Disposable implements ITerminalConfigHelper { - panelContainer: HTMLElement | undefined; - - private _charMeasureElement: HTMLElement | undefined; - private _lastFontMeasurement: ITerminalFont | undefined; - protected _linuxDistro: LinuxDistro = LinuxDistro.Unknown; - config!: ITerminalConfiguration; - - private readonly _onConfigChanged = this._register(new Emitter()); - get onConfigChanged(): Event { return this._onConfigChanged.event; } - - constructor( - @IConfigurationService private readonly _configurationService: IConfigurationService, - @IExtensionManagementService private readonly _extensionManagementService: IExtensionManagementService, - @INotificationService private readonly _notificationService: INotificationService, - @IInstantiationService private readonly _instantiationService: IInstantiationService, - @IProductService private readonly _productService: IProductService, - ) { - super(); - this._updateConfig(); - this._register(this._configurationService.onDidChangeConfiguration(e => { - if (e.affectsConfiguration(TERMINAL_CONFIG_SECTION)) { - this._updateConfig(); - } - })); - if (isLinux) { - if (navigator.userAgent.includes('Ubuntu')) { - this._linuxDistro = LinuxDistro.Ubuntu; - } else if (navigator.userAgent.includes('Fedora')) { - this._linuxDistro = LinuxDistro.Fedora; - } - } - } - - private _updateConfig(): void { - const configValues = this._configurationService.getValue(TERMINAL_CONFIG_SECTION); - configValues.fontWeight = this._normalizeFontWeight(configValues.fontWeight, DEFAULT_FONT_WEIGHT); - configValues.fontWeightBold = this._normalizeFontWeight(configValues.fontWeightBold, DEFAULT_BOLD_FONT_WEIGHT); - - this.config = configValues; - this._onConfigChanged.fire(); - } - - configFontIsMonospace(): boolean { - const fontSize = 15; - const fontFamily = this.config.fontFamily || this._configurationService.getValue('editor').fontFamily || EDITOR_FONT_DEFAULTS.fontFamily; - const iRect = this._getBoundingRectFor('i', fontFamily, fontSize); - const wRect = this._getBoundingRectFor('w', fontFamily, fontSize); - - // Check for invalid bounds, there is no reason to believe the font is not monospace - if (!iRect || !wRect || !iRect.width || !wRect.width) { - return true; - } - - return iRect.width === wRect.width; - } - - private _createCharMeasureElementIfNecessary(): HTMLElement { - if (!this.panelContainer) { - throw new Error('Cannot measure element when terminal is not attached'); - } - // Create charMeasureElement if it hasn't been created or if it was orphaned by its parent - if (!this._charMeasureElement || !this._charMeasureElement.parentElement) { - this._charMeasureElement = document.createElement('div'); - this.panelContainer.appendChild(this._charMeasureElement); - } - return this._charMeasureElement; - } - - private _getBoundingRectFor(char: string, fontFamily: string, fontSize: number): ClientRect | DOMRect | undefined { - let charMeasureElement: HTMLElement; - try { - charMeasureElement = this._createCharMeasureElementIfNecessary(); - } catch { - return undefined; - } - const style = charMeasureElement.style; - style.display = 'inline-block'; - style.fontFamily = fontFamily; - style.fontSize = fontSize + 'px'; - style.lineHeight = 'normal'; - charMeasureElement.innerText = char; - const rect = charMeasureElement.getBoundingClientRect(); - style.display = 'none'; - - return rect; - } - - private _measureFont(w: Window, fontFamily: string, fontSize: number, letterSpacing: number, lineHeight: number): ITerminalFont { - const rect = this._getBoundingRectFor('X', fontFamily, fontSize); - - // Bounding client rect was invalid, use last font measurement if available. - if (this._lastFontMeasurement && (!rect || !rect.width || !rect.height)) { - return this._lastFontMeasurement; - } - - this._lastFontMeasurement = { - fontFamily, - fontSize, - letterSpacing, - lineHeight, - charWidth: 0, - charHeight: 0 - }; - - if (rect && rect.width && rect.height) { - this._lastFontMeasurement.charHeight = Math.ceil(rect.height); - // Char width is calculated differently for DOM and the other renderer types. Refer to - // how each renderer updates their dimensions in xterm.js - if (this.config.gpuAcceleration === 'off') { - this._lastFontMeasurement.charWidth = rect.width; - } else { - const deviceCharWidth = Math.floor(rect.width * w.devicePixelRatio); - const deviceCellWidth = deviceCharWidth + Math.round(letterSpacing); - const cssCellWidth = deviceCellWidth / w.devicePixelRatio; - this._lastFontMeasurement.charWidth = cssCellWidth - Math.round(letterSpacing) / w.devicePixelRatio; - } - } - - return this._lastFontMeasurement; - } - - /** - * Gets the font information based on the terminal.integrated.fontFamily - * terminal.integrated.fontSize, terminal.integrated.lineHeight configuration properties - */ - getFont(w: Window, xtermCore?: IXtermCore, excludeDimensions?: boolean): ITerminalFont { - const editorConfig = this._configurationService.getValue('editor'); - - let fontFamily = this.config.fontFamily || editorConfig.fontFamily || EDITOR_FONT_DEFAULTS.fontFamily; - let fontSize = this._clampInt(this.config.fontSize, FontConstants.MinimumFontSize, FontConstants.MaximumFontSize, EDITOR_FONT_DEFAULTS.fontSize); - - // Work around bad font on Fedora/Ubuntu - if (!this.config.fontFamily) { - if (this._linuxDistro === LinuxDistro.Fedora) { - fontFamily = '\'DejaVu Sans Mono\''; - } - if (this._linuxDistro === LinuxDistro.Ubuntu) { - fontFamily = '\'Ubuntu Mono\''; - - // Ubuntu mono is somehow smaller, so set fontSize a bit larger to get the same perceived size. - fontSize = this._clampInt(fontSize + 2, FontConstants.MinimumFontSize, FontConstants.MaximumFontSize, EDITOR_FONT_DEFAULTS.fontSize); - } - } - - // Always fallback to monospace, otherwise a proportional font may become the default - fontFamily += ', monospace'; - - const letterSpacing = this.config.letterSpacing ? Math.max(Math.floor(this.config.letterSpacing), MINIMUM_LETTER_SPACING) : DEFAULT_LETTER_SPACING; - const lineHeight = this.config.lineHeight ? Math.max(this.config.lineHeight, 1) : DEFAULT_LINE_HEIGHT; - - if (excludeDimensions) { - return { - fontFamily, - fontSize, - letterSpacing, - lineHeight - }; - } - - // Get the character dimensions from xterm if it's available - if (xtermCore?._renderService?._renderer.value) { - const cellDims = xtermCore._renderService.dimensions.css.cell; - if (cellDims?.width && cellDims?.height) { - return { - fontFamily, - fontSize, - letterSpacing, - lineHeight, - charHeight: cellDims.height / lineHeight, - charWidth: cellDims.width - Math.round(letterSpacing) / w.devicePixelRatio - }; - } - } - - // Fall back to measuring the font ourselves - return this._measureFont(w, fontFamily, fontSize, letterSpacing, lineHeight); - } - - private _clampInt(source: any, minimum: number, maximum: number, fallback: T): number | T { - let r = parseInt(source, 10); - if (isNaN(r)) { - return fallback; - } - if (typeof minimum === 'number') { - r = Math.max(minimum, r); - } - if (typeof maximum === 'number') { - r = Math.min(maximum, r); - } - return r; - } - - private _recommendationsShown = false; - - async showRecommendations(shellLaunchConfig: IShellLaunchConfig): Promise { - if (this._recommendationsShown) { - return; - } - this._recommendationsShown = true; - - if (isWindows && shellLaunchConfig.executable && basename(shellLaunchConfig.executable).toLowerCase() === 'wsl.exe') { - const exeBasedExtensionTips = this._productService.exeBasedExtensionTips; - if (!exeBasedExtensionTips || !exeBasedExtensionTips.wsl) { - return; - } - const extId = Object.keys(exeBasedExtensionTips.wsl.recommendations).find(extId => exeBasedExtensionTips.wsl.recommendations[extId].important); - if (extId && ! await this._isExtensionInstalled(extId)) { - this._notificationService.prompt( - Severity.Info, - nls.localize( - 'useWslExtension.title', "The '{0}' extension is recommended for opening a terminal in WSL.", exeBasedExtensionTips.wsl.friendlyName), - [ - { - label: nls.localize('install', 'Install'), - run: () => { - this._instantiationService.createInstance(InstallRecommendedExtensionAction, extId).run(); - } - } - ], - { - sticky: true, - neverShowAgain: { id: 'terminalConfigHelper/launchRecommendationsIgnore', scope: NeverShowAgainScope.APPLICATION }, - onCancel: () => { } - } - ); - } - } - } - - private async _isExtensionInstalled(id: string): Promise { - const extensions = await this._extensionManagementService.getInstalled(); - return extensions.some(e => e.identifier.id === id); - } - - private _normalizeFontWeight(input: any, defaultWeight: FontWeight): FontWeight { - if (input === 'normal' || input === 'bold') { - return input; - } - return this._clampInt(input, MINIMUM_FONT_WEIGHT, MAXIMUM_FONT_WEIGHT, defaultWeight); - } -} diff --git a/src/vs/workbench/contrib/terminal/browser/terminalConfigurationService.ts b/src/vs/workbench/contrib/terminal/browser/terminalConfigurationService.ts new file mode 100644 index 0000000000000..68f404ddad875 --- /dev/null +++ b/src/vs/workbench/contrib/terminal/browser/terminalConfigurationService.ts @@ -0,0 +1,244 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Emitter, Event } from 'vs/base/common/event'; +import { Disposable, toDisposable } from 'vs/base/common/lifecycle'; +import { EDITOR_FONT_DEFAULTS, type IEditorOptions } from 'vs/editor/common/config/editorOptions'; +import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; +import { ITerminalConfigurationService, LinuxDistro } from 'vs/workbench/contrib/terminal/browser/terminal'; +import type { IXtermCore } from 'vs/workbench/contrib/terminal/browser/xterm-private'; +import { DEFAULT_BOLD_FONT_WEIGHT, DEFAULT_FONT_WEIGHT, DEFAULT_LETTER_SPACING, DEFAULT_LINE_HEIGHT, FontWeight, ITerminalConfiguration, MAXIMUM_FONT_WEIGHT, MINIMUM_FONT_WEIGHT, MINIMUM_LETTER_SPACING, TERMINAL_CONFIG_SECTION, type ITerminalFont } from 'vs/workbench/contrib/terminal/common/terminal'; + +// #region TerminalConfigurationService + +export class TerminalConfigurationService extends Disposable implements ITerminalConfigurationService { + declare _serviceBrand: undefined; + + protected _fontMetrics: TerminalFontMetrics; + + private _config!: Readonly; + get config() { return this._config; } + + private readonly _onConfigChanged = new Emitter(); + get onConfigChanged(): Event { return this._onConfigChanged.event; } + + constructor( + @IConfigurationService private readonly _configurationService: IConfigurationService, + ) { + super(); + + this._fontMetrics = this._register(new TerminalFontMetrics(this, _configurationService)); + + this._register(Event.runAndSubscribe(this._configurationService.onDidChangeConfiguration, e => { + if (!e || e.affectsConfiguration(TERMINAL_CONFIG_SECTION)) { + this._updateConfig(); + } + })); + } + + setPanelContainer(panelContainer: HTMLElement): void { return this._fontMetrics.setPanelContainer(panelContainer); } + configFontIsMonospace(): boolean { return this._fontMetrics.configFontIsMonospace(); } + getFont(w: Window, xtermCore?: IXtermCore, excludeDimensions?: boolean): ITerminalFont { return this._fontMetrics.getFont(w, xtermCore, excludeDimensions); } + + private _updateConfig(): void { + const configValues = { ...this._configurationService.getValue(TERMINAL_CONFIG_SECTION) }; + configValues.fontWeight = this._normalizeFontWeight(configValues.fontWeight, DEFAULT_FONT_WEIGHT); + configValues.fontWeightBold = this._normalizeFontWeight(configValues.fontWeightBold, DEFAULT_BOLD_FONT_WEIGHT); + this._config = configValues; + this._onConfigChanged.fire(); + } + + private _normalizeFontWeight(input: any, defaultWeight: FontWeight): FontWeight { + if (input === 'normal' || input === 'bold') { + return input; + } + return clampInt(input, MINIMUM_FONT_WEIGHT, MAXIMUM_FONT_WEIGHT, defaultWeight); + } +} + +// #endregion TerminalConfigurationService + +// #region TerminalFontMetrics + +const enum FontConstants { + MinimumFontSize = 6, + MaximumFontSize = 100, +} + +class TerminalFontMetrics extends Disposable { + private _panelContainer: HTMLElement | undefined; + private _charMeasureElement: HTMLElement | undefined; + private _lastFontMeasurement: ITerminalFont | undefined; + + linuxDistro: LinuxDistro = LinuxDistro.Unknown; + + constructor( + private readonly _terminalConfigurationService: ITerminalConfigurationService, + private readonly _configurationService: IConfigurationService, + ) { + super(); + this._register(toDisposable(() => this._charMeasureElement?.remove())); + } + + setPanelContainer(panelContainer: HTMLElement): void { + this._panelContainer = panelContainer; + } + + configFontIsMonospace(): boolean { + const fontSize = 15; + const fontFamily = this._terminalConfigurationService.config.fontFamily || this._configurationService.getValue('editor').fontFamily || EDITOR_FONT_DEFAULTS.fontFamily; + const iRect = this._getBoundingRectFor('i', fontFamily, fontSize); + const wRect = this._getBoundingRectFor('w', fontFamily, fontSize); + + // Check for invalid bounds, there is no reason to believe the font is not monospace + if (!iRect || !wRect || !iRect.width || !wRect.width) { + return true; + } + + return iRect.width === wRect.width; + } + + /** + * Gets the font information based on the terminal.integrated.fontFamily + * terminal.integrated.fontSize, terminal.integrated.lineHeight configuration properties + */ + getFont(w: Window, xtermCore?: IXtermCore, excludeDimensions?: boolean): ITerminalFont { + const editorConfig = this._configurationService.getValue('editor'); + + let fontFamily = this._terminalConfigurationService.config.fontFamily || editorConfig.fontFamily || EDITOR_FONT_DEFAULTS.fontFamily; + let fontSize = clampInt(this._terminalConfigurationService.config.fontSize, FontConstants.MinimumFontSize, FontConstants.MaximumFontSize, EDITOR_FONT_DEFAULTS.fontSize); + + // Work around bad font on Fedora/Ubuntu + if (!this._terminalConfigurationService.config.fontFamily) { + if (this.linuxDistro === LinuxDistro.Fedora) { + fontFamily = '\'DejaVu Sans Mono\''; + } + if (this.linuxDistro === LinuxDistro.Ubuntu) { + fontFamily = '\'Ubuntu Mono\''; + + // Ubuntu mono is somehow smaller, so set fontSize a bit larger to get the same perceived size. + fontSize = clampInt(fontSize + 2, FontConstants.MinimumFontSize, FontConstants.MaximumFontSize, EDITOR_FONT_DEFAULTS.fontSize); + } + } + + // Always fallback to monospace, otherwise a proportional font may become the default + fontFamily += ', monospace'; + + const letterSpacing = this._terminalConfigurationService.config.letterSpacing ? Math.max(Math.floor(this._terminalConfigurationService.config.letterSpacing), MINIMUM_LETTER_SPACING) : DEFAULT_LETTER_SPACING; + const lineHeight = this._terminalConfigurationService.config.lineHeight ? Math.max(this._terminalConfigurationService.config.lineHeight, 1) : DEFAULT_LINE_HEIGHT; + + if (excludeDimensions) { + return { + fontFamily, + fontSize, + letterSpacing, + lineHeight + }; + } + + // Get the character dimensions from xterm if it's available + if (xtermCore?._renderService?._renderer.value) { + const cellDims = xtermCore._renderService.dimensions.css.cell; + if (cellDims?.width && cellDims?.height) { + return { + fontFamily, + fontSize, + letterSpacing, + lineHeight, + charHeight: cellDims.height / lineHeight, + charWidth: cellDims.width - Math.round(letterSpacing) / w.devicePixelRatio + }; + } + } + + // Fall back to measuring the font ourselves + return this._measureFont(w, fontFamily, fontSize, letterSpacing, lineHeight); + } + + private _createCharMeasureElementIfNecessary(): HTMLElement { + if (!this._panelContainer) { + throw new Error('Cannot measure element when terminal is not attached'); + } + // Create charMeasureElement if it hasn't been created or if it was orphaned by its parent + if (!this._charMeasureElement || !this._charMeasureElement.parentElement) { + this._charMeasureElement = document.createElement('div'); + this._panelContainer.appendChild(this._charMeasureElement); + } + return this._charMeasureElement; + } + + private _getBoundingRectFor(char: string, fontFamily: string, fontSize: number): ClientRect | DOMRect | undefined { + let charMeasureElement: HTMLElement; + try { + charMeasureElement = this._createCharMeasureElementIfNecessary(); + } catch { + return undefined; + } + const style = charMeasureElement.style; + style.display = 'inline-block'; + style.fontFamily = fontFamily; + style.fontSize = fontSize + 'px'; + style.lineHeight = 'normal'; + charMeasureElement.innerText = char; + const rect = charMeasureElement.getBoundingClientRect(); + style.display = 'none'; + + return rect; + } + + private _measureFont(w: Window, fontFamily: string, fontSize: number, letterSpacing: number, lineHeight: number): ITerminalFont { + const rect = this._getBoundingRectFor('X', fontFamily, fontSize); + + // Bounding client rect was invalid, use last font measurement if available. + if (this._lastFontMeasurement && (!rect || !rect.width || !rect.height)) { + return this._lastFontMeasurement; + } + + this._lastFontMeasurement = { + fontFamily, + fontSize, + letterSpacing, + lineHeight, + charWidth: 0, + charHeight: 0 + }; + + if (rect && rect.width && rect.height) { + this._lastFontMeasurement.charHeight = Math.ceil(rect.height); + // Char width is calculated differently for DOM and the other renderer types. Refer to + // how each renderer updates their dimensions in xterm.js + if (this._terminalConfigurationService.config.gpuAcceleration === 'off') { + this._lastFontMeasurement.charWidth = rect.width; + } else { + const deviceCharWidth = Math.floor(rect.width * w.devicePixelRatio); + const deviceCellWidth = deviceCharWidth + Math.round(letterSpacing); + const cssCellWidth = deviceCellWidth / w.devicePixelRatio; + this._lastFontMeasurement.charWidth = cssCellWidth - Math.round(letterSpacing) / w.devicePixelRatio; + } + } + + return this._lastFontMeasurement; + } +} + +// #endregion TerminalFontMetrics + +// #region Utils + +function clampInt(source: any, minimum: number, maximum: number, fallback: T): number | T { + let r = parseInt(source, 10); + if (isNaN(r)) { + return fallback; + } + if (typeof minimum === 'number') { + r = Math.max(minimum, r); + } + if (typeof maximum === 'number') { + r = Math.min(maximum, r); + } + return r; +} + +// #endregion Utils diff --git a/src/vs/workbench/contrib/terminal/browser/terminalContribExports.ts b/src/vs/workbench/contrib/terminal/browser/terminalContribExports.ts new file mode 100644 index 0000000000000..3f8dd46a31a95 --- /dev/null +++ b/src/vs/workbench/contrib/terminal/browser/terminalContribExports.ts @@ -0,0 +1,11 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +// This is a one-off/safe import, to expose to outside contfibs as in general we don't want them +// to touch terminalContrib either. +// eslint-disable-next-line local/code-import-patterns +export { TerminalChatController } from 'vs/workbench/contrib/terminalContrib/chat/browser/terminalChatController'; +// eslint-disable-next-line local/code-import-patterns +export { TerminalChatContextKeys } from 'vs/workbench/contrib/terminalContrib/chat/browser/terminalChat'; diff --git a/src/vs/workbench/contrib/terminal/browser/terminalEditor.ts b/src/vs/workbench/contrib/terminal/browser/terminalEditor.ts index 63ae90748b4f0..06e6be5305e4c 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminalEditor.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminalEditor.ts @@ -5,7 +5,7 @@ import * as dom from 'vs/base/browser/dom'; import { IActionViewItem } from 'vs/base/browser/ui/actionbar/actionbar'; -import { IAction } from 'vs/base/common/actions'; +import { Action, IAction } from 'vs/base/common/actions'; import { CancellationToken } from 'vs/base/common/cancellation'; import { DropdownWithPrimaryActionViewItem } from 'vs/platform/actions/browser/dropdownWithPrimaryActionViewItem'; import { IMenu, IMenuService, MenuId, MenuItemAction } from 'vs/platform/actions/common/actions'; @@ -18,17 +18,19 @@ import { ITelemetryService } from 'vs/platform/telemetry/common/telemetry'; import { IThemeService } from 'vs/platform/theme/common/themeService'; import { EditorPane } from 'vs/workbench/browser/parts/editor/editorPane'; import { IEditorOpenContext } from 'vs/workbench/common/editor'; -import { ITerminalEditorService, ITerminalService, terminalEditorId } from 'vs/workbench/contrib/terminal/browser/terminal'; +import { ITerminalConfigurationService, ITerminalEditorService, ITerminalService, terminalEditorId } from 'vs/workbench/contrib/terminal/browser/terminal'; import { TerminalEditorInput } from 'vs/workbench/contrib/terminal/browser/terminalEditorInput'; import { getTerminalActionBarArgs } from 'vs/workbench/contrib/terminal/browser/terminalMenus'; import { ITerminalProfileResolverService, ITerminalProfileService, TerminalCommandId } from 'vs/workbench/contrib/terminal/common/terminal'; import { IEditorGroup } from 'vs/workbench/services/editor/common/editorGroupsService'; -import { isLinux, isMacintosh } from 'vs/base/common/platform'; +import { isMacintosh } from 'vs/base/common/platform'; import { BrowserFeatures } from 'vs/base/browser/canIUse'; import { INotificationService } from 'vs/platform/notification/common/notification'; import { openContextMenu } from 'vs/workbench/contrib/terminal/browser/terminalContextMenu'; import { ACTIVE_GROUP } from 'vs/workbench/services/editor/common/editorService'; import { IWorkbenchLayoutService, Parts } from 'vs/workbench/services/layout/browser/layoutService'; +import { IBaseActionViewItemOptions } from 'vs/base/browser/ui/actionbar/actionViewItems'; +import { DisposableStore } from 'vs/base/common/lifecycle'; export class TerminalEditor extends EditorPane { @@ -45,13 +47,17 @@ export class TerminalEditor extends EditorPane { private _cancelContextMenu: boolean = false; + private readonly _disposableStore = this._register(new DisposableStore()); + constructor( + group: IEditorGroup, @ITelemetryService telemetryService: ITelemetryService, @IThemeService themeService: IThemeService, @IStorageService storageService: IStorageService, @ITerminalEditorService private readonly _terminalEditorService: ITerminalEditorService, @ITerminalProfileResolverService private readonly _terminalProfileResolverService: ITerminalProfileResolverService, @ITerminalService private readonly _terminalService: ITerminalService, + @ITerminalConfigurationService private readonly _terminalConfigurationService: ITerminalConfigurationService, @IContextKeyService contextKeyService: IContextKeyService, @IMenuService menuService: IMenuService, @IInstantiationService private readonly _instantiationService: IInstantiationService, @@ -60,7 +66,7 @@ export class TerminalEditor extends EditorPane { @ITerminalProfileService private readonly _terminalProfileService: ITerminalProfileService, @IWorkbenchLayoutService private readonly _workbenchLayoutService: IWorkbenchLayoutService ) { - super(terminalEditorId, telemetryService, themeService, storageService); + super(terminalEditorId, group, telemetryService, themeService, storageService); this._dropdownMenu = this._register(menuService.createMenu(MenuId.TerminalNewDropdownContext, contextKeyService)); this._instanceMenu = this._register(menuService.createMenu(MenuId.TerminalInstanceContext, contextKeyService)); } @@ -73,7 +79,7 @@ export class TerminalEditor extends EditorPane { if (this._lastDimension) { this.layout(this._lastDimension); } - this._editorInput.terminalInstance?.setVisible(this.isVisible() && this._workbenchLayoutService.isVisible(Parts.EDITOR_PART, dom.getWindow(this._editorInstanceElement))); + this._editorInput.terminalInstance?.setVisible(this.isVisible() && this._workbenchLayoutService.isVisible(Parts.EDITOR_PART, this.window)); if (this._editorInput.terminalInstance) { // since the editor does not monitor focus changes, for ex. between the terminal // panel and the editors, this is needed so that the active instance gets set @@ -101,7 +107,7 @@ export class TerminalEditor extends EditorPane { override focus() { super.focus(); - this._editorInput?.terminalInstance?.focus(); + this._editorInput?.terminalInstance?.focus(true); } // eslint-disable-next-line @typescript-eslint/naming-convention @@ -117,17 +123,25 @@ export class TerminalEditor extends EditorPane { return; } this._register(dom.addDisposableListener(this._editorInstanceElement, 'mousedown', async (event: MouseEvent) => { - if (this._terminalEditorService.instances.length === 0) { + const terminal = this._terminalEditorService.activeInstance; + if (this._terminalEditorService.instances.length === 0 || !terminal) { return; } - if (event.which === 2 && isLinux) { - // Drop selection and focus terminal on Linux to enable middle button paste when click - // occurs on the selection itself. - const terminal = this._terminalEditorService.activeInstance; - terminal?.focus(); + if (event.which === 2) { + switch (this._terminalConfigurationService.config.middleClickBehavior) { + case 'paste': + terminal.paste(); + break; + case 'default': + default: + // Drop selection and focus terminal on Linux to enable middle button paste + // when click occurs on the selection itself. + terminal.focus(); + break; + } } else if (event.which === 3) { - const rightClickBehavior = this._terminalService.configHelper.config.rightClickBehavior; + const rightClickBehavior = this._terminalConfigurationService.config.rightClickBehavior; if (rightClickBehavior === 'nothing') { if (!event.shiftKey) { this._cancelContextMenu = true; @@ -135,14 +149,9 @@ export class TerminalEditor extends EditorPane { return; } else if (rightClickBehavior === 'copyPaste' || rightClickBehavior === 'paste') { - const terminal = this._terminalEditorService.activeInstance; - if (!terminal) { - return; - } - // copyPaste: Shift+right click should open context menu if (rightClickBehavior === 'copyPaste' && event.shiftKey) { - openContextMenu(dom.getWindow(this._editorInstanceElement), event, this._editorInput?.terminalInstance, this._instanceMenu, this._contextMenuService); + openContextMenu(this.window, event, this._editorInput?.terminalInstance, this._instanceMenu, this._contextMenuService); return; } @@ -170,7 +179,7 @@ export class TerminalEditor extends EditorPane { } })); this._register(dom.addDisposableListener(this._editorInstanceElement, 'contextmenu', (event: MouseEvent) => { - const rightClickBehavior = this._terminalService.configHelper.config.rightClickBehavior; + const rightClickBehavior = this._terminalConfigurationService.config.rightClickBehavior; if (rightClickBehavior === 'nothing' && !event.shiftKey) { event.preventDefault(); event.stopImmediatePropagation(); @@ -180,7 +189,7 @@ export class TerminalEditor extends EditorPane { else if (!this._cancelContextMenu && rightClickBehavior !== 'copyPaste' && rightClickBehavior !== 'paste') { if (!this._cancelContextMenu) { - openContextMenu(dom.getWindow(this._editorInstanceElement), event, this._editorInput?.terminalInstance, this._instanceMenu, this._contextMenuService); + openContextMenu(this.window, event, this._editorInput?.terminalInstance, this._instanceMenu, this._contextMenuService); } event.preventDefault(); event.stopImmediatePropagation(); @@ -198,23 +207,35 @@ export class TerminalEditor extends EditorPane { this._lastDimension = dimension; } - override setVisible(visible: boolean, group?: IEditorGroup): void { - super.setVisible(visible, group); - this._editorInput?.terminalInstance?.setVisible(visible && this._workbenchLayoutService.isVisible(Parts.EDITOR_PART, dom.getWindow(this._editorInstanceElement))); + override setVisible(visible: boolean): void { + super.setVisible(visible); + this._editorInput?.terminalInstance?.setVisible(visible && this._workbenchLayoutService.isVisible(Parts.EDITOR_PART, this.window)); } - override getActionViewItem(action: IAction): IActionViewItem | undefined { + override getActionViewItem(action: IAction, options: IBaseActionViewItemOptions): IActionViewItem | undefined { switch (action.id) { case TerminalCommandId.CreateTerminalEditor: { if (action instanceof MenuItemAction) { const location = { viewColumn: ACTIVE_GROUP }; const actions = getTerminalActionBarArgs(location, this._terminalProfileService.availableProfiles, this._getDefaultProfileName(), this._terminalProfileService.contributedProfiles, this._terminalService, this._dropdownMenu); - const button = this._instantiationService.createInstance(DropdownWithPrimaryActionViewItem, action, actions.dropdownAction, actions.dropdownMenuActions, actions.className, this._contextMenuService, {}); + this._registerDisposableActions(actions.dropdownAction, actions.dropdownMenuActions); + const button = this._instantiationService.createInstance(DropdownWithPrimaryActionViewItem, action, actions.dropdownAction, actions.dropdownMenuActions, actions.className, this._contextMenuService, { hoverDelegate: options.hoverDelegate }); return button; } } } - return super.getActionViewItem(action); + return super.getActionViewItem(action, options); + } + + /** + * Actions might be of type Action (disposable) or Separator or SubmenuAction, which don't extend Disposable + */ + private _registerDisposableActions(dropdownAction: IAction, dropdownMenuActions: IAction[]): void { + this._disposableStore.clear(); + if (dropdownAction instanceof Action) { + this._disposableStore.add(dropdownAction); + } + dropdownMenuActions.filter(a => a instanceof Action).forEach(a => this._disposableStore.add(a)); } private _getDefaultProfileName(): string { diff --git a/src/vs/workbench/contrib/terminal/browser/terminalEditorSerializer.ts b/src/vs/workbench/contrib/terminal/browser/terminalEditorSerializer.ts index 5ad20c381c0f3..72200823f2ed3 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminalEditorSerializer.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminalEditorSerializer.ts @@ -14,16 +14,15 @@ export class TerminalInputSerializer implements IEditorSerializer { @ITerminalEditorService private readonly _terminalEditorService: ITerminalEditorService ) { } - public canSerialize(editorInput: TerminalEditorInput): boolean { - return !!editorInput.terminalInstance?.persistentProcessId; + public canSerialize(editorInput: TerminalEditorInput): editorInput is TerminalEditorInput & { readonly terminalInstance: ITerminalInstance } { + return typeof editorInput.terminalInstance?.persistentProcessId === 'number' && editorInput.terminalInstance.shouldPersist; } public serialize(editorInput: TerminalEditorInput): string | undefined { - if (!editorInput.terminalInstance?.persistentProcessId || !editorInput.terminalInstance.shouldPersist) { + if (!this.canSerialize(editorInput)) { return; } - const term = JSON.stringify(this._toJson(editorInput.terminalInstance)); - return term; + return JSON.stringify(this._toJson(editorInput.terminalInstance)); } public deserialize(instantiationService: IInstantiationService, serializedEditorInput: string): EditorInput | undefined { diff --git a/src/vs/workbench/contrib/terminal/browser/terminalEvents.ts b/src/vs/workbench/contrib/terminal/browser/terminalEvents.ts index 9f1630864a82d..dc1d7a2c51569 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminalEvents.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminalEvents.ts @@ -33,30 +33,30 @@ export function createInstanceCapabilityEventMultiplexer Event.map(instance.capabilities.onDidAddCapability, changeEvent => ({ instance, changeEvent })) - ); - addCapabilityMultiplexer.event(e => { + )); + store.add(addCapabilityMultiplexer.event(e => { if (e.changeEvent.id === capabilityId) { addCapability(e.instance, e.changeEvent.capability); } - }); + })); // Removed capabilities - const removeCapabilityMultiplexer = new DynamicListEventMultiplexer( + const removeCapabilityMultiplexer = store.add(new DynamicListEventMultiplexer( currentInstances, onAddInstance, onRemoveInstance, instance => instance.capabilities.onDidRemoveCapability - ); - removeCapabilityMultiplexer.event(e => { + )); + store.add(removeCapabilityMultiplexer.event(e => { if (e.id === capabilityId) { capabilityListeners.deleteAndDispose(e.capability); } - }); + })); return { dispose: () => store.dispose(), diff --git a/src/vs/workbench/contrib/terminal/browser/terminalGroup.ts b/src/vs/workbench/contrib/terminal/browser/terminalGroup.ts index d993243770f5a..466958b03197b 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminalGroup.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminalGroup.ts @@ -7,9 +7,9 @@ import { TERMINAL_VIEW_ID } from 'vs/workbench/contrib/terminal/common/terminal' import { Event, Emitter } from 'vs/base/common/event'; import { IDisposable, Disposable, DisposableStore, dispose, toDisposable } from 'vs/base/common/lifecycle'; import { SplitView, Orientation, IView, Sizing } from 'vs/base/browser/ui/splitview/splitview'; -import { IWorkbenchLayoutService, Parts, Position } from 'vs/workbench/services/layout/browser/layoutService'; +import { IWorkbenchLayoutService, Position } from 'vs/workbench/services/layout/browser/layoutService'; import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; -import { ITerminalInstance, Direction, ITerminalGroup, ITerminalService, ITerminalInstanceService } from 'vs/workbench/contrib/terminal/browser/terminal'; +import { ITerminalInstance, Direction, ITerminalGroup, ITerminalInstanceService, ITerminalConfigurationService } from 'vs/workbench/contrib/terminal/browser/terminal'; import { ViewContainerLocation, IViewDescriptorService } from 'vs/workbench/common/views'; import { IShellLaunchConfig, ITerminalTabLayoutInfoById, TerminalLocation } from 'vs/platform/terminal/common/terminal'; import { TerminalStatus } from 'vs/workbench/contrib/terminal/browser/terminalStatusList'; @@ -42,7 +42,6 @@ class SplitPaneContainer extends Disposable { constructor( private _container: HTMLElement, public orientation: Orientation, - @IWorkbenchLayoutService private readonly _layoutService: IWorkbenchLayoutService ) { super(); this._width = this._container.offsetWidth; @@ -61,25 +60,7 @@ class SplitPaneContainer extends Disposable { this._addChild(instance, index); } - resizePane(index: number, direction: Direction, amount: number, part: Parts): void { - const isHorizontal = (direction === Direction.Left) || (direction === Direction.Right); - - if ((isHorizontal && this.orientation !== Orientation.HORIZONTAL) || - (!isHorizontal && this.orientation !== Orientation.VERTICAL)) { - // Resize the entire pane as a whole - if ( - (this.orientation === Orientation.HORIZONTAL && direction === Direction.Down) || - (part === Parts.SIDEBAR_PART && direction === Direction.Left) || - (part === Parts.AUXILIARYBAR_PART && direction === Direction.Right) - ) { - amount *= -1; - } - - this._layoutService.resizePart(part, amount, amount); - return; - } - - // Resize left/right in horizontal or up/down in vertical + resizePane(index: number, direction: Direction, amount: number): void { // Only resize when there is more than one pane if (this._children.length <= 1) { return; @@ -291,7 +272,7 @@ export class TerminalGroup extends Disposable implements ITerminalGroup { constructor( private _container: HTMLElement | undefined, shellLaunchConfigOrInstance: IShellLaunchConfig | ITerminalInstance | undefined, - @ITerminalService private readonly _terminalService: ITerminalService, + @ITerminalConfigurationService private readonly _terminalConfigurationService: ITerminalConfigurationService, @ITerminalInstanceService private readonly _terminalInstanceService: ITerminalInstanceService, @IWorkbenchLayoutService private readonly _layoutService: IWorkbenchLayoutService, @IViewDescriptorService private readonly _viewDescriptorService: IViewDescriptorService, @@ -514,7 +495,7 @@ export class TerminalGroup extends Disposable implements ITerminalGroup { } private _getBellTitle(instance: ITerminalInstance) { - if (this._terminalService.configHelper.config.enableBell && instance.statusList.statuses.some(e => e.id === TerminalStatus.Bell)) { + if (this._terminalConfigurationService.config.enableBell && instance.statusList.statuses.some(e => e.id === TerminalStatus.Bell)) { return '*'; } return ''; @@ -570,17 +551,57 @@ export class TerminalGroup extends Disposable implements ITerminalGroup { this.setActiveInstanceByIndex(newIndex); } + private _getPosition(): Position { + switch (this._terminalLocation) { + case ViewContainerLocation.Panel: + return this._panelPosition; + case ViewContainerLocation.Sidebar: + return this._layoutService.getSideBarPosition(); + case ViewContainerLocation.AuxiliaryBar: + return this._layoutService.getSideBarPosition() === Position.LEFT ? Position.RIGHT : Position.LEFT; + } + } + + private _getOrientation(): Orientation { + return this._getPosition() === Position.BOTTOM ? Orientation.HORIZONTAL : Orientation.VERTICAL; + } + resizePane(direction: Direction): void { if (!this._splitPaneContainer) { return; } - const isHorizontal = (direction === Direction.Left || direction === Direction.Right); - const font = this._terminalService.configHelper.getFont(getWindow(this._groupElement)); + const isHorizontalResize = (direction === Direction.Left || direction === Direction.Right); + + const groupOrientation = this._getOrientation(); + + const shouldResizePart = + (isHorizontalResize && groupOrientation === Orientation.VERTICAL) || + (!isHorizontalResize && groupOrientation === Orientation.HORIZONTAL); + + const font = this._terminalConfigurationService.getFont(getWindow(this._groupElement)); // TODO: Support letter spacing and line height - const charSize = (isHorizontal ? font.charWidth : font.charHeight); + const charSize = (isHorizontalResize ? font.charWidth : font.charHeight); + if (charSize) { - this._splitPaneContainer.resizePane(this._activeInstanceIndex, direction, charSize * Constants.ResizePartCellCount, getPartByLocation(this._terminalLocation)); + let resizeAmount = charSize * Constants.ResizePartCellCount; + + if (shouldResizePart) { + + const shouldShrink = + (this._getPosition() === Position.LEFT && direction === Direction.Left) || + (this._getPosition() === Position.RIGHT && direction === Direction.Right) || + (this._getPosition() === Position.BOTTOM && direction === Direction.Down); + + if (shouldShrink) { + resizeAmount *= -1; + } + + this._layoutService.resizePart(getPartByLocation(this._terminalLocation), resizeAmount, resizeAmount); + } else { + this._splitPaneContainer.resizePane(this._activeInstanceIndex, direction, resizeAmount); + } + } } diff --git a/src/vs/workbench/contrib/terminal/browser/terminalGroupService.ts b/src/vs/workbench/contrib/terminal/browser/terminalGroupService.ts index b177b4fad029e..e2fefaf2a797d 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminalGroupService.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminalGroupService.ts @@ -66,13 +66,11 @@ export class TerminalGroupService extends Disposable implements ITerminalGroupSe ) { super(); - this.onDidDisposeGroup(group => this._removeGroup(group)); - this._terminalGroupCountContextKey = TerminalContextKeys.groupCount.bindTo(this._contextKeyService); - this.onDidChangeGroups(() => this._terminalGroupCountContextKey.set(this.groups.length)); - - Event.any(this.onDidChangeActiveGroup, this.onDidChangeInstances)(() => this.updateVisibility()); + this._register(this.onDidDisposeGroup(group => this._removeGroup(group))); + this._register(this.onDidChangeGroups(() => this._terminalGroupCountContextKey.set(this.groups.length))); + this._register(Event.any(this.onDidChangeActiveGroup, this.onDidChangeInstances)(() => this.updateVisibility())); } hidePanel(): void { @@ -393,7 +391,7 @@ export class TerminalGroupService extends Disposable implements ITerminalGroupSe break; } } - if (!differentGroups) { + if (!differentGroups && group.terminalInstances.length === instances.length) { return; } } diff --git a/src/vs/workbench/contrib/terminal/browser/terminalIconPicker.ts b/src/vs/workbench/contrib/terminal/browser/terminalIconPicker.ts new file mode 100644 index 0000000000000..3e18f395ab528 --- /dev/null +++ b/src/vs/workbench/contrib/terminal/browser/terminalIconPicker.ts @@ -0,0 +1,77 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Dimension, getActiveDocument } from 'vs/base/browser/dom'; +import { HoverPosition } from 'vs/base/browser/ui/hover/hoverWidget'; +import { Lazy } from 'vs/base/common/lazy'; +import { Disposable } from 'vs/base/common/lifecycle'; +import type { ThemeIcon } from 'vs/base/common/themables'; +import { IHoverService } from 'vs/platform/hover/browser/hover'; +import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; +import { defaultInputBoxStyles } from 'vs/platform/theme/browser/defaultStyles'; +import { getIconRegistry, IconContribution } from 'vs/platform/theme/common/iconRegistry'; +import { WorkbenchIconSelectBox } from 'vs/workbench/services/userDataProfile/browser/iconSelectBox'; + +const icons = new Lazy(() => { + const iconDefinitions = getIconRegistry().getIcons(); + const includedChars = new Set(); + const dedupedIcons = iconDefinitions.filter(e => { + if (!('fontCharacter' in e.defaults)) { + return false; + } + if (includedChars.has(e.defaults.fontCharacter)) { + return false; + } + includedChars.add(e.defaults.fontCharacter); + return true; + }); + return dedupedIcons; +}); + +export class TerminalIconPicker extends Disposable { + private readonly _iconSelectBox: WorkbenchIconSelectBox; + + constructor( + @IInstantiationService instantiationService: IInstantiationService, + @IHoverService private readonly _hoverService: IHoverService + ) { + super(); + + this._iconSelectBox = instantiationService.createInstance(WorkbenchIconSelectBox, { + icons: icons.value, + inputBoxStyles: defaultInputBoxStyles, + showIconInfo: true + }); + } + + async pickIcons(): Promise { + const dimension = new Dimension(486, 260); + return new Promise(resolve => { + this._register(this._iconSelectBox.onDidSelect(e => { + resolve(e); + this._iconSelectBox.dispose(); + })); + this._iconSelectBox.clearInput(); + const hoverWidget = this._hoverService.showHover({ + content: this._iconSelectBox.domNode, + target: getActiveDocument().body, + position: { + hoverPosition: HoverPosition.BELOW, + }, + persistence: { + sticky: true, + }, + appearance: { + showPointer: true + } + }, true); + if (hoverWidget) { + this._register(hoverWidget); + } + this._iconSelectBox.layout(dimension); + this._iconSelectBox.focus(); + }); + } +} diff --git a/src/vs/workbench/contrib/terminal/browser/terminalInstance.ts b/src/vs/workbench/contrib/terminal/browser/terminalInstance.ts index 9bd13171c8fe0..347c0357e17b7 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminalInstance.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminalInstance.ts @@ -11,7 +11,7 @@ import { StandardKeyboardEvent } from 'vs/base/browser/keyboardEvent'; import { Orientation } from 'vs/base/browser/ui/sash/sash'; import { DomScrollableElement } from 'vs/base/browser/ui/scrollbar/scrollableElement'; import { AutoOpenBarrier, Promises, disposableTimeout, timeout } from 'vs/base/common/async'; -import { Codicon, getAllCodicons } from 'vs/base/common/codicons'; +import { Codicon } from 'vs/base/common/codicons'; import { debounce } from 'vs/base/common/decorators'; import { ErrorNoTelemetry, onUnexpectedError } from 'vs/base/common/errors'; import { Emitter, Event } from 'vs/base/common/event'; @@ -26,7 +26,7 @@ import { URI } from 'vs/base/common/uri'; import { TabFocus } from 'vs/editor/browser/config/tabFocus'; import * as nls from 'vs/nls'; import { IAccessibilityService } from 'vs/platform/accessibility/common/accessibility'; -import { AudioCue, IAudioCueService } from 'vs/platform/audioCues/browser/audioCueService'; +import { AccessibilitySignal, IAccessibilitySignalService } from 'vs/platform/accessibilitySignal/browser/accessibilitySignalService'; import { IClipboardService } from 'vs/platform/clipboard/common/clipboardService'; import { ICommandService } from 'vs/platform/commands/common/commands'; import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; @@ -58,9 +58,8 @@ import { PANEL_BACKGROUND, SIDE_BAR_BACKGROUND } from 'vs/workbench/common/theme import { IViewDescriptorService, ViewContainerLocation } from 'vs/workbench/common/views'; import { IViewsService } from 'vs/workbench/services/views/common/viewsService'; import { AccessibilityVerbositySettingId } from 'vs/workbench/contrib/accessibility/browser/accessibilityConfiguration'; -import { IRequestAddInstanceToGroupEvent, ITerminalContribution, ITerminalInstance, IXtermColorProvider, TerminalDataTransfers } from 'vs/workbench/contrib/terminal/browser/terminal'; +import { IRequestAddInstanceToGroupEvent, ITerminalConfigurationService, ITerminalContribution, ITerminalInstance, IXtermColorProvider, TerminalDataTransfers } from 'vs/workbench/contrib/terminal/browser/terminal'; import { TerminalLaunchHelpAction } from 'vs/workbench/contrib/terminal/browser/terminalActions'; -import { TerminalConfigHelper } from 'vs/workbench/contrib/terminal/browser/terminalConfigHelper'; import { TerminalEditorInput } from 'vs/workbench/contrib/terminal/browser/terminalEditorInput'; import { TerminalExtensionsRegistry } from 'vs/workbench/contrib/terminal/browser/terminalExtensions'; import { getColorClass, createColorStyleElement, getStandardColors } from 'vs/workbench/contrib/terminal/browser/terminalIcon'; @@ -88,6 +87,7 @@ import type { IMarker, Terminal as XTermTerminal } from '@xterm/xterm'; import { AccessibilityCommandId } from 'vs/workbench/contrib/accessibility/common/accessibilityCommands'; import { terminalStrings } from 'vs/workbench/contrib/terminal/common/terminalStrings'; import { shouldPasteTerminalText } from 'vs/workbench/contrib/terminal/common/terminalClipboard'; +import { TerminalIconPicker } from 'vs/workbench/contrib/terminal/browser/terminalIconPicker'; const enum Constants { /** @@ -118,6 +118,7 @@ const shellIntegrationSupportedShellTypes = [ PosixShellType.Bash, PosixShellType.Zsh, PosixShellType.PowerShell, + PosixShellType.Python, WindowsShellType.PowerShell ]; @@ -168,9 +169,9 @@ export class TerminalInstance extends Disposable implements ITerminalInstance { private _containerReadyBarrier: AutoOpenBarrier; private _attachBarrier: AutoOpenBarrier; private _icon: TerminalIcon | undefined; - private _messageTitleDisposable: MutableDisposable = this._register(new MutableDisposable()); + private readonly _messageTitleDisposable: MutableDisposable = this._register(new MutableDisposable()); private _widgetManager: TerminalWidgetManager; - private _dndObserver: MutableDisposable = this._register(new MutableDisposable()); + private readonly _dndObserver: MutableDisposable = this._register(new MutableDisposable()); private _lastLayoutDimensions: dom.Dimension | undefined; private _hasHadInput: boolean; private _description?: string; @@ -337,10 +338,10 @@ export class TerminalInstance extends Disposable implements ITerminalInstance { constructor( private readonly _terminalShellTypeContextKey: IContextKey, private readonly _terminalInRunCommandPicker: IContextKey, - private readonly _configHelper: TerminalConfigHelper, private _shellLaunchConfig: IShellLaunchConfig, @IContextKeyService private readonly _contextKeyService: IContextKeyService, @IInstantiationService instantiationService: IInstantiationService, + @ITerminalConfigurationService private readonly _terminalConfigurationService: ITerminalConfigurationService, @ITerminalProfileResolverService private readonly _terminalProfileResolverService: ITerminalProfileResolverService, @IPathService private readonly _pathService: IPathService, @IKeybindingService private readonly _keybindingService: IKeybindingService, @@ -363,8 +364,8 @@ export class TerminalInstance extends Disposable implements ITerminalInstance { @ITelemetryService private readonly _telemetryService: ITelemetryService, @IOpenerService private readonly _openerService: IOpenerService, @ICommandService private readonly _commandService: ICommandService, - @IAudioCueService private readonly _audioCueService: IAudioCueService, - @IViewDescriptorService private readonly _viewDescriptorService: IViewDescriptorService + @IAccessibilitySignalService private readonly _accessibilitySignalService: IAccessibilitySignalService, + @IViewDescriptorService private readonly _viewDescriptorService: IViewDescriptorService, ) { super(); @@ -472,7 +473,7 @@ export class TerminalInstance extends Disposable implements ITerminalInstance { // Resolve the executable ahead of time if shell integration is enabled, this should not // be done for custom PTYs as that would cause extension Pseudoterminal-based terminals // to hang in resolver extensions - if (!this.shellLaunchConfig.customPtyImplementation && this._configHelper.config.shellIntegration?.enabled && !this.shellLaunchConfig.executable) { + if (!this.shellLaunchConfig.customPtyImplementation && this._terminalConfigurationService.config.shellIntegration?.enabled && !this.shellLaunchConfig.executable) { const os = await this._processManager.getBackendOS(); const defaultProfile = (await this._terminalProfileResolverService.getDefaultProfile({ remoteAuthority: this.remoteAuthority, os })); this.shellLaunchConfig.executable = defaultProfile.path; @@ -646,7 +647,7 @@ export class TerminalInstance extends Disposable implements ITerminalInstance { return null; } - const font = this.xterm ? this.xterm.getFont() : this._configHelper.getFont(dom.getWindow(this.domElement)); + const font = this.xterm ? this.xterm.getFont() : this._terminalConfigurationService.getFont(dom.getWindow(this.domElement)); const newRC = getXtermScaledDimensions(dom.getWindow(this.domElement), font, dimension.width, dimension.height); if (!newRC) { this._setLastKnownColsAndRows(); @@ -676,7 +677,7 @@ export class TerminalInstance extends Disposable implements ITerminalInstance { private _getDimension(width: number, height: number): ICanvasDimensions | undefined { // The font needs to have been initialized - const font = this.xterm ? this.xterm.getFont() : this._configHelper.getFont(dom.getWindow(this.domElement)); + const font = this.xterm ? this.xterm.getFont() : this._terminalConfigurationService.getFont(dom.getWindow(this.domElement)); if (!font || !font.charWidth || !font.charHeight) { return undefined; } @@ -720,11 +721,10 @@ export class TerminalInstance extends Disposable implements ITerminalInstance { throw new ErrorNoTelemetry('Terminal disposed of during xterm.js creation'); } - const disableShellIntegrationReporting = (this.shellLaunchConfig.hideFromUser || this.shellLaunchConfig.executable === undefined || this.shellType === undefined) || !shellIntegrationSupportedShellTypes.includes(this.shellType); + const disableShellIntegrationReporting = (this.shellLaunchConfig.executable === undefined || this.shellType === undefined) || !shellIntegrationSupportedShellTypes.includes(this.shellType); const xterm = this._scopedInstantiationService.createInstance( XtermTerminal, Terminal, - this._configHelper, this._cols, this._rows, this._scopedInstantiationService.createInstance(TerminalInstanceColorProvider, this), @@ -753,15 +753,15 @@ export class TerminalInstance extends Disposable implements ITerminalInstance { // starts up or reconnects disposableTimeout(() => { this._register(xterm.raw.onBell(() => { - if (this._configHelper.config.enableBell) { + if (this._configurationService.getValue(TerminalSettingId.EnableBell) || this._configurationService.getValue(TerminalSettingId.EnableVisualBell)) { this.statusList.add({ id: TerminalStatus.Bell, severity: Severity.Warning, icon: Codicon.bell, tooltip: nls.localize('bellStatus', "Bell") - }, this._configHelper.config.bellDuration); + }, this._terminalConfigurationService.config.bellDuration); } - this._audioCueService.playAudioCue(AudioCue.terminalBell); + this._accessibilitySignalService.playSignal(AccessibilitySignal.terminalBell); })); }, 1000, this._store); this._register(xterm.raw.onSelectionChange(async () => this._onSelectionChange())); @@ -823,10 +823,29 @@ export class TerminalInstance extends Disposable implements ITerminalInstance { } async runCommand(commandLine: string, shouldExecute: boolean): Promise { + let commandDetection = this.capabilities.get(TerminalCapability.CommandDetection); + + // Await command detection if the terminal is starting up + if (!commandDetection && (this._processManager.processState === ProcessState.Uninitialized || this._processManager.processState === ProcessState.Launching)) { + const store = new DisposableStore(); + await Promise.race([ + new Promise(r => { + store.add(this.capabilities.onDidAddCapabilityType(e => { + if (e === TerminalCapability.CommandDetection) { + commandDetection = this.capabilities.get(TerminalCapability.CommandDetection); + r(); + } + })); + }), + timeout(2000), + ]); + store.dispose(); + } + // Determine whether to send ETX (ctrl+c) before running the command. This should always // happen unless command detection can reliably say that a command is being entered and // there is no content in the prompt - if (this.capabilities.get(TerminalCapability.CommandDetection)?.hasInput !== false) { + if (commandDetection?.hasInput !== false) { await this.sendText('\x03', false); // Wait a little before running the command to avoid the sequences being echoed while the ^C // is being evaluated @@ -930,7 +949,7 @@ export class TerminalInstance extends Disposable implements ITerminalInstance { // Respect chords if the allowChords setting is set and it's not Escape. Escape is // handled specially for Zen Mode's Escape, Escape chord, plus it's important in // terminals generally - const isValidChord = resolveResult.kind === ResultKind.MoreChordsNeeded && this._configHelper.config.allowChords && event.key !== 'Escape'; + const isValidChord = resolveResult.kind === ResultKind.MoreChordsNeeded && this._terminalConfigurationService.config.allowChords && event.key !== 'Escape'; if (this._keybindingService.inChordMode || isValidChord) { event.preventDefault(); return false; @@ -950,7 +969,7 @@ export class TerminalInstance extends Disposable implements ITerminalInstance { // for keyboard events that resolve to commands described // within commandsToSkipShell, either alert or skip processing by xterm.js - if (resolveResult.kind === ResultKind.KbFound && resolveResult.commandId && this._skipTerminalCommands.some(k => k === resolveResult.commandId) && !this._configHelper.config.sendKeybindingsToShell) { + if (resolveResult.kind === ResultKind.KbFound && resolveResult.commandId && this._skipTerminalCommands.some(k => k === resolveResult.commandId) && !this._terminalConfigurationService.config.sendKeybindingsToShell) { // don't alert when terminal is opened or closed if (this._storageService.getBoolean(SHOW_TERMINAL_CONFIG_PROMPT_KEY, StorageScope.APPLICATION, true) && this._hasHadInput && @@ -974,7 +993,7 @@ export class TerminalInstance extends Disposable implements ITerminalInstance { } // Skip processing by xterm.js of keyboard events that match menu bar mnemonics - if (this._configHelper.config.allowMnemonics && !isMacintosh && event.altKey) { + if (this._terminalConfigurationService.config.allowMnemonics && !isMacintosh && event.altKey) { return false; } @@ -1069,13 +1088,15 @@ export class TerminalInstance extends Disposable implements ITerminalInstance { } private _initDragAndDrop(container: HTMLElement) { - const dndController = this._register(this._scopedInstantiationService.createInstance(TerminalInstanceDragAndDropController, container)); - dndController.onDropTerminal(e => this._onRequestAddInstanceToGroup.fire(e)); - dndController.onDropFile(async path => { + const store = new DisposableStore(); + const dndController = store.add(this._scopedInstantiationService.createInstance(TerminalInstanceDragAndDropController, container)); + store.add(dndController.onDropTerminal(e => this._onRequestAddInstanceToGroup.fire(e))); + store.add(dndController.onDropFile(async path => { this.focus(); await this.sendPath(path, false); - }); - this._dndObserver.value = new dom.DragAndDropObserver(container, dndController); + })); + store.add(new dom.DragAndDropObserver(container, dndController)); + this._dndObserver.value = store; } hasSelection(): boolean { @@ -1302,7 +1323,6 @@ export class TerminalInstance extends Disposable implements ITerminalInstance { const processManager = this._scopedInstantiationService.createInstance( TerminalProcessManager, this._instanceId, - this._configHelper, this.shellLaunchConfig?.cwd, deserializedCollections, this.shellLaunchConfig.attachPersistentProcess?.shellIntegrationNonce @@ -1314,7 +1334,7 @@ export class TerminalInstance extends Disposable implements ITerminalInstance { // Set the initial name based on the _resolved_ shell launch config, this will also // ensure the resolved icon gets shown if (!this._labelComputer) { - this._labelComputer = this._register(this._scopedInstantiationService.createInstance(TerminalLabelComputer, this._configHelper)); + this._labelComputer = this._register(this._scopedInstantiationService.createInstance(TerminalLabelComputer)); this._register(this._labelComputer.onDidChangeLabel(e => { const wasChanged = this._title !== e.title || this._description !== e.description; if (wasChanged) { @@ -1458,23 +1478,22 @@ export class TerminalInstance extends Disposable implements ITerminalInstance { } private _onProcessData(ev: IProcessDataEvent): void { - const messageId = ++this._latestXtermWriteData; if (ev.trackCommit) { - ev.writePromise = new Promise(r => { - this.xterm?.raw.write(ev.data, () => { - this._latestXtermParseData = messageId; - this._processManager.acknowledgeDataEvent(ev.data.length); - r(); - }); - }); + ev.writePromise = new Promise(r => this._writeProcessData(ev, r)); } else { - this.xterm?.raw.write(ev.data, () => { - this._latestXtermParseData = messageId; - this._processManager.acknowledgeDataEvent(ev.data.length); - }); + this._writeProcessData(ev); } } + private _writeProcessData(ev: IProcessDataEvent, cb?: () => void) { + const messageId = ++this._latestXtermWriteData; + this.xterm?.raw.write(ev.data, () => { + this._latestXtermParseData = messageId; + this._processManager.acknowledgeDataEvent(ev.data.length); + cb?.(); + }); + } + /** * Called when either a process tied to a terminal has exited or when a terminal renderer * simulates a process exiting (e.g. custom execution task). @@ -1531,7 +1550,7 @@ export class TerminalInstance extends Disposable implements ITerminalInstance { this.dispose(TerminalExitReason.Process); if (exitMessage) { const failedDuringLaunch = this._processManager.processState === ProcessState.KilledDuringLaunch; - if (failedDuringLaunch || this._configHelper.config.showExitAlert) { + if (failedDuringLaunch || this._terminalConfigurationService.config.showExitAlert) { // Always show launch failures this._notificationService.notify({ message: exitMessage, @@ -1742,12 +1761,12 @@ export class TerminalInstance extends Disposable implements ITerminalInstance { } updateConfig(): void { - this._setCommandsToSkipShell(this._configHelper.config.commandsToSkipShell); + this._setCommandsToSkipShell(this._terminalConfigurationService.config.commandsToSkipShell); this._refreshEnvironmentVariableInfoWidgetState(this._processManager.environmentVariableInfo); } private async _updateUnicodeVersion(): Promise { - this._processManager.setUnicodeVersion(this._configHelper.config.unicodeVersion); + this._processManager.setUnicodeVersion(this._terminalConfigurationService.config.unicodeVersion); } updateAccessibilitySupport(): void { @@ -1808,7 +1827,7 @@ export class TerminalInstance extends Disposable implements ITerminalInstance { // the characters are measured correctly. if (this._isVisible && this._layoutSettingsChanged) { const font = this.xterm.getFont(); - const config = this._configHelper.config; + const config = this._terminalConfigurationService.config; this.xterm.raw.options.letterSpacing = font.letterSpacing; this.xterm.raw.options.lineHeight = font.lineHeight; this.xterm.raw.options.fontSize = font.fontSize; @@ -1838,10 +1857,6 @@ export class TerminalInstance extends Disposable implements ITerminalInstance { this.xterm.raw.resize(cols, rows); TerminalInstance._lastKnownGridDimensions = { cols, rows }; - - if (this._isVisible) { - this.xterm.forceUnpause(); - } } if (immediate) { @@ -1983,7 +1998,7 @@ export class TerminalInstance extends Disposable implements ITerminalInstance { this._initDimensions(); await this._resize(); } else { - const font = this.xterm ? this.xterm.getFont() : this._configHelper.getFont(dom.getWindow(this.domElement)); + const font = this.xterm ? this.xterm.getFont() : this._terminalConfigurationService.getFont(dom.getWindow(this.domElement)); const maxColsForTexture = Math.floor(Constants.MaxCanvasWidth / (font.charWidth ?? 20)); // Fixed columns should be at least xterm.js' regular column count const proposedCols = Math.max(this.maxCols, Math.min(this.xterm.getLongestViewportWrappedLineLength(), maxColsForTexture)); @@ -2006,7 +2021,7 @@ export class TerminalInstance extends Disposable implements ITerminalInstance { } private async _addScrollbar(): Promise { - const charWidth = (this.xterm ? this.xterm.getFont() : this._configHelper.getFont(dom.getWindow(this.domElement))).charWidth; + const charWidth = (this.xterm ? this.xterm.getFont() : this._terminalConfigurationService.getFont(dom.getWindow(this.domElement))).charWidth; if (!this.xterm?.raw.element || !this._container || !charWidth || !this._fixedCols) { return; } @@ -2082,7 +2097,7 @@ export class TerminalInstance extends Disposable implements ITerminalInstance { // The change requires a relaunch info.requiresAction && // The feature is enabled - this._configHelper.config.environmentChangesRelaunch && + this._terminalConfigurationService.config.environmentChangesRelaunch && // Has not been interacted with !this._processManager.hasWrittenData && // Not a feature terminal or is a reconnecting task terminal (TODO: Need to explain the latter case) @@ -2094,7 +2109,7 @@ export class TerminalInstance extends Disposable implements ITerminalInstance { // Not a reconnected or revived terminal !this._shellLaunchConfig.attachPersistentProcess && // Not a Windows remote using ConPTY (#187084) - !(this._processManager.remoteAuthority && this._configHelper.config.windowsEnableConpty && (await this._processManager.getBackendOS()) === OperatingSystem.Windows) + !(this._processManager.remoteAuthority && this._terminalConfigurationService.config.windowsEnableConpty && (await this._processManager.getBackendOS()) === OperatingSystem.Windows) ) { this.relaunch(); return; @@ -2152,21 +2167,15 @@ export class TerminalInstance extends Disposable implements ITerminalInstance { this._onIconChanged.fire({ instance: this, userInitiated: true }); return icon; } - type Item = IQuickPickItem & { icon: TerminalIcon }; - const items: Item[] = []; - for (const icon of getAllCodicons()) { - items.push({ label: `$(${icon.id})`, description: `${icon.id}`, icon }); - } - const result = await this._quickInputService.pick(items, { - matchOnDescription: true, - placeHolder: nls.localize('changeIcon', 'Select an icon for the terminal') - }); - if (result) { - this._icon = result.icon; - this._onIconChanged.fire({ instance: this, userInitiated: true }); - return this._icon; + const iconPicker = this._scopedInstantiationService.createInstance(TerminalIconPicker); + const pickedIcon = await iconPicker.pickIcons(); + iconPicker.dispose(); + if (!pickedIcon) { + return undefined; } - return; + this._icon = pickedIcon; + this._onIconChanged.fire({ instance: this, userInitiated: true }); + return pickedIcon; } async changeColor(color?: string, skipQuickPick?: boolean): Promise { @@ -2389,16 +2398,16 @@ export class TerminalLabelComputer extends Disposable { readonly onDidChangeLabel = this._onDidChangeLabel.event; constructor( - private readonly _configHelper: TerminalConfigHelper, @IFileService private readonly _fileService: IFileService, + @ITerminalConfigurationService private readonly _terminalConfigurationService: ITerminalConfigurationService, @IWorkspaceContextService private readonly _workspaceContextService: IWorkspaceContextService ) { super(); } refreshLabel(instance: Pick, reset?: boolean): void { - this._title = this.computeLabel(instance, this._configHelper.config.tabs.title, TerminalLabelType.Title, reset); - this._description = this.computeLabel(instance, this._configHelper.config.tabs.description, TerminalLabelType.Description); + this._title = this.computeLabel(instance, this._terminalConfigurationService.config.tabs.title, TerminalLabelType.Title, reset); + this._description = this.computeLabel(instance, this._terminalConfigurationService.config.tabs.description, TerminalLabelType.Description); if (this._title !== instance.title || this._description !== instance.description || reset) { this._onDidChangeLabel.fire({ title: this._title, description: this._description }); } @@ -2422,7 +2431,7 @@ export class TerminalLabelComputer extends Disposable { fixedDimensions: instance.fixedCols ? (instance.fixedRows ? `\u2194${instance.fixedCols} \u2195${instance.fixedRows}` : `\u2194${instance.fixedCols}`) : (instance.fixedRows ? `\u2195${instance.fixedRows}` : ''), - separator: { label: this._configHelper.config.tabs.separator } + separator: { label: this._terminalConfigurationService.config.tabs.separator } }; labelTemplate = labelTemplate.trim(); if (!labelTemplate) { diff --git a/src/vs/workbench/contrib/terminal/browser/terminalInstanceService.ts b/src/vs/workbench/contrib/terminal/browser/terminalInstanceService.ts index 1dc0a7b24c1fc..6fa966735e4e8 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminalInstanceService.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminalInstanceService.ts @@ -10,7 +10,6 @@ import { IShellLaunchConfig, ITerminalBackend, ITerminalBackendRegistry, ITermin import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; import { TerminalInstance } from 'vs/workbench/contrib/terminal/browser/terminalInstance'; import { IContextKey, IContextKeyService } from 'vs/platform/contextkey/common/contextkey'; -import { TerminalConfigHelper } from 'vs/workbench/contrib/terminal/browser/terminalConfigHelper'; import { URI } from 'vs/base/common/uri'; import { Emitter, Event } from 'vs/base/common/event'; import { TerminalContextKeys } from 'vs/workbench/contrib/terminal/common/terminalContextKey'; @@ -22,7 +21,6 @@ export class TerminalInstanceService extends Disposable implements ITerminalInst declare _serviceBrand: undefined; private _terminalShellTypeContextKey: IContextKey; private _terminalInRunCommandPicker: IContextKey; - private _configHelper: TerminalConfigHelper; private _backendRegistration = new Map; resolve: () => void }>(); private readonly _onDidCreateInstance = this._register(new Emitter()); @@ -31,14 +29,13 @@ export class TerminalInstanceService extends Disposable implements ITerminalInst constructor( @IInstantiationService private readonly _instantiationService: IInstantiationService, @IContextKeyService private readonly _contextKeyService: IContextKeyService, - @IWorkbenchEnvironmentService readonly _environmentService: IWorkbenchEnvironmentService, + @IWorkbenchEnvironmentService environmentService: IWorkbenchEnvironmentService, ) { super(); this._terminalShellTypeContextKey = TerminalContextKeys.shellType.bindTo(this._contextKeyService); this._terminalInRunCommandPicker = TerminalContextKeys.inTerminalRunCommandPicker.bindTo(this._contextKeyService); - this._configHelper = _instantiationService.createInstance(TerminalConfigHelper); - for (const remoteAuthority of [undefined, _environmentService.remoteAuthority]) { + for (const remoteAuthority of [undefined, environmentService.remoteAuthority]) { const { promise, resolve } = promiseWithResolvers(); this._backendRegistration.set(remoteAuthority, { promise, resolve }); } @@ -51,7 +48,6 @@ export class TerminalInstanceService extends Disposable implements ITerminalInst const instance = this._instantiationService.createInstance(TerminalInstance, this._terminalShellTypeContextKey, this._terminalInRunCommandPicker, - this._configHelper, shellLaunchConfig ); instance.target = target; diff --git a/src/vs/workbench/contrib/terminal/browser/terminalMainContribution.ts b/src/vs/workbench/contrib/terminal/browser/terminalMainContribution.ts index 69fe49e69dac3..2ff5e51cfe6d7 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminalMainContribution.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminalMainContribution.ts @@ -12,6 +12,8 @@ import { ITerminalEditorService, ITerminalGroupService, ITerminalInstanceService import { parseTerminalUri } from 'vs/workbench/contrib/terminal/browser/terminalUri'; import { terminalStrings } from 'vs/workbench/contrib/terminal/common/terminalStrings'; import { IEditorResolverService, RegisteredEditorPriority } from 'vs/workbench/services/editor/common/editorResolverService'; +import { IWorkbenchEnvironmentService } from 'vs/workbench/services/environment/common/environmentService'; +import { ILifecycleService, LifecyclePhase } from 'vs/workbench/services/lifecycle/common/lifecycle'; import { IEmbedderTerminalService } from 'vs/workbench/services/terminal/common/embedderTerminalService'; /** @@ -20,10 +22,14 @@ import { IEmbedderTerminalService } from 'vs/workbench/services/terminal/common/ * be more relevant). */ export class TerminalMainContribution extends Disposable implements IWorkbenchContribution { + static ID = 'terminalMain'; + constructor( @IEditorResolverService editorResolverService: IEditorResolverService, @IEmbedderTerminalService embedderTerminalService: IEmbedderTerminalService, + @IWorkbenchEnvironmentService workbenchEnvironmentService: IWorkbenchEnvironmentService, @ILabelService labelService: ILabelService, + @ILifecycleService lifecycleService: ILifecycleService, @ITerminalService terminalService: ITerminalService, @ITerminalEditorService terminalEditorService: ITerminalEditorService, @ITerminalGroupService terminalGroupService: ITerminalGroupService, @@ -31,8 +37,50 @@ export class TerminalMainContribution extends Disposable implements IWorkbenchCo ) { super(); + this._init( + editorResolverService, + embedderTerminalService, + workbenchEnvironmentService, + labelService, + lifecycleService, + terminalService, + terminalEditorService, + terminalGroupService, + terminalInstanceService + ); + } + + private async _init( + editorResolverService: IEditorResolverService, + embedderTerminalService: IEmbedderTerminalService, + workbenchEnvironmentService: IWorkbenchEnvironmentService, + labelService: ILabelService, + lifecycleService: ILifecycleService, + terminalService: ITerminalService, + terminalEditorService: ITerminalEditorService, + terminalGroupService: ITerminalGroupService, + terminalInstanceService: ITerminalInstanceService + ) { + // Defer this for the local case only. This is important for the + // window.createTerminal web embedder API to work before the workbench + // is loaded on remote + if (workbenchEnvironmentService.remoteAuthority === undefined) { + await lifecycleService.when(LifecyclePhase.Restored); + } + + this._register(embedderTerminalService.onDidCreateTerminal(async embedderTerminal => { + const terminal = await terminalService.createTerminal({ + config: embedderTerminal, + location: TerminalLocation.Panel + }); + terminalService.setActiveInstance(terminal); + await terminalService.revealActiveTerminal(); + })); + + await lifecycleService.when(LifecyclePhase.Restored); + // Register terminal editors - editorResolverService.registerEditor( + this._register(editorResolverService.registerEditor( `${Schemas.vscodeTerminal}:/**`, { id: terminalEditorId, @@ -80,24 +128,15 @@ export class TerminalMainContribution extends Disposable implements IWorkbenchCo }; } } - ); + )); // Register a resource formatter for terminal URIs - labelService.registerFormatter({ + this._register(labelService.registerFormatter({ scheme: Schemas.vscodeTerminal, formatting: { label: '${path}', separator: '' } - }); - - embedderTerminalService.onDidCreateTerminal(async embedderTerminal => { - const terminal = await terminalService.createTerminal({ - config: embedderTerminal, - location: TerminalLocation.Panel - }); - terminalService.setActiveInstance(terminal); - await terminalService.revealActiveTerminal(); - }); + })); } } diff --git a/src/vs/workbench/contrib/terminal/browser/terminalMenus.ts b/src/vs/workbench/contrib/terminal/browser/terminalMenus.ts index 7f119914b0921..73e72755f3e83 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminalMenus.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminalMenus.ts @@ -20,17 +20,17 @@ import { ACTIVE_GROUP, SIDE_GROUP } from 'vs/workbench/services/editor/common/ed const enum ContextMenuGroup { Create = '1_create', - Edit = '2_edit', - Clear = '3_clear', - Kill = '4_kill', - Config = '5_config' + Edit = '3_edit', + Clear = '5_clear', + Kill = '7_kill', + Config = '9_config' } export const enum TerminalMenuBarGroup { Create = '1_create', - Run = '2_run', - Manage = '3_manage', - Configure = '4_configure' + Run = '3_run', + Manage = '5_manage', + Configure = '7_configure' } export function setupTerminalMenus(): void { @@ -293,7 +293,7 @@ export function setupTerminalMenus(): void { item: { command: { id: TerminalCommandId.NewWithProfile, - title: localize('workbench.action.terminal.newWithProfile.short', "New Terminal With Profile") + title: localize('workbench.action.terminal.newWithProfile.short', "New Terminal With Profile...") }, group: ContextMenuGroup.Create } diff --git a/src/vs/workbench/contrib/terminal/browser/terminalProcessManager.ts b/src/vs/workbench/contrib/terminal/browser/terminalProcessManager.ts index 81770243f916f..2e51964baaf89 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminalProcessManager.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminalProcessManager.ts @@ -22,7 +22,7 @@ import { FlowControlConstants, IProcessDataEvent, IProcessProperty, IProcessProp import { TerminalRecorder } from 'vs/platform/terminal/common/terminalRecorder'; import { IWorkspaceContextService, IWorkspaceFolder } from 'vs/platform/workspace/common/workspace'; import { EnvironmentVariableInfoChangesActive, EnvironmentVariableInfoStale } from 'vs/workbench/contrib/terminal/browser/environmentVariableInfo'; -import { ITerminalConfigHelper, ITerminalInstanceService } from 'vs/workbench/contrib/terminal/browser/terminal'; +import { ITerminalConfigurationService, ITerminalInstanceService } from 'vs/workbench/contrib/terminal/browser/terminal'; import { IEnvironmentVariableInfo, IEnvironmentVariableService } from 'vs/workbench/contrib/terminal/common/environmentVariable'; import { MergedEnvironmentVariableCollection } from 'vs/platform/terminal/common/environmentVariableCollection'; import { serializeEnvironmentVariableCollections } from 'vs/platform/terminal/common/environmentVariableShared'; @@ -40,6 +40,7 @@ import { IEnvironmentVariableCollection, IMergedEnvironmentVariableCollection } import { generateUuid } from 'vs/base/common/uuid'; import { getActiveWindow, runWhenWindowIdle } from 'vs/base/browser/dom'; import { mainWindow } from 'vs/base/browser/window'; +import { shouldUseEnvironmentVariableCollection } from 'vs/platform/terminal/common/terminalEnvironment'; const enum ProcessConstants { /** @@ -74,7 +75,7 @@ export class TerminalProcessManager extends Disposable implements ITerminalProce userHome: string | undefined; environmentVariableInfo: IEnvironmentVariableInfo | undefined; backend: ITerminalBackend | undefined; - readonly capabilities = new TerminalCapabilityStore(); + readonly capabilities = this._register(new TerminalCapabilityStore()); readonly shellIntegrationNonce: string; private _isDisposed: boolean = false; @@ -129,7 +130,6 @@ export class TerminalProcessManager extends Disposable implements ITerminalProce constructor( private readonly _instanceId: number, - private readonly _configHelper: ITerminalConfigHelper, cwd: string | URI | undefined, environmentVariableCollections: ReadonlyMap | undefined, shellIntegrationNonce: string | undefined, @@ -143,6 +143,7 @@ export class TerminalProcessManager extends Disposable implements ITerminalProce @IRemoteAgentService private readonly _remoteAgentService: IRemoteAgentService, @IPathService private readonly _pathService: IPathService, @IEnvironmentVariableService private readonly _environmentVariableService: IEnvironmentVariableService, + @ITerminalConfigurationService private readonly _terminalConfigurationService: ITerminalConfigurationService, @ITerminalProfileResolverService private readonly _terminalProfileResolverService: ITerminalProfileResolverService, @IConfigurationService private readonly _configurationService: IConfigurationService, @ITerminalInstanceService private readonly _terminalInstanceService: ITerminalInstanceService, @@ -153,8 +154,8 @@ export class TerminalProcessManager extends Disposable implements ITerminalProce this._cwdWorkspaceFolder = terminalEnvironment.getWorkspaceForTerminal(cwd, this._workspaceContextService, this._historyService); this.ptyProcessReady = this._createPtyProcessReadyPromise(); this._ackDataBufferer = new AckDataBufferer(e => this._process?.acknowledgeDataEvent(e)); - this._dataFilter = this._instantiationService.createInstance(SeamlessRelaunchDataFilter); - this._dataFilter.onProcessData(ev => { + this._dataFilter = this._register(this._instantiationService.createInstance(SeamlessRelaunchDataFilter)); + this._register(this._dataFilter.onProcessData(ev => { const data = (typeof ev === 'string' ? ev : ev.data); const beforeProcessDataEvent: IBeforeProcessDataEvent = { data }; this._onBeforeProcessData.fire(beforeProcessDataEvent); @@ -165,7 +166,7 @@ export class TerminalProcessManager extends Disposable implements ITerminalProce } this._onProcessData.fire(typeof ev !== 'string' ? ev : { data: beforeProcessDataEvent.data, trackCommit: false }); } - }); + })); if (cwd && typeof cwd === 'object') { this.remoteAuthority = getRemoteAuthority(cwd); @@ -207,12 +208,14 @@ export class TerminalProcessManager extends Disposable implements ITerminalProce } private _createPtyProcessReadyPromise(): Promise { + return new Promise(c => { - const listener = this.onProcessReady(() => { + const listener = Event.once(this.onProcessReady)(() => { this._logService.debug(`Terminal process ready (shellProcessId: ${this.shellProcessId})`); - listener.dispose(); + this._store.delete(listener); c(undefined); }); + this._store.add(listener); }); } @@ -263,7 +266,7 @@ export class TerminalProcessManager extends Disposable implements ITerminalProce // this is a copy of what the merged environment collection is on the remote side const env = await this._resolveEnvironment(backend, variableResolver, shellLaunchConfig); - const shouldPersist = ((this._configurationService.getValue(TaskSettingId.Reconnection) && shellLaunchConfig.reconnectionProperties) || !shellLaunchConfig.isFeatureTerminal) && this._configHelper.config.enablePersistentSessions && !shellLaunchConfig.isTransient; + const shouldPersist = ((this._configurationService.getValue(TaskSettingId.Reconnection) && shellLaunchConfig.reconnectionProperties) || !shellLaunchConfig.isFeatureTerminal) && this._terminalConfigurationService.config.enablePersistentSessions && !shellLaunchConfig.isTransient; if (shellLaunchConfig.attachPersistentProcess) { const result = await backend.attachToProcess(shellLaunchConfig.attachPersistentProcess.id); if (result) { @@ -285,7 +288,7 @@ export class TerminalProcessManager extends Disposable implements ITerminalProce suggestEnabled: this._configurationService.getValue(TerminalSettingId.ShellIntegrationSuggestEnabled), nonce: this.shellIntegrationNonce }, - windowsEnableConpty: this._configHelper.config.windowsEnableConpty, + windowsEnableConpty: this._terminalConfigurationService.config.windowsEnableConpty, environmentVariableCollections: this._extEnvironmentVariableCollection?.collections ? serializeEnvironmentVariableCollections(this._extEnvironmentVariableCollection.collections) : undefined, workspaceFolder: this._cwdWorkspaceFolder, }; @@ -295,7 +298,7 @@ export class TerminalProcessManager extends Disposable implements ITerminalProce '', // TODO: Fix cwd cols, rows, - this._configHelper.config.unicodeVersion, + this._terminalConfigurationService.config.unicodeVersion, env, // TODO: options, shouldPersist @@ -426,7 +429,6 @@ export class TerminalProcessManager extends Disposable implements ITerminalProce const workspaceFolder = terminalEnvironment.getWorkspaceForTerminal(shellLaunchConfig.cwd, this._workspaceContextService, this._historyService); const platformKey = isWindows ? 'windows' : (isMacintosh ? 'osx' : 'linux'); const envFromConfigValue = this._configurationService.getValue(`terminal.integrated.env.${platformKey}`); - this._configHelper.showRecommendations(shellLaunchConfig); let baseEnv: IProcessEnvironment; if (shellLaunchConfig.useShellEnvironment) { @@ -435,8 +437,8 @@ export class TerminalProcessManager extends Disposable implements ITerminalProce } else { baseEnv = await this._terminalProfileResolverService.getEnvironment(this.remoteAuthority); } - const env = await terminalEnvironment.createTerminalEnvironment(shellLaunchConfig, envFromConfigValue, variableResolver, this._productService.version, this._configHelper.config.detectLocale, baseEnv); - if (!this._isDisposed && !shellLaunchConfig.strictEnv && !shellLaunchConfig.hideFromUser) { + const env = await terminalEnvironment.createTerminalEnvironment(shellLaunchConfig, envFromConfigValue, variableResolver, this._productService.version, this._terminalConfigurationService.config.detectLocale, baseEnv); + if (!this._isDisposed && shouldUseEnvironmentVariableCollection(shellLaunchConfig)) { this._extEnvironmentVariableCollection = this._environmentVariableService.mergedCollection; this._register(this._environmentVariableService.onDidChangeCollections(newCollection => this._onEnvironmentVariableCollectionChange(newCollection))); @@ -474,7 +476,7 @@ export class TerminalProcessManager extends Disposable implements ITerminalProce userHome, variableResolver, activeWorkspaceRootUri, - this._configHelper.config.cwd, + this._terminalConfigurationService.config.cwd, this._logService ); @@ -486,12 +488,12 @@ export class TerminalProcessManager extends Disposable implements ITerminalProce suggestEnabled: this._configurationService.getValue(TerminalSettingId.ShellIntegrationSuggestEnabled), nonce: this.shellIntegrationNonce }, - windowsEnableConpty: this._configHelper.config.windowsEnableConpty, + windowsEnableConpty: this._terminalConfigurationService.config.windowsEnableConpty, environmentVariableCollections: this._extEnvironmentVariableCollection ? serializeEnvironmentVariableCollections(this._extEnvironmentVariableCollection.collections) : undefined, workspaceFolder: this._cwdWorkspaceFolder, }; - const shouldPersist = ((this._configurationService.getValue(TaskSettingId.Reconnection) && shellLaunchConfig.reconnectionProperties) || !shellLaunchConfig.isFeatureTerminal) && this._configHelper.config.enablePersistentSessions && !shellLaunchConfig.isTransient; - return await backend.createProcess(shellLaunchConfig, initialCwd, cols, rows, this._configHelper.config.unicodeVersion, env, options, shouldPersist); + const shouldPersist = ((this._configurationService.getValue(TaskSettingId.Reconnection) && shellLaunchConfig.reconnectionProperties) || !shellLaunchConfig.isFeatureTerminal) && this._terminalConfigurationService.config.enablePersistentSessions && !shellLaunchConfig.isTransient; + return await backend.createProcess(shellLaunchConfig, initialCwd, cols, rows, this._terminalConfigurationService.config.unicodeVersion, env, options, shouldPersist); } private _setupPtyHostListeners(backend: ITerminalBackend) { diff --git a/src/vs/workbench/contrib/terminal/browser/terminalProfileQuickpick.ts b/src/vs/workbench/contrib/terminal/browser/terminalProfileQuickpick.ts index b7afa342a6721..20fd702cd145b 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminalProfileQuickpick.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminalProfileQuickpick.ts @@ -6,7 +6,7 @@ import { Codicon } from 'vs/base/common/codicons'; import { ConfigurationTarget, IConfigurationService } from 'vs/platform/configuration/common/configuration'; import { IQuickInputService, IKeyMods, IPickOptions, IQuickPickSeparator, IQuickInputButton, IQuickPickItem } from 'vs/platform/quickinput/common/quickInput'; -import { IExtensionTerminalProfile, ITerminalProfile, ITerminalProfileObject, TerminalSettingPrefix } from 'vs/platform/terminal/common/terminal'; +import { IExtensionTerminalProfile, ITerminalProfile, ITerminalProfileObject, TerminalSettingPrefix, type ITerminalExecutable } from 'vs/platform/terminal/common/terminal'; import { getUriClasses, getColorClass, createColorStyleElement } from 'vs/workbench/contrib/terminal/browser/terminalIcon'; import { configureTerminalProfileIcon } from 'vs/workbench/contrib/terminal/browser/terminalIcons'; import * as nls from 'vs/nls'; @@ -69,9 +69,9 @@ export class TerminalProfileQuickpick { if (result.profile.args) { newProfile.args = result.profile.args; } - (profilesConfig as { [key: string]: ITerminalProfileObject })[result.profile.profileName] = newProfile; + (profilesConfig as { [key: string]: ITerminalProfileObject })[result.profile.profileName] = this._createNewProfileConfig(result.profile); + await this._configurationService.updateValue(profilesKey, profilesConfig, ConfigurationTarget.USER); } - await this._configurationService.updateValue(profilesKey, profilesConfig, ConfigurationTarget.USER); } // Set the default profile await this._configurationService.updateValue(defaultProfileKey, result.profileName, ConfigurationTarget.USER); @@ -131,10 +131,9 @@ export class TerminalProfileQuickpick { if (!name) { return; } - const newConfigValue: { [key: string]: ITerminalProfileObject } = { ...configProfiles }; - newConfigValue[name] = { - path: context.item.profile.path, - args: context.item.profile.args + const newConfigValue: { [key: string]: ITerminalExecutable } = { + ...configProfiles, + [name]: this._createNewProfileConfig(context.item.profile) }; await this._configurationService.updateValue(profilesKey, newConfigValue, ConfigurationTarget.USER); }, @@ -212,6 +211,17 @@ export class TerminalProfileQuickpick { return result; } + private _createNewProfileConfig(profile: ITerminalProfile): ITerminalExecutable { + const result: ITerminalExecutable = { path: profile.path }; + if (profile.args) { + result.args = profile.args; + } + if (profile.env) { + result.env = profile.env; + } + return result; + } + private async _isProfileSafe(profile: ITerminalProfile | IExtensionTerminalProfile): Promise { const isUnsafePath = 'isUnsafePath' in profile && profile.isUnsafePath; const requiresUnsafePath = 'requiresUnsafePath' in profile && profile.requiresUnsafePath; diff --git a/src/vs/workbench/contrib/terminal/browser/terminalProfileService.ts b/src/vs/workbench/contrib/terminal/browser/terminalProfileService.ts index 63dafffca6272..87a2bb83a65d6 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminalProfileService.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminalProfileService.ts @@ -89,7 +89,7 @@ export class TerminalProfileService extends Disposable implements ITerminalProfi private async _setupConfigListener(): Promise { const platformKey = await this.getPlatformKey(); - this._configurationService.onDidChangeConfiguration(async e => { + this._register(this._configurationService.onDidChangeConfiguration(async e => { if (e.affectsConfiguration(TerminalSettingPrefix.AutomationProfile + platformKey) || e.affectsConfiguration(TerminalSettingPrefix.DefaultProfile + platformKey) || e.affectsConfiguration(TerminalSettingPrefix.Profiles + platformKey) || @@ -103,7 +103,7 @@ export class TerminalProfileService extends Disposable implements ITerminalProfi this._platformConfigJustRefreshed = true; } } - }); + })); } getDefaultProfileName(): string | undefined { diff --git a/src/vs/workbench/contrib/terminal/browser/terminalQuickAccess.ts b/src/vs/workbench/contrib/terminal/browser/terminalQuickAccess.ts index 892cbc5dcb990..6aa5ee283dda2 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminalQuickAccess.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminalQuickAccess.ts @@ -74,7 +74,7 @@ export class TerminalQuickAccessProvider extends PickerQuickAccessProvider this._commandService.executeCommand(TerminalCommandId.New) }); - const createWithProfileLabel = localize("workbench.action.terminal.newWithProfilePlus", "Create New Terminal With Profile"); + const createWithProfileLabel = localize("workbench.action.terminal.newWithProfilePlus", "Create New Terminal With Profile..."); terminalPicks.push({ label: `$(plus) ${createWithProfileLabel}`, ariaLabel: createWithProfileLabel, diff --git a/src/vs/workbench/contrib/terminal/browser/terminalRunRecentQuickPick.ts b/src/vs/workbench/contrib/terminal/browser/terminalRunRecentQuickPick.ts index e1ab421d42358..c6f6bdb61d4b4 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminalRunRecentQuickPick.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminalRunRecentQuickPick.ts @@ -218,7 +218,7 @@ export async function showRunRecentQuickPick( instantiationService.invokeFunction(showRunRecentQuickPick, instance, terminalInRunCommandPicker, type, fuzzySearchToggle.checked ? 'fuzzy' : 'contiguous', quickPick.value); }); const outputProvider = instantiationService.createInstance(TerminalOutputProvider); - const quickPick = quickInputService.createQuickPick(); + const quickPick = quickInputService.createQuickPick(); const originalItems = items; quickPick.items = [...originalItems]; quickPick.sortByLabel = false; @@ -258,6 +258,39 @@ export async function showRunRecentQuickPick( await instantiationService.invokeFunction(showRunRecentQuickPick, instance, terminalInRunCommandPicker, type, filterMode, value); } }); + let terminalScrollStateSaved = false; + function restoreScrollState() { + terminalScrollStateSaved = false; + instance.xterm?.markTracker.restoreScrollState(); + instance.xterm?.markTracker.clear(); + } + quickPick.onDidChangeActive(async () => { + const xterm = instance.xterm; + if (!xterm) { + return; + } + const [item] = quickPick.activeItems; + if ('command' in item && item.command && item.command.marker) { + if (!terminalScrollStateSaved) { + xterm.markTracker.saveScrollState(); + terminalScrollStateSaved = true; + } + const promptRowCount = item.command.getPromptRowCount(); + const commandRowCount = item.command.getCommandRowCount(); + xterm.markTracker.revealRange({ + start: { + x: 1, + y: item.command.marker.line - (promptRowCount - 1) + 1 + }, + end: { + x: instance.cols, + y: item.command.marker.line + (commandRowCount - 1) + 1 + } + }); + } else { + restoreScrollState(); + } + }); quickPick.onDidAccept(async () => { const result = quickPick.activeItems[0]; let text: string; @@ -271,7 +304,9 @@ export async function showRunRecentQuickPick( if (quickPick.keyMods.alt) { instance.focus(); } + restoreScrollState(); }); + quickPick.onDidHide(() => restoreScrollState()); if (value) { quickPick.value = value; } diff --git a/src/vs/workbench/contrib/terminal/browser/terminalService.ts b/src/vs/workbench/contrib/terminal/browser/terminalService.ts index f78008e14db96..debb509f84761 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminalService.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminalService.ts @@ -30,9 +30,8 @@ import { IWorkspaceContextService } from 'vs/platform/workspace/common/workspace import { VirtualWorkspaceContext } from 'vs/workbench/common/contextkeys'; import { IEditableData } from 'vs/workbench/common/views'; import { IViewsService } from 'vs/workbench/services/views/common/viewsService'; -import { ICreateTerminalOptions, IDetachedTerminalInstance, IDetachedXTermOptions, IRequestAddInstanceToGroupEvent, ITerminalConfigHelper, ITerminalEditorService, ITerminalGroup, ITerminalGroupService, ITerminalInstance, ITerminalInstanceHost, ITerminalInstanceService, ITerminalLocationOptions, ITerminalService, ITerminalServiceNativeDelegate, TerminalConnectionState, TerminalEditorLocation } from 'vs/workbench/contrib/terminal/browser/terminal'; +import { ICreateTerminalOptions, IDetachedTerminalInstance, IDetachedXTermOptions, IRequestAddInstanceToGroupEvent, ITerminalConfigurationService, ITerminalEditorService, ITerminalGroup, ITerminalGroupService, ITerminalInstance, ITerminalInstanceHost, ITerminalInstanceService, ITerminalLocationOptions, ITerminalService, ITerminalServiceNativeDelegate, TerminalConnectionState, TerminalEditorLocation } from 'vs/workbench/contrib/terminal/browser/terminal'; import { getCwdForSplit } from 'vs/workbench/contrib/terminal/browser/terminalActions'; -import { TerminalConfigHelper } from 'vs/workbench/contrib/terminal/browser/terminalConfigHelper'; import { TerminalEditorInput } from 'vs/workbench/contrib/terminal/browser/terminalEditorInput'; import { getColorStyleContent, getUriClasses } from 'vs/workbench/contrib/terminal/browser/terminalIcon'; import { TerminalProfileQuickpick } from 'vs/workbench/contrib/terminal/browser/terminalProfileQuickpick'; @@ -76,7 +75,6 @@ export class TerminalService extends Disposable implements ITerminalService { private _primaryBackend?: ITerminalBackend; private _terminalHasBeenCreated: IContextKey; private _terminalCountContextKey: IContextKey; - private _configHelper: TerminalConfigHelper; private _nativeDelegate?: ITerminalServiceNativeDelegate; private _shutdownWindowCount?: number; @@ -93,7 +91,6 @@ export class TerminalService extends Disposable implements ITerminalService { private _restoredGroupCount: number = 0; get restoredGroupCount(): number { return this._restoredGroupCount; } - get configHelper(): ITerminalConfigHelper { return this._configHelper; } get instances(): ITerminalInstance[] { return this._terminalGroupService.instances.concat(this._terminalEditorService.instances); } @@ -108,7 +105,7 @@ export class TerminalService extends Disposable implements ITerminalService { return this._reconnectedTerminals.get(reconnectionOwner); } - get defaultLocation(): TerminalLocation { return this.configHelper.config.defaultLocation === TerminalLocationString.Editor ? TerminalLocation.Editor : TerminalLocation.Panel; } + get defaultLocation(): TerminalLocation { return this._terminalConfigurationService.config.defaultLocation === TerminalLocationString.Editor ? TerminalLocation.Editor : TerminalLocation.Panel; } private _activeInstance: ITerminalInstance | undefined; get activeInstance(): ITerminalInstance | undefined { @@ -154,13 +151,15 @@ export class TerminalService extends Disposable implements ITerminalService { get onDidChangeActiveGroup(): Event { return this._onDidChangeActiveGroup.event; } // Lazily initialized events that fire when the specified event fires on _any_ terminal - @memoize get onAnyInstanceDataInput() { return this.createOnInstanceEvent(e => e.onDidInputData); } - @memoize get onAnyInstanceIconChange() { return this.createOnInstanceEvent(e => e.onIconChanged); } - @memoize get onAnyInstanceMaximumDimensionsChange() { return this.createOnInstanceEvent(e => Event.map(e.onMaximumDimensionsChanged, () => e, e.store)); } - @memoize get onAnyInstancePrimaryStatusChange() { return this.createOnInstanceEvent(e => Event.map(e.statusList.onDidChangePrimaryStatus, () => e, e.store)); } - @memoize get onAnyInstanceProcessIdReady() { return this.createOnInstanceEvent(e => e.onProcessIdReady); } - @memoize get onAnyInstanceSelectionChange() { return this.createOnInstanceEvent(e => e.onDidChangeSelection); } - @memoize get onAnyInstanceTitleChange() { return this.createOnInstanceEvent(e => e.onTitleChanged); } + // TODO: Batch events + @memoize get onAnyInstanceData() { return this._register(this.createOnInstanceEvent(instance => Event.map(instance.onData, data => ({ instance, data })))).event; } + @memoize get onAnyInstanceDataInput() { return this._register(this.createOnInstanceEvent(e => e.onDidInputData)).event; } + @memoize get onAnyInstanceIconChange() { return this._register(this.createOnInstanceEvent(e => e.onIconChanged)).event; } + @memoize get onAnyInstanceMaximumDimensionsChange() { return this._register(this.createOnInstanceEvent(e => Event.map(e.onMaximumDimensionsChanged, () => e, e.store))).event; } + @memoize get onAnyInstancePrimaryStatusChange() { return this._register(this.createOnInstanceEvent(e => Event.map(e.statusList.onDidChangePrimaryStatus, () => e, e.store))).event; } + @memoize get onAnyInstanceProcessIdReady() { return this._register(this.createOnInstanceEvent(e => e.onProcessIdReady)).event; } + @memoize get onAnyInstanceSelectionChange() { return this._register(this.createOnInstanceEvent(e => e.onDidChangeSelection)).event; } + @memoize get onAnyInstanceTitleChange() { return this._register(this.createOnInstanceEvent(e => e.onTitleChanged)).event; } constructor( @IContextKeyService private _contextKeyService: IContextKeyService, @@ -171,7 +170,9 @@ export class TerminalService extends Disposable implements ITerminalService { @IRemoteAgentService private _remoteAgentService: IRemoteAgentService, @IViewsService private _viewsService: IViewsService, @IConfigurationService private readonly _configurationService: IConfigurationService, + @ITerminalConfigurationService private readonly _terminalConfigService: ITerminalConfigurationService, @IWorkbenchEnvironmentService private readonly _environmentService: IWorkbenchEnvironmentService, + @ITerminalConfigurationService private readonly _terminalConfigurationService: ITerminalConfigurationService, @ITerminalEditorService private readonly _terminalEditorService: ITerminalEditorService, @ITerminalGroupService private readonly _terminalGroupService: ITerminalGroupService, @ITerminalInstanceService private readonly _terminalInstanceService: ITerminalInstanceService, @@ -186,23 +187,22 @@ export class TerminalService extends Disposable implements ITerminalService { ) { super(); - this._configHelper = this._register(this._instantiationService.createInstance(TerminalConfigHelper)); // the below avoids having to poll routinely. // we update detected profiles when an instance is created so that, // for example, we detect if you've installed a pwsh - this.onDidCreateInstance(() => this._terminalProfileService.refreshAvailableProfiles()); + this._register(this.onDidCreateInstance(() => this._terminalProfileService.refreshAvailableProfiles())); this._forwardInstanceHostEvents(this._terminalGroupService); this._forwardInstanceHostEvents(this._terminalEditorService); - this._terminalGroupService.onDidChangeActiveGroup(this._onDidChangeActiveGroup.fire, this._onDidChangeActiveGroup); - this._terminalInstanceService.onDidCreateInstance(instance => { + this._register(this._terminalGroupService.onDidChangeActiveGroup(this._onDidChangeActiveGroup.fire, this._onDidChangeActiveGroup)); + this._register(this._terminalInstanceService.onDidCreateInstance(instance => { this._initInstanceListeners(instance); this._onDidCreateInstance.fire(instance); - }); + })); // Hide the panel if there are no more instances, provided that VS Code is not shutting // down. When shutting down the panel is locked in place so that it is restored upon next // launch. - this._terminalGroupService.onDidChangeActiveInstance(instance => { + this._register(this._terminalGroupService.onDidChangeActiveInstance(instance => { if (!instance && !this._isShuttingDown) { this._terminalGroupService.hidePanel(); } @@ -211,7 +211,7 @@ export class TerminalService extends Disposable implements ITerminalService { } else if (!instance) { this._terminalShellTypeContextKey.reset(); } - }); + })); this._handleInstanceContextKeys(); this._terminalShellTypeContextKey = TerminalContextKeys.shellType.bindTo(this._contextKeyService); @@ -228,7 +228,7 @@ export class TerminalService extends Disposable implements ITerminalService { _lifecycleService.onBeforeShutdown(async e => e.veto(this._onBeforeShutdown(e.reason), 'veto.terminal')); _lifecycleService.onWillShutdown(e => this._onWillShutdown(e)); - this.initializePrimaryBackend(); + this._initializePrimaryBackend(); // Create async as the class depends on `this` timeout(0).then(() => this._register(this._instantiationService.createInstance(TerminalEditorStyle, mainWindow.document.head))); @@ -273,11 +273,11 @@ export class TerminalService extends Disposable implements ITerminalService { return undefined; } - async initializePrimaryBackend() { + private async _initializePrimaryBackend() { mark('code/terminal/willGetTerminalBackend'); this._primaryBackend = await this._terminalInstanceService.getBackend(this._environmentService.remoteAuthority); mark('code/terminal/didGetTerminalBackend'); - const enableTerminalReconnection = this.configHelper.config.enablePersistentSessions; + const enableTerminalReconnection = this._terminalConfigurationService.config.enablePersistentSessions; // Connect to the extension host if it's there, set the connection state to connected when // it's done. This should happen even when there is no extension host. @@ -409,7 +409,7 @@ export class TerminalService extends Disposable implements ITerminalService { // Confirm on kill in the editor is handled by the editor input if (instance.target !== TerminalLocation.Editor && instance.hasChildProcesses && - (this.configHelper.config.confirmOnKill === 'panel' || this.configHelper.config.confirmOnKill === 'always')) { + (this._terminalConfigurationService.config.confirmOnKill === 'panel' || this._terminalConfigurationService.config.confirmOnKill === 'always')) { const veto = await this._showTerminalCloseConfirmation(true); if (veto) { @@ -520,14 +520,14 @@ export class TerminalService extends Disposable implements ITerminalService { } private _attachProcessLayoutListeners(): void { - this.onDidChangeActiveGroup(() => this._saveState()); - this.onDidChangeActiveInstance(() => this._saveState()); - this.onDidChangeInstances(() => this._saveState()); + this._register(this.onDidChangeActiveGroup(() => this._saveState())); + this._register(this.onDidChangeActiveInstance(() => this._saveState())); + this._register(this.onDidChangeInstances(() => this._saveState())); // The state must be updated when the terminal is relaunched, otherwise the persistent // terminal ID will be stale and the process will be leaked. - this.onAnyInstanceProcessIdReady(() => this._saveState()); - this.onAnyInstanceTitleChange(instance => this._updateTitle(instance)); - this.onAnyInstanceIconChange(e => this._updateIcon(e.instance, e.userInitiated)); + this._register(this.onAnyInstanceProcessIdReady(() => this._saveState())); + this._register(this.onAnyInstanceTitleChange(instance => this._updateTitle(instance))); + this._register(this.onAnyInstanceIconChange(e => this._updateIcon(e.instance, e.userInitiated))); } private _handleInstanceContextKeys(): void { @@ -628,11 +628,11 @@ export class TerminalService extends Disposable implements ITerminalService { } // Persist terminal _processes_ - const shouldPersistProcesses = this._configHelper.config.enablePersistentSessions && reason === ShutdownReason.RELOAD; + const shouldPersistProcesses = this._terminalConfigurationService.config.enablePersistentSessions && reason === ShutdownReason.RELOAD; if (!shouldPersistProcesses) { const hasDirtyInstances = ( - (this.configHelper.config.confirmOnExit === 'always' && this.instances.length > 0) || - (this.configHelper.config.confirmOnExit === 'hasChildProcesses' && this.instances.some(e => e.hasChildProcesses)) + (this._terminalConfigurationService.config.confirmOnExit === 'always' && this.instances.length > 0) || + (this._terminalConfigurationService.config.confirmOnExit === 'hasChildProcesses' && this.instances.some(e => e.hasChildProcesses)) ); if (hasDirtyInstances) { return this._onBeforeShutdownConfirmation(reason); @@ -653,10 +653,10 @@ export class TerminalService extends Disposable implements ITerminalService { } private _shouldReviveProcesses(reason: ShutdownReason): boolean { - if (!this._configHelper.config.enablePersistentSessions) { + if (!this._terminalConfigurationService.config.enablePersistentSessions) { return false; } - switch (this.configHelper.config.persistentSessionReviveProcess) { + switch (this._terminalConfigurationService.config.persistentSessionReviveProcess) { case 'onExit': { // Allow on close if it's the last window on Windows or Linux if (reason === ShutdownReason.CLOSE && (this._shutdownWindowCount === 1 && !isMacintosh)) { @@ -681,7 +681,7 @@ export class TerminalService extends Disposable implements ITerminalService { private _onWillShutdown(e: WillShutdownEvent): void { // Don't touch processes if the shutdown was a result of reload as they will be reattached - const shouldPersistTerminals = this._configHelper.config.enablePersistentSessions && e.reason === ShutdownReason.RELOAD; + const shouldPersistTerminals = this._terminalConfigurationService.config.enablePersistentSessions && e.reason === ShutdownReason.RELOAD; for (const instance of [...this._terminalGroupService.instances, ...this._backgroundedTerminalInstances]) { if (shouldPersistTerminals && instance.shouldPersist) { @@ -703,7 +703,7 @@ export class TerminalService extends Disposable implements ITerminalService { if (this._isShuttingDown) { return; } - if (!this.configHelper.config.enablePersistentSessions) { + if (!this._terminalConfigurationService.config.enablePersistentSessions) { return; } const tabs = this._terminalGroupService.groups.map(g => g.getLayoutInfo(g === this._terminalGroupService.activeGroup)); @@ -713,7 +713,7 @@ export class TerminalService extends Disposable implements ITerminalService { @debounce(500) private _updateTitle(instance: ITerminalInstance | undefined): void { - if (!this.configHelper.config.enablePersistentSessions || !instance || !instance.persistentProcessId || !instance.title || instance.isDisposed) { + if (!this._terminalConfigurationService.config.enablePersistentSessions || !instance || !instance.persistentProcessId || !instance.title || instance.isDisposed) { return; } if (instance.staticTitle) { @@ -725,7 +725,7 @@ export class TerminalService extends Disposable implements ITerminalService { @debounce(500) private _updateIcon(instance: ITerminalInstance, userInitiated: boolean): void { - if (!this.configHelper.config.enablePersistentSessions || !instance || !instance.persistentProcessId || !instance.icon || instance.isDisposed) { + if (!this._terminalConfigurationService.config.enablePersistentSessions || !instance || !instance.persistentProcessId || !instance.icon || instance.isDisposed) { return; } this._primaryBackend?.updateIcon(instance.persistentProcessId, userInitiated, instance.icon, instance.color); @@ -825,7 +825,7 @@ export class TerminalService extends Disposable implements ITerminalService { const instanceDisposables: IDisposable[] = [ instance.onDimensionsChanged(() => { this._onDidChangeInstanceDimensions.fire(instance); - if (this.configHelper.config.enablePersistentSessions && this.isProcessSupportRegistered) { + if (this._terminalConfigurationService.config.enablePersistentSessions && this.isProcessSupportRegistered) { this._saveState(); } }), @@ -1016,7 +1016,6 @@ export class TerminalService extends Disposable implements ITerminalService { const xterm = this._instantiationService.createInstance( XtermTerminal, ctor, - this._configHelper, options.cols, options.rows, options.colorProvider, @@ -1052,7 +1051,7 @@ export class TerminalService extends Disposable implements ITerminalService { if (!parent) { throw new Error('Cannot split without an active instance'); } - shellLaunchConfig.cwd = await getCwdForSplit(this.configHelper, parent, this._workspaceContextService.getWorkspace().folders, this._commandService); + shellLaunchConfig.cwd = await getCwdForSplit(parent, this._workspaceContextService.getWorkspace().folders, this._commandService, this._terminalConfigService); } } } @@ -1174,7 +1173,7 @@ export class TerminalService extends Disposable implements ITerminalService { } async setContainers(panelContainer: HTMLElement, terminalContainer: HTMLElement): Promise { - this._configHelper.panelContainer = panelContainer; + this._terminalConfigurationService.setPanelContainer(panelContainer); this._terminalGroupService.setContainer(terminalContainer); } @@ -1186,8 +1185,8 @@ export class TerminalService extends Disposable implements ITerminalService { this._editingTerminal = instance; } - createOnInstanceEvent(getEvent: (instance: ITerminalInstance) => Event): Event { - return this._register(new DynamicListEventMultiplexer(this.instances, this.onDidCreateInstance, this.onDidDisposeInstance, getEvent)).event; + createOnInstanceEvent(getEvent: (instance: ITerminalInstance) => Event): DynamicListEventMultiplexer { + return new DynamicListEventMultiplexer(this.instances, this.onDidCreateInstance, this.onDidDisposeInstance, getEvent); } createOnInstanceCapabilityEvent(capabilityId: T, getEvent: (capability: ITerminalCapabilityImplMap[T]) => Event): IDynamicListEventMultiplexer<{ instance: ITerminalInstance; data: K }> { @@ -1253,7 +1252,7 @@ class TerminalEditorStyle extends Themable { if (uri instanceof URI && iconClasses && iconClasses.length > 1) { css += ( `.monaco-workbench .terminal-tab.${iconClasses[0]}::before` + - `{background-image: ${dom.asCSSUrl(uri)};}` + `{content: ''; background-image: ${dom.asCSSUrl(uri)};}` ); } if (ThemeIcon.isThemeIcon(icon)) { diff --git a/src/vs/workbench/contrib/terminal/browser/terminalTabbedView.ts b/src/vs/workbench/contrib/terminal/browser/terminalTabbedView.ts index 8cd412341c5df..a93fccec1b53d 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminalTabbedView.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminalTabbedView.ts @@ -7,9 +7,9 @@ import { LayoutPriority, Orientation, Sizing, SplitView } from 'vs/base/browser/ import { Disposable, dispose, IDisposable } from 'vs/base/common/lifecycle'; import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; -import { ITerminalGroupService, ITerminalInstance, ITerminalService, TerminalConnectionState } from 'vs/workbench/contrib/terminal/browser/terminal'; +import { ITerminalConfigurationService, ITerminalGroupService, ITerminalInstance, ITerminalService, TerminalConnectionState } from 'vs/workbench/contrib/terminal/browser/terminal'; import { TerminalTabsListSizes, TerminalTabList } from 'vs/workbench/contrib/terminal/browser/terminalTabsList'; -import { isLinux, isMacintosh } from 'vs/base/common/platform'; +import { isMacintosh } from 'vs/base/common/platform'; import * as dom from 'vs/base/browser/dom'; import { BrowserFeatures } from 'vs/base/browser/canIUse'; import { INotificationService } from 'vs/platform/notification/common/notification'; @@ -70,6 +70,7 @@ export class TerminalTabbedView extends Disposable { constructor( parentElement: HTMLElement, @ITerminalService private readonly _terminalService: ITerminalService, + @ITerminalConfigurationService private readonly _terminalConfigurationService: ITerminalConfigurationService, @ITerminalGroupService private readonly _terminalGroupService: ITerminalGroupService, @IInstantiationService private readonly _instantiationService: IInstantiationService, @INotificationService private readonly _notificationService: INotificationService, @@ -104,16 +105,16 @@ export class TerminalTabbedView extends Disposable { this._terminalTabsFocusContextKey = TerminalContextKeys.tabsFocus.bindTo(contextKeyService); this._terminalTabsMouseContextKey = TerminalContextKeys.tabsMouse.bindTo(contextKeyService); - this._tabTreeIndex = this._terminalService.configHelper.config.tabs.location === 'left' ? 0 : 1; - this._terminalContainerIndex = this._terminalService.configHelper.config.tabs.location === 'left' ? 1 : 0; + this._tabTreeIndex = this._terminalConfigurationService.config.tabs.location === 'left' ? 0 : 1; + this._terminalContainerIndex = this._terminalConfigurationService.config.tabs.location === 'left' ? 1 : 0; - _configurationService.onDidChangeConfiguration(e => { + this._register(_configurationService.onDidChangeConfiguration(e => { if (e.affectsConfiguration(TerminalSettingId.TabsEnabled) || e.affectsConfiguration(TerminalSettingId.TabsHideCondition)) { this._refreshShowTabs(); } else if (e.affectsConfiguration(TerminalSettingId.TabsLocation)) { - this._tabTreeIndex = this._terminalService.configHelper.config.tabs.location === 'left' ? 0 : 1; - this._terminalContainerIndex = this._terminalService.configHelper.config.tabs.location === 'left' ? 1 : 0; + this._tabTreeIndex = this._terminalConfigurationService.config.tabs.location === 'left' ? 0 : 1; + this._terminalContainerIndex = this._terminalConfigurationService.config.tabs.location === 'left' ? 1 : 0; if (this._shouldShowTabs()) { this._splitView.swapViews(0, 1); this._removeSashListener(); @@ -121,28 +122,28 @@ export class TerminalTabbedView extends Disposable { this._splitView.resizeView(this._tabTreeIndex, this._getLastListWidth()); } } - }); + })); this._register(this._terminalGroupService.onDidChangeInstances(() => this._refreshShowTabs())); this._register(this._terminalGroupService.onDidChangeGroups(() => this._refreshShowTabs())); this._attachEventListeners(parentElement, this._terminalContainer); - this._terminalGroupService.onDidChangePanelOrientation((orientation) => { + this._register(this._terminalGroupService.onDidChangePanelOrientation((orientation) => { this._panelOrientation = orientation; if (this._panelOrientation === Orientation.VERTICAL) { this._terminalContainer.classList.add(CssClass.ViewIsVertical); } else { this._terminalContainer.classList.remove(CssClass.ViewIsVertical); } - }); + })); this._splitView = new SplitView(parentElement, { orientation: Orientation.HORIZONTAL, proportionalLayout: false }); this._setupSplitView(terminalOuterContainer); } private _shouldShowTabs(): boolean { - const enabled = this._terminalService.configHelper.config.tabs.enabled; - const hide = this._terminalService.configHelper.config.tabs.hideCondition; + const enabled = this._terminalConfigurationService.config.tabs.enabled; + const hide = this._terminalConfigurationService.config.tabs.hideCondition; if (!enabled) { return false; } @@ -338,12 +339,20 @@ export class TerminalTabbedView extends Disposable { return; } - if (event.which === 2 && isLinux) { - // Drop selection and focus terminal on Linux to enable middle button paste when click - // occurs on the selection itself. - terminal.focus(); + if (event.which === 2) { + switch (this._terminalConfigurationService.config.middleClickBehavior) { + case 'paste': + terminal.paste(); + break; + case 'default': + default: + // Drop selection and focus terminal on Linux to enable middle button paste + // when click occurs on the selection itself. + terminal.focus(); + break; + } } else if (event.which === 3) { - const rightClickBehavior = this._terminalService.configHelper.config.rightClickBehavior; + const rightClickBehavior = this._terminalConfigurationService.config.rightClickBehavior; if (rightClickBehavior === 'nothing') { if (!event.shiftKey) { this._cancelContextMenu = true; @@ -381,7 +390,7 @@ export class TerminalTabbedView extends Disposable { } })); this._register(dom.addDisposableListener(terminalContainer, 'contextmenu', (event: MouseEvent) => { - const rightClickBehavior = this._terminalService.configHelper.config.rightClickBehavior; + const rightClickBehavior = this._terminalConfigurationService.config.rightClickBehavior; if (rightClickBehavior === 'nothing' && !event.shiftKey) { this._cancelContextMenu = true; } @@ -394,7 +403,7 @@ export class TerminalTabbedView extends Disposable { this._cancelContextMenu = false; })); this._register(dom.addDisposableListener(this._tabContainer, 'contextmenu', (event: MouseEvent) => { - const rightClickBehavior = this._terminalService.configHelper.config.rightClickBehavior; + const rightClickBehavior = this._terminalConfigurationService.config.rightClickBehavior; if (rightClickBehavior === 'nothing' && !event.shiftKey) { this._cancelContextMenu = true; } diff --git a/src/vs/workbench/contrib/terminal/browser/terminalTabsList.ts b/src/vs/workbench/contrib/terminal/browser/terminalTabsList.ts index e9bf671e021c6..b32bd3f28d609 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminalTabsList.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminalTabsList.ts @@ -23,7 +23,7 @@ import { Codicon } from 'vs/base/common/codicons'; import { Action } from 'vs/base/common/actions'; import { DEFAULT_LABELS_CONTAINER, IResourceLabel, ResourceLabels } from 'vs/workbench/browser/labels'; import { IDecorationData, IDecorationsProvider, IDecorationsService } from 'vs/workbench/services/decorations/common/decorations'; -import { IHoverAction, IHoverService } from 'vs/platform/hover/browser/hover'; +import { IHoverService } from 'vs/platform/hover/browser/hover'; import Severity from 'vs/base/common/severity'; import { Disposable, DisposableStore, dispose, IDisposable, toDisposable } from 'vs/base/common/lifecycle'; import { IListDragAndDrop, IListDragOverReaction, IListRenderer, ListDragOverEffectPosition, ListDragOverEffectType } from 'vs/base/browser/ui/list/list'; @@ -50,6 +50,7 @@ import { Emitter } from 'vs/base/common/event'; import { Schemas } from 'vs/base/common/network'; import { getColorForSeverity } from 'vs/workbench/contrib/terminal/browser/terminalStatusList'; import { TerminalContextActionRunner } from 'vs/workbench/contrib/terminal/browser/terminalContextMenu'; +import type { IHoverAction } from 'vs/base/browser/ui/hover/hover'; const $ = DOM.$; @@ -130,11 +131,16 @@ export class TerminalTabList extends WorkbenchList { // Dispose of instance listeners on shutdown to avoid extra work and so tabs don't disappear // briefly - lifecycleService.onWillShutdown(e => { + this.disposables.add(lifecycleService.onWillShutdown(e => { dispose(instanceDisposables); - }); + instanceDisposables.length = 0; + })); + this.disposables.add(toDisposable(() => { + dispose(instanceDisposables); + instanceDisposables.length = 0; + })); - this.onMouseDblClick(async e => { + this.disposables.add(this.onMouseDblClick(async e => { const focus = this.getFocus(); if (focus.length === 0) { const instance = await this._terminalService.createTerminal({ location: TerminalLocation.Panel }); @@ -149,11 +155,11 @@ export class TerminalTabList extends WorkbenchList { if (this._getFocusMode() === 'doubleClick' && this.getFocus().length === 1) { e.element?.focus(true); } - }); + })); // on left click, if focus mode = single click, focus the element // unless multi-selection is in progress - this.onMouseClick(async e => { + this.disposables.add(this.onMouseClick(async e => { if (this._terminalService.getEditingTerminal()?.instanceId === e.element?.instanceId) { return; } @@ -165,11 +171,11 @@ export class TerminalTabList extends WorkbenchList { e.element?.focus(true); } } - }); + })); // on right click, set the focus to that element // unless multi-selection is in progress - this.onContextMenu(e => { + this.disposables.add(this.onContextMenu(e => { if (!e.element) { this.setSelection([]); return; @@ -178,15 +184,15 @@ export class TerminalTabList extends WorkbenchList { if (!selection || !selection.find(s => e.element === s)) { this.setFocus(e.index !== undefined ? [e.index] : []); } - }); + })); this._terminalTabsSingleSelectedContextKey = TerminalContextKeys.tabsSingularSelection.bindTo(contextKeyService); this._isSplitContextKey = TerminalContextKeys.splitTerminal.bindTo(contextKeyService); - this.onDidChangeSelection(e => this._updateContextKey()); - this.onDidChangeFocus(() => this._updateContextKey()); + this.disposables.add(this.onDidChangeSelection(e => this._updateContextKey())); + this.disposables.add(this.onDidChangeFocus(() => this._updateContextKey())); - this.onDidOpen(async e => { + this.disposables.add(this.onDidOpen(async e => { const instance = e.element; if (!instance) { return; @@ -195,7 +201,7 @@ export class TerminalTabList extends WorkbenchList { if (!e.editorOptions.preserveFocus) { await instance.focusWhenReady(); } - }); + })); if (!this._decorationsProvider) { this._decorationsProvider = this.disposables.add(instantiationService.createInstance(TabDecorationsProvider)); this.disposables.add(decorationsService.registerDecorationsProvider(this._decorationsProvider)); @@ -279,9 +285,9 @@ class TerminalTabsRenderer implements IListRenderer + actionViewItemProvider: (action, options) => action instanceof MenuItemAction - ? this._instantiationService.createInstance(MenuEntryActionViewItem, action, undefined) + ? this._instantiationService.createInstance(MenuEntryActionViewItem, action, { hoverDelegate: options.hoverDelegate }) : undefined }); diff --git a/src/vs/workbench/contrib/terminal/browser/terminalTooltip.ts b/src/vs/workbench/contrib/terminal/browser/terminalTooltip.ts index ebd4abf5e19ab..e1d6086cd0d98 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminalTooltip.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminalTooltip.ts @@ -7,8 +7,8 @@ import { localize } from 'vs/nls'; import { ITerminalInstance } from 'vs/workbench/contrib/terminal/browser/terminal'; import { TerminalCapability } from 'vs/platform/terminal/common/capabilities/capabilities'; import { asArray } from 'vs/base/common/arrays'; -import { IHoverAction } from 'vs/platform/hover/browser/hover'; import { MarkdownString } from 'vs/base/common/htmlContent'; +import type { IHoverAction } from 'vs/base/browser/ui/hover/hover'; export function getInstanceHoverInfo(instance: ITerminalInstance): { content: MarkdownString; actions: IHoverAction[] } { let statusString = ''; @@ -54,7 +54,7 @@ export function getShellIntegrationTooltip(instance: ITerminalInstance, markdown export function getShellProcessTooltip(instance: ITerminalInstance, markdown: boolean): string { const lines: string[] = []; - if (instance.processId) { + if (instance.processId && instance.processId > 0) { lines.push(localize({ key: 'shellProcessTooltip.processId', comment: ['The first arg is "PID" which shouldn\'t be translated'] }, "Process ID ({0}): {1}", 'PID', instance.processId) + '\n'); } diff --git a/src/vs/workbench/contrib/terminal/browser/terminalView.ts b/src/vs/workbench/contrib/terminal/browser/terminalView.ts index c5d7591819ee4..5309bcd0696ed 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminalView.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminalView.ts @@ -14,7 +14,7 @@ import { IThemeService, Themable } from 'vs/platform/theme/common/themeService'; import { ThemeIcon } from 'vs/base/common/themables'; import { switchTerminalActionViewItemSeparator, switchTerminalShowTabsTitle } from 'vs/workbench/contrib/terminal/browser/terminalActions'; import { INotificationService, IPromptChoice, Severity } from 'vs/platform/notification/common/notification'; -import { ICreateTerminalOptions, ITerminalGroupService, ITerminalInstance, ITerminalService, TerminalConnectionState, TerminalDataTransfers } from 'vs/workbench/contrib/terminal/browser/terminal'; +import { ICreateTerminalOptions, ITerminalConfigurationService, ITerminalGroupService, ITerminalInstance, ITerminalService, TerminalConnectionState, TerminalDataTransfers } from 'vs/workbench/contrib/terminal/browser/terminal'; import { ViewPane, IViewPaneOptions } from 'vs/workbench/browser/parts/views/viewPane'; import { IKeybindingService } from 'vs/platform/keybinding/common/keybinding'; import { IContextKey, IContextKeyService } from 'vs/platform/contextkey/common/contextkey'; @@ -23,7 +23,7 @@ import { IOpenerService } from 'vs/platform/opener/common/opener'; import { IMenu, IMenuService, MenuId, MenuItemAction } from 'vs/platform/actions/common/actions'; import { ITerminalProfileResolverService, ITerminalProfileService, TerminalCommandId } from 'vs/workbench/contrib/terminal/common/terminal'; import { TerminalSettingId, ITerminalProfile, TerminalLocation } from 'vs/platform/terminal/common/terminal'; -import { ActionViewItem, SelectActionViewItem } from 'vs/base/browser/ui/actionbar/actionViewItems'; +import { ActionViewItem, IBaseActionViewItemOptions, SelectActionViewItem } from 'vs/base/browser/ui/actionbar/actionViewItems'; import { asCssVariable, selectBorder } from 'vs/platform/theme/common/colorRegistry'; import { ISelectOptionItem } from 'vs/base/browser/ui/selectBox/selectBox'; import { IActionViewItem } from 'vs/base/browser/ui/actionbar/actionbar'; @@ -33,7 +33,7 @@ import { renderLabelWithIcons } from 'vs/base/browser/ui/iconLabel/iconLabels'; import { getColorForSeverity } from 'vs/workbench/contrib/terminal/browser/terminalStatusList'; import { createAndFillInContextMenuActions, MenuEntryActionViewItem } from 'vs/platform/actions/browser/menuEntryActionViewItem'; import { DropdownWithPrimaryActionViewItem } from 'vs/platform/actions/browser/dropdownWithPrimaryActionViewItem'; -import { dispose, IDisposable, toDisposable } from 'vs/base/common/lifecycle'; +import { DisposableStore, dispose, IDisposable, toDisposable } from 'vs/base/common/lifecycle'; import { URI } from 'vs/base/common/uri'; import { ColorScheme } from 'vs/platform/theme/common/theme'; import { getColorClass, getUriClasses } from 'vs/workbench/contrib/terminal/browser/terminalIcon'; @@ -44,7 +44,7 @@ import { ServicesAccessor } from 'vs/editor/browser/editorExtensions'; import { TerminalCapability } from 'vs/platform/terminal/common/capabilities/capabilities'; import { defaultSelectBoxStyles } from 'vs/platform/theme/browser/defaultStyles'; import { Event } from 'vs/base/common/event'; -import { IHoverDelegate, IHoverDelegateOptions } from 'vs/base/browser/ui/iconLabel/iconHoverDelegate'; +import { IHoverDelegate, IHoverDelegateOptions } from 'vs/base/browser/ui/hover/hoverDelegate'; import { IHoverService } from 'vs/platform/hover/browser/hover'; import { IAccessibilityService } from 'vs/platform/accessibility/common/accessibility'; import { InstanceContext, TerminalContextActionRunner } from 'vs/workbench/contrib/terminal/browser/terminalContextMenu'; @@ -58,6 +58,7 @@ export class TerminalViewPane extends ViewPane { private readonly _dropdownMenu: IMenu; private readonly _singleTabMenu: IMenu; private _viewShowing: IContextKey; + private readonly _disposableStore = this._register(new DisposableStore()); constructor( options: IViewPaneOptions, @@ -68,9 +69,11 @@ export class TerminalViewPane extends ViewPane { @IContextMenuService private readonly _contextMenuService: IContextMenuService, @IInstantiationService private readonly _instantiationService: IInstantiationService, @ITerminalService private readonly _terminalService: ITerminalService, + @ITerminalConfigurationService private readonly _terminalConfigurationService: ITerminalConfigurationService, @ITerminalGroupService private readonly _terminalGroupService: ITerminalGroupService, @IThemeService themeService: IThemeService, @ITelemetryService telemetryService: ITelemetryService, + @IHoverService hoverService: IHoverService, @INotificationService private readonly _notificationService: INotificationService, @IKeybindingService private readonly _keybindingService: IKeybindingService, @IOpenerService openerService: IOpenerService, @@ -80,7 +83,7 @@ export class TerminalViewPane extends ViewPane { @IThemeService private readonly _themeService: IThemeService, @IAccessibilityService private readonly _accessibilityService: IAccessibilityService ) { - super(options, keybindingService, _contextMenuService, _configurationService, _contextKeyService, viewDescriptorService, _instantiationService, openerService, themeService, telemetryService); + super(options, keybindingService, _contextMenuService, _configurationService, _contextKeyService, viewDescriptorService, _instantiationService, openerService, themeService, telemetryService, hoverService); this._register(this._terminalService.onDidRegisterProcessSupport(() => { this._onDidChangeViewWelcomeState.fire(); })); @@ -191,8 +194,7 @@ export class TerminalViewPane extends ViewPane { this._register(this.configurationService.onDidChangeConfiguration(e => { if (e.affectsConfiguration(TerminalSettingId.FontFamily) || e.affectsConfiguration('editor.fontFamily')) { - const configHelper = this._terminalService.configHelper; - if (!configHelper.configFontIsMonospace()) { + if (!this._terminalConfigurationService.configFontIsMonospace()) { const choices: IPromptChoice[] = [{ label: nls.localize('terminal.useMonospace', "Use 'monospace'"), run: () => this.configurationService.updateValue(TerminalSettingId.FontFamily, 'monospace'), @@ -236,7 +238,7 @@ export class TerminalViewPane extends ViewPane { this._terminalTabbedView?.layout(width, height); } - override getActionViewItem(action: Action): IActionViewItem | undefined { + override getActionViewItem(action: Action, options: IBaseActionViewItemOptions): IActionViewItem | undefined { switch (action.id) { case TerminalCommandId.Split: { // Split needs to be special cased to force splitting within the panel, not the editor @@ -257,7 +259,7 @@ export class TerminalViewPane extends ViewPane { return; } }; - return new ActionViewItem(action, panelOnlySplitAction, { icon: true, label: false, keybinding: this._getKeybindingLabel(action) }); + return new ActionViewItem(action, panelOnlySplitAction, { ...options, icon: true, label: false, keybinding: this._getKeybindingLabel(action) }); } case TerminalCommandId.SwitchTerminal: { return this._instantiationService.createInstance(SwitchTerminalActionViewItem, action); @@ -272,14 +274,26 @@ export class TerminalViewPane extends ViewPane { case TerminalCommandId.New: { if (action instanceof MenuItemAction) { const actions = getTerminalActionBarArgs(TerminalLocation.Panel, this._terminalProfileService.availableProfiles, this._getDefaultProfileName(), this._terminalProfileService.contributedProfiles, this._terminalService, this._dropdownMenu); + this._registerDisposableActions(actions.dropdownAction, actions.dropdownMenuActions); this._newDropdown?.dispose(); - this._newDropdown = new DropdownWithPrimaryActionViewItem(action, actions.dropdownAction, actions.dropdownMenuActions, actions.className, this._contextMenuService, {}, this._keybindingService, this._notificationService, this._contextKeyService, this._themeService, this._accessibilityService); + this._newDropdown = new DropdownWithPrimaryActionViewItem(action, actions.dropdownAction, actions.dropdownMenuActions, actions.className, this._contextMenuService, { hoverDelegate: options.hoverDelegate }, this._keybindingService, this._notificationService, this._contextKeyService, this._themeService, this._accessibilityService); this._updateTabActionBar(this._terminalProfileService.availableProfiles); return this._newDropdown; } } } - return super.getActionViewItem(action); + return super.getActionViewItem(action, options); + } + + /** + * Actions might be of type Action (disposable) or Separator or SubmenuAction, which don't extend Disposable + */ + private _registerDisposableActions(dropdownAction: IAction, dropdownMenuActions: IAction[]): void { + this._disposableStore.clear(); + if (dropdownAction instanceof Action) { + this._disposableStore.add(dropdownAction); + } + dropdownMenuActions.filter(a => a instanceof Action).forEach(a => this._disposableStore.add(a)); } private _getDefaultProfileName(): string { @@ -298,6 +312,7 @@ export class TerminalViewPane extends ViewPane { private _updateTabActionBar(profiles: ITerminalProfile[]): void { const actions = getTerminalActionBarArgs(TerminalLocation.Panel, profiles, this._getDefaultProfileName(), this._terminalProfileService.contributedProfiles, this._terminalService, this._dropdownMenu); + this._registerDisposableActions(actions.dropdownAction, actions.dropdownMenuActions); this._newDropdown?.update(actions.dropdownAction, actions.dropdownMenuActions); } @@ -391,6 +406,7 @@ class SingleTerminalTabActionViewItem extends MenuEntryActionViewItem { @IContextKeyService contextKeyService: IContextKeyService, @IThemeService themeService: IThemeService, @ITerminalService private readonly _terminalService: ITerminalService, + @ITerminalConfigurationService private readonly _terminaConfigurationService: ITerminalConfigurationService, @ITerminalGroupService private readonly _terminalGroupService: ITerminalGroupService, @IContextMenuService contextMenuService: IContextMenuService, @ICommandService private readonly _commandService: ICommandService, @@ -488,7 +504,7 @@ class SingleTerminalTabActionViewItem extends MenuEntryActionViewItem { } } label.style.color = colorStyle; - dom.reset(label, ...renderLabelWithIcons(this._instantiationService.invokeFunction(getSingleTabLabel, instance, this._terminalService.configHelper.config.tabs.separator, ThemeIcon.isThemeIcon(this._commandAction.item.icon) ? this._commandAction.item.icon : undefined))); + dom.reset(label, ...renderLabelWithIcons(this._instantiationService.invokeFunction(getSingleTabLabel, instance, this._terminaConfigurationService.config.tabs.separator, ThemeIcon.isThemeIcon(this._commandAction.item.icon) ? this._commandAction.item.icon : undefined))); if (this._altCommand) { label.classList.remove(this._altCommand); diff --git a/src/vs/workbench/contrib/terminal/browser/terminalVoice.ts b/src/vs/workbench/contrib/terminal/browser/terminalVoice.ts index 57464855e57f2..df40dd18c5a3c 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminalVoice.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminalVoice.ts @@ -5,7 +5,7 @@ import { RunOnceScheduler } from 'vs/base/common/async'; import { CancellationTokenSource } from 'vs/base/common/cancellation'; -import { Disposable, DisposableStore } from 'vs/base/common/lifecycle'; +import { Disposable, DisposableStore, toDisposable } from 'vs/base/common/lifecycle'; import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; import { AccessibilityVoiceSettingId, SpeechTimeoutDefault } from 'vs/workbench/contrib/accessibility/browser/accessibilityConfiguration'; @@ -67,9 +67,8 @@ export class TerminalVoiceSession extends Disposable { private readonly _disposables: DisposableStore; constructor( @ISpeechService private readonly _speechService: ISpeechService, - @ITerminalService readonly _terminalService: ITerminalService, - @IConfigurationService readonly configurationService: IConfigurationService, - @IInstantiationService readonly _instantationService: IInstantiationService + @ITerminalService private readonly _terminalService: ITerminalService, + @IConfigurationService private readonly configurationService: IConfigurationService, ) { super(); this._register(this._terminalService.onDidChangeActiveInstance(() => this.stop())); @@ -77,7 +76,7 @@ export class TerminalVoiceSession extends Disposable { this._disposables = this._register(new DisposableStore()); } - start(): void { + async start(): Promise { this.stop(); let voiceTimeout = this.configurationService.getValue(AccessibilityVoiceSettingId.SpeechTimeout); if (!isNumber(voiceTimeout) || voiceTimeout < 0) { @@ -87,8 +86,9 @@ export class TerminalVoiceSession extends Disposable { this._sendText(); this.stop(); }, voiceTimeout)); - this._cancellationTokenSource = this._register(new CancellationTokenSource()); - const session = this._disposables.add(this._speechService.createSpeechToTextSession(this._cancellationTokenSource!.token)); + this._cancellationTokenSource = new CancellationTokenSource(); + this._register(toDisposable(() => this._cancellationTokenSource?.dispose(true))); + const session = await this._speechService.createSpeechToTextSession(this._cancellationTokenSource?.token, 'terminal'); this._disposables.add(session.onDidChange((e) => { if (this._cancellationTokenSource?.token.isCancellationRequested) { @@ -96,7 +96,6 @@ export class TerminalVoiceSession extends Disposable { } switch (e.status) { case SpeechToTextStatus.Started: - // TODO: play start audio cue if (!this._decoration) { this._createDecoration(); } @@ -116,7 +115,6 @@ export class TerminalVoiceSession extends Disposable { } break; case SpeechToTextStatus.Stopped: - // TODO: play stop audio cue this.stop(); break; } diff --git a/src/vs/workbench/contrib/terminal/browser/terminalWslRecommendationContribution.ts b/src/vs/workbench/contrib/terminal/browser/terminalWslRecommendationContribution.ts new file mode 100644 index 0000000000000..18635b3fb13cb --- /dev/null +++ b/src/vs/workbench/contrib/terminal/browser/terminalWslRecommendationContribution.ts @@ -0,0 +1,76 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable, type IDisposable } from 'vs/base/common/lifecycle'; +import { basename } from 'vs/base/common/path'; +import { isWindows } from 'vs/base/common/platform'; +import { localize } from 'vs/nls'; +import { IExtensionManagementService } from 'vs/platform/extensionManagement/common/extensionManagement'; +import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; +import { INotificationService, NeverShowAgainScope, Severity } from 'vs/platform/notification/common/notification'; +import { IProductService } from 'vs/platform/product/common/productService'; +import type { IWorkbenchContribution } from 'vs/workbench/common/contributions'; +import { InstallRecommendedExtensionAction } from 'vs/workbench/contrib/extensions/browser/extensionsActions'; +import { ITerminalService } from 'vs/workbench/contrib/terminal/browser/terminal'; + +export class TerminalWslRecommendationContribution extends Disposable implements IWorkbenchContribution { + static ID = 'terminalWslRecommendation'; + + constructor( + @IInstantiationService instantiationService: IInstantiationService, + @IProductService productService: IProductService, + @INotificationService notificationService: INotificationService, + @IExtensionManagementService extensionManagementService: IExtensionManagementService, + @ITerminalService terminalService: ITerminalService, + ) { + super(); + + if (!isWindows) { + return; + } + + const exeBasedExtensionTips = productService.exeBasedExtensionTips; + if (!exeBasedExtensionTips || !exeBasedExtensionTips.wsl) { + return; + } + + let listener: IDisposable | undefined = terminalService.onDidCreateInstance(async instance => { + async function isExtensionInstalled(id: string): Promise { + const extensions = await extensionManagementService.getInstalled(); + return extensions.some(e => e.identifier.id === id); + } + + if (!instance.shellLaunchConfig.executable || basename(instance.shellLaunchConfig.executable).toLowerCase() !== 'wsl.exe') { + return; + } + + listener?.dispose(); + listener = undefined; + + const extId = Object.keys(exeBasedExtensionTips.wsl.recommendations).find(extId => exeBasedExtensionTips.wsl.recommendations[extId].important); + if (!extId || await isExtensionInstalled(extId)) { + return; + } + + notificationService.prompt( + Severity.Info, + localize('useWslExtension.title', "The '{0}' extension is recommended for opening a terminal in WSL.", exeBasedExtensionTips.wsl.friendlyName), + [ + { + label: localize('install', 'Install'), + run: () => { + instantiationService.createInstance(InstallRecommendedExtensionAction, extId).run(); + } + } + ], + { + sticky: true, + neverShowAgain: { id: 'terminalConfigHelper/launchRecommendationsIgnore', scope: NeverShowAgainScope.APPLICATION }, + onCancel: () => { } + } + ); + }); + } +} diff --git a/src/vs/workbench/contrib/terminal/browser/widgets/terminalHoverWidget.ts b/src/vs/workbench/contrib/terminal/browser/widgets/terminalHoverWidget.ts index 7d3cd51268413..57eabad7669af 100644 --- a/src/vs/workbench/contrib/terminal/browser/widgets/terminalHoverWidget.ts +++ b/src/vs/workbench/contrib/terminal/browser/widgets/terminalHoverWidget.ts @@ -9,9 +9,10 @@ import { Widget } from 'vs/base/browser/ui/widget'; import { ITerminalWidget } from 'vs/workbench/contrib/terminal/browser/widgets/widgets'; import * as dom from 'vs/base/browser/dom'; import type { IViewportRange } from '@xterm/xterm'; -import { IHoverTarget, IHoverService, IHoverAction } from 'vs/platform/hover/browser/hover'; +import { IHoverService } from 'vs/platform/hover/browser/hover'; import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; import { TerminalSettingId } from 'vs/platform/terminal/common/terminal'; +import type { IHoverAction, IHoverTarget } from 'vs/base/browser/ui/hover/hover'; const $ = dom.$; diff --git a/src/vs/workbench/contrib/terminal/browser/xterm-private.d.ts b/src/vs/workbench/contrib/terminal/browser/xterm-private.d.ts index f361d56eb8896..23729e0c8d1d6 100644 --- a/src/vs/workbench/contrib/terminal/browser/xterm-private.d.ts +++ b/src/vs/workbench/contrib/terminal/browser/xterm-private.d.ts @@ -3,8 +3,6 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -/* eslint-disable @typescript-eslint/naming-convention */ - import { IBufferCell } from '@xterm/xterm'; export type XtermAttributes = Omit & { clone?(): XtermAttributes }; @@ -14,17 +12,6 @@ export interface IXtermCore { readonly scrollBarWidth: number; _innerRefresh(): void; }; - _onData: IEventEmitter; - _onKey: IEventEmitter<{ key: string }>; - - _charSizeService: { - width: number; - height: number; - }; - - coreService: { - triggerDataEvent(data: string, wasUserInput?: boolean): void; - }; _inputHandler: { _curAttrData: XtermAttributes; @@ -40,14 +27,7 @@ export interface IXtermCore { } }, _renderer: { - value?: { - _renderLayers?: any[]; - } + value?: unknown; }; - _handleIntersectionChange: any; }; } - -export interface IEventEmitter { - fire(e: T): void; -} diff --git a/src/vs/workbench/contrib/terminal/browser/xterm/decorationAddon.ts b/src/vs/workbench/contrib/terminal/browser/xterm/decorationAddon.ts index da0a7fc9d0c8b..9b025bd50b6db 100644 --- a/src/vs/workbench/contrib/terminal/browser/xterm/decorationAddon.ts +++ b/src/vs/workbench/contrib/terminal/browser/xterm/decorationAddon.ts @@ -9,7 +9,7 @@ import { Emitter } from 'vs/base/common/event'; import { Disposable, IDisposable, dispose, toDisposable } from 'vs/base/common/lifecycle'; import { ThemeIcon } from 'vs/base/common/themables'; import { localize } from 'vs/nls'; -import { AudioCue, IAudioCueService } from 'vs/platform/audioCues/browser/audioCueService'; +import { AccessibilitySignal, IAccessibilitySignalService } from 'vs/platform/accessibilitySignal/browser/accessibilitySignalService'; import { IClipboardService } from 'vs/platform/clipboard/common/clipboardService'; import { ICommandService } from 'vs/platform/commands/common/commands'; import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; @@ -52,7 +52,7 @@ export class DecorationAddon extends Disposable implements ITerminalAddon { @ILifecycleService lifecycleService: ILifecycleService, @ICommandService private readonly _commandService: ICommandService, @IInstantiationService instantiationService: IInstantiationService, - @IAudioCueService private readonly _audioCueService: IAudioCueService, + @IAccessibilitySignalService private readonly _accessibilitySignalService: IAccessibilitySignalService, @INotificationService private readonly _notificationService: INotificationService ) { super(); @@ -219,7 +219,7 @@ export class DecorationAddon extends Disposable implements ITerminalAddon { commandDetectionListeners.push(capability.onCommandFinished(command => { this.registerCommandDecoration(command); if (command.exitCode) { - this._audioCueService.playAudioCue(AudioCue.terminalCommandFailed); + this._accessibilitySignalService.playSignal(AccessibilitySignal.terminalCommandFailed); } })); // Command invalidated diff --git a/src/vs/workbench/contrib/terminal/browser/xterm/markNavigationAddon.ts b/src/vs/workbench/contrib/terminal/browser/xterm/markNavigationAddon.ts index 499e1d2f57df5..0585e653caacb 100644 --- a/src/vs/workbench/contrib/terminal/browser/xterm/markNavigationAddon.ts +++ b/src/vs/workbench/contrib/terminal/browser/xterm/markNavigationAddon.ts @@ -7,12 +7,14 @@ import { coalesce } from 'vs/base/common/arrays'; import { Disposable, DisposableStore, MutableDisposable, dispose } from 'vs/base/common/lifecycle'; import { IMarkTracker } from 'vs/workbench/contrib/terminal/browser/terminal'; import { ITerminalCapabilityStore, ITerminalCommand, TerminalCapability } from 'vs/platform/terminal/common/capabilities/capabilities'; -import type { Terminal, IMarker, ITerminalAddon, IDecoration } from '@xterm/xterm'; +import type { Terminal, IMarker, ITerminalAddon, IDecoration, IBufferRange } from '@xterm/xterm'; import { timeout } from 'vs/base/common/async'; import { IThemeService } from 'vs/platform/theme/common/themeService'; import { TERMINAL_OVERVIEW_RULER_CURSOR_FOREGROUND_COLOR } from 'vs/workbench/contrib/terminal/common/terminalColorRegistry'; import { getWindow } from 'vs/base/browser/dom'; import { ICurrentPartialCommand } from 'vs/platform/terminal/common/capabilities/commandDetection/terminalCommand'; +import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; +import { TerminalSettingId } from 'vs/platform/terminal/common/terminal'; enum Boundary { Top, @@ -24,6 +26,13 @@ export const enum ScrollPosition { Middle } +interface IScrollToMarkerOptions { + hideDecoration?: boolean; + /** Scroll even if the line is within the viewport */ + forceScroll?: boolean; + bufferRange?: IBufferRange; +} + export class MarkNavigationAddon extends Disposable implements IMarkTracker, ITerminalAddon { private _currentMarker: IMarker | Boundary = Boundary.Bottom; private _selectionStart: IMarker | Boundary | null = null; @@ -32,7 +41,7 @@ export class MarkNavigationAddon extends Disposable implements IMarkTracker, ITe private _navigationDecorations: IDecoration[] | undefined; private _activeCommandGuide?: ITerminalCommand; - private _commandGuideDecorations = this._register(new MutableDisposable()); + private readonly _commandGuideDecorations = this._register(new MutableDisposable()); activate(terminal: Terminal): void { this._terminal = terminal; @@ -43,6 +52,7 @@ export class MarkNavigationAddon extends Disposable implements IMarkTracker, ITe constructor( private readonly _capabilities: ITerminalCapabilityStore, + @IConfigurationService private readonly _configurationService: IConfigurationService, @IThemeService private readonly _themeService: IThemeService ) { super(); @@ -91,7 +101,7 @@ export class MarkNavigationAddon extends Disposable implements IMarkTracker, ITe return undefined; } - clearMarker(): void { + clear(): void { // Clear the current marker so successive focus/selection actions are performed from the // bottom of the buffer this._currentMarker = Boundary.Bottom; @@ -219,16 +229,20 @@ export class MarkNavigationAddon extends Disposable implements IMarkTracker, ITe } } - private _scrollToMarker(start: IMarker | number, position: ScrollPosition, end?: IMarker | number, hideDecoration?: boolean): void { + private _scrollToMarker(start: IMarker | number, position: ScrollPosition, end?: IMarker | number, options?: IScrollToMarkerOptions): void { if (!this._terminal) { return; } - if (!this._isMarkerInViewport(this._terminal, start)) { + if (!this._isMarkerInViewport(this._terminal, start) || options?.forceScroll) { const line = this.getTargetScrollLine(toLineIndex(start), position); this._terminal.scrollToLine(line); } - if (!hideDecoration) { - this.registerTemporaryDecoration(start, end, true); + if (!options?.hideDecoration) { + if (options?.bufferRange) { + this._highlightBufferRange(options.bufferRange); + } else { + this.registerTemporaryDecoration(start, end, true); + } } } @@ -260,6 +274,19 @@ export class MarkNavigationAddon extends Disposable implements IMarkTracker, ITe ); } + revealRange(range: IBufferRange): void { + this._scrollToMarker( + range.start.y - 1, + ScrollPosition.Middle, + range.end.y - 1, + { + bufferRange: range, + // Ensure scroll shows the line when sticky scroll is enabled + forceScroll: !!this._configurationService.getValue(TerminalSettingId.StickyScrollEnabled) + } + ); + } + showCommandGuide(command: ITerminalCommand | undefined): void { if (!this._terminal) { return; @@ -314,6 +341,50 @@ export class MarkNavigationAddon extends Disposable implements IMarkTracker, ITe } } + + private _scrollState: { viewportY: number } | undefined; + + saveScrollState(): void { + this._scrollState = { viewportY: this._terminal?.buffer.active.viewportY ?? 0 }; + } + + restoreScrollState(): void { + if (this._scrollState && this._terminal) { + this._terminal.scrollToLine(this._scrollState.viewportY); + this._scrollState = undefined; + } + } + + private _highlightBufferRange(range: IBufferRange): void { + if (!this._terminal) { + return; + } + + this._resetNavigationDecorations(); + const startLine = range.start.y; + const decorationCount = range.end.y - range.start.y + 1; + for (let i = 0; i < decorationCount; i++) { + const decoration = this._terminal.registerDecoration({ + marker: this._createMarkerForOffset(startLine - 1, i), + x: range.start.x - 1, + width: (range.end.x - 1) - (range.start.x - 1) + 1, + overviewRulerOptions: undefined + }); + if (decoration) { + this._navigationDecorations?.push(decoration); + let renderedElement: HTMLElement | undefined; + + decoration.onRender(element => { + if (!renderedElement) { + renderedElement = element; + element.classList.add('terminal-range-highlight'); + } + }); + decoration.onDispose(() => { this._navigationDecorations = this._navigationDecorations?.filter(d => d !== decoration); }); + } + } + } + registerTemporaryDecoration(marker: IMarker | number, endMarker: IMarker | number | undefined, showOutline: boolean): void { if (!this._terminal) { return; @@ -373,7 +444,7 @@ export class MarkNavigationAddon extends Disposable implements IMarkTracker, ITe } getTargetScrollLine(line: number, position: ScrollPosition): number { - // Middle is treated at 1/4 of the viewport's size because context below is almost always + // Middle is treated as 1/4 of the viewport's size because context below is almost always // more important than context above in the terminal. if (this._terminal && position === ScrollPosition.Middle) { return Math.max(line - Math.floor(this._terminal.rows / 4), 0); @@ -397,7 +468,7 @@ export class MarkNavigationAddon extends Disposable implements IMarkTracker, ITe return; } const endMarker = endMarkerId ? detectionCapability.getMark(endMarkerId) : startMarker; - this._scrollToMarker(startMarker, ScrollPosition.Top, endMarker, !highlight); + this._scrollToMarker(startMarker, ScrollPosition.Top, endMarker, { hideDecoration: !highlight }); } selectToPreviousMark(): void { diff --git a/src/vs/workbench/contrib/terminal/browser/xterm/xtermTerminal.ts b/src/vs/workbench/contrib/terminal/browser/xterm/xtermTerminal.ts index 6f9028565c584..7aeb0a5941744 100644 --- a/src/vs/workbench/contrib/terminal/browser/xterm/xtermTerminal.ts +++ b/src/vs/workbench/contrib/terminal/browser/xterm/xtermTerminal.ts @@ -12,18 +12,14 @@ import type { SerializeAddon as SerializeAddonType } from '@xterm/addon-serializ import type { ImageAddon as ImageAddonType } from '@xterm/addon-image'; import * as dom from 'vs/base/browser/dom'; import { IXtermCore } from 'vs/workbench/contrib/terminal/browser/xterm-private'; -import { ConfigurationTarget, IConfigurationService } from 'vs/platform/configuration/common/configuration'; -import { TerminalConfigHelper } from 'vs/workbench/contrib/terminal/browser/terminalConfigHelper'; +import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; import { Disposable, DisposableStore } from 'vs/base/common/lifecycle'; import { IEditorOptions } from 'vs/editor/common/config/editorOptions'; import { IShellIntegration, ITerminalLogService, TerminalSettingId } from 'vs/platform/terminal/common/terminal'; import { ITerminalFont, ITerminalConfiguration } from 'vs/workbench/contrib/terminal/common/terminal'; -import { isSafari } from 'vs/base/browser/browser'; -import { IMarkTracker, IInternalXtermTerminal, IXtermTerminal, IXtermColorProvider, XtermTerminalConstants, IXtermAttachToElementOptions, IDetachedXtermTerminal } from 'vs/workbench/contrib/terminal/browser/terminal'; +import { IMarkTracker, IInternalXtermTerminal, IXtermTerminal, IXtermColorProvider, XtermTerminalConstants, IXtermAttachToElementOptions, IDetachedXtermTerminal, ITerminalConfigurationService } from 'vs/workbench/contrib/terminal/browser/terminal'; import { LogLevel } from 'vs/platform/log/common/log'; -import { IStorageService, StorageScope, StorageTarget } from 'vs/platform/storage/common/storage'; -import { TerminalStorageKeys } from 'vs/workbench/contrib/terminal/common/terminalStorageKeys'; -import { INotificationService, IPromptChoice, Severity } from 'vs/platform/notification/common/notification'; +import { INotificationService } from 'vs/platform/notification/common/notification'; import { MarkNavigationAddon, ScrollPosition } from 'vs/workbench/contrib/terminal/browser/xterm/markNavigationAddon'; import { localize } from 'vs/nls'; import { IColorTheme, IThemeService } from 'vs/platform/theme/common/themeService'; @@ -43,15 +39,9 @@ import { debounce } from 'vs/base/common/decorators'; import { MouseWheelClassifier } from 'vs/base/browser/ui/scrollbar/scrollableElement'; import { IMouseWheelEvent, StandardWheelEvent } from 'vs/base/browser/mouseEvent'; import { ILayoutService } from 'vs/platform/layout/browser/layoutService'; -import { AudioCue, IAudioCueService } from 'vs/platform/audioCues/browser/audioCueService'; +import { AccessibilitySignal, IAccessibilitySignalService } from 'vs/platform/accessibilitySignal/browser/accessibilitySignalService'; const enum RenderConstants { - /** - * How long in milliseconds should an average frame take to render for a notification to appear - * which suggests the fallback DOM-based renderer. - */ - SlowCanvasRenderThreshold = 50, - NumberOfFramestoMeasure = 20, SmoothScrollDuration = 125 } @@ -120,7 +110,7 @@ export class XtermTerminal extends Disposable implements IXtermTerminal, IDetach /** The raw xterm.js instance */ readonly raw: RawXtermTerminal; private _core: IXtermCore; - private static _suggestedRendererType: 'canvas' | 'dom' | undefined = undefined; + private static _suggestedRendererType: 'dom' | undefined = undefined; private static _checkedWebglCompatible = false; private _attached?: { container: HTMLElement; options: IXtermAttachToElementOptions }; private _isPhysicalMouseWheel = MouseWheelClassifier.INSTANCE.isPhysicalMouseWheel(); @@ -189,7 +179,6 @@ export class XtermTerminal extends Disposable implements IXtermTerminal, IDetach */ constructor( xtermCtor: typeof RawXtermTerminal, - private readonly _configHelper: TerminalConfigHelper, cols: number, rows: number, private readonly _xtermColorProvider: IXtermColorProvider, @@ -200,17 +189,17 @@ export class XtermTerminal extends Disposable implements IXtermTerminal, IDetach @IInstantiationService private readonly _instantiationService: IInstantiationService, @ITerminalLogService private readonly _logService: ITerminalLogService, @INotificationService private readonly _notificationService: INotificationService, - @IStorageService private readonly _storageService: IStorageService, @IThemeService private readonly _themeService: IThemeService, @ITelemetryService private readonly _telemetryService: ITelemetryService, + @ITerminalConfigurationService private readonly _terminalConfigurationService: ITerminalConfigurationService, @IClipboardService private readonly _clipboardService: IClipboardService, @IContextKeyService contextKeyService: IContextKeyService, - @IAudioCueService private readonly _audioCueService: IAudioCueService, + @IAccessibilitySignalService private readonly _accessibilitySignalService: IAccessibilitySignalService, @ILayoutService layoutService: ILayoutService ) { super(); - const font = this._configHelper.getFont(dom.getActiveWindow(), undefined, true); - const config = this._configHelper.config; + const font = this._terminalConfigurationService.getFont(dom.getActiveWindow(), undefined, true); + const config = this._terminalConfigurationService.config; const editorOptions = this._configurationService.getValue('editor'); this.raw = this._register(new xtermCtor({ @@ -244,7 +233,13 @@ export class XtermTerminal extends Disposable implements IXtermTerminal, IDetach scrollSensitivity: config.mouseWheelScrollSensitivity, wordSeparator: config.wordSeparators, overviewRulerWidth: 10, - ignoreBracketedPasteMode: config.ignoreBracketedPasteMode + ignoreBracketedPasteMode: config.ignoreBracketedPasteMode, + rescaleOverlappingGlyphs: config.rescaleOverlappingGlyphs, + windowOptions: { + getWinSizePixels: true, + getCellSizePixels: true, + getWinSizeChars: true, + }, })); this._updateSmoothScrolling(); this._core = (this.raw as any)._core as IXtermCore; @@ -384,7 +379,7 @@ export class XtermTerminal extends Disposable implements IXtermTerminal, IDetach } updateConfig(): void { - const config = this._configHelper.config; + const config = this._terminalConfigurationService.config; this.raw.options.altClickMovesCursor = config.altClickMovesCursor; this._setCursorBlink(config.cursorBlinking); this._setCursorStyle(config.cursorStyle); @@ -404,6 +399,7 @@ export class XtermTerminal extends Disposable implements IXtermTerminal, IDetach this.raw.options.wordSeparator = config.wordSeparators; this.raw.options.customGlyphs = config.customGlyphs; this.raw.options.ignoreBracketedPasteMode = config.ignoreBracketedPasteMode; + this.raw.options.rescaleOverlappingGlyphs = config.rescaleOverlappingGlyphs; this._updateSmoothScrolling(); if (this._attached?.options.enableGpu) { if (this._shouldLoadWebgl()) { @@ -420,15 +416,15 @@ export class XtermTerminal extends Disposable implements IXtermTerminal, IDetach } private _updateSmoothScrolling() { - this.raw.options.smoothScrollDuration = this._configHelper.config.smoothScrolling && this._isPhysicalMouseWheel ? RenderConstants.SmoothScrollDuration : 0; + this.raw.options.smoothScrollDuration = this._terminalConfigurationService.config.smoothScrolling && this._isPhysicalMouseWheel ? RenderConstants.SmoothScrollDuration : 0; } private _shouldLoadWebgl(): boolean { - return !isSafari && (this._configHelper.config.gpuAcceleration === 'auto' && XtermTerminal._suggestedRendererType === undefined) || this._configHelper.config.gpuAcceleration === 'on'; + return (this._terminalConfigurationService.config.gpuAcceleration === 'auto' && XtermTerminal._suggestedRendererType === undefined) || this._terminalConfigurationService.config.gpuAcceleration === 'on'; } private _shouldLoadCanvas(): boolean { - return (this._configHelper.config.gpuAcceleration === 'auto' && (XtermTerminal._suggestedRendererType === undefined || XtermTerminal._suggestedRendererType === 'canvas')) || this._configHelper.config.gpuAcceleration === 'canvas'; + return this._terminalConfigurationService.config.gpuAcceleration === 'canvas'; } forceRedraw() { @@ -443,19 +439,6 @@ export class XtermTerminal extends Disposable implements IXtermTerminal, IDetach this._core.viewport?._innerRefresh(); } - forceUnpause() { - // HACK: Force the renderer to unpause by simulating an IntersectionObserver event. - // This is to fix an issue where dragging the windpow to the top of the screen to - // maximize on Windows/Linux would fire an event saying that the terminal was not - // visible. - if (!!this._canvasAddon) { - this._core._renderService?._handleIntersectionChange({ intersectionRatio: 1 }); - // HACK: Force a refresh of the screen to ensure links are refresh corrected. - // This can probably be removed when the above hack is fixed in Chromium. - this.raw.refresh(0, this.raw.rows - 1); - } - } - async findNext(term: string, searchOptions: ISearchOptions): Promise { this._updateFindColors(searchOptions); return (await this._getSearchAddon()).findNext(term, searchOptions); @@ -515,7 +498,7 @@ export class XtermTerminal extends Disposable implements IXtermTerminal, IDetach } getFont(): ITerminalFont { - return this._configHelper.getFont(dom.getWindow(this.raw.element), this._core); + return this._terminalConfigurationService.getFont(dom.getWindow(this.raw.element), this._core); } getLongestViewportWrappedLineLength(): number { @@ -584,7 +567,7 @@ export class XtermTerminal extends Disposable implements IXtermTerminal, IDetach // the prompt being written this._capabilities.get(TerminalCapability.CommandDetection)?.handlePromptStart(); this._capabilities.get(TerminalCapability.CommandDetection)?.handleCommandStart(); - this._audioCueService.playAudioCue(AudioCue.clear); + this._accessibilitySignalService.playSignal(AccessibilitySignal.clear); } hasSelection(): boolean { @@ -713,22 +696,19 @@ export class XtermTerminal extends Disposable implements IXtermTerminal, IDetach // } // }, 5000); } catch (e) { - this._logService.warn(`Webgl could not be loaded. Falling back to the canvas renderer type.`, e); - const neverMeasureRenderTime = this._storageService.getBoolean(TerminalStorageKeys.NeverMeasureRenderTime, StorageScope.APPLICATION, false); - // if it's already set to dom, no need to measure render time - if (!neverMeasureRenderTime && this._configHelper.config.gpuAcceleration !== 'off') { - this._measureRenderTime(); - } + this._logService.warn(`Webgl could not be loaded. Falling back to the DOM renderer`, e); this._disableWebglForThisSession(); } } private _disableWebglForThisSession() { - XtermTerminal._suggestedRendererType = 'canvas'; + XtermTerminal._suggestedRendererType = 'dom'; this._disposeOfWebglRenderer(); - this._enableCanvasRenderer(); } + /** + * @deprecated This will be removed in the future, see https://github.com/microsoft/vscode/issues/209276 + */ private async _enableCanvasRenderer(): Promise { if (!this.raw.element || this._canvasAddon) { return; @@ -741,11 +721,6 @@ export class XtermTerminal extends Disposable implements IXtermTerminal, IDetach this._logService.trace('Canvas renderer was loaded'); } catch (e) { this._logService.warn(`Canvas renderer could not be loaded, falling back to dom renderer`, e); - const neverMeasureRenderTime = this._storageService.getBoolean(TerminalStorageKeys.NeverMeasureRenderTime, StorageScope.APPLICATION, false); - // if it's already set to dom, no need to measure render time - if (!neverMeasureRenderTime && this._configHelper.config.gpuAcceleration !== 'off') { - this._measureRenderTime(); - } XtermTerminal._suggestedRendererType = 'dom'; this._disposeOfCanvasRenderer(); } @@ -762,7 +737,7 @@ export class XtermTerminal extends Disposable implements IXtermTerminal, IDetach @debounce(100) private async _refreshImageAddon(): Promise { // Only allow the image addon when a canvas is being used to avoid possible GPU issues - if (this._configHelper.config.enableImages && (this._canvasAddon || this._webglAddon)) { + if (this._terminalConfigurationService.config.enableImages && (this._canvasAddon || this._webglAddon)) { if (!this._imageAddon) { const AddonCtor = await this._getImageAddonConstructor(); this._imageAddon = new AddonCtor(); @@ -833,59 +808,6 @@ export class XtermTerminal extends Disposable implements IXtermTerminal, IDetach this._refreshImageAddon(); } - private async _measureRenderTime(): Promise { - const frameTimes: number[] = []; - if (!this._core._renderService?._renderer.value?._renderLayers) { - return; - } - const textRenderLayer = this._core._renderService._renderer.value._renderLayers[0]; - const originalOnGridChanged = textRenderLayer?.onGridChanged; - const evaluateCanvasRenderer = () => { - // Discard first frame time as it's normal to take longer - frameTimes.shift(); - - const medianTime = frameTimes.sort((a, b) => a - b)[Math.floor(frameTimes.length / 2)]; - if (medianTime > RenderConstants.SlowCanvasRenderThreshold) { - if (this._configHelper.config.gpuAcceleration === 'auto') { - XtermTerminal._suggestedRendererType = 'dom'; - this.updateConfig(); - } else { - const promptChoices: IPromptChoice[] = [ - { - label: localize('yes', "Yes"), - run: () => this._configurationService.updateValue(TerminalSettingId.GpuAcceleration, 'off', ConfigurationTarget.USER) - } as IPromptChoice, - { - label: localize('no', "No"), - run: () => { } - } as IPromptChoice, - { - label: localize('dontShowAgain', "Don't Show Again"), - isSecondary: true, - run: () => this._storageService.store(TerminalStorageKeys.NeverMeasureRenderTime, true, StorageScope.APPLICATION, StorageTarget.MACHINE) - } as IPromptChoice - ]; - this._notificationService.prompt( - Severity.Warning, - localize('terminal.slowRendering', 'Terminal GPU acceleration appears to be slow on your computer. Would you like to switch to disable it which may improve performance? [Read more about terminal settings](https://code.visualstudio.com/docs/editor/integrated-terminal#_changing-how-the-terminal-is-rendered).'), - promptChoices - ); - } - } - }; - - textRenderLayer.onGridChanged = (terminal: RawXtermTerminal, firstRow: number, lastRow: number) => { - const startTime = performance.now(); - originalOnGridChanged.call(textRenderLayer, terminal, firstRow, lastRow); - frameTimes.push(performance.now() - startTime); - if (frameTimes.length === RenderConstants.NumberOfFramestoMeasure) { - evaluateCanvasRenderer(); - // Restore original function - textRenderLayer.onGridChanged = originalOnGridChanged; - } - }; - } - getXtermTheme(theme?: IColorTheme): ITheme { if (!theme) { theme = this._themeService.getColorTheme(); @@ -936,13 +858,13 @@ export class XtermTerminal extends Disposable implements IXtermTerminal, IDetach } private async _updateUnicodeVersion(): Promise { - if (!this._unicode11Addon && this._configHelper.config.unicodeVersion === '11') { + if (!this._unicode11Addon && this._terminalConfigurationService.config.unicodeVersion === '11') { const Addon = await this._getUnicode11Constructor(); this._unicode11Addon = new Addon(); this.raw.loadAddon(this._unicode11Addon); } - if (this.raw.unicode.activeVersion !== this._configHelper.config.unicodeVersion) { - this.raw.unicode.activeVersion = this._configHelper.config.unicodeVersion; + if (this.raw.unicode.activeVersion !== this._terminalConfigurationService.config.unicodeVersion) { + this.raw.unicode.activeVersion = this._terminalConfigurationService.config.unicodeVersion; } } diff --git a/src/vs/workbench/contrib/terminal/common/history.ts b/src/vs/workbench/contrib/terminal/common/history.ts index cf0d940e716a1..ca2588453d2ab 100644 --- a/src/vs/workbench/contrib/terminal/common/history.ts +++ b/src/vs/workbench/contrib/terminal/common/history.ts @@ -92,6 +92,9 @@ export async function getShellFileHistory(accessor: ServicesAccessor, shellType: case PosixShellType.Fish: result = await fetchFishHistory(accessor); break; + case PosixShellType.Python: + result = await fetchPythonHistory(accessor); + break; default: return []; } if (result === undefined) { @@ -295,6 +298,30 @@ export async function fetchZshHistory(accessor: ServicesAccessor) { return result.values(); } + +export async function fetchPythonHistory(accessor: ServicesAccessor): Promise | undefined> { + const fileService = accessor.get(IFileService); + const remoteAgentService = accessor.get(IRemoteAgentService); + + const content = await fetchFileContents(env['HOME'], '.python_history', false, fileService, remoteAgentService); + + if (content === undefined) { + return undefined; + } + + // Python history file is a simple text file with one command per line + const fileLines = content.split('\n'); + const result: Set = new Set(); + + fileLines.forEach(line => { + if (line.trim().length > 0) { + result.add(line.trim()); + } + }); + + return result.values(); +} + export async function fetchPwshHistory(accessor: ServicesAccessor) { const fileService: Pick = accessor.get(IFileService); const remoteAgentService: Pick = accessor.get(IRemoteAgentService); diff --git a/src/vs/workbench/contrib/terminal/common/terminal.ts b/src/vs/workbench/contrib/terminal/common/terminal.ts index fc925b646bb1c..a2c8e2a804ed1 100644 --- a/src/vs/workbench/contrib/terminal/common/terminal.ts +++ b/src/vs/workbench/contrib/terminal/common/terminal.ts @@ -137,6 +137,7 @@ export interface ITerminalConfiguration { macOptionClickForcesSelection: boolean; gpuAcceleration: 'auto' | 'on' | 'canvas' | 'off'; rightClickBehavior: 'default' | 'copyPaste' | 'paste' | 'selectWord' | 'nothing'; + middleClickBehavior: 'default' | 'paste'; cursorBlinking: boolean; cursorStyle: 'block' | 'underline' | 'line'; cursorStyleInactive: 'outline' | 'block' | 'underline' | 'line' | 'none'; @@ -175,6 +176,7 @@ export interface ITerminalConfiguration { windowsEnableConpty: boolean; wordSeparators: string; enableFileLinks: 'off' | 'on' | 'notRemote'; + allowedLinkSchemes: string[]; unicodeVersion: '6' | '11'; localEchoLatencyThreshold: number; localEchoExcludePrograms: ReadonlyArray; @@ -204,6 +206,7 @@ export interface ITerminalConfiguration { enableImages: boolean; smoothScrolling: boolean; ignoreBracketedPasteMode: boolean; + rescaleOverlappingGlyphs: boolean; } export const DEFAULT_LOCAL_ECHO_EXCLUDE: ReadonlyArray = ['vim', 'vi', 'nano', 'tmux']; @@ -649,7 +652,18 @@ export const DEFAULT_COMMANDS_TO_SKIP_SHELL: string[] = [ 'workbench.action.quickOpenView', 'workbench.action.toggleMaximizedPanel', 'notification.acceptPrimaryAction', - 'runCommands' + 'runCommands', + 'workbench.action.terminal.chat.start', + 'workbench.action.terminal.chat.close', + 'workbench.action.terminal.chat.discard', + 'workbench.action.terminal.chat.makeRequest', + 'workbench.action.terminal.chat.cancel', + 'workbench.action.terminal.chat.feedbackHelpful', + 'workbench.action.terminal.chat.feedbackUnhelpful', + 'workbench.action.terminal.chat.feedbackReportIssue', + 'workbench.action.terminal.chat.runCommand', + 'workbench.action.terminal.chat.insertCommand', + 'workbench.action.terminal.chat.viewInChat', ]; export const terminalContributionsDescriptor: IExtensionPointDescriptor = { diff --git a/src/vs/workbench/contrib/terminal/common/terminalConfiguration.ts b/src/vs/workbench/contrib/terminal/common/terminalConfiguration.ts index ac0903b8ae3eb..c325d2fb8360b 100644 --- a/src/vs/workbench/contrib/terminal/common/terminalConfiguration.ts +++ b/src/vs/workbench/contrib/terminal/common/terminalConfiguration.ts @@ -12,6 +12,7 @@ import { Registry } from 'vs/platform/registry/common/platform'; import { Codicon } from 'vs/base/common/codicons'; import { terminalColorSchema, terminalIconSchema } from 'vs/platform/terminal/common/terminalPlatformConfiguration'; import product from 'vs/platform/product/common/product'; +import { Extensions as WorkbenchExtensions, IConfigurationMigrationRegistry, ConfigurationKeyValuePairs } from 'vs/workbench/common/configuration'; const terminalDescriptors = '\n- ' + [ '`\${cwd}`: ' + localize("cwd", "the terminal's current working directory"), @@ -333,6 +334,16 @@ const terminalConfiguration: IConfigurationNode = { default: isMacintosh ? 'selectWord' : isWindows ? 'copyPaste' : 'default', description: localize('terminal.integrated.rightClickBehavior', "Controls how terminal reacts to right click.") }, + [TerminalSettingId.MiddleClickBehavior]: { + type: 'string', + enum: ['default', 'paste'], + enumDescriptions: [ + localize('terminal.integrated.middleClickBehavior.default', "The platform default to focus the terminal. On Linux this will also paste the selection."), + localize('terminal.integrated.middleClickBehavior.paste', "Paste on middle click."), + ], + default: 'default', + description: localize('terminal.integrated.middleClickBehavior', "Controls how terminal reacts to middle click.") + }, [TerminalSettingId.Cwd]: { restricted: true, description: localize('terminal.integrated.cwd', "An explicit start path where the terminal will be launched, this is used as the current working directory (cwd) for the shell process. This may be particularly useful in workspace settings if the root directory is not a convenient cwd."), @@ -364,7 +375,12 @@ const terminalConfiguration: IConfigurationNode = { default: 'editor' }, [TerminalSettingId.EnableBell]: { - description: localize('terminal.integrated.enableBell', "Controls whether the terminal bell is enabled. This shows up as a visual bell next to the terminal's name."), + markdownDeprecationMessage: localize('terminal.integrated.enableBell', "This is now deprecated. Instead use the `terminal.integrated.enableVisualBell` and `accessibility.signals.terminalBell` settings."), + type: 'boolean', + default: false + }, + [TerminalSettingId.EnableVisualBell]: { + description: localize('terminal.integrated.enableVisualBell', "Controls whether the visual terminal bell is enabled. This shows up next to the terminal's name."), type: 'boolean', default: false }, @@ -473,6 +489,21 @@ const terminalConfiguration: IConfigurationNode = { ], default: 'on' }, + [TerminalSettingId.AllowedLinkSchemes]: { + description: localize('terminal.integrated.allowedLinkSchemes', "An array of strings containing the URI schemes that the terminal is allowed to open links for. By default, only a small subset of possible schemes are allowed for security reasons."), + type: 'array', + items: { + type: 'string' + }, + default: [ + 'file', + 'http', + 'https', + 'mailto', + 'vscode', + 'vscode-insiders', + ] + }, [TerminalSettingId.UnicodeVersion]: { type: 'string', enum: ['6', '11'], @@ -554,6 +585,11 @@ const terminalConfiguration: IConfigurationNode = { type: 'boolean', default: true }, + [TerminalSettingId.RescaleOverlappingGlyphs]: { + markdownDescription: localize('terminal.integrated.rescaleOverlappingGlyphs', "Whether to rescale glyphs horizontally that are a single cell wide but have glyphs that would overlap following cell(s). This typically happens for ambiguous width characters (eg. the roman numeral characters U+2160+) which aren't featured in monospace fonts. Emoji glyphs are never rescaled."), + type: 'boolean', + default: false + }, [TerminalSettingId.AutoReplies]: { markdownDescription: localize('terminal.integrated.autoReplies', "A set of messages that, when encountered in the terminal, will be automatically responded to. Provided the message is specific enough, this can help automate away common responses.\n\nRemarks:\n\n- Use {0} to automatically respond to the terminate batch job prompt on Windows.\n- The message includes escape sequences so the reply might not happen with styled text.\n- Each reply can only happen once every second.\n- Use {1} in the reply to mean the enter key.\n- To unset a default key, set the value to null.\n- Restart VS Code if new don't apply.", '`"Terminate batch job (Y/N)": "Y\\r"`', '`"\\r"`'), type: 'object', @@ -568,7 +604,7 @@ const terminalConfiguration: IConfigurationNode = { }, [TerminalSettingId.ShellIntegrationEnabled]: { restricted: true, - markdownDescription: localize('terminal.integrated.shellIntegration.enabled', "Determines whether or not shell integration is auto-injected to support features like enhanced command tracking and current working directory detection. \n\nShell integration works by injecting the shell with a startup script. The script gives VS Code insight into what is happening within the terminal.\n\nSupported shells:\n\n- Linux/macOS: bash, fish, pwsh, zsh\n - Windows: pwsh\n\nThis setting applies only when terminals are created, so you will need to restart your terminals for it to take effect.\n\n Note that the script injection may not work if you have custom arguments defined in the terminal profile, have enabled {1}, have a [complex bash `PROMPT_COMMAND`](https://code.visualstudio.com/docs/editor/integrated-terminal#_complex-bash-promptcommand), or other unsupported setup. To disable decorations, see {0}", '`#terminal.integrated.shellIntegrations.decorationsEnabled#`', '`#editor.accessibilitySupport#`'), + markdownDescription: localize('terminal.integrated.shellIntegration.enabled', "Determines whether or not shell integration is auto-injected to support features like enhanced command tracking and current working directory detection. \n\nShell integration works by injecting the shell with a startup script. The script gives VS Code insight into what is happening within the terminal.\n\nSupported shells:\n\n- Linux/macOS: bash, fish, pwsh, zsh\n - Windows: pwsh, git bash\n\nThis setting applies only when terminals are created, so you will need to restart your terminals for it to take effect.\n\n Note that the script injection may not work if you have custom arguments defined in the terminal profile, have enabled {1}, have a [complex bash `PROMPT_COMMAND`](https://code.visualstudio.com/docs/editor/integrated-terminal#_complex-bash-promptcommand), or other unsupported setup. To disable decorations, see {0}", '`#terminal.integrated.shellIntegrations.decorationsEnabled#`', '`#editor.accessibilitySupport#`'), type: 'boolean', default: true }, @@ -595,7 +631,8 @@ const terminalConfiguration: IConfigurationNode = { restricted: true, markdownDescription: localize('terminal.integrated.shellIntegration.suggestEnabled', "Enables experimental terminal Intellisense suggestions for supported shells when {0} is set to {1}. If shell integration is installed manually, {2} needs to be set to {3} before calling the script.", '`#terminal.integrated.shellIntegration.enabled#`', '`true`', '`VSCODE_SUGGEST`', '`1`'), type: 'boolean', - default: false + default: false, + markdownDeprecationMessage: localize('suggestEnabled.deprecated', 'This is an experimental setting and may break the terminal! Use at your own risk.') }, [TerminalSettingId.SmoothScrolling]: { markdownDescription: localize('terminal.integrated.smoothScrolling', "Controls whether the terminal will scroll using an animation."), @@ -653,6 +690,11 @@ const terminalConfiguration: IConfigurationNode = { type: 'boolean', default: false }, + [TerminalSettingId.ExperimentalInlineChat]: { + markdownDescription: localize('terminal.integrated.experimentalInlineChat', "Whether to enable the upcoming experimental inline terminal chat UI."), + type: 'boolean', + default: false + } } }; @@ -660,3 +702,19 @@ export function registerTerminalConfiguration() { const configurationRegistry = Registry.as(Extensions.Configuration); configurationRegistry.registerConfiguration(terminalConfiguration); } + +Registry.as(WorkbenchExtensions.ConfigurationMigration) + .registerConfigurationMigrations([{ + key: TerminalSettingId.EnableBell, + migrateFn: (enableBell, accessor) => { + const configurationKeyValuePairs: ConfigurationKeyValuePairs = []; + let announcement = accessor('accessibility.signals.terminalBell')?.announcement ?? accessor('accessibility.alert.terminalBell'); + if (announcement !== undefined && typeof announcement !== 'string') { + announcement = announcement ? 'auto' : 'off'; + } + configurationKeyValuePairs.push(['accessibility.signals.terminalBell', { value: { sound: enableBell ? 'on' : 'off', announcement } }]); + configurationKeyValuePairs.push([TerminalSettingId.EnableBell, { value: undefined }]); + configurationKeyValuePairs.push([TerminalSettingId.EnableVisualBell, { value: enableBell }]); + return configurationKeyValuePairs; + } + }]); diff --git a/src/vs/workbench/contrib/terminal/common/terminalEnvironment.ts b/src/vs/workbench/contrib/terminal/common/terminalEnvironment.ts index 22aedd0758cbe..27ccb9cb61fe0 100644 --- a/src/vs/workbench/contrib/terminal/common/terminalEnvironment.ts +++ b/src/vs/workbench/contrib/terminal/common/terminalEnvironment.ts @@ -13,7 +13,7 @@ import { IWorkspaceContextService, IWorkspaceFolder } from 'vs/platform/workspac import { IConfigurationResolverService } from 'vs/workbench/services/configurationResolver/common/configurationResolver'; import { sanitizeProcessEnvironment } from 'vs/base/common/processes'; import { IShellLaunchConfig, ITerminalBackend, ITerminalEnvironment, TerminalShellType, WindowsShellType } from 'vs/platform/terminal/common/terminal'; -import { IProcessEnvironment, isWindows, language, OperatingSystem } from 'vs/base/common/platform'; +import { IProcessEnvironment, isWindows, isMacintosh, language, OperatingSystem } from 'vs/base/common/platform'; import { escapeNonWindowsPath, sanitizeCwd } from 'vs/platform/terminal/common/terminalEnvironment'; import { isString } from 'vs/base/common/types'; import { IHistoryService } from 'vs/workbench/services/history/common/history'; @@ -269,6 +269,26 @@ export async function createTerminalEnvironment( } } + // Workaround for https://github.com/microsoft/vscode/issues/204005 + // We should restore the following environment variables when a user + // launches the application using the CLI so that integrated terminal + // can still inherit these variables. + // We are not bypassing the restrictions implied in https://github.com/electron/electron/pull/40770 + // since this only affects integrated terminal and not the application itself. + if (isMacintosh) { + // Restore NODE_OPTIONS if it was set + if (env['VSCODE_NODE_OPTIONS']) { + env['NODE_OPTIONS'] = env['VSCODE_NODE_OPTIONS']; + delete env['VSCODE_NODE_OPTIONS']; + } + + // Restore NODE_REPL_EXTERNAL_MODULE if it was set + if (env['VSCODE_NODE_REPL_EXTERNAL_MODULE']) { + env['NODE_REPL_EXTERNAL_MODULE'] = env['VSCODE_NODE_REPL_EXTERNAL_MODULE']; + delete env['VSCODE_NODE_REPL_EXTERNAL_MODULE']; + } + } + // Sanitize the environment, removing any undesirable VS Code and Electron environment // variables sanitizeProcessEnvironment(env, 'VSCODE_IPC_HOOK_CLI'); diff --git a/src/vs/workbench/contrib/terminal/common/terminalStorageKeys.ts b/src/vs/workbench/contrib/terminal/common/terminalStorageKeys.ts index 521a2cde049df..6a495eca81b53 100644 --- a/src/vs/workbench/contrib/terminal/common/terminalStorageKeys.ts +++ b/src/vs/workbench/contrib/terminal/common/terminalStorageKeys.ts @@ -4,7 +4,6 @@ *--------------------------------------------------------------------------------------------*/ export const enum TerminalStorageKeys { - NeverMeasureRenderTime = 'terminal.integrated.neverMeasureRenderTime', SuggestedRendererType = 'terminal.integrated.suggestedRendererType', TabsListWidthHorizontal = 'tabs-list-width-horizontal', TabsListWidthVertical = 'tabs-list-width-vertical', diff --git a/src/vs/workbench/contrib/terminal/electron-sandbox/localTerminalBackend.ts b/src/vs/workbench/contrib/terminal/electron-sandbox/localTerminalBackend.ts index 06779c99e215e..7f40100cf84dd 100644 --- a/src/vs/workbench/contrib/terminal/electron-sandbox/localTerminalBackend.ts +++ b/src/vs/workbench/contrib/terminal/electron-sandbox/localTerminalBackend.ts @@ -37,6 +37,7 @@ import { IStatusbarService } from 'vs/workbench/services/statusbar/browser/statu import { memoize } from 'vs/base/common/decorators'; import { StopWatch } from 'vs/base/common/stopwatch'; import { IRemoteAgentService } from 'vs/workbench/services/remote/common/remoteAgentService'; +import { shouldUseEnvironmentVariableCollection } from 'vs/platform/terminal/common/terminalEnvironment'; export class LocalTerminalBackendContribution implements IWorkbenchContribution { @@ -94,11 +95,11 @@ class LocalTerminalBackend extends BaseTerminalBackend implements ITerminalBacke ) { super(_localPtyService, logService, historyService, _configurationResolverService, statusBarService, workspaceContextService); - this.onPtyHostRestart(() => { + this._register(this.onPtyHostRestart(() => { this._directProxy = undefined; this._directProxyClientEventually = undefined; this._connectToDirectProxy(); - }); + })); } /** @@ -373,7 +374,7 @@ class LocalTerminalBackend extends BaseTerminalBackend implements ITerminalBacke const envFromConfigValue = this._configurationService.getValue(`terminal.integrated.env.${platformKey}`); const baseEnv = await (shellLaunchConfig.useShellEnvironment ? this.getShellEnvironment() : this.getEnvironment()); const env = await terminalEnvironment.createTerminalEnvironment(shellLaunchConfig, envFromConfigValue, variableResolver, this._productService.version, this._configurationService.getValue(TerminalSettingId.DetectLocale), baseEnv); - if (!shellLaunchConfig.strictEnv && !shellLaunchConfig.hideFromUser) { + if (shouldUseEnvironmentVariableCollection(shellLaunchConfig)) { const workspaceFolder = terminalEnvironment.getWorkspaceForTerminal(shellLaunchConfig.cwd, this._workspaceContextService, this._historyService); await this._environmentVariableService.mergedCollection.applyToProcessEnvironment(env, { workspaceFolder }, variableResolver); } diff --git a/src/vs/workbench/contrib/terminal/terminal.all.ts b/src/vs/workbench/contrib/terminal/terminal.all.ts index b49aa829f7bf6..e9cdc25321298 100644 --- a/src/vs/workbench/contrib/terminal/terminal.all.ts +++ b/src/vs/workbench/contrib/terminal/terminal.all.ts @@ -17,6 +17,7 @@ import 'vs/workbench/contrib/terminalContrib/accessibility/browser/terminal.acce import 'vs/workbench/contrib/terminalContrib/developer/browser/terminal.developer.contribution'; import 'vs/workbench/contrib/terminalContrib/environmentChanges/browser/terminal.environmentChanges.contribution'; import 'vs/workbench/contrib/terminalContrib/find/browser/terminal.find.contribution'; +import 'vs/workbench/contrib/terminalContrib/chat/browser/terminal.chat.contribution'; import 'vs/workbench/contrib/terminalContrib/highlight/browser/terminal.highlight.contribution'; import 'vs/workbench/contrib/terminalContrib/links/browser/terminal.links.contribution'; import 'vs/workbench/contrib/terminalContrib/zoom/browser/terminal.zoom.contribution'; diff --git a/src/vs/workbench/contrib/terminal/test/browser/capabilities/partialCommandDetectionCapability.test.ts b/src/vs/workbench/contrib/terminal/test/browser/capabilities/partialCommandDetectionCapability.test.ts index e37020815fddd..016da9ab599ac 100644 --- a/src/vs/workbench/contrib/terminal/test/browser/capabilities/partialCommandDetectionCapability.test.ts +++ b/src/vs/workbench/contrib/terminal/test/browser/capabilities/partialCommandDetectionCapability.test.ts @@ -3,20 +3,15 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { deepStrictEqual } from 'assert'; -import { PartialCommandDetectionCapability } from 'vs/platform/terminal/common/capabilities/partialCommandDetectionCapability'; import type { IMarker, Terminal } from '@xterm/xterm'; -import { IXtermCore } from 'vs/workbench/contrib/terminal/browser/xterm-private'; +import { deepStrictEqual } from 'assert'; import { importAMDNodeModule } from 'vs/amdX'; -import { writeP } from 'vs/workbench/contrib/terminal/browser/terminalTestHelpers'; import { ensureNoDisposablesAreLeakedInTestSuite } from 'vs/base/test/common/utils'; - -interface TestTerminal extends Terminal { - _core: IXtermCore; -} +import { PartialCommandDetectionCapability } from 'vs/platform/terminal/common/capabilities/partialCommandDetectionCapability'; +import { writeP } from 'vs/workbench/contrib/terminal/browser/terminalTestHelpers'; suite('PartialCommandDetectionCapability', () => { - let xterm: TestTerminal; + let xterm: Terminal; let capability: PartialCommandDetectionCapability; let addEvents: IMarker[]; @@ -28,7 +23,7 @@ suite('PartialCommandDetectionCapability', () => { setup(async () => { const TerminalCtor = (await importAMDNodeModule('@xterm/xterm', 'lib/xterm.js')).Terminal; - xterm = new TerminalCtor({ allowProposedApi: true, cols: 80 }) as TestTerminal; + xterm = new TerminalCtor({ allowProposedApi: true, cols: 80 }) as Terminal; capability = new PartialCommandDetectionCapability(xterm); addEvents = []; capability.onCommandFinished(e => addEvents.push(e)); @@ -38,11 +33,11 @@ suite('PartialCommandDetectionCapability', () => { test('should not add commands when the cursor position is too close to the left side', async () => { assertCommands([]); - xterm._core._onData.fire('\x0d'); + xterm.input('\x0d'); await writeP(xterm, '\r\n'); assertCommands([]); await writeP(xterm, 'a'); - xterm._core._onData.fire('\x0d'); + xterm.input('\x0d'); await writeP(xterm, '\r\n'); assertCommands([]); }); @@ -50,11 +45,11 @@ suite('PartialCommandDetectionCapability', () => { test('should add commands when the cursor position is not too close to the left side', async () => { assertCommands([]); await writeP(xterm, 'ab'); - xterm._core._onData.fire('\x0d'); + xterm.input('\x0d'); await writeP(xterm, '\r\n\r\n'); assertCommands([0]); await writeP(xterm, 'cd'); - xterm._core._onData.fire('\x0d'); + xterm.input('\x0d'); await writeP(xterm, '\r\n'); assertCommands([0, 2]); }); diff --git a/src/vs/workbench/contrib/terminal/test/browser/terminalConfigHelper.test.ts b/src/vs/workbench/contrib/terminal/test/browser/terminalConfigHelper.test.ts deleted file mode 100644 index 331f5a1f55777..0000000000000 --- a/src/vs/workbench/contrib/terminal/test/browser/terminalConfigHelper.test.ts +++ /dev/null @@ -1,286 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import * as assert from 'assert'; -import { TerminalConfigHelper } from 'vs/workbench/contrib/terminal/browser/terminalConfigHelper'; -import { EDITOR_FONT_DEFAULTS } from 'vs/editor/common/config/editorOptions'; -import { TestConfigurationService } from 'vs/platform/configuration/test/common/testConfigurationService'; -import { LinuxDistro } from 'vs/workbench/contrib/terminal/browser/terminal'; -import { ensureNoDisposablesAreLeakedInTestSuite } from 'vs/base/test/common/utils'; -import { DisposableStore } from 'vs/base/common/lifecycle'; -import { mainWindow } from 'vs/base/browser/window'; -import { getActiveWindow } from 'vs/base/browser/dom'; - -class TestTerminalConfigHelper extends TerminalConfigHelper { - set linuxDistro(distro: LinuxDistro) { - this._linuxDistro = distro; - } -} - -suite('Workbench - TerminalConfigHelper', function () { - let store: DisposableStore; - let fixture: HTMLElement; - - // This suite has retries setup because the font-related tests flake only on GitHub actions, not - // ADO. It seems Electron hangs for some reason only on GH actions, so the two options are to - // retry or remove the test outright (which would drop coverage). - this.retries(3); - - setup(() => { - store = new DisposableStore(); - fixture = mainWindow.document.body; - }); - teardown(() => store.dispose()); - - ensureNoDisposablesAreLeakedInTestSuite(); - - test('TerminalConfigHelper - getFont fontFamily', () => { - const configurationService = new TestConfigurationService({ - editor: { fontFamily: 'foo' }, - terminal: { integrated: { fontFamily: 'bar' } } - }); - const configHelper = store.add(new TestTerminalConfigHelper(configurationService, null!, null!, null!, null!)); - configHelper.panelContainer = fixture; - assert.strictEqual(configHelper.getFont(getActiveWindow()).fontFamily, 'bar, monospace', 'terminal.integrated.fontFamily should be selected over editor.fontFamily'); - }); - - test('TerminalConfigHelper - getFont fontFamily (Linux Fedora)', () => { - const configurationService = new TestConfigurationService({ - editor: { fontFamily: 'foo' }, - terminal: { integrated: { fontFamily: null } } - }); - const configHelper = store.add(new TestTerminalConfigHelper(configurationService, null!, null!, null!, null!)); - configHelper.linuxDistro = LinuxDistro.Fedora; - configHelper.panelContainer = fixture; - assert.strictEqual(configHelper.getFont(getActiveWindow()).fontFamily, '\'DejaVu Sans Mono\', monospace', 'Fedora should have its font overridden when terminal.integrated.fontFamily not set'); - }); - - test('TerminalConfigHelper - getFont fontFamily (Linux Ubuntu)', () => { - const configurationService = new TestConfigurationService({ - editor: { fontFamily: 'foo' }, - terminal: { integrated: { fontFamily: null } } - }); - const configHelper = store.add(new TestTerminalConfigHelper(configurationService, null!, null!, null!, null!)); - configHelper.linuxDistro = LinuxDistro.Ubuntu; - configHelper.panelContainer = fixture; - assert.strictEqual(configHelper.getFont(getActiveWindow()).fontFamily, '\'Ubuntu Mono\', monospace', 'Ubuntu should have its font overridden when terminal.integrated.fontFamily not set'); - }); - - test('TerminalConfigHelper - getFont fontFamily (Linux Unknown)', () => { - const configurationService = new TestConfigurationService({ - editor: { fontFamily: 'foo' }, - terminal: { integrated: { fontFamily: null } } - }); - const configHelper = store.add(new TestTerminalConfigHelper(configurationService, null!, null!, null!, null!)); - configHelper.panelContainer = fixture; - assert.strictEqual(configHelper.getFont(getActiveWindow()).fontFamily, 'foo, monospace', 'editor.fontFamily should be the fallback when terminal.integrated.fontFamily not set'); - }); - - test('TerminalConfigHelper - getFont fontSize 10', () => { - const configurationService = new TestConfigurationService({ - editor: { - fontFamily: 'foo', - fontSize: 9 - }, - terminal: { - integrated: { - fontFamily: 'bar', - fontSize: 10 - } - } - }); - const configHelper = store.add(new TestTerminalConfigHelper(configurationService, null!, null!, null!, null!)); - configHelper.panelContainer = fixture; - assert.strictEqual(configHelper.getFont(getActiveWindow()).fontSize, 10, 'terminal.integrated.fontSize should be selected over editor.fontSize'); - }); - - test('TerminalConfigHelper - getFont fontSize 0', () => { - const configurationService = new TestConfigurationService({ - editor: { - fontFamily: 'foo' - }, - terminal: { - integrated: { - fontFamily: null, - fontSize: 0 - } - } - }); - let configHelper = store.add(new TestTerminalConfigHelper(configurationService, null!, null!, null!, null!)); - configHelper.linuxDistro = LinuxDistro.Ubuntu; - configHelper.panelContainer = fixture; - assert.strictEqual(configHelper.getFont(getActiveWindow()).fontSize, 8, 'The minimum terminal font size (with adjustment) should be used when terminal.integrated.fontSize less than it'); - - configHelper = store.add(new TestTerminalConfigHelper(configurationService, null!, null!, null!, null!)); - configHelper.panelContainer = fixture; - assert.strictEqual(configHelper.getFont(getActiveWindow()).fontSize, 6, 'The minimum terminal font size should be used when terminal.integrated.fontSize less than it'); - }); - - test('TerminalConfigHelper - getFont fontSize 1500', () => { - const configurationService = new TestConfigurationService({ - editor: { - fontFamily: 'foo' - }, - terminal: { - integrated: { - fontFamily: 0, - fontSize: 1500 - } - } - }); - const configHelper = store.add(new TestTerminalConfigHelper(configurationService, null!, null!, null!, null!)); - configHelper.panelContainer = fixture; - assert.strictEqual(configHelper.getFont(getActiveWindow()).fontSize, 100, 'The maximum terminal font size should be used when terminal.integrated.fontSize more than it'); - }); - - test('TerminalConfigHelper - getFont fontSize null', () => { - const configurationService = new TestConfigurationService({ - editor: { - fontFamily: 'foo' - }, - terminal: { - integrated: { - fontFamily: 0, - fontSize: null - } - } - }); - let configHelper = store.add(new TestTerminalConfigHelper(configurationService, null!, null!, null!, null!)); - configHelper.linuxDistro = LinuxDistro.Ubuntu; - configHelper.panelContainer = fixture; - assert.strictEqual(configHelper.getFont(getActiveWindow()).fontSize, EDITOR_FONT_DEFAULTS.fontSize + 2, 'The default editor font size (with adjustment) should be used when terminal.integrated.fontSize is not set'); - - configHelper = store.add(new TestTerminalConfigHelper(configurationService, null!, null!, null!, null!)); - configHelper.panelContainer = fixture; - assert.strictEqual(configHelper.getFont(getActiveWindow()).fontSize, EDITOR_FONT_DEFAULTS.fontSize, 'The default editor font size should be used when terminal.integrated.fontSize is not set'); - }); - - test('TerminalConfigHelper - getFont lineHeight 2', () => { - const configurationService = new TestConfigurationService({ - editor: { - fontFamily: 'foo', - lineHeight: 1 - }, - terminal: { - integrated: { - fontFamily: 0, - lineHeight: 2 - } - } - }); - const configHelper = store.add(new TestTerminalConfigHelper(configurationService, null!, null!, null!, null!)); - configHelper.panelContainer = fixture; - assert.strictEqual(configHelper.getFont(getActiveWindow()).lineHeight, 2, 'terminal.integrated.lineHeight should be selected over editor.lineHeight'); - }); - - test('TerminalConfigHelper - getFont lineHeight 0', () => { - const configurationService = new TestConfigurationService({ - editor: { - fontFamily: 'foo', - lineHeight: 1 - }, - terminal: { - integrated: { - fontFamily: 0, - lineHeight: 0 - } - } - }); - const configHelper = store.add(new TestTerminalConfigHelper(configurationService, null!, null!, null!, null!)); - configHelper.panelContainer = fixture; - assert.strictEqual(configHelper.getFont(getActiveWindow()).lineHeight, 1, 'editor.lineHeight should be 1 when terminal.integrated.lineHeight not set'); - }); - - test('TerminalConfigHelper - isMonospace monospace', () => { - const configurationService = new TestConfigurationService({ - terminal: { - integrated: { - fontFamily: 'monospace' - } - } - }); - - const configHelper = store.add(new TestTerminalConfigHelper(configurationService, null!, null!, null!, null!)); - configHelper.panelContainer = fixture; - assert.strictEqual(configHelper.configFontIsMonospace(), true, 'monospace is monospaced'); - }); - - test('TerminalConfigHelper - isMonospace sans-serif', () => { - const configurationService = new TestConfigurationService({ - terminal: { - integrated: { - fontFamily: 'sans-serif' - } - } - }); - const configHelper = store.add(new TestTerminalConfigHelper(configurationService, null!, null!, null!, null!)); - configHelper.panelContainer = fixture; - assert.strictEqual(configHelper.configFontIsMonospace(), false, 'sans-serif is not monospaced'); - }); - - test('TerminalConfigHelper - isMonospace serif', () => { - const configurationService = new TestConfigurationService({ - terminal: { - integrated: { - fontFamily: 'serif' - } - } - }); - const configHelper = store.add(new TestTerminalConfigHelper(configurationService, null!, null!, null!, null!)); - configHelper.panelContainer = fixture; - assert.strictEqual(configHelper.configFontIsMonospace(), false, 'serif is not monospaced'); - }); - - test('TerminalConfigHelper - isMonospace monospace falls back to editor.fontFamily', () => { - const configurationService = new TestConfigurationService({ - editor: { - fontFamily: 'monospace' - }, - terminal: { - integrated: { - fontFamily: null - } - } - }); - - const configHelper = store.add(new TestTerminalConfigHelper(configurationService, null!, null!, null!, null!)); - configHelper.panelContainer = fixture; - assert.strictEqual(configHelper.configFontIsMonospace(), true, 'monospace is monospaced'); - }); - - test('TerminalConfigHelper - isMonospace sans-serif falls back to editor.fontFamily', () => { - const configurationService = new TestConfigurationService({ - editor: { - fontFamily: 'sans-serif' - }, - terminal: { - integrated: { - fontFamily: null - } - } - }); - - const configHelper = store.add(new TestTerminalConfigHelper(configurationService, null!, null!, null!, null!)); - configHelper.panelContainer = fixture; - assert.strictEqual(configHelper.configFontIsMonospace(), false, 'sans-serif is not monospaced'); - }); - - test('TerminalConfigHelper - isMonospace serif falls back to editor.fontFamily', () => { - const configurationService = new TestConfigurationService({ - editor: { - fontFamily: 'serif' - }, - terminal: { - integrated: { - fontFamily: null - } - } - }); - - const configHelper = store.add(new TestTerminalConfigHelper(configurationService, null!, null!, null!, null!)); - configHelper.panelContainer = fixture; - assert.strictEqual(configHelper.configFontIsMonospace(), false, 'serif is not monospaced'); - }); -}); diff --git a/src/vs/workbench/contrib/terminal/test/browser/terminalConfigurationService.test.ts b/src/vs/workbench/contrib/terminal/test/browser/terminalConfigurationService.test.ts new file mode 100644 index 0000000000000..dec6d5b1f2e6e --- /dev/null +++ b/src/vs/workbench/contrib/terminal/test/browser/terminalConfigurationService.test.ts @@ -0,0 +1,303 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { notStrictEqual, strictEqual } from 'assert'; +import { getActiveWindow } from 'vs/base/browser/dom'; +import { mainWindow } from 'vs/base/browser/window'; +import { ensureNoDisposablesAreLeakedInTestSuite } from 'vs/base/test/common/utils'; +import { EDITOR_FONT_DEFAULTS } from 'vs/editor/common/config/editorOptions'; +import { ConfigurationTarget, IConfigurationService } from 'vs/platform/configuration/common/configuration'; +import { TestConfigurationService } from 'vs/platform/configuration/test/common/testConfigurationService'; +import { TestInstantiationService } from 'vs/platform/instantiation/test/common/instantiationServiceMock'; +import { ITerminalConfigurationService, LinuxDistro } from 'vs/workbench/contrib/terminal/browser/terminal'; +import { TerminalConfigurationService } from 'vs/workbench/contrib/terminal/browser/terminalConfigurationService'; + +class TestTerminalConfigurationService extends TerminalConfigurationService { + get fontMetrics() { return this._fontMetrics; } +} + +suite('Workbench - TerminalConfigurationService', () => { + let configurationService: TestConfigurationService; + let terminalConfigurationService: ITerminalConfigurationService; + + setup(() => { + const instantiationService = new TestInstantiationService(); + configurationService = new TestConfigurationService(); + instantiationService.set(IConfigurationService, configurationService); + terminalConfigurationService = instantiationService.createInstance(TerminalConfigurationService); + }); + + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + suite('config', () => { + test('should update on any change to terminal.integrated', () => { + const originalConfig = terminalConfigurationService.config; + configurationService.onDidChangeConfigurationEmitter.fire({ + affectsConfiguration: configuration => configuration.startsWith('terminal.integrated'), + affectedKeys: new Set(['terminal.integrated.fontWeight']), + change: null!, + source: ConfigurationTarget.USER + }); + notStrictEqual(terminalConfigurationService.config, originalConfig, 'Object reference must change'); + }); + + suite('onConfigChanged', () => { + test('should fire on any change to terminal.integrated', async () => { + await new Promise(r => { + store.add(terminalConfigurationService.onConfigChanged(() => r())); + configurationService.onDidChangeConfigurationEmitter.fire({ + affectsConfiguration: configuration => configuration.startsWith('terminal.integrated'), + affectedKeys: new Set(['terminal.integrated.fontWeight']), + change: null!, + source: ConfigurationTarget.USER + }); + }); + }); + }); + }); + + function createTerminalConfigationService(config: any, linuxDistro?: LinuxDistro): ITerminalConfigurationService { + const instantiationService = new TestInstantiationService(); + instantiationService.set(IConfigurationService, new TestConfigurationService(config)); + const terminalConfigurationService = store.add(instantiationService.createInstance(TestTerminalConfigurationService)); + instantiationService.set(ITerminalConfigurationService, terminalConfigurationService); + terminalConfigurationService.setPanelContainer(mainWindow.document.body); + if (linuxDistro) { + terminalConfigurationService.fontMetrics.linuxDistro = linuxDistro; + } + return terminalConfigurationService; + } + + suite('getFont', () => { + test('fontFamily', () => { + const terminalConfigurationService = createTerminalConfigationService({ + editor: { fontFamily: 'foo' }, + terminal: { integrated: { fontFamily: 'bar' } } + }); + strictEqual(terminalConfigurationService.getFont(getActiveWindow()).fontFamily, 'bar, monospace', 'terminal.integrated.fontFamily should be selected over editor.fontFamily'); + }); + + test('fontFamily (Linux Fedora)', () => { + const terminalConfigurationService = createTerminalConfigationService({ + editor: { fontFamily: 'foo' }, + terminal: { integrated: { fontFamily: null } } + }, LinuxDistro.Fedora); + strictEqual(terminalConfigurationService.getFont(getActiveWindow()).fontFamily, '\'DejaVu Sans Mono\', monospace', 'Fedora should have its font overridden when terminal.integrated.fontFamily not set'); + }); + + test('fontFamily (Linux Ubuntu)', () => { + const terminalConfigurationService = createTerminalConfigationService({ + editor: { fontFamily: 'foo' }, + terminal: { integrated: { fontFamily: null } } + }, LinuxDistro.Ubuntu); + strictEqual(terminalConfigurationService.getFont(getActiveWindow()).fontFamily, '\'Ubuntu Mono\', monospace', 'Ubuntu should have its font overridden when terminal.integrated.fontFamily not set'); + }); + + test('fontFamily (Linux Unknown)', () => { + const terminalConfigurationService = createTerminalConfigationService({ + editor: { fontFamily: 'foo' }, + terminal: { integrated: { fontFamily: null } } + }); + strictEqual(terminalConfigurationService.getFont(getActiveWindow()).fontFamily, 'foo, monospace', 'editor.fontFamily should be the fallback when terminal.integrated.fontFamily not set'); + }); + + test('fontSize 10', () => { + const terminalConfigurationService = createTerminalConfigationService({ + editor: { + fontFamily: 'foo', + fontSize: 9 + }, + terminal: { + integrated: { + fontFamily: 'bar', + fontSize: 10 + } + } + }); + strictEqual(terminalConfigurationService.getFont(getActiveWindow()).fontSize, 10, 'terminal.integrated.fontSize should be selected over editor.fontSize'); + }); + + test('fontSize 0', () => { + let terminalConfigurationService = createTerminalConfigationService({ + editor: { + fontFamily: 'foo' + }, + terminal: { + integrated: { + fontFamily: null, + fontSize: 0 + } + } + }, LinuxDistro.Ubuntu); + strictEqual(terminalConfigurationService.getFont(getActiveWindow()).fontSize, 8, 'The minimum terminal font size (with adjustment) should be used when terminal.integrated.fontSize less than it'); + + terminalConfigurationService = createTerminalConfigationService({ + editor: { + fontFamily: 'foo' + }, + terminal: { + integrated: { + fontFamily: null, + fontSize: 0 + } + } + }); + strictEqual(terminalConfigurationService.getFont(getActiveWindow()).fontSize, 6, 'The minimum terminal font size should be used when terminal.integrated.fontSize less than it'); + }); + + test('fontSize 1500', () => { + const terminalConfigurationService = createTerminalConfigationService({ + editor: { + fontFamily: 'foo' + }, + terminal: { + integrated: { + fontFamily: 0, + fontSize: 1500 + } + } + }); + strictEqual(terminalConfigurationService.getFont(getActiveWindow()).fontSize, 100, 'The maximum terminal font size should be used when terminal.integrated.fontSize more than it'); + }); + + test('fontSize null', () => { + let terminalConfigurationService = createTerminalConfigationService({ + editor: { + fontFamily: 'foo' + }, + terminal: { + integrated: { + fontFamily: 0, + fontSize: null + } + } + }, LinuxDistro.Ubuntu); + strictEqual(terminalConfigurationService.getFont(getActiveWindow()).fontSize, EDITOR_FONT_DEFAULTS.fontSize + 2, 'The default editor font size (with adjustment) should be used when terminal.integrated.fontSize is not set'); + + terminalConfigurationService = createTerminalConfigationService({ + editor: { + fontFamily: 'foo' + }, + terminal: { + integrated: { + fontFamily: 0, + fontSize: null + } + } + }); + strictEqual(terminalConfigurationService.getFont(getActiveWindow()).fontSize, EDITOR_FONT_DEFAULTS.fontSize, 'The default editor font size should be used when terminal.integrated.fontSize is not set'); + }); + + test('lineHeight 2', () => { + const terminalConfigurationService = createTerminalConfigationService({ + editor: { + fontFamily: 'foo', + lineHeight: 1 + }, + terminal: { + integrated: { + fontFamily: 0, + lineHeight: 2 + } + } + }); + strictEqual(terminalConfigurationService.getFont(getActiveWindow()).lineHeight, 2, 'terminal.integrated.lineHeight should be selected over editor.lineHeight'); + }); + + test('lineHeight 0', () => { + const terminalConfigurationService = createTerminalConfigationService({ + editor: { + fontFamily: 'foo', + lineHeight: 1 + }, + terminal: { + integrated: { + fontFamily: 0, + lineHeight: 0 + } + } + }); + strictEqual(terminalConfigurationService.getFont(getActiveWindow()).lineHeight, 1, 'editor.lineHeight should be 1 when terminal.integrated.lineHeight not set'); + }); + }); + + suite('configFontIsMonospace', () => { + test('isMonospace monospace', () => { + const terminalConfigurationService = createTerminalConfigationService({ + terminal: { + integrated: { + fontFamily: 'monospace' + } + } + }); + + strictEqual(terminalConfigurationService.configFontIsMonospace(), true, 'monospace is monospaced'); + }); + + test('isMonospace sans-serif', () => { + const terminalConfigurationService = createTerminalConfigationService({ + terminal: { + integrated: { + fontFamily: 'sans-serif' + } + } + }); + strictEqual(terminalConfigurationService.configFontIsMonospace(), false, 'sans-serif is not monospaced'); + }); + + test('isMonospace serif', () => { + const terminalConfigurationService = createTerminalConfigationService({ + terminal: { + integrated: { + fontFamily: 'serif' + } + } + }); + strictEqual(terminalConfigurationService.configFontIsMonospace(), false, 'serif is not monospaced'); + }); + + test('isMonospace monospace falls back to editor.fontFamily', () => { + const terminalConfigurationService = createTerminalConfigationService({ + editor: { + fontFamily: 'monospace' + }, + terminal: { + integrated: { + fontFamily: null + } + } + }); + strictEqual(terminalConfigurationService.configFontIsMonospace(), true, 'monospace is monospaced'); + }); + + test('isMonospace sans-serif falls back to editor.fontFamily', () => { + const terminalConfigurationService = createTerminalConfigationService({ + editor: { + fontFamily: 'sans-serif' + }, + terminal: { + integrated: { + fontFamily: null + } + } + }); + strictEqual(terminalConfigurationService.configFontIsMonospace(), false, 'sans-serif is not monospaced'); + }); + + test('isMonospace serif falls back to editor.fontFamily', () => { + const terminalConfigurationService = createTerminalConfigationService({ + editor: { + fontFamily: 'serif' + }, + terminal: { + integrated: { + fontFamily: null + } + } + }); + strictEqual(terminalConfigurationService.configFontIsMonospace(), false, 'serif is not monospaced'); + }); + }); +}); diff --git a/src/vs/workbench/contrib/terminal/test/browser/terminalInstance.test.ts b/src/vs/workbench/contrib/terminal/test/browser/terminalInstance.test.ts index bcbb193e128e1..930a029e0de6f 100644 --- a/src/vs/workbench/contrib/terminal/test/browser/terminalInstance.test.ts +++ b/src/vs/workbench/contrib/terminal/test/browser/terminalInstance.test.ts @@ -12,8 +12,7 @@ import { TestInstantiationService } from 'vs/platform/instantiation/test/common/ import { TestContextService } from 'vs/workbench/test/common/workbenchTestServices'; import { fixPath, getUri } from 'vs/workbench/services/search/test/browser/queryBuilder.test'; import { TestConfigurationService } from 'vs/platform/configuration/test/common/testConfigurationService'; -import { TerminalConfigHelper } from 'vs/workbench/contrib/terminal/browser/terminalConfigHelper'; -import { ITerminalInstance } from 'vs/workbench/contrib/terminal/browser/terminal'; +import { ITerminalConfigurationService, ITerminalInstance } from 'vs/workbench/contrib/terminal/browser/terminal'; import { ProcessState } from 'vs/workbench/contrib/terminal/common/terminal'; import { URI } from 'vs/base/common/uri'; import { TerminalCapabilityStore } from 'vs/platform/terminal/common/capabilities/terminalCapabilityStore'; @@ -21,7 +20,9 @@ import { TerminalCapability } from 'vs/platform/terminal/common/capabilities/cap import { Schemas } from 'vs/base/common/network'; import { TestFileService } from 'vs/workbench/test/browser/workbenchTestServices'; import { ensureNoDisposablesAreLeakedInTestSuite } from 'vs/base/test/common/utils'; -import { DisposableStore } from 'vs/base/common/lifecycle'; +import { TerminalConfigurationService } from 'vs/workbench/contrib/terminal/browser/terminalConfigurationService'; +import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; +import { IFileService } from 'vs/platform/files/common/files'; const root1 = '/foo/root1'; const ROOT_1 = fixPath(root1); @@ -31,7 +32,7 @@ const emptyRoot = '/foo'; const ROOT_EMPTY = fixPath(emptyRoot); suite('Workbench - TerminalInstance', () => { - ensureNoDisposablesAreLeakedInTestSuite(); + const store = ensureNoDisposablesAreLeakedInTestSuite(); suite('parseExitResult', () => { test('should return no message for exit code = undefined', () => { @@ -140,8 +141,6 @@ suite('Workbench - TerminalInstance', () => { }); }); suite('TerminalLabelComputer', () => { - let store: DisposableStore; - let configurationService: TestConfigurationService; let terminalLabelComputer: TerminalLabelComputer; let instantiationService: TestInstantiationService; let mockContextService: TestContextService; @@ -151,7 +150,6 @@ suite('Workbench - TerminalInstance', () => { let mockMultiRootWorkspace: Workspace; let emptyWorkspace: Workspace; let capabilities: TerminalCapabilityStore; - let configHelper: TerminalConfigHelper; function createInstance(partial?: Partial): Pick { const capabilities = store.add(new TerminalCapabilityStore()); @@ -175,9 +173,10 @@ suite('Workbench - TerminalInstance', () => { } setup(async () => { - store = new DisposableStore(); instantiationService = store.add(new TestInstantiationService()); - instantiationService.stub(IWorkspaceContextService, new TestContextService()); + instantiationService.set(IWorkspaceContextService, new TestContextService()); + instantiationService.set(IFileService, new TestFileService()); + instantiationService.set(IWorkspaceContextService, mockContextService); capabilities = store.add(new TerminalCapabilityStore()); if (!isWindows) { capabilities.add(TerminalCapability.NaiveCwdDetection, null!); @@ -199,12 +198,14 @@ suite('Workbench - TerminalInstance', () => { emptyContextService.setWorkspace(emptyWorkspace); }); - teardown(() => store.dispose()); + function createLabelComputer(configuration: any) { + instantiationService.set(IConfigurationService, new TestConfigurationService(configuration)); + instantiationService.set(ITerminalConfigurationService, store.add(instantiationService.createInstance(TerminalConfigurationService))); + return store.add(instantiationService.createInstance(TerminalLabelComputer)); + } test('should resolve to "" when the template variables are empty', () => { - configurationService = new TestConfigurationService({ terminal: { integrated: { tabs: { separator: ' - ', title: '', description: '' } } } }); - configHelper = store.add(new TerminalConfigHelper(configurationService, null!, null!, null!, null!)); - terminalLabelComputer = store.add(new TerminalLabelComputer(configHelper, new TestFileService(), mockContextService)); + terminalLabelComputer = createLabelComputer({ terminal: { integrated: { tabs: { separator: ' - ', title: '', description: '' } } } }); terminalLabelComputer.refreshLabel(createInstance({ capabilities, processName: '' })); // TODO: // terminalLabelComputer.onLabelChanged(e => { @@ -215,81 +216,60 @@ suite('Workbench - TerminalInstance', () => { strictEqual(terminalLabelComputer.description, ''); }); test('should resolve cwd', () => { - configurationService = new TestConfigurationService({ terminal: { integrated: { tabs: { separator: ' - ', title: '${cwd}', description: '${cwd}' } } } }); - configHelper = store.add(new TerminalConfigHelper(configurationService, null!, null!, null!, null!)); - terminalLabelComputer = store.add(new TerminalLabelComputer(configHelper, new TestFileService(), mockContextService)); + terminalLabelComputer = createLabelComputer({ terminal: { integrated: { tabs: { separator: ' - ', title: '${cwd}', description: '${cwd}' } } } }); terminalLabelComputer.refreshLabel(createInstance({ capabilities, cwd: ROOT_1 })); strictEqual(terminalLabelComputer.title, ROOT_1); strictEqual(terminalLabelComputer.description, ROOT_1); }); test('should resolve workspaceFolder', () => { - configurationService = new TestConfigurationService({ terminal: { integrated: { tabs: { separator: ' - ', title: '${workspaceFolder}', description: '${workspaceFolder}' } } } }); - configHelper = store.add(new TerminalConfigHelper(configurationService, null!, null!, null!, null!)); - terminalLabelComputer = store.add(new TerminalLabelComputer(configHelper, new TestFileService(), mockContextService)); + terminalLabelComputer = createLabelComputer({ terminal: { integrated: { tabs: { separator: ' - ', title: '${workspaceFolder}', description: '${workspaceFolder}' } } } }); terminalLabelComputer.refreshLabel(createInstance({ capabilities, processName: 'zsh', workspaceFolder: { uri: URI.from({ scheme: Schemas.file, path: 'folder' }) } as IWorkspaceFolder })); strictEqual(terminalLabelComputer.title, 'folder'); strictEqual(terminalLabelComputer.description, 'folder'); }); test('should resolve local', () => { - configurationService = new TestConfigurationService({ terminal: { integrated: { tabs: { separator: ' - ', title: '${local}', description: '${local}' } } } }); - configHelper = store.add(new TerminalConfigHelper(configurationService, null!, null!, null!, null!)); - terminalLabelComputer = store.add(new TerminalLabelComputer(configHelper, new TestFileService(), mockContextService)); + terminalLabelComputer = createLabelComputer({ terminal: { integrated: { tabs: { separator: ' - ', title: '${local}', description: '${local}' } } } }); terminalLabelComputer.refreshLabel(createInstance({ capabilities, processName: 'zsh', shellLaunchConfig: { type: 'Local' } })); strictEqual(terminalLabelComputer.title, 'Local'); strictEqual(terminalLabelComputer.description, 'Local'); }); test('should resolve process', () => { - configurationService = new TestConfigurationService({ terminal: { integrated: { tabs: { separator: ' - ', title: '${process}', description: '${process}' } } } }); - configHelper = store.add(new TerminalConfigHelper(configurationService, null!, null!, null!, null!)); - terminalLabelComputer = store.add(new TerminalLabelComputer(configHelper, new TestFileService(), mockContextService)); + terminalLabelComputer = createLabelComputer({ terminal: { integrated: { tabs: { separator: ' - ', title: '${process}', description: '${process}' } } } }); terminalLabelComputer.refreshLabel(createInstance({ capabilities, processName: 'zsh' })); strictEqual(terminalLabelComputer.title, 'zsh'); strictEqual(terminalLabelComputer.description, 'zsh'); }); test('should resolve sequence', () => { - configurationService = new TestConfigurationService({ terminal: { integrated: { tabs: { separator: ' - ', title: '${sequence}', description: '${sequence}' } } } }); - configHelper = store.add(new TerminalConfigHelper(configurationService, null!, null!, null!, null!)); - terminalLabelComputer = store.add(new TerminalLabelComputer(configHelper, new TestFileService(), mockContextService)); + terminalLabelComputer = createLabelComputer({ terminal: { integrated: { tabs: { separator: ' - ', title: '${sequence}', description: '${sequence}' } } } }); terminalLabelComputer.refreshLabel(createInstance({ capabilities, sequence: 'sequence' })); strictEqual(terminalLabelComputer.title, 'sequence'); strictEqual(terminalLabelComputer.description, 'sequence'); }); test('should resolve task', () => { - configurationService = new TestConfigurationService({ terminal: { integrated: { tabs: { separator: ' ~ ', title: '${process}${separator}${task}', description: '${task}' } } } }); - configHelper = store.add(new TerminalConfigHelper(configurationService, null!, null!, null!, null!)); - terminalLabelComputer = store.add(new TerminalLabelComputer(configHelper, new TestFileService(), mockContextService)); + terminalLabelComputer = createLabelComputer({ terminal: { integrated: { tabs: { separator: ' ~ ', title: '${process}${separator}${task}', description: '${task}' } } } }); terminalLabelComputer.refreshLabel(createInstance({ capabilities, processName: 'zsh', shellLaunchConfig: { type: 'Task' } })); strictEqual(terminalLabelComputer.title, 'zsh ~ Task'); strictEqual(terminalLabelComputer.description, 'Task'); }); test('should resolve separator', () => { - configurationService = new TestConfigurationService({ terminal: { integrated: { tabs: { separator: ' ~ ', title: '${separator}', description: '${separator}' } } } }); - configHelper = store.add(new TerminalConfigHelper(configurationService, null!, null!, null!, null!)); - terminalLabelComputer = store.add(new TerminalLabelComputer(configHelper, new TestFileService(), mockContextService)); + terminalLabelComputer = createLabelComputer({ terminal: { integrated: { tabs: { separator: ' ~ ', title: '${separator}', description: '${separator}' } } } }); terminalLabelComputer.refreshLabel(createInstance({ capabilities, processName: 'zsh', shellLaunchConfig: { type: 'Task' } })); strictEqual(terminalLabelComputer.title, 'zsh'); strictEqual(terminalLabelComputer.description, ''); }); test('should always return static title when specified', () => { - configurationService = new TestConfigurationService({ terminal: { integrated: { tabs: { separator: ' ~ ', title: '${process}', description: '${workspaceFolder}' } } } }); - configHelper = store.add(new TerminalConfigHelper(configurationService, null!, null!, null!, null!)); - terminalLabelComputer = store.add(new TerminalLabelComputer(configHelper, new TestFileService(), mockContextService)); + terminalLabelComputer = createLabelComputer({ terminal: { integrated: { tabs: { separator: ' ~ ', title: '${process}', description: '${workspaceFolder}' } } } }); terminalLabelComputer.refreshLabel(createInstance({ capabilities, processName: 'process', workspaceFolder: { uri: URI.from({ scheme: Schemas.file, path: 'folder' }) } as IWorkspaceFolder, staticTitle: 'my-title' })); strictEqual(terminalLabelComputer.title, 'my-title'); strictEqual(terminalLabelComputer.description, 'folder'); }); test('should provide cwdFolder for all cwds only when in multi-root', () => { - configurationService = new TestConfigurationService({ terminal: { integrated: { tabs: { separator: ' ~ ', title: '${process}${separator}${cwdFolder}', description: '${cwdFolder}' } } } }); - configHelper = store.add(new TerminalConfigHelper(configurationService, null!, null!, null!, null!)); - terminalLabelComputer = store.add(new TerminalLabelComputer(configHelper, new TestFileService(), mockContextService)); + terminalLabelComputer = createLabelComputer({ terminal: { integrated: { tabs: { separator: ' ~ ', title: '${process}${separator}${cwdFolder}', description: '${cwdFolder}' } } } }); terminalLabelComputer.refreshLabel(createInstance({ capabilities, processName: 'process', workspaceFolder: { uri: URI.from({ scheme: Schemas.file, path: ROOT_1 }) } as IWorkspaceFolder, cwd: ROOT_1 })); // single-root, cwd is same as root strictEqual(terminalLabelComputer.title, 'process'); strictEqual(terminalLabelComputer.description, ''); // multi-root - configurationService = new TestConfigurationService({ terminal: { integrated: { tabs: { separator: ' ~ ', title: '${process}${separator}${cwdFolder}', description: '${cwdFolder}' } } } }); - configHelper = store.add(new TerminalConfigHelper(configurationService, null!, null!, null!, null!)); - terminalLabelComputer = store.add(new TerminalLabelComputer(configHelper, new TestFileService(), mockMultiRootContextService)); terminalLabelComputer.refreshLabel(createInstance({ capabilities, processName: 'process', workspaceFolder: { uri: URI.from({ scheme: Schemas.file, path: ROOT_1 }) } as IWorkspaceFolder, cwd: ROOT_2 })); if (isWindows) { strictEqual(terminalLabelComputer.title, 'process'); @@ -300,14 +280,12 @@ suite('Workbench - TerminalInstance', () => { } }); test('should hide cwdFolder in single folder workspaces when cwd matches the workspace\'s default cwd even when slashes differ', async () => { - configurationService = new TestConfigurationService({ terminal: { integrated: { tabs: { separator: ' ~ ', title: '${process}${separator}${cwdFolder}', description: '${cwdFolder}' } } } }); - configHelper = store.add(new TerminalConfigHelper(configurationService, null!, null!, null!, null!)); - terminalLabelComputer = store.add(new TerminalLabelComputer(configHelper, new TestFileService(), mockContextService)); + terminalLabelComputer = createLabelComputer({ terminal: { integrated: { tabs: { separator: ' ~ ', title: '${process}${separator}${cwdFolder}', description: '${cwdFolder}' } } } }); terminalLabelComputer.refreshLabel(createInstance({ capabilities, processName: 'process', workspaceFolder: { uri: URI.from({ scheme: Schemas.file, path: ROOT_1 }) } as IWorkspaceFolder, cwd: ROOT_1 })); strictEqual(terminalLabelComputer.title, 'process'); strictEqual(terminalLabelComputer.description, ''); if (!isWindows) { - terminalLabelComputer = store.add(new TerminalLabelComputer(configHelper, new TestFileService(), mockContextService)); + terminalLabelComputer = createLabelComputer({ terminal: { integrated: { tabs: { separator: ' ~ ', title: '${process}${separator}${cwdFolder}', description: '${cwdFolder}' } } } }); terminalLabelComputer.refreshLabel(createInstance({ capabilities, processName: 'process', workspaceFolder: { uri: URI.from({ scheme: Schemas.file, path: ROOT_1 }) } as IWorkspaceFolder, cwd: ROOT_2 })); strictEqual(terminalLabelComputer.title, 'process ~ root2'); strictEqual(terminalLabelComputer.description, 'root2'); diff --git a/src/vs/workbench/contrib/terminal/test/browser/terminalInstanceService.test.ts b/src/vs/workbench/contrib/terminal/test/browser/terminalInstanceService.test.ts index 0b14bd97a30ac..1f7f625034f10 100644 --- a/src/vs/workbench/contrib/terminal/test/browser/terminalInstanceService.test.ts +++ b/src/vs/workbench/contrib/terminal/test/browser/terminalInstanceService.test.ts @@ -18,29 +18,17 @@ import { TestEnvironmentService } from 'vs/workbench/test/browser/workbenchTestS import { ensureNoDisposablesAreLeakedInTestSuite } from 'vs/base/test/common/utils'; suite('Workbench - TerminalInstanceService', () => { - let instantiationService: TestInstantiationService; let terminalInstanceService: ITerminalInstanceService; setup(async () => { - instantiationService = new TestInstantiationService(); - // TODO: Should be able to create these services without this config set - instantiationService.stub(IConfigurationService, new TestConfigurationService({ - terminal: { - integrated: { - fontWeight: 'normal' - } - } - })); + const instantiationService = new TestInstantiationService(); + instantiationService.stub(IConfigurationService, new TestConfigurationService()); instantiationService.stub(IContextKeyService, instantiationService.createInstance(ContextKeyService)); instantiationService.stub(IWorkbenchEnvironmentService, TestEnvironmentService); terminalInstanceService = instantiationService.createInstance(TerminalInstanceService); }); - teardown(() => { - instantiationService.dispose(); - }); - ensureNoDisposablesAreLeakedInTestSuite(); suite('convertProfileToShellLaunchConfig', () => { diff --git a/src/vs/workbench/contrib/terminal/test/browser/terminalProcessManager.test.ts b/src/vs/workbench/contrib/terminal/test/browser/terminalProcessManager.test.ts index 8f4c4a1410b07..c02db9816339b 100644 --- a/src/vs/workbench/contrib/terminal/test/browser/terminalProcessManager.test.ts +++ b/src/vs/workbench/contrib/terminal/test/browser/terminalProcessManager.test.ts @@ -4,24 +4,23 @@ *--------------------------------------------------------------------------------------------*/ import { strictEqual } from 'assert'; +import { Event } from 'vs/base/common/event'; +import { Schemas } from 'vs/base/common/network'; +import { URI } from 'vs/base/common/uri'; +import { ensureNoDisposablesAreLeakedInTestSuite } from 'vs/base/test/common/utils'; import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; -import { TerminalConfigHelper } from 'vs/workbench/contrib/terminal/browser/terminalConfigHelper'; -import { TerminalProcessManager } from 'vs/workbench/contrib/terminal/browser/terminalProcessManager'; import { TestConfigurationService } from 'vs/platform/configuration/test/common/testConfigurationService'; -import { ITestInstantiationService, TestTerminalProfileResolverService, workbenchInstantiationService } from 'vs/workbench/test/browser/workbenchTestServices'; +import { NullLogService } from 'vs/platform/log/common/log'; import { IProductService } from 'vs/platform/product/common/productService'; +import { ITerminalChildProcess, ITerminalLogService } from 'vs/platform/terminal/common/terminal'; +import { ITerminalConfigurationService, ITerminalInstanceService } from 'vs/workbench/contrib/terminal/browser/terminal'; +import { TerminalConfigurationService } from 'vs/workbench/contrib/terminal/browser/terminalConfigurationService'; +import { TerminalProcessManager } from 'vs/workbench/contrib/terminal/browser/terminalProcessManager'; import { IEnvironmentVariableService } from 'vs/workbench/contrib/terminal/common/environmentVariable'; import { EnvironmentVariableService } from 'vs/workbench/contrib/terminal/common/environmentVariableService'; -import { Schemas } from 'vs/base/common/network'; -import { URI } from 'vs/base/common/uri'; -import { ITerminalChildProcess, ITerminalLogService } from 'vs/platform/terminal/common/terminal'; import { ITerminalProfileResolverService } from 'vs/workbench/contrib/terminal/common/terminal'; -import { ITerminalInstanceService } from 'vs/workbench/contrib/terminal/browser/terminal'; -import { DisposableStore } from 'vs/base/common/lifecycle'; -import { Event } from 'vs/base/common/event'; +import { TestTerminalProfileResolverService, workbenchInstantiationService } from 'vs/workbench/test/browser/workbenchTestServices'; import { TestProductService } from 'vs/workbench/test/common/workbenchTestServices'; -import { ensureNoDisposablesAreLeakedInTestSuite } from 'vs/base/test/common/utils'; -import { NullLogService } from 'vs/platform/log/common/log'; class TestTerminalChildProcess implements ITerminalChildProcess { id: number = 0; @@ -82,13 +81,12 @@ class TestTerminalInstanceService implements Partial { } suite('Workbench - TerminalProcessManager', () => { - let store: DisposableStore; - let instantiationService: ITestInstantiationService; let manager: TerminalProcessManager; + const store = ensureNoDisposablesAreLeakedInTestSuite(); + setup(async () => { - store = new DisposableStore(); - instantiationService = workbenchInstantiationService(undefined, store); + const instantiationService = workbenchInstantiationService(undefined, store); const configurationService = new TestConfigurationService(); await configurationService.setUserConfiguration('editor', { fontFamily: 'foo' }); await configurationService.setUserConfiguration('terminal', { @@ -101,20 +99,16 @@ suite('Workbench - TerminalProcessManager', () => { } }); instantiationService.stub(IConfigurationService, configurationService); + instantiationService.stub(ITerminalConfigurationService, store.add(instantiationService.createInstance(TerminalConfigurationService))); instantiationService.stub(IProductService, TestProductService); instantiationService.stub(ITerminalLogService, new NullLogService()); - instantiationService.stub(IEnvironmentVariableService, instantiationService.createInstance(EnvironmentVariableService)); + instantiationService.stub(IEnvironmentVariableService, store.add(instantiationService.createInstance(EnvironmentVariableService))); instantiationService.stub(ITerminalProfileResolverService, TestTerminalProfileResolverService); instantiationService.stub(ITerminalInstanceService, new TestTerminalInstanceService()); - const configHelper = store.add(instantiationService.createInstance(TerminalConfigHelper)); - manager = store.add(instantiationService.createInstance(TerminalProcessManager, 1, configHelper, undefined, undefined, undefined)); + manager = store.add(instantiationService.createInstance(TerminalProcessManager, 1, undefined, undefined, undefined)); }); - teardown(() => store.dispose()); - - ensureNoDisposablesAreLeakedInTestSuite(); - suite('process persistence', () => { suite('local', () => { test('regular terminal should persist', async () => { diff --git a/src/vs/workbench/contrib/terminal/test/browser/terminalService.test.ts b/src/vs/workbench/contrib/terminal/test/browser/terminalService.test.ts index d6c6aee8ef8ad..6e214b379e130 100644 --- a/src/vs/workbench/contrib/terminal/test/browser/terminalService.test.ts +++ b/src/vs/workbench/contrib/terminal/test/browser/terminalService.test.ts @@ -13,7 +13,7 @@ import { ContextKeyService } from 'vs/platform/contextkey/browser/contextKeyServ import { TestConfigurationService } from 'vs/platform/configuration/test/common/testConfigurationService'; import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; import { TestEditorService, TestEnvironmentService, TestLifecycleService, TestRemoteAgentService, TestTerminalEditorService, TestTerminalGroupService, TestTerminalInstanceService, TestTerminalProfileService } from 'vs/workbench/test/browser/workbenchTestServices'; -import { ITerminalEditorService, ITerminalGroupService, ITerminalInstance, ITerminalInstanceService, ITerminalService } from 'vs/workbench/contrib/terminal/browser/terminal'; +import { ITerminalConfigurationService, ITerminalEditorService, ITerminalGroupService, ITerminalInstance, ITerminalInstanceService, ITerminalService } from 'vs/workbench/contrib/terminal/browser/terminal'; import { ILifecycleService } from 'vs/workbench/services/lifecycle/common/lifecycle'; import { IThemeService } from 'vs/platform/theme/common/themeService'; import { TestThemeService } from 'vs/platform/theme/test/common/testThemeService'; @@ -26,6 +26,7 @@ import { ensureNoDisposablesAreLeakedInTestSuite } from 'vs/base/test/common/uti import { DisposableStore } from 'vs/base/common/lifecycle'; import { IEnvironmentService } from 'vs/platform/environment/common/environment'; import { NullLogService } from 'vs/platform/log/common/log'; +import { TerminalConfigurationService } from 'vs/workbench/contrib/terminal/browser/terminalConfigurationService'; suite('Workbench - TerminalService', () => { let store: DisposableStore; @@ -47,6 +48,7 @@ suite('Workbench - TerminalService', () => { instantiationService = store.add(new TestInstantiationService()); instantiationService.stub(IConfigurationService, configurationService); + instantiationService.stub(ITerminalConfigurationService, instantiationService.createInstance(TerminalConfigurationService)); instantiationService.stub(IContextKeyService, instantiationService.createInstance(ContextKeyService)); instantiationService.stub(ILifecycleService, new TestLifecycleService()); instantiationService.stub(IThemeService, new TestThemeService()); diff --git a/src/vs/workbench/contrib/terminal/test/browser/xterm/xtermTerminal.test.ts b/src/vs/workbench/contrib/terminal/test/browser/xterm/xtermTerminal.test.ts index bedbbf1d6bc1f..52740bbacb7e5 100644 --- a/src/vs/workbench/contrib/terminal/test/browser/xterm/xtermTerminal.test.ts +++ b/src/vs/workbench/contrib/terminal/test/browser/xterm/xtermTerminal.test.ts @@ -5,7 +5,6 @@ import type { IEvent, Terminal } from '@xterm/xterm'; import { XtermTerminal } from 'vs/workbench/contrib/terminal/browser/xterm/xtermTerminal'; -import { TerminalConfigHelper } from 'vs/workbench/contrib/terminal/browser/terminalConfigHelper'; import { TestInstantiationService } from 'vs/platform/instantiation/test/common/instantiationServiceMock'; import { ITerminalConfiguration, TERMINAL_VIEW_ID } from 'vs/workbench/contrib/terminal/common/terminal'; import { deepStrictEqual, strictEqual } from 'assert'; @@ -35,6 +34,8 @@ import { IContextKeyService } from 'vs/platform/contextkey/common/contextkey'; import { ITerminalLogService } from 'vs/platform/terminal/common/terminal'; import { ensureNoDisposablesAreLeakedInTestSuite } from 'vs/base/test/common/utils'; import { ILayoutService } from 'vs/platform/layout/browser/layoutService'; +import { ITerminalConfigurationService } from 'vs/workbench/contrib/terminal/browser/terminal'; +import { TerminalConfigurationService } from 'vs/workbench/contrib/terminal/browser/terminalConfigurationService'; registerColors(); @@ -103,7 +104,6 @@ suite('XtermTerminal', () => { let themeService: TestThemeService; let viewDescriptorService: TestViewDescriptorService; let xterm: TestXtermTerminal; - let configHelper: TerminalConfigHelper; let XTermBaseCtor: typeof Terminal; setup(async () => { @@ -121,6 +121,7 @@ suite('XtermTerminal', () => { instantiationService = store.add(new TestInstantiationService()); instantiationService.stub(IConfigurationService, configurationService); + instantiationService.stub(ITerminalConfigurationService, store.add(instantiationService.createInstance(TerminalConfigurationService))); instantiationService.stub(ITerminalLogService, new NullLogService()); instantiationService.stub(IStorageService, store.add(new TestStorageService())); instantiationService.stub(IThemeService, themeService); @@ -130,11 +131,10 @@ suite('XtermTerminal', () => { instantiationService.stub(IContextKeyService, new MockContextKeyService()); instantiationService.stub(ILayoutService, new TestLayoutService()); - configHelper = store.add(instantiationService.createInstance(TerminalConfigHelper)); XTermBaseCtor = (await importAMDNodeModule('@xterm/xterm', 'lib/xterm.js')).Terminal; const capabilityStore = store.add(new TerminalCapabilityStore()); - xterm = store.add(instantiationService.createInstance(TestXtermTerminal, XTermBaseCtor, configHelper, 80, 30, { getBackgroundColor: () => undefined }, capabilityStore, '', true)); + xterm = store.add(instantiationService.createInstance(TestXtermTerminal, XTermBaseCtor, 80, 30, { getBackgroundColor: () => undefined }, capabilityStore, '', true)); TestWebglAddon.shouldThrow = false; TestWebglAddon.isEnabled = false; @@ -151,7 +151,7 @@ suite('XtermTerminal', () => { [PANEL_BACKGROUND]: '#ff0000', [SIDE_BAR_BACKGROUND]: '#00ff00' })); - xterm = store.add(instantiationService.createInstance(XtermTerminal, XTermBaseCtor, configHelper, 80, 30, { getBackgroundColor: () => new Color(new RGBA(255, 0, 0)) }, store.add(new TerminalCapabilityStore()), '', true)); + xterm = store.add(instantiationService.createInstance(XtermTerminal, XTermBaseCtor, 80, 30, { getBackgroundColor: () => new Color(new RGBA(255, 0, 0)) }, store.add(new TerminalCapabilityStore()), '', true)); strictEqual(xterm.raw.options.theme?.background, '#ff0000'); }); test('should react to and apply theme changes', () => { @@ -180,7 +180,7 @@ suite('XtermTerminal', () => { 'terminal.ansiBrightCyan': '#150000', 'terminal.ansiBrightWhite': '#160000', })); - xterm = store.add(instantiationService.createInstance(XtermTerminal, XTermBaseCtor, configHelper, 80, 30, { getBackgroundColor: () => undefined }, store.add(new TerminalCapabilityStore()), '', true)); + xterm = store.add(instantiationService.createInstance(XtermTerminal, XTermBaseCtor, 80, 30, { getBackgroundColor: () => undefined }, store.add(new TerminalCapabilityStore()), '', true)); deepStrictEqual(xterm.raw.options.theme, { background: undefined, foreground: '#000200', diff --git a/src/vs/workbench/contrib/terminalContrib/accessibility/browser/terminal.accessibility.contribution.ts b/src/vs/workbench/contrib/terminalContrib/accessibility/browser/terminal.accessibility.contribution.ts index 03916c068556e..972766c0be194 100644 --- a/src/vs/workbench/contrib/terminalContrib/accessibility/browser/terminal.accessibility.contribution.ts +++ b/src/vs/workbench/contrib/terminalContrib/accessibility/browser/terminal.accessibility.contribution.ts @@ -66,7 +66,7 @@ export class TerminalAccessibleViewContribution extends Disposable implements IT private _bufferTracker: BufferContentTracker | undefined; private _bufferProvider: TerminalAccessibleBufferProvider | undefined; private _xterm: Pick & { raw: Terminal } | undefined; - private _onDidRunCommand: MutableDisposable = new MutableDisposable(); + private readonly _onDidRunCommand: MutableDisposable = new MutableDisposable(); constructor( private readonly _instance: ITerminalInstance, diff --git a/src/vs/workbench/contrib/terminalContrib/accessibility/browser/textAreaSyncAddon.ts b/src/vs/workbench/contrib/terminalContrib/accessibility/browser/textAreaSyncAddon.ts index a2c3bf0eb15f3..b58e4eb86b027 100644 --- a/src/vs/workbench/contrib/terminalContrib/accessibility/browser/textAreaSyncAddon.ts +++ b/src/vs/workbench/contrib/terminalContrib/accessibility/browser/textAreaSyncAddon.ts @@ -19,7 +19,7 @@ export interface ITextAreaData { export class TextAreaSyncAddon extends Disposable implements ITerminalAddon { private _terminal: Terminal | undefined; - private _listeners = this._register(new MutableDisposable()); + private readonly _listeners = this._register(new MutableDisposable()); private _currentCommand: string | undefined; private _cursorX: number | undefined; diff --git a/src/vs/workbench/contrib/terminalContrib/accessibility/test/browser/bufferContentTracker.test.ts b/src/vs/workbench/contrib/terminalContrib/accessibility/test/browser/bufferContentTracker.test.ts index 833db09d807b7..ef6c5b46e76ea 100644 --- a/src/vs/workbench/contrib/terminalContrib/accessibility/test/browser/bufferContentTracker.test.ts +++ b/src/vs/workbench/contrib/terminalContrib/accessibility/test/browser/bufferContentTracker.test.ts @@ -21,7 +21,6 @@ import { TerminalCapabilityStore } from 'vs/platform/terminal/common/capabilitie import { ITerminalLogService } from 'vs/platform/terminal/common/terminal'; import { IThemeService } from 'vs/platform/theme/common/themeService'; import { TestThemeService } from 'vs/platform/theme/test/common/testThemeService'; -import { TerminalConfigHelper } from 'vs/workbench/contrib/terminal/browser/terminalConfigHelper'; import { writeP } from 'vs/workbench/contrib/terminal/browser/terminalTestHelpers'; import { XtermTerminal } from 'vs/workbench/contrib/terminal/browser/xterm/xtermTerminal'; import { ITerminalConfiguration } from 'vs/workbench/contrib/terminal/common/terminal'; @@ -30,7 +29,9 @@ import { ILifecycleService } from 'vs/workbench/services/lifecycle/common/lifecy import { TestLayoutService, TestLifecycleService } from 'vs/workbench/test/browser/workbenchTestServices'; import { TestLoggerService } from 'vs/workbench/test/common/workbenchTestServices'; import type { Terminal } from '@xterm/xterm'; -import { IAudioCueService } from 'vs/platform/audioCues/browser/audioCueService'; +import { IAccessibilitySignalService } from 'vs/platform/accessibilitySignal/browser/accessibilitySignalService'; +import { ITerminalConfigurationService } from 'vs/workbench/contrib/terminal/browser/terminal'; +import { TerminalConfigurationService } from 'vs/workbench/contrib/terminal/browser/terminalConfigurationService'; const defaultTerminalConfig: Partial = { fontFamily: 'monospace', @@ -51,7 +52,6 @@ suite('Buffer Content Tracker', () => { let themeService: TestThemeService; let xterm: XtermTerminal; let capabilities: TerminalCapabilityStore; - let configHelper: TerminalConfigHelper; let bufferTracker: BufferContentTracker; const prompt = 'vscode-git:(prompt/more-tests)'; const promptPlusData = 'vscode-git:(prompt/more-tests) ' + 'some data'; @@ -61,22 +61,25 @@ suite('Buffer Content Tracker', () => { instantiationService = store.add(new TestInstantiationService()); themeService = new TestThemeService(); instantiationService.stub(IConfigurationService, configurationService); + instantiationService.stub(ITerminalConfigurationService, store.add(instantiationService.createInstance(TerminalConfigurationService))); instantiationService.stub(IThemeService, themeService); instantiationService.stub(ITerminalLogService, new NullLogService()); instantiationService.stub(ILoggerService, store.add(new TestLoggerService())); instantiationService.stub(IContextMenuService, store.add(instantiationService.createInstance(ContextMenuService))); instantiationService.stub(ILifecycleService, store.add(new TestLifecycleService())); instantiationService.stub(IContextKeyService, store.add(new MockContextKeyService())); - instantiationService.stub(IAudioCueService, { playAudioCue: async () => { }, isEnabled(cue: unknown) { return false; } } as any); + instantiationService.stub(IAccessibilitySignalService, { + playSignal: async () => { }, + isSoundEnabled(signal: unknown) { return false; }, + } as any); instantiationService.stub(ILayoutService, new TestLayoutService()); - configHelper = store.add(instantiationService.createInstance(TerminalConfigHelper)); capabilities = store.add(new TerminalCapabilityStore()); if (!isWindows) { capabilities.add(TerminalCapability.NaiveCwdDetection, null!); } const TerminalCtor = (await importAMDNodeModule('@xterm/xterm', 'lib/xterm.js')).Terminal; - xterm = store.add(instantiationService.createInstance(XtermTerminal, TerminalCtor, configHelper, 80, 30, { getBackgroundColor: () => undefined }, capabilities, '', true)); + xterm = store.add(instantiationService.createInstance(XtermTerminal, TerminalCtor, 80, 30, { getBackgroundColor: () => undefined }, capabilities, '', true)); const container = document.createElement('div'); xterm.raw.open(container); configurationService = new TestConfigurationService({ terminal: { integrated: { tabs: { separator: ' - ', title: '${cwd}', description: '${cwd}' } } } }); diff --git a/src/vs/workbench/contrib/terminalContrib/chat/browser/media/terminalChatWidget.css b/src/vs/workbench/contrib/terminalContrib/chat/browser/media/terminalChatWidget.css new file mode 100644 index 0000000000000..e515158cd7795 --- /dev/null +++ b/src/vs/workbench/contrib/terminalContrib/chat/browser/media/terminalChatWidget.css @@ -0,0 +1,29 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +.terminal-inline-chat { + position: absolute; + left: 0; + bottom: 0; + z-index: 100; + height: auto !important; +} + +.terminal-inline-chat .inline-chat { + margin-top: 0 !important; +} + +.terminal-inline-chat.hide { + visibility: hidden; +} + +.terminal-inline-chat .chatMessageContent .value { + padding-top: 10px; +} + +.terminal-inline-chat .inline-chat-input .monaco-editor-background { + /* Override the global panel rule for monaco backgrounds */ + background-color: var(--vscode-inlineChatInput-background) !important; +} diff --git a/src/vs/workbench/contrib/terminalContrib/chat/browser/terminal.chat.contribution.ts b/src/vs/workbench/contrib/terminalContrib/chat/browser/terminal.chat.contribution.ts new file mode 100644 index 0000000000000..44eabbf13f6b1 --- /dev/null +++ b/src/vs/workbench/contrib/terminalContrib/chat/browser/terminal.chat.contribution.ts @@ -0,0 +1,17 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { WorkbenchPhase, registerWorkbenchContribution2 } from 'vs/workbench/common/contributions'; +import { registerTerminalContribution } from 'vs/workbench/contrib/terminal/browser/terminalExtensions'; +import { TerminalInlineChatAccessibleViewContribution } from 'vs/workbench/contrib/terminalContrib/chat/browser/terminalChatAccessibleView'; +import { TerminalChatController } from 'vs/workbench/contrib/terminalContrib/chat/browser/terminalChatController'; + +import 'vs/workbench/contrib/terminalContrib/chat/browser/terminalChatActions'; +import { TerminalChatAccessibilityHelpContribution } from 'vs/workbench/contrib/terminalContrib/chat/browser/terminalChatAccessibilityHelp'; + +registerTerminalContribution(TerminalChatController.ID, TerminalChatController, false); + +registerWorkbenchContribution2(TerminalInlineChatAccessibleViewContribution.ID, TerminalInlineChatAccessibleViewContribution, WorkbenchPhase.Eventually); +registerWorkbenchContribution2(TerminalChatAccessibilityHelpContribution.ID, TerminalChatAccessibilityHelpContribution, WorkbenchPhase.Eventually); diff --git a/src/vs/workbench/contrib/terminalContrib/chat/browser/terminalChat.ts b/src/vs/workbench/contrib/terminalContrib/chat/browser/terminalChat.ts new file mode 100644 index 0000000000000..bf95499ee325a --- /dev/null +++ b/src/vs/workbench/contrib/terminalContrib/chat/browser/terminalChat.ts @@ -0,0 +1,78 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { localize } from 'vs/nls'; +import { MenuId } from 'vs/platform/actions/common/actions'; +import { RawContextKey } from 'vs/platform/contextkey/common/contextkey'; + +export const enum TerminalChatCommandId { + Start = 'workbench.action.terminal.chat.start', + Close = 'workbench.action.terminal.chat.close', + FocusResponse = 'workbench.action.terminal.chat.focusResponse', + FocusInput = 'workbench.action.terminal.chat.focusInput', + Discard = 'workbench.action.terminal.chat.discard', + MakeRequest = 'workbench.action.terminal.chat.makeRequest', + Cancel = 'workbench.action.terminal.chat.cancel', + FeedbackHelpful = 'workbench.action.terminal.chat.feedbackHelpful', + FeedbackUnhelpful = 'workbench.action.terminal.chat.feedbackUnhelpful', + FeedbackReportIssue = 'workbench.action.terminal.chat.feedbackReportIssue', + RunCommand = 'workbench.action.terminal.chat.runCommand', + RunFirstCommand = 'workbench.action.terminal.chat.runFirstCommand', + InsertCommand = 'workbench.action.terminal.chat.insertCommand', + InsertFirstCommand = 'workbench.action.terminal.chat.insertFirstCommand', + ViewInChat = 'workbench.action.terminal.chat.viewInChat', + PreviousFromHistory = 'workbench.action.terminal.chat.previousFromHistory', + NextFromHistory = 'workbench.action.terminal.chat.nextFromHistory', +} + +export const MENU_TERMINAL_CHAT_INPUT = MenuId.for('terminalChatInput'); +export const MENU_TERMINAL_CHAT_WIDGET = MenuId.for('terminalChatWidget'); +export const MENU_TERMINAL_CHAT_WIDGET_STATUS = MenuId.for('terminalChatWidget.status'); +export const MENU_TERMINAL_CHAT_WIDGET_FEEDBACK = MenuId.for('terminalChatWidget.feedback'); +export const MENU_TERMINAL_CHAT_WIDGET_TOOLBAR = MenuId.for('terminalChatWidget.toolbar'); + +export const enum TerminalChatContextKeyStrings { + ChatFocus = 'terminalChatFocus', + ChatVisible = 'terminalChatVisible', + ChatActiveRequest = 'terminalChatActiveRequest', + ChatInputHasText = 'terminalChatInputHasText', + ChatAgentRegistered = 'terminalChatAgentRegistered', + ChatResponseEditorFocused = 'terminalChatResponseEditorFocused', + ChatResponseContainsCodeBlock = 'terminalChatResponseContainsCodeBlock', + ChatResponseContainsMultipleCodeBlocks = 'terminalChatResponseContainsMultipleCodeBlocks', + ChatResponseSupportsIssueReporting = 'terminalChatResponseSupportsIssueReporting', + ChatSessionResponseVote = 'terminalChatSessionResponseVote', +} + + +export namespace TerminalChatContextKeys { + + /** Whether the chat widget is focused */ + export const focused = new RawContextKey(TerminalChatContextKeyStrings.ChatFocus, false, localize('chatFocusedContextKey', "Whether the chat view is focused.")); + + /** Whether the chat widget is visible */ + export const visible = new RawContextKey(TerminalChatContextKeyStrings.ChatVisible, false, localize('chatVisibleContextKey', "Whether the chat view is visible.")); + + /** Whether there is an active chat request */ + export const requestActive = new RawContextKey(TerminalChatContextKeyStrings.ChatActiveRequest, false, localize('chatRequestActiveContextKey', "Whether there is an active chat request.")); + + /** Whether the chat input has text */ + export const inputHasText = new RawContextKey(TerminalChatContextKeyStrings.ChatInputHasText, false, localize('chatInputHasTextContextKey', "Whether the chat input has text.")); + + /** Whether the terminal chat agent has been registered */ + export const agentRegistered = new RawContextKey(TerminalChatContextKeyStrings.ChatAgentRegistered, false, localize('chatAgentRegisteredContextKey', "Whether the terminal chat agent has been registered.")); + + /** The chat response contains at least one code block */ + export const responseContainsCodeBlock = new RawContextKey(TerminalChatContextKeyStrings.ChatResponseContainsCodeBlock, false, localize('chatResponseContainsCodeBlockContextKey', "Whether the chat response contains a code block.")); + + /** The chat response contains multiple code blocks */ + export const responseContainsMultipleCodeBlocks = new RawContextKey(TerminalChatContextKeyStrings.ChatResponseContainsMultipleCodeBlocks, false, localize('chatResponseContainsMultipleCodeBlocksContextKey', "Whether the chat response contains multiple code blocks.")); + + /** Whether the response supports issue reporting */ + export const responseSupportsIssueReporting = new RawContextKey(TerminalChatContextKeyStrings.ChatResponseSupportsIssueReporting, false, localize('chatResponseSupportsIssueReportingContextKey', "Whether the response supports issue reporting")); + + /** The chat vote, if any for the response, if any */ + export const sessionResponseVote = new RawContextKey(TerminalChatContextKeyStrings.ChatSessionResponseVote, undefined, { type: 'string', description: localize('interactiveSessionResponseVote', "When the response has been voted up, is set to 'up'. When voted down, is set to 'down'. Otherwise an empty string.") }); +} diff --git a/src/vs/workbench/contrib/terminalContrib/chat/browser/terminalChatAccessibilityHelp.ts b/src/vs/workbench/contrib/terminalContrib/chat/browser/terminalChatAccessibilityHelp.ts new file mode 100644 index 0000000000000..584bdc753d8e6 --- /dev/null +++ b/src/vs/workbench/contrib/terminalContrib/chat/browser/terminalChatAccessibilityHelp.ts @@ -0,0 +1,65 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable } from 'vs/base/common/lifecycle'; +import { localize } from 'vs/nls'; +import { ServicesAccessor } from 'vs/platform/instantiation/common/instantiation'; +import { IKeybindingService } from 'vs/platform/keybinding/common/keybinding'; +import { AccessibilityVerbositySettingId, AccessibleViewProviderId } from 'vs/workbench/contrib/accessibility/browser/accessibilityConfiguration'; +import { AccessibleViewType, IAccessibleViewService } from 'vs/workbench/contrib/accessibility/browser/accessibleView'; +import { AccessibilityHelpAction } from 'vs/workbench/contrib/accessibility/browser/accessibleViewActions'; +import { ITerminalService } from 'vs/workbench/contrib/terminal/browser/terminal'; +import { TerminalChatCommandId, TerminalChatContextKeys } from 'vs/workbench/contrib/terminalContrib/chat/browser/terminalChat'; +import { TerminalChatController } from 'vs/workbench/contrib/terminalContrib/chat/browser/terminalChatController'; + +export class TerminalChatAccessibilityHelpContribution extends Disposable { + static ID = 'terminalChatAccessiblityHelp'; + constructor() { + super(); + this._register(AccessibilityHelpAction.addImplementation(110, 'terminalChat', runAccessibilityHelpAction, TerminalChatContextKeys.focused)); + } +} + +export async function runAccessibilityHelpAction(accessor: ServicesAccessor): Promise { + const accessibleViewService = accessor.get(IAccessibleViewService); + const terminalService = accessor.get(ITerminalService); + + const instance = terminalService.activeInstance; + if (!instance) { + return; + } + + const helpText = getAccessibilityHelpText(accessor); + accessibleViewService.show({ + id: AccessibleViewProviderId.TerminalChat, + verbositySettingKey: AccessibilityVerbositySettingId.TerminalChat, + provideContent: () => helpText, + onClose: () => TerminalChatController.get(instance)?.focus(), + options: { type: AccessibleViewType.Help } + }); +} + +export function getAccessibilityHelpText(accessor: ServicesAccessor): string { + const keybindingService = accessor.get(IKeybindingService); + const content = []; + const openAccessibleViewKeybinding = keybindingService.lookupKeybinding('editor.action.accessibleView')?.getAriaLabel(); + const runCommandKeybinding = keybindingService.lookupKeybinding(TerminalChatCommandId.RunCommand)?.getAriaLabel(); + const insertCommandKeybinding = keybindingService.lookupKeybinding(TerminalChatCommandId.InsertCommand)?.getAriaLabel(); + const makeRequestKeybinding = keybindingService.lookupKeybinding(TerminalChatCommandId.MakeRequest)?.getAriaLabel(); + const startChatKeybinding = keybindingService.lookupKeybinding(TerminalChatCommandId.Start)?.getAriaLabel(); + const focusResponseKeybinding = keybindingService.lookupKeybinding(TerminalChatCommandId.FocusResponse)?.getAriaLabel(); + const focusInputKeybinding = keybindingService.lookupKeybinding(TerminalChatCommandId.FocusInput)?.getAriaLabel(); + content.push(localize('inlineChat.overview', "Inline chat occurs within a terminal. It is useful for suggesting terminal commands. Keep in mind that AI generated code may be incorrect.")); + content.push(localize('inlineChat.access', "It can be activated using the command: Terminal: Start Chat ({0}), which will focus the input box.", startChatKeybinding)); + content.push(makeRequestKeybinding ? localize('inlineChat.input', "The input box is where the user can type a request and can make the request ({0}). The widget will be closed and all content will be discarded when the Escape key is pressed and the terminal will regain focus.", makeRequestKeybinding) : localize('inlineChat.inputNoKb', "The input box is where the user can type a request and can make the request by tabbing to the Make Request button, which is not currently triggerable via keybindings. The widget will be closed and all content will be discarded when the Escape key is pressed and the terminal will regain focus.")); + content.push(openAccessibleViewKeybinding ? localize('inlineChat.inspectResponseMessage', 'The response can be inspected in the accessible view ({0}).', openAccessibleViewKeybinding) : localize('inlineChat.inspectResponseNoKb', 'With the input box focused, inspect the response in the accessible view via the Open Accessible View command, which is currently not triggerable by a keybinding.')); + content.push(focusResponseKeybinding ? localize('inlineChat.focusResponse', 'Reach the response from the input box ({0}).', focusResponseKeybinding) : localize('inlineChat.focusResponseNoKb', 'Reach the response from the input box by tabbing or assigning a keybinding for the command: Focus Terminal Response.')); + content.push(focusInputKeybinding ? localize('inlineChat.focusInput', 'Reach the input box from the response ({0}).', focusInputKeybinding) : localize('inlineChat.focusInputNoKb', 'Reach the response from the input box by shift+tabbing or assigning a keybinding for the command: Focus Terminal Input.')); + content.push(runCommandKeybinding ? localize('inlineChat.runCommand', 'With focus in the input box or command editor, the Terminal: Run Chat Command ({0}) action.', runCommandKeybinding) : localize('inlineChat.runCommandNoKb', 'Run a command by tabbing to the button as the action is currently not triggerable by a keybinding.')); + content.push(insertCommandKeybinding ? localize('inlineChat.insertCommand', 'With focus in the input box command editor, the Terminal: Insert Chat Command ({0}) action.', insertCommandKeybinding) : localize('inlineChat.insertCommandNoKb', 'Insert a command by tabbing to the button as the action is currently not triggerable by a keybinding.')); + content.push(localize('inlineChat.toolbar', "Use tab to reach conditional parts like commands, status, message responses and more.")); + content.push(localize('chat.signals', "Accessibility Signals can be changed via settings with a prefix of signals.chat. By default, if a request takes more than 4 seconds, you will hear a sound indicating that progress is still occurring.")); + return content.join('\n\n'); +} diff --git a/src/vs/workbench/contrib/terminalContrib/chat/browser/terminalChatAccessibleView.ts b/src/vs/workbench/contrib/terminalContrib/chat/browser/terminalChatAccessibleView.ts new file mode 100644 index 0000000000000..f5bbe82de037f --- /dev/null +++ b/src/vs/workbench/contrib/terminalContrib/chat/browser/terminalChatAccessibleView.ts @@ -0,0 +1,38 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable } from 'vs/base/common/lifecycle'; +import { AccessibilityVerbositySettingId, AccessibleViewProviderId } from 'vs/workbench/contrib/accessibility/browser/accessibilityConfiguration'; +import { AccessibleViewType, IAccessibleViewService } from 'vs/workbench/contrib/accessibility/browser/accessibleView'; +import { AccessibleViewAction } from 'vs/workbench/contrib/accessibility/browser/accessibleViewActions'; +import { ITerminalService } from 'vs/workbench/contrib/terminal/browser/terminal'; +import { TerminalChatContextKeys } from 'vs/workbench/contrib/terminalContrib/chat/browser/terminalChat'; +import { TerminalChatController } from 'vs/workbench/contrib/terminalContrib/chat/browser/terminalChatController'; + +export class TerminalInlineChatAccessibleViewContribution extends Disposable { + static ID: 'terminalInlineChatAccessibleViewContribution'; + constructor() { + super(); + this._register(AccessibleViewAction.addImplementation(105, 'terminalInlineChat', accessor => { + const accessibleViewService = accessor.get(IAccessibleViewService); + const terminalService = accessor.get(ITerminalService); + const controller: TerminalChatController | undefined = terminalService.activeInstance?.getContribution(TerminalChatController.ID) ?? undefined; + if (!controller?.lastResponseContent) { + return false; + } + const responseContent = controller.lastResponseContent; + accessibleViewService.show({ + id: AccessibleViewProviderId.TerminalChat, + verbositySettingKey: AccessibilityVerbositySettingId.InlineChat, + provideContent(): string { return responseContent; }, + onClose() { + controller.focus(); + }, + options: { type: AccessibleViewType.View } + }); + return true; + }, TerminalChatContextKeys.focused)); + } +} diff --git a/src/vs/workbench/contrib/terminalContrib/chat/browser/terminalChatActions.ts b/src/vs/workbench/contrib/terminalContrib/chat/browser/terminalChatActions.ts new file mode 100644 index 0000000000000..a79a54a851565 --- /dev/null +++ b/src/vs/workbench/contrib/terminalContrib/chat/browser/terminalChatActions.ts @@ -0,0 +1,391 @@ +/*--------------------------------------------------------------------------------------------- + * 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 'vs/base/common/codicons'; +import { KeyCode, KeyMod } from 'vs/base/common/keyCodes'; +import { localize2 } from 'vs/nls'; +import { ContextKeyExpr } from 'vs/platform/contextkey/common/contextkey'; +import { KeybindingWeight } from 'vs/platform/keybinding/common/keybindingsRegistry'; +import { TerminalSettingId } from 'vs/platform/terminal/common/terminal'; +import { AbstractInlineChatAction } from 'vs/workbench/contrib/inlineChat/browser/inlineChatActions'; +import { CTX_INLINE_CHAT_EMPTY, CTX_INLINE_CHAT_FOCUSED, CTX_INLINE_CHAT_HAS_PROVIDER } from 'vs/workbench/contrib/inlineChat/common/inlineChat'; +import { isDetachedTerminalInstance } from 'vs/workbench/contrib/terminal/browser/terminal'; +import { registerActiveXtermAction } from 'vs/workbench/contrib/terminal/browser/terminalActions'; +import { TerminalContextKeys } from 'vs/workbench/contrib/terminal/common/terminalContextKey'; +import { MENU_TERMINAL_CHAT_INPUT, MENU_TERMINAL_CHAT_WIDGET, MENU_TERMINAL_CHAT_WIDGET_FEEDBACK, MENU_TERMINAL_CHAT_WIDGET_STATUS, TerminalChatCommandId, TerminalChatContextKeys } from 'vs/workbench/contrib/terminalContrib/chat/browser/terminalChat'; +import { TerminalChatController } from 'vs/workbench/contrib/terminalContrib/chat/browser/terminalChatController'; + +registerActiveXtermAction({ + id: TerminalChatCommandId.Start, + title: localize2('startChat', 'Start in Terminal'), + keybinding: { + primary: KeyMod.CtrlCmd | KeyCode.KeyI, + when: ContextKeyExpr.and(TerminalContextKeys.focusInAny), + // HACK: Force weight to be higher than the extension contributed keybinding to override it until it gets replaced + weight: KeybindingWeight.ExternalExtension + 1, // KeybindingWeight.WorkbenchContrib, + }, + f1: true, + category: AbstractInlineChatAction.category, + precondition: ContextKeyExpr.and( + ContextKeyExpr.has(`config.${TerminalSettingId.ExperimentalInlineChat}`), + ContextKeyExpr.or(TerminalContextKeys.processSupported, TerminalContextKeys.terminalHasBeenCreated), + // TODO: This needs to change to check for a terminal location capable agent + CTX_INLINE_CHAT_HAS_PROVIDER + ), + run: (_xterm, _accessor, activeInstance) => { + if (isDetachedTerminalInstance(activeInstance)) { + return; + } + const contr = TerminalChatController.activeChatWidget || TerminalChatController.get(activeInstance); + contr?.reveal(); + } +}); + +registerActiveXtermAction({ + id: TerminalChatCommandId.Close, + title: localize2('closeChat', 'Close Chat'), + keybinding: { + primary: KeyCode.Escape, + secondary: [KeyMod.Shift | KeyCode.Escape], + when: ContextKeyExpr.and(TerminalChatContextKeys.focused, TerminalChatContextKeys.visible), + weight: KeybindingWeight.WorkbenchContrib, + }, + icon: Codicon.close, + menu: { + id: MENU_TERMINAL_CHAT_WIDGET, + group: 'navigation', + order: 2 + }, + f1: true, + precondition: ContextKeyExpr.and( + ContextKeyExpr.has(`config.${TerminalSettingId.ExperimentalInlineChat}`), + ContextKeyExpr.and(TerminalChatContextKeys.focused, TerminalChatContextKeys.visible) + ), + run: (_xterm, _accessor, activeInstance) => { + if (isDetachedTerminalInstance(activeInstance)) { + return; + } + const contr = TerminalChatController.activeChatWidget || TerminalChatController.get(activeInstance); + contr?.clear(); + } +}); + +registerActiveXtermAction({ + id: TerminalChatCommandId.FocusResponse, + title: localize2('focusTerminalResponse', 'Focus Terminal Response'), + keybinding: { + primary: KeyMod.CtrlCmd | KeyCode.DownArrow, + when: TerminalChatContextKeys.focused, + weight: KeybindingWeight.WorkbenchContrib, + }, + f1: true, + category: AbstractInlineChatAction.category, + precondition: ContextKeyExpr.and( + ContextKeyExpr.has(`config.${TerminalSettingId.ExperimentalInlineChat}`), + TerminalChatContextKeys.focused + ), + run: (_xterm, _accessor, activeInstance) => { + if (isDetachedTerminalInstance(activeInstance)) { + return; + } + const contr = TerminalChatController.activeChatWidget || TerminalChatController.get(activeInstance); + contr?.chatWidget?.inlineChatWidget.chatWidget.focusLastMessage(); + } +}); + +registerActiveXtermAction({ + id: TerminalChatCommandId.FocusInput, + title: localize2('focusTerminalInput', 'Focus Terminal Input'), + keybinding: { + primary: KeyMod.CtrlCmd | KeyCode.UpArrow, + secondary: [KeyMod.CtrlCmd | KeyCode.KeyI], + when: ContextKeyExpr.and(TerminalChatContextKeys.focused, CTX_INLINE_CHAT_FOCUSED.toNegated()), + weight: KeybindingWeight.WorkbenchContrib, + }, + f1: true, + category: AbstractInlineChatAction.category, + precondition: ContextKeyExpr.and( + ContextKeyExpr.has(`config.${TerminalSettingId.ExperimentalInlineChat}`), + TerminalChatContextKeys.focused + ), + run: (_xterm, _accessor, activeInstance) => { + if (isDetachedTerminalInstance(activeInstance)) { + return; + } + const contr = TerminalChatController.activeChatWidget || TerminalChatController.get(activeInstance); + contr?.chatWidget?.focus(); + } +}); + + +registerActiveXtermAction({ + id: TerminalChatCommandId.Discard, + title: localize2('discard', 'Discard'), + metadata: { + description: localize2('discardDescription', 'Discards the terminal current chat response, hide the chat widget, and clear the chat input.') + }, + icon: Codicon.discard, + menu: { + id: MENU_TERMINAL_CHAT_WIDGET_STATUS, + group: '0_main', + order: 2, + when: ContextKeyExpr.and(TerminalChatContextKeys.focused, TerminalChatContextKeys.responseContainsCodeBlock) + }, + f1: true, + precondition: ContextKeyExpr.and( + ContextKeyExpr.has(`config.${TerminalSettingId.ExperimentalInlineChat}`), + ContextKeyExpr.or(TerminalContextKeys.processSupported, TerminalContextKeys.terminalHasBeenCreated), + TerminalChatContextKeys.focused, + TerminalChatContextKeys.responseContainsCodeBlock + ), + run: (_xterm, _accessor, activeInstance) => { + if (isDetachedTerminalInstance(activeInstance)) { + return; + } + const contr = TerminalChatController.activeChatWidget || TerminalChatController.get(activeInstance); + contr?.clear(); + } +}); + + +registerActiveXtermAction({ + id: TerminalChatCommandId.RunCommand, + title: localize2('runCommand', 'Run Chat Command'), + shortTitle: localize2('run', 'Run'), + precondition: ContextKeyExpr.and( + ContextKeyExpr.has(`config.${TerminalSettingId.ExperimentalInlineChat}`), + ContextKeyExpr.or(TerminalContextKeys.processSupported, TerminalContextKeys.terminalHasBeenCreated), + TerminalChatContextKeys.requestActive.negate(), + TerminalChatContextKeys.agentRegistered, + TerminalChatContextKeys.responseContainsCodeBlock, + TerminalChatContextKeys.responseContainsMultipleCodeBlocks.negate() + ), + icon: Codicon.play, + keybinding: { + when: TerminalChatContextKeys.requestActive.negate(), + weight: KeybindingWeight.WorkbenchContrib, + primary: KeyMod.CtrlCmd | KeyCode.Enter, + }, + menu: { + id: MENU_TERMINAL_CHAT_WIDGET_STATUS, + group: '0_main', + order: 0, + when: ContextKeyExpr.and(TerminalChatContextKeys.responseContainsCodeBlock, TerminalChatContextKeys.responseContainsMultipleCodeBlocks.negate(), TerminalChatContextKeys.requestActive.negate()) + }, + run: (_xterm, _accessor, activeInstance) => { + if (isDetachedTerminalInstance(activeInstance)) { + return; + } + const contr = TerminalChatController.activeChatWidget || TerminalChatController.get(activeInstance); + contr?.acceptCommand(true); + } +}); + +registerActiveXtermAction({ + id: TerminalChatCommandId.RunFirstCommand, + title: localize2('runFirstCommand', 'Run First Chat Command'), + shortTitle: localize2('runFirst', 'Run First'), + precondition: ContextKeyExpr.and( + ContextKeyExpr.has(`config.${TerminalSettingId.ExperimentalInlineChat}`), + ContextKeyExpr.or(TerminalContextKeys.processSupported, TerminalContextKeys.terminalHasBeenCreated), + TerminalChatContextKeys.requestActive.negate(), + TerminalChatContextKeys.agentRegistered, + TerminalChatContextKeys.responseContainsMultipleCodeBlocks + ), + icon: Codicon.play, + keybinding: { + when: TerminalChatContextKeys.requestActive.negate(), + weight: KeybindingWeight.WorkbenchContrib, + primary: KeyMod.CtrlCmd | KeyCode.Enter, + }, + menu: { + id: MENU_TERMINAL_CHAT_WIDGET_STATUS, + group: '0_main', + order: 0, + when: ContextKeyExpr.and(TerminalChatContextKeys.responseContainsMultipleCodeBlocks, TerminalChatContextKeys.requestActive.negate()) + }, + run: (_xterm, _accessor, activeInstance) => { + if (isDetachedTerminalInstance(activeInstance)) { + return; + } + const contr = TerminalChatController.activeChatWidget || TerminalChatController.get(activeInstance); + contr?.acceptCommand(true); + } +}); + +registerActiveXtermAction({ + id: TerminalChatCommandId.InsertCommand, + title: localize2('insertCommand', 'Insert Chat Command'), + shortTitle: localize2('insert', 'Insert'), + precondition: ContextKeyExpr.and( + ContextKeyExpr.has(`config.${TerminalSettingId.ExperimentalInlineChat}`), + ContextKeyExpr.or(TerminalContextKeys.processSupported, TerminalContextKeys.terminalHasBeenCreated), + TerminalChatContextKeys.requestActive.negate(), + TerminalChatContextKeys.agentRegistered, + TerminalChatContextKeys.responseContainsCodeBlock, + TerminalChatContextKeys.responseContainsMultipleCodeBlocks.negate() + ), + keybinding: { + when: TerminalChatContextKeys.requestActive.negate(), + weight: KeybindingWeight.WorkbenchContrib, + primary: KeyMod.Alt | KeyCode.Enter, + secondary: [KeyMod.CtrlCmd | KeyCode.Enter | KeyMod.Alt] + }, + menu: { + id: MENU_TERMINAL_CHAT_WIDGET_STATUS, + group: '0_main', + order: 1, + when: ContextKeyExpr.and(TerminalChatContextKeys.responseContainsCodeBlock, TerminalChatContextKeys.responseContainsMultipleCodeBlocks.negate(), TerminalChatContextKeys.requestActive.negate()) + }, + run: (_xterm, _accessor, activeInstance) => { + if (isDetachedTerminalInstance(activeInstance)) { + return; + } + const contr = TerminalChatController.activeChatWidget || TerminalChatController.get(activeInstance); + contr?.acceptCommand(false); + } +}); + +registerActiveXtermAction({ + id: TerminalChatCommandId.InsertFirstCommand, + title: localize2('insertFirstCommand', 'Insert First Chat Command'), + shortTitle: localize2('insertFirst', 'Insert First'), + precondition: ContextKeyExpr.and( + ContextKeyExpr.has(`config.${TerminalSettingId.ExperimentalInlineChat}`), + ContextKeyExpr.or(TerminalContextKeys.processSupported, TerminalContextKeys.terminalHasBeenCreated), + TerminalChatContextKeys.requestActive.negate(), + TerminalChatContextKeys.agentRegistered, + TerminalChatContextKeys.responseContainsMultipleCodeBlocks + ), + keybinding: { + when: TerminalChatContextKeys.requestActive.negate(), + weight: KeybindingWeight.WorkbenchContrib, + primary: KeyMod.Alt | KeyCode.Enter, + secondary: [KeyMod.CtrlCmd | KeyCode.Enter | KeyMod.Alt] + }, + menu: { + id: MENU_TERMINAL_CHAT_WIDGET_STATUS, + group: '0_main', + order: 1, + when: ContextKeyExpr.and(TerminalChatContextKeys.responseContainsMultipleCodeBlocks, TerminalChatContextKeys.requestActive.negate()) + }, + run: (_xterm, _accessor, activeInstance) => { + if (isDetachedTerminalInstance(activeInstance)) { + return; + } + const contr = TerminalChatController.activeChatWidget || TerminalChatController.get(activeInstance); + contr?.acceptCommand(false); + } +}); + +registerActiveXtermAction({ + id: TerminalChatCommandId.ViewInChat, + title: localize2('viewInChat', 'View in Chat'), + precondition: ContextKeyExpr.and( + ContextKeyExpr.has(`config.${TerminalSettingId.ExperimentalInlineChat}`), + ContextKeyExpr.or(TerminalContextKeys.processSupported, TerminalContextKeys.terminalHasBeenCreated), + TerminalChatContextKeys.requestActive.negate(), + TerminalChatContextKeys.agentRegistered, + ), + icon: Codicon.commentDiscussion, + menu: [{ + id: MENU_TERMINAL_CHAT_WIDGET_STATUS, + group: '0_main', + order: 1, + when: ContextKeyExpr.and(TerminalChatContextKeys.responseContainsCodeBlock.negate(), TerminalChatContextKeys.requestActive.negate()), + }, + { + id: MENU_TERMINAL_CHAT_WIDGET, + group: 'navigation', + order: 1, + when: ContextKeyExpr.and(CTX_INLINE_CHAT_EMPTY.negate(), TerminalChatContextKeys.responseContainsCodeBlock, TerminalChatContextKeys.requestActive.negate()), + }], + run: (_xterm, _accessor, activeInstance) => { + if (isDetachedTerminalInstance(activeInstance)) { + return; + } + const contr = TerminalChatController.activeChatWidget || TerminalChatController.get(activeInstance); + contr?.viewInChat(); + } +}); + +registerActiveXtermAction({ + id: TerminalChatCommandId.MakeRequest, + title: localize2('makeChatRequest', 'Make Chat Request'), + precondition: ContextKeyExpr.and( + ContextKeyExpr.has(`config.${TerminalSettingId.ExperimentalInlineChat}`), + ContextKeyExpr.or(TerminalContextKeys.processSupported, TerminalContextKeys.terminalHasBeenCreated), + TerminalChatContextKeys.requestActive.negate(), + TerminalChatContextKeys.agentRegistered, + CTX_INLINE_CHAT_EMPTY.negate() + ), + icon: Codicon.send, + keybinding: { + when: ContextKeyExpr.and(CTX_INLINE_CHAT_FOCUSED, TerminalChatContextKeys.requestActive.negate()), + weight: KeybindingWeight.WorkbenchContrib, + primary: KeyCode.Enter + }, + menu: { + id: MENU_TERMINAL_CHAT_INPUT, + group: 'navigation', + order: 1, + when: TerminalChatContextKeys.requestActive.negate(), + }, + run: (_xterm, _accessor, activeInstance) => { + if (isDetachedTerminalInstance(activeInstance)) { + return; + } + const contr = TerminalChatController.activeChatWidget || TerminalChatController.get(activeInstance); + contr?.acceptInput(); + } +}); + +registerActiveXtermAction({ + id: TerminalChatCommandId.Cancel, + title: localize2('cancelChat', 'Cancel Chat'), + precondition: ContextKeyExpr.and( + ContextKeyExpr.has(`config.${TerminalSettingId.ExperimentalInlineChat}`), + TerminalChatContextKeys.requestActive, + TerminalChatContextKeys.agentRegistered + ), + icon: Codicon.debugStop, + menu: { + id: MENU_TERMINAL_CHAT_INPUT, + group: 'navigation', + when: TerminalChatContextKeys.requestActive, + }, + run: (_xterm, _accessor, activeInstance) => { + if (isDetachedTerminalInstance(activeInstance)) { + return; + } + const contr = TerminalChatController.activeChatWidget || TerminalChatController.get(activeInstance); + contr?.cancel(); + } +}); + +registerActiveXtermAction({ + id: TerminalChatCommandId.FeedbackReportIssue, + title: localize2('reportIssue', 'Report Issue'), + precondition: ContextKeyExpr.and( + ContextKeyExpr.has(`config.${TerminalSettingId.ExperimentalInlineChat}`), + TerminalChatContextKeys.requestActive.negate(), + TerminalChatContextKeys.responseContainsCodeBlock.notEqualsTo(undefined), + TerminalChatContextKeys.responseSupportsIssueReporting + ), + icon: Codicon.report, + menu: [{ + id: MENU_TERMINAL_CHAT_WIDGET_FEEDBACK, + when: ContextKeyExpr.and(TerminalChatContextKeys.responseContainsCodeBlock.notEqualsTo(undefined), TerminalChatContextKeys.responseSupportsIssueReporting), + group: 'inline', + order: 3 + }], + run: (_xterm, _accessor, activeInstance) => { + if (isDetachedTerminalInstance(activeInstance)) { + return; + } + const contr = TerminalChatController.activeChatWidget || TerminalChatController.get(activeInstance); + contr?.acceptFeedback(); + } +}); diff --git a/src/vs/workbench/contrib/terminalContrib/chat/browser/terminalChatController.ts b/src/vs/workbench/contrib/terminalContrib/chat/browser/terminalChatController.ts new file mode 100644 index 0000000000000..d6cdd0c866a37 --- /dev/null +++ b/src/vs/workbench/contrib/terminalContrib/chat/browser/terminalChatController.ts @@ -0,0 +1,409 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import type { Terminal as RawXtermTerminal } from '@xterm/xterm'; +import { CancellationToken, CancellationTokenSource } from 'vs/base/common/cancellation'; +import { Emitter, Event } from 'vs/base/common/event'; +import { Lazy } from 'vs/base/common/lazy'; +import { Disposable, MutableDisposable } from 'vs/base/common/lifecycle'; +import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; +import { IContextKey, IContextKeyService } from 'vs/platform/contextkey/common/contextkey'; +import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; +import { TerminalSettingId } from 'vs/platform/terminal/common/terminal'; +import { GeneratingPhrase, IChatAccessibilityService, IChatCodeBlockContextProviderService, IChatWidgetService } from 'vs/workbench/contrib/chat/browser/chat'; +import { ChatAgentLocation, IChatAgentRequest, IChatAgentService } from 'vs/workbench/contrib/chat/common/chatAgents'; +import { IParsedChatRequest } from 'vs/workbench/contrib/chat/common/chatParserTypes'; +import { ChatUserAction, IChatProgress, IChatService, InteractiveSessionVoteDirection } from 'vs/workbench/contrib/chat/common/chatService'; +import { ITerminalContribution, ITerminalInstance, ITerminalService, IXtermTerminal, isDetachedTerminalInstance } from 'vs/workbench/contrib/terminal/browser/terminal'; +import { TerminalWidgetManager } from 'vs/workbench/contrib/terminal/browser/widgets/widgetManager'; +import { ITerminalProcessManager } from 'vs/workbench/contrib/terminal/common/terminal'; +import { TerminalChatWidget } from 'vs/workbench/contrib/terminalContrib/chat/browser/terminalChatWidget'; + +import { MarkdownString } from 'vs/base/common/htmlContent'; +import { ChatModel, ChatRequestModel, IChatRequestVariableData, getHistoryEntriesFromModel } from 'vs/workbench/contrib/chat/common/chatModel'; +import { TerminalChatContextKeys } from 'vs/workbench/contrib/terminalContrib/chat/browser/terminalChat'; + +const enum Message { + NONE = 0, + ACCEPT_SESSION = 1 << 0, + CANCEL_SESSION = 1 << 1, + PAUSE_SESSION = 1 << 2, + CANCEL_REQUEST = 1 << 3, + CANCEL_INPUT = 1 << 4, + ACCEPT_INPUT = 1 << 5, + RERUN_INPUT = 1 << 6, +} + +export class TerminalChatController extends Disposable implements ITerminalContribution { + static readonly ID = 'terminal.chat'; + + static get(instance: ITerminalInstance): TerminalChatController | null { + return instance.getContribution(TerminalChatController.ID); + } + /** + * Currently focused chat widget. This is used to track action context since 'active terminals' + * are only tracked for non-detached terminal instanecs. + */ + static activeChatWidget?: TerminalChatController; + + /** + * The chat widget for the controller, this is lazy as we don't want to instantiate it until + * both it's required and xterm is ready. + */ + private _chatWidget: Lazy | undefined; + + /** + * The chat widget for the controller, this will be undefined if xterm is not ready yet (ie. the + * terminal is still initializing). + */ + get chatWidget(): TerminalChatWidget | undefined { return this._chatWidget?.value; } + + private readonly _requestActiveContextKey: IContextKey; + private readonly _terminalAgentRegisteredContextKey: IContextKey; + private readonly _responseContainsCodeBlockContextKey: IContextKey; + private readonly _responseContainsMulitpleCodeBlocksContextKey: IContextKey; + private readonly _responseSupportsIssueReportingContextKey: IContextKey; + private readonly _sessionResponseVoteContextKey: IContextKey; + + private _messages = this._store.add(new Emitter()); + + private _currentRequest: ChatRequestModel | undefined; + + private _lastInput: string | undefined; + private _lastResponseContent: string | undefined; + get lastResponseContent(): string | undefined { + return this._lastResponseContent; + } + + readonly onDidAcceptInput = Event.filter(this._messages.event, m => m === Message.ACCEPT_INPUT, this._store); + readonly onDidCancelInput = Event.filter(this._messages.event, m => m === Message.CANCEL_INPUT || m === Message.CANCEL_SESSION, this._store); + + private _terminalAgentName = 'terminal'; + private _terminalAgentId: string | undefined; + + private readonly _model: MutableDisposable = this._register(new MutableDisposable()); + + constructor( + private readonly _instance: ITerminalInstance, + processManager: ITerminalProcessManager, + widgetManager: TerminalWidgetManager, + @IConfigurationService private _configurationService: IConfigurationService, + @ITerminalService private readonly _terminalService: ITerminalService, + @IInstantiationService private readonly _instantiationService: IInstantiationService, + @IChatAgentService private readonly _chatAgentService: IChatAgentService, + @IContextKeyService private readonly _contextKeyService: IContextKeyService, + @IChatAccessibilityService private readonly _chatAccessibilityService: IChatAccessibilityService, + @IChatWidgetService private readonly _chatWidgetService: IChatWidgetService, + @IChatService private readonly _chatService: IChatService, + @IChatCodeBlockContextProviderService private readonly _chatCodeBlockContextProviderService: IChatCodeBlockContextProviderService, + ) { + super(); + + this._requestActiveContextKey = TerminalChatContextKeys.requestActive.bindTo(this._contextKeyService); + this._terminalAgentRegisteredContextKey = TerminalChatContextKeys.agentRegistered.bindTo(this._contextKeyService); + this._responseContainsCodeBlockContextKey = TerminalChatContextKeys.responseContainsCodeBlock.bindTo(this._contextKeyService); + this._responseContainsMulitpleCodeBlocksContextKey = TerminalChatContextKeys.responseContainsMultipleCodeBlocks.bindTo(this._contextKeyService); + this._responseSupportsIssueReportingContextKey = TerminalChatContextKeys.responseSupportsIssueReporting.bindTo(this._contextKeyService); + this._sessionResponseVoteContextKey = TerminalChatContextKeys.sessionResponseVote.bindTo(this._contextKeyService); + + if (!this._configurationService.getValue(TerminalSettingId.ExperimentalInlineChat)) { + return; + } + + if (!this.initTerminalAgent()) { + this._register(this._chatAgentService.onDidChangeAgents(() => this.initTerminalAgent())); + } + this._register(this._chatCodeBlockContextProviderService.registerProvider({ + getCodeBlockContext: (editor) => { + if (!editor || !this._chatWidget?.hasValue || !this.hasFocus()) { + return; + } + return { + element: editor, + code: editor.getValue(), + codeBlockIndex: 0, + languageId: editor.getModel()!.getLanguageId() + }; + } + }, 'terminal')); + + // TODO + // This is glue/debt that's needed while ChatModel isn't yet adopted. The chat model uses + // a default chat model (unless configured) and feedback is reported against that one. This + // code forwards the feedback to an actual registered provider + this._register(this._chatService.onDidPerformUserAction(e => { + if (e.providerId === this._chatWidget?.rawValue?.inlineChatWidget.getChatModel().providerId) { + if (e.action.kind === 'bug') { + this.acceptFeedback(undefined); + } else if (e.action.kind === 'vote') { + this.acceptFeedback(e.action.direction === InteractiveSessionVoteDirection.Up); + } + } + })); + } + + private initTerminalAgent(): boolean { + const terminalAgent = this._chatAgentService.getAgentsByName(this._terminalAgentName)[0]; + if (terminalAgent) { + this._terminalAgentId = terminalAgent.id; + this._terminalAgentRegisteredContextKey.set(true); + return true; + } + + return false; + } + + xtermReady(xterm: IXtermTerminal & { raw: RawXtermTerminal }): void { + if (!this._configurationService.getValue(TerminalSettingId.ExperimentalInlineChat)) { + return; + } + this._chatWidget = new Lazy(() => { + const chatWidget = this._register(this._instantiationService.createInstance(TerminalChatWidget, this._instance.domElement!, this._instance)); + this._register(chatWidget.focusTracker.onDidFocus(() => { + TerminalChatController.activeChatWidget = this; + if (!isDetachedTerminalInstance(this._instance)) { + this._terminalService.setActiveInstance(this._instance); + } + })); + this._register(chatWidget.focusTracker.onDidBlur(() => { + TerminalChatController.activeChatWidget = undefined; + this._instance.resetScrollbarVisibility(); + })); + if (!this._instance.domElement) { + throw new Error('FindWidget expected terminal DOM to be initialized'); + } + return chatWidget; + }); + } + + acceptFeedback(helpful?: boolean): void { + const providerId = this._chatService.getProviderInfos()?.[0]?.id; + const model = this._model.value; + if (!providerId || !this._currentRequest || !model) { + return; + } + let action: ChatUserAction; + if (helpful === undefined) { + action = { kind: 'bug' }; + } else { + this._sessionResponseVoteContextKey.set(helpful ? 'up' : 'down'); + action = { kind: 'vote', direction: helpful ? InteractiveSessionVoteDirection.Up : InteractiveSessionVoteDirection.Down }; + } + // TODO:extract into helper method + for (const request of model.getRequests()) { + if (request.response?.response.value || request.response?.result) { + this._chatService.notifyUserAction({ + providerId, + sessionId: request.session.sessionId, + requestId: request.id, + agentId: request.response?.agent?.id, + result: request.response?.result, + action + }); + } + } + this._chatWidget?.value.inlineChatWidget.updateStatus('Thank you for your feedback!', { resetAfter: 1250 }); + } + + cancel(): void { + if (this._currentRequest) { + this._model.value?.cancelRequest(this._currentRequest); + } + this._requestActiveContextKey.set(false); + this._chatWidget?.value.inlineChatWidget.updateProgress(false); + this._chatWidget?.value.inlineChatWidget.updateInfo(''); + this._chatWidget?.value.inlineChatWidget.updateToolbar(true); + } + + private _forcedPlaceholder: string | undefined = undefined; + + private _updatePlaceholder(): void { + const inlineChatWidget = this._chatWidget?.value.inlineChatWidget; + if (inlineChatWidget) { + inlineChatWidget.placeholder = this._getPlaceholderText(); + } + } + + private _getPlaceholderText(): string { + return this._forcedPlaceholder ?? ''; + } + + setPlaceholder(text: string): void { + this._forcedPlaceholder = text; + this._updatePlaceholder(); + } + + resetPlaceholder(): void { + this._forcedPlaceholder = undefined; + this._updatePlaceholder(); + } + + clear(): void { + if (this._currentRequest) { + this._model.value?.cancelRequest(this._currentRequest); + } + this._model.clear(); + this._chatWidget?.rawValue?.hide(); + this._chatWidget?.rawValue?.setValue(undefined); + this._responseContainsCodeBlockContextKey.reset(); + this._sessionResponseVoteContextKey.reset(); + this._requestActiveContextKey.reset(); + } + + async acceptInput(): Promise { + const providerInfo = this._chatService.getProviderInfos()?.[0]; + if (!providerInfo) { + return; + } + if (!this._model.value) { + this._model.value = this._chatService.startSession(providerInfo.id, CancellationToken.None); + if (!this._model.value) { + throw new Error('Could not start chat session'); + } + } + this._messages.fire(Message.ACCEPT_INPUT); + const model = this._model.value; + + this._lastInput = this._chatWidget?.value?.input(); + if (!this._lastInput) { + return; + } + const accessibilityRequestId = this._chatAccessibilityService.acceptRequest(); + this._requestActiveContextKey.set(true); + const cancellationToken = new CancellationTokenSource().token; + let responseContent = ''; + const progressCallback = (progress: IChatProgress) => { + if (cancellationToken.isCancellationRequested) { + return; + } + + if (progress.kind === 'content') { + responseContent += progress.content; + } else if (progress.kind === 'markdownContent') { + responseContent += progress.content.value; + } + if (this._currentRequest) { + model.acceptResponseProgress(this._currentRequest, progress); + } + }; + + await model.waitForInitialization(); + this._chatWidget?.value.addToHistory(this._lastInput); + const request: IParsedChatRequest = { + text: this._lastInput, + parts: [] + }; + const requestVarData: IChatRequestVariableData = { + variables: [] + }; + this._currentRequest = model.addRequest(request, requestVarData); + const requestProps: IChatAgentRequest = { + sessionId: model.sessionId, + requestId: this._currentRequest!.id, + agentId: this._terminalAgentId!, + message: this._lastInput, + variables: { variables: [] }, + location: ChatAgentLocation.Terminal + }; + try { + const task = this._chatAgentService.invokeAgent(this._terminalAgentId!, requestProps, progressCallback, getHistoryEntriesFromModel(model), cancellationToken); + this._chatWidget?.value.inlineChatWidget.updateChatMessage(undefined); + this._chatWidget?.value.inlineChatWidget.updateFollowUps(undefined); + this._chatWidget?.value.inlineChatWidget.updateProgress(true); + this._chatWidget?.value.inlineChatWidget.updateInfo(GeneratingPhrase + '\u2026'); + await task; + } catch (e) { + + } finally { + this._requestActiveContextKey.set(false); + this._chatWidget?.value.inlineChatWidget.updateProgress(false); + this._chatWidget?.value.inlineChatWidget.updateInfo(''); + this._chatWidget?.value.inlineChatWidget.updateToolbar(true); + if (this._currentRequest) { + model.completeResponse(this._currentRequest); + } + this._lastResponseContent = responseContent; + if (this._currentRequest) { + this._chatAccessibilityService.acceptResponse(responseContent, accessibilityRequestId); + const containsCode = responseContent.includes('```'); + this._chatWidget?.value.inlineChatWidget.updateChatMessage({ message: new MarkdownString(responseContent), requestId: this._currentRequest.id, providerId: 'terminal' }, false, containsCode); + const firstCodeBlock = await this.chatWidget?.inlineChatWidget.getCodeBlockInfo(0); + const secondCodeBlock = await this.chatWidget?.inlineChatWidget.getCodeBlockInfo(1); + this._responseContainsCodeBlockContextKey.set(!!firstCodeBlock); + this._responseContainsMulitpleCodeBlocksContextKey.set(!!secondCodeBlock); + this._chatWidget?.value.inlineChatWidget.updateToolbar(true); + } + const supportIssueReporting = this._currentRequest?.response?.agent?.metadata?.supportIssueReporting; + if (supportIssueReporting !== undefined) { + this._responseSupportsIssueReportingContextKey.set(supportIssueReporting); + } + } + } + + updateInput(text: string, selectAll = true): void { + const widget = this._chatWidget?.value.inlineChatWidget; + if (widget) { + widget.value = text; + if (selectAll) { + widget.selectAll(); + } + } + } + + getInput(): string { + return this._chatWidget?.value.input() ?? ''; + } + + focus(): void { + this._chatWidget?.value.focus(); + } + + hasFocus(): boolean { + return !!this._chatWidget?.rawValue?.hasFocus() ?? false; + } + + async acceptCommand(shouldExecute: boolean): Promise { + const code = await this.chatWidget?.inlineChatWidget.getCodeBlockInfo(0); + if (!code) { + return; + } + this._chatWidget?.value.acceptCommand(code.textEditorModel.getValue(), shouldExecute); + } + + reveal(): void { + this._chatWidget?.value.reveal(); + } + + async viewInChat(): Promise { + const providerInfo = this._chatService.getProviderInfos()?.[0]; + if (!providerInfo) { + return; + } + const widget = await this._chatWidgetService.revealViewForProvider(providerInfo.id); + const request = this._currentRequest; + if (!widget || !request?.response) { + return; + } + this._chatService.addCompleteRequest(widget!.viewModel!.sessionId, + request.message.text, + request.variableData, + { + message: request.response!.response.value, + result: request.response!.result, + followups: request.response!.followups + }); + widget.focusLastMessage(); + this._chatWidget?.rawValue?.hide(); + } + + // TODO: Move to register calls, don't override + override dispose() { + if (this._currentRequest) { + this._model.value?.cancelRequest(this._currentRequest); + } + super.dispose(); + this.clear(); + } +} diff --git a/src/vs/workbench/contrib/terminalContrib/chat/browser/terminalChatWidget.ts b/src/vs/workbench/contrib/terminalContrib/chat/browser/terminalChatWidget.ts new file mode 100644 index 0000000000000..23e165947255c --- /dev/null +++ b/src/vs/workbench/contrib/terminalContrib/chat/browser/terminalChatWidget.ts @@ -0,0 +1,185 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Dimension, IFocusTracker, trackFocus } from 'vs/base/browser/dom'; +import { Event } from 'vs/base/common/event'; +import { Disposable, toDisposable } from 'vs/base/common/lifecycle'; +import 'vs/css!./media/terminalChatWidget'; +import { localize } from 'vs/nls'; +import { IContextKey, IContextKeyService } from 'vs/platform/contextkey/common/contextkey'; +import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; +import { ChatAgentLocation } from 'vs/workbench/contrib/chat/common/chatAgents'; +import { IChatProgress } from 'vs/workbench/contrib/chat/common/chatService'; +import { InlineChatWidget } from 'vs/workbench/contrib/inlineChat/browser/inlineChatWidget'; +import { ITerminalInstance } from 'vs/workbench/contrib/terminal/browser/terminal'; +import { MENU_TERMINAL_CHAT_INPUT, MENU_TERMINAL_CHAT_WIDGET, MENU_TERMINAL_CHAT_WIDGET_FEEDBACK, MENU_TERMINAL_CHAT_WIDGET_STATUS, TerminalChatCommandId, TerminalChatContextKeys } from 'vs/workbench/contrib/terminalContrib/chat/browser/terminalChat'; + +const enum Constants { + HorizontalMargin = 10 +} + +export class TerminalChatWidget extends Disposable { + + private readonly _container: HTMLElement; + + private readonly _inlineChatWidget: InlineChatWidget; + public get inlineChatWidget(): InlineChatWidget { return this._inlineChatWidget; } + + private readonly _focusTracker: IFocusTracker; + + private readonly _focusedContextKey: IContextKey; + private readonly _visibleContextKey: IContextKey; + + constructor( + private readonly _terminalElement: HTMLElement, + private readonly _instance: ITerminalInstance, + @IInstantiationService private readonly _instantiationService: IInstantiationService, + @IContextKeyService private readonly _contextKeyService: IContextKeyService + ) { + super(); + + this._focusedContextKey = TerminalChatContextKeys.focused.bindTo(this._contextKeyService); + this._visibleContextKey = TerminalChatContextKeys.visible.bindTo(this._contextKeyService); + + this._container = document.createElement('div'); + this._container.classList.add('terminal-inline-chat'); + _terminalElement.appendChild(this._container); + + this._inlineChatWidget = this._instantiationService.createInstance( + InlineChatWidget, + ChatAgentLocation.Terminal, + { + inputMenuId: MENU_TERMINAL_CHAT_INPUT, + widgetMenuId: MENU_TERMINAL_CHAT_WIDGET, + statusMenuId: { + menu: MENU_TERMINAL_CHAT_WIDGET_STATUS, + options: { + buttonConfigProvider: action => { + if (action.id === TerminalChatCommandId.ViewInChat || action.id === TerminalChatCommandId.RunCommand || action.id === TerminalChatCommandId.RunFirstCommand) { + return { isSecondary: false }; + } else { + return { isSecondary: true }; + } + } + } + }, + feedbackMenuId: MENU_TERMINAL_CHAT_WIDGET_FEEDBACK, + telemetrySource: 'terminal-inline-chat', + rendererOptions: { editableCodeBlock: true } + } + ); + this._register(Event.any( + this._inlineChatWidget.onDidChangeHeight, + this._instance.onDimensionsChanged, + )(() => this._relayout())); + + const observer = new ResizeObserver(() => this._relayout()); + observer.observe(this._terminalElement); + this._register(toDisposable(() => observer.disconnect())); + + this._reset(); + this._container.appendChild(this._inlineChatWidget.domNode); + + this._focusTracker = this._register(trackFocus(this._container)); + this.hide(); + } + + private _dimension?: Dimension; + + private _relayout() { + if (this._dimension) { + this._doLayout(this._inlineChatWidget.contentHeight); + } + } + + private _doLayout(heightInPixel: number) { + const width = Math.min(640, this._terminalElement.clientWidth - 12/* padding */ - 2/* border */ - Constants.HorizontalMargin); + const height = Math.min(480, heightInPixel, this._getTerminalWrapperHeight() ?? Number.MAX_SAFE_INTEGER); + if (width === 0 || height === 0) { + return; + } + this._dimension = new Dimension(width, height); + this._inlineChatWidget.layout(this._dimension); + this._updateVerticalPosition(); + } + + private _reset() { + this._inlineChatWidget.placeholder = localize('default.placeholder', "Ask how to do something in the terminal"); + this._inlineChatWidget.updateInfo(localize('welcome.1', "AI-generated commands may be incorrect")); + } + + reveal(): void { + this._doLayout(this._inlineChatWidget.contentHeight); + this._container.classList.remove('hide'); + this._focusedContextKey.set(true); + this._visibleContextKey.set(true); + this._inlineChatWidget.focus(); + } + + private _updateVerticalPosition(): void { + const font = this._instance.xterm?.getFont(); + if (!font?.charHeight) { + return; + } + const terminalWrapperHeight = this._getTerminalWrapperHeight() ?? 0; + const cellHeight = font.charHeight * font.lineHeight; + const topPadding = terminalWrapperHeight - (this._instance.rows * cellHeight); + const cursorY = (this._instance.xterm?.raw.buffer.active.cursorY ?? 0) + 1; + const top = topPadding + cursorY * cellHeight; + this._container.style.top = `${top}px`; + const widgetHeight = this._inlineChatWidget.contentHeight; + if (!terminalWrapperHeight) { + return; + } + if (top > terminalWrapperHeight - widgetHeight) { + this._container.style.top = ''; + } + } + + private _getTerminalWrapperHeight(): number | undefined { + return this._terminalElement.clientHeight; + } + + hide(): void { + this._container.classList.add('hide'); + this._reset(); + this._inlineChatWidget.updateChatMessage(undefined); + this._inlineChatWidget.updateFollowUps(undefined); + this._inlineChatWidget.updateProgress(false); + this._inlineChatWidget.updateToolbar(false); + this._inlineChatWidget.reset(); + this._focusedContextKey.set(false); + this._visibleContextKey.set(false); + this._inlineChatWidget.value = ''; + this._instance.focus(); + } + focus(): void { + this._inlineChatWidget.focus(); + } + hasFocus(): boolean { + return this._inlineChatWidget.hasFocus(); + } + input(): string { + return this._inlineChatWidget.value; + } + addToHistory(input: string): void { + this._inlineChatWidget.addToHistory(input); + this._inlineChatWidget.saveState(); + } + setValue(value?: string) { + this._inlineChatWidget.value = value ?? ''; + } + acceptCommand(code: string, shouldExecute: boolean): void { + this._instance.runCommand(code, shouldExecute); + this.hide(); + } + + updateProgress(progress?: IChatProgress): void { + this._inlineChatWidget.updateProgress(progress?.kind === 'content' || progress?.kind === 'markdownContent'); + } + public get focusTracker(): IFocusTracker { + return this._focusTracker; + } +} diff --git a/src/vs/workbench/contrib/terminalContrib/developer/browser/terminal.developer.contribution.ts b/src/vs/workbench/contrib/terminalContrib/developer/browser/terminal.developer.contribution.ts index e7af8e061a737..41845df189a41 100644 --- a/src/vs/workbench/contrib/terminalContrib/developer/browser/terminal.developer.contribution.ts +++ b/src/vs/workbench/contrib/terminalContrib/developer/browser/terminal.developer.contribution.ts @@ -16,7 +16,7 @@ import { IOpenerService } from 'vs/platform/opener/common/opener'; import { IQuickInputService } from 'vs/platform/quickinput/common/quickInput'; import { ITerminalLogService, TerminalSettingId } from 'vs/platform/terminal/common/terminal'; import { IWorkspaceContextService } from 'vs/platform/workspace/common/workspace'; -import { IInternalXtermTerminal, ITerminalContribution, ITerminalInstance, ITerminalService, IXtermTerminal } from 'vs/workbench/contrib/terminal/browser/terminal'; +import { IInternalXtermTerminal, ITerminalConfigurationService, ITerminalContribution, ITerminalInstance, IXtermTerminal } from 'vs/workbench/contrib/terminal/browser/terminal'; import { registerTerminalAction } from 'vs/workbench/contrib/terminal/browser/terminalActions'; import { registerTerminalContribution } from 'vs/workbench/contrib/terminal/browser/terminalExtensions'; import { TerminalWidgetManager } from 'vs/workbench/contrib/terminal/browser/widgets/widgetManager'; @@ -118,7 +118,7 @@ class DevModeContribution extends Disposable implements ITerminalContribution { } private _xterm: IXtermTerminal & { raw: Terminal } | undefined; - private _activeDevModeDisposables = new MutableDisposable(); + private readonly _activeDevModeDisposables = new MutableDisposable(); private _currentColor = 0; constructor( @@ -126,7 +126,7 @@ class DevModeContribution extends Disposable implements ITerminalContribution { processManager: ITerminalProcessManager, widgetManager: TerminalWidgetManager, @IConfigurationService private readonly _configurationService: IConfigurationService, - @ITerminalService private readonly _terminalService: ITerminalService + @ITerminalConfigurationService private readonly _terminalConfigurationService: ITerminalConfigurationService, ) { super(); this._register(this._configurationService.onDidChangeConfiguration(e => { @@ -147,7 +147,7 @@ class DevModeContribution extends Disposable implements ITerminalContribution { // Text area syncing if (this._xterm?.raw.textarea) { - const font = this._terminalService.configHelper.getFont(getWindow(this._xterm.raw.textarea)); + const font = this._terminalConfigurationService.getFont(getWindow(this._xterm.raw.textarea)); this._xterm.raw.textarea.style.fontFamily = font.fontFamily; this._xterm.raw.textarea.style.fontSize = `${font.fontSize}px`; } diff --git a/src/vs/workbench/contrib/terminalContrib/find/browser/terminalFindWidget.ts b/src/vs/workbench/contrib/terminalContrib/find/browser/terminalFindWidget.ts index 076e3fab52b98..eee6410fd9b01 100644 --- a/src/vs/workbench/contrib/terminalContrib/find/browser/terminalFindWidget.ts +++ b/src/vs/workbench/contrib/terminalContrib/find/browser/terminalFindWidget.ts @@ -18,6 +18,7 @@ import { TerminalCommandId } from 'vs/workbench/contrib/terminal/common/terminal import { IClipboardService } from 'vs/platform/clipboard/common/clipboardService'; import { openContextMenu } from 'vs/workbench/contrib/terminalContrib/find/browser/textInputContextMenu'; import { IDisposable } from 'vs/base/common/lifecycle'; +import { IHoverService } from 'vs/platform/hover/browser/hover'; const TERMINAL_FIND_WIDGET_INITIAL_WIDTH = 419; @@ -35,6 +36,7 @@ export class TerminalFindWidget extends SimpleFindWidget { @IContextKeyService private readonly _contextKeyService: IContextKeyService, @IContextMenuService _contextMenuService: IContextMenuService, @IClipboardService _clipboardService: IClipboardService, + @IHoverService hoverService: IHoverService, @IThemeService private readonly _themeService: IThemeService, @IConfigurationService private readonly _configurationService: IConfigurationService ) { @@ -52,7 +54,7 @@ export class TerminalFindWidget extends SimpleFindWidget { closeWidgetActionId: TerminalCommandId.FindHide, type: 'Terminal', matchesLimit: XtermTerminalConstants.SearchHighlightLimit - }, _contextViewService, _contextKeyService, keybindingService); + }, _contextViewService, _contextKeyService, hoverService, keybindingService); this._register(this.state.onFindReplaceStateChange(() => { this.show(); diff --git a/src/vs/workbench/contrib/terminalContrib/links/browser/links.ts b/src/vs/workbench/contrib/terminalContrib/links/browser/links.ts index b680864e23d5a..9ddd423a1d098 100644 --- a/src/vs/workbench/contrib/terminalContrib/links/browser/links.ts +++ b/src/vs/workbench/contrib/terminalContrib/links/browser/links.ts @@ -5,7 +5,6 @@ import type { IBufferLine, IBufferRange, Terminal } from '@xterm/xterm'; import { URI } from 'vs/base/common/uri'; -import { IHoverAction } from 'vs/platform/hover/browser/hover'; import { createDecorator } from 'vs/platform/instantiation/common/instantiation'; import { ITerminalProcessManager } from 'vs/workbench/contrib/terminal/common/terminal'; import { IParsedLink } from 'vs/workbench/contrib/terminalContrib/links/browser/terminalLinkParsing'; @@ -14,6 +13,7 @@ import { ITerminalExternalLinkProvider } from 'vs/workbench/contrib/terminal/bro import { Event } from 'vs/base/common/event'; import { ITerminalBackend } from 'vs/platform/terminal/common/terminal'; import { ITextEditorSelection } from 'vs/platform/editor/common/editor'; +import type { IHoverAction } from 'vs/base/browser/ui/hover/hover'; export const ITerminalLinkProviderService = createDecorator('terminalLinkProviderService'); export interface ITerminalLinkProviderService { @@ -83,6 +83,11 @@ export interface ITerminalSimpleLink { */ uri?: URI; + /** + * An optional full line to be used for context when resolving. + */ + contextLine?: string; + /** * The location or selection range of the link. */ diff --git a/src/vs/workbench/contrib/terminalContrib/links/browser/terminal.links.contribution.ts b/src/vs/workbench/contrib/terminalContrib/links/browser/terminal.links.contribution.ts index 0327aa4816884..dbdaf9200a000 100644 --- a/src/vs/workbench/contrib/terminalContrib/links/browser/terminal.links.contribution.ts +++ b/src/vs/workbench/contrib/terminalContrib/links/browser/terminal.links.contribution.ts @@ -83,7 +83,7 @@ class TerminalLinkContribution extends DisposableStore implements ITerminalContr }); } const links = await this._getLinks(); - return await this._terminalLinkQuickpick.show(links); + return await this._terminalLinkQuickpick.show(this._instance, links); } private async _getLinks(): Promise<{ viewport: IDetectedLinks; all: Promise }> { @@ -126,6 +126,9 @@ registerActiveInstanceAction({ registerActiveInstanceAction({ id: TerminalCommandId.OpenWebLink, title: localize2('workbench.action.terminal.openLastUrlLink', 'Open Last URL Link'), + metadata: { + description: localize2('workbench.action.terminal.openLastUrlLink.description', 'Opens the last detected URL/URI link in the terminal') + }, f1: true, category, precondition: TerminalContextKeys.terminalHasBeenCreated, diff --git a/src/vs/workbench/contrib/terminalContrib/links/browser/terminalLink.ts b/src/vs/workbench/contrib/terminalContrib/links/browser/terminalLink.ts index 4654d703f1d9f..338b09dcc07fd 100644 --- a/src/vs/workbench/contrib/terminalContrib/links/browser/terminalLink.ts +++ b/src/vs/workbench/contrib/terminalContrib/links/browser/terminalLink.ts @@ -12,7 +12,9 @@ import { isMacintosh } from 'vs/base/common/platform'; import { Emitter, Event } from 'vs/base/common/event'; import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; import { TerminalLinkType } from 'vs/workbench/contrib/terminalContrib/links/browser/links'; -import { IHoverAction } from 'vs/platform/hover/browser/hover'; +import type { URI } from 'vs/base/common/uri'; +import type { IParsedLink } from 'vs/workbench/contrib/terminalContrib/links/browser/terminalLinkParsing'; +import type { IHoverAction } from 'vs/base/browser/ui/hover/hover'; export class TerminalLink extends DisposableStore implements ILink { decorations: ILinkDecorations; @@ -30,6 +32,8 @@ export class TerminalLink extends DisposableStore implements ILink { private readonly _xterm: Terminal, readonly range: IBufferRange, readonly text: string, + readonly uri: URI | undefined, + readonly parsedLink: IParsedLink | undefined, readonly actions: IHoverAction[] | undefined, private readonly _viewportY: number, private readonly _activateCallback: (event: MouseEvent | undefined, uri: string) => Promise, diff --git a/src/vs/workbench/contrib/terminalContrib/links/browser/terminalLinkDetectorAdapter.ts b/src/vs/workbench/contrib/terminalContrib/links/browser/terminalLinkDetectorAdapter.ts index 8226841a9a027..26c047ea42df3 100644 --- a/src/vs/workbench/contrib/terminalContrib/links/browser/terminalLinkDetectorAdapter.ts +++ b/src/vs/workbench/contrib/terminalContrib/links/browser/terminalLinkDetectorAdapter.ts @@ -92,9 +92,7 @@ export class TerminalLinkDetectorAdapter extends Disposable implements ILinkProv const detectedLinks = await this._detector.detect(lines, startLine, endLine); for (const link of detectedLinks) { - links.push(this._createTerminalLink(link, async (event) => { - this._onDidActivateLink.fire({ link, event }); - })); + links.push(this._createTerminalLink(link, async (event) => this._onDidActivateLink.fire({ link, event }))); } return links; @@ -110,6 +108,8 @@ export class TerminalLinkDetectorAdapter extends Disposable implements ILinkProv this._detector.xterm, l.bufferRange, l.text, + l.uri, + l.parsedLink, l.actions, this._detector.xterm.buffer.active.viewportY, activateCallback, diff --git a/src/vs/workbench/contrib/terminalContrib/links/browser/terminalLinkManager.ts b/src/vs/workbench/contrib/terminalContrib/links/browser/terminalLinkManager.ts index 8d981fb62d27b..4cd78b913f469 100644 --- a/src/vs/workbench/contrib/terminalContrib/links/browser/terminalLinkManager.ts +++ b/src/vs/workbench/contrib/terminalContrib/links/browser/terminalLinkManager.ts @@ -20,18 +20,19 @@ import { TerminalLocalFileLinkOpener, TerminalLocalFolderInWorkspaceLinkOpener, import { TerminalLocalLinkDetector } from 'vs/workbench/contrib/terminalContrib/links/browser/terminalLocalLinkDetector'; import { TerminalUriLinkDetector } from 'vs/workbench/contrib/terminalContrib/links/browser/terminalUriLinkDetector'; import { TerminalWordLinkDetector } from 'vs/workbench/contrib/terminalContrib/links/browser/terminalWordLinkDetector'; -import { ITerminalExternalLinkProvider, TerminalLinkQuickPickEvent } from 'vs/workbench/contrib/terminal/browser/terminal'; +import { ITerminalConfigurationService, ITerminalExternalLinkProvider, TerminalLinkQuickPickEvent } from 'vs/workbench/contrib/terminal/browser/terminal'; import { ILinkHoverTargetOptions, TerminalHover } from 'vs/workbench/contrib/terminal/browser/widgets/terminalHoverWidget'; import { TerminalWidgetManager } from 'vs/workbench/contrib/terminal/browser/widgets/widgetManager'; import { IXtermCore } from 'vs/workbench/contrib/terminal/browser/xterm-private'; import { ITerminalCapabilityStore } from 'vs/platform/terminal/common/capabilities/capabilities'; import { ITerminalConfiguration, ITerminalProcessInfo, TERMINAL_CONFIG_SECTION } from 'vs/workbench/contrib/terminal/common/terminal'; -import { IHoverAction } from 'vs/platform/hover/browser/hover'; import type { ILink, ILinkProvider, IViewportRange, Terminal } from '@xterm/xterm'; import { convertBufferRangeToViewport } from 'vs/workbench/contrib/terminalContrib/links/browser/terminalLinkHelpers'; import { RunOnceScheduler } from 'vs/base/common/async'; import { ITerminalLogService } from 'vs/platform/terminal/common/terminal'; import { TerminalMultiLineLinkDetector } from 'vs/workbench/contrib/terminalContrib/links/browser/terminalMultiLineLinkDetector'; +import { INotificationService, Severity } from 'vs/platform/notification/common/notification'; +import type { IHoverAction } from 'vs/base/browser/ui/hover/hover'; export type XtermLinkMatcherHandler = (event: MouseEvent | undefined, link: string) => Promise; @@ -53,7 +54,9 @@ export class TerminalLinkManager extends DisposableStore { capabilities: ITerminalCapabilityStore, private readonly _linkResolver: ITerminalLinkResolver, @IConfigurationService private readonly _configurationService: IConfigurationService, + @ITerminalConfigurationService private readonly _terminalConfigurationService: ITerminalConfigurationService, @IInstantiationService private readonly _instantiationService: IInstantiationService, + @INotificationService private readonly _notificationService: INotificationService, @ITerminalLogService private readonly _logService: ITerminalLogService, @ITunnelService private readonly _tunnelService: ITunnelService ) { @@ -99,7 +102,30 @@ export class TerminalLinkManager extends DisposableStore { activeTooltipScheduler?.dispose(); })); this._xterm.options.linkHandler = { - activate: (_, text) => { + allowNonHttpProtocols: true, + activate: (event, text) => { + if (!this._isLinkActivationModifierDown(event)) { + return; + } + const colonIndex = text.indexOf(':'); + if (colonIndex === -1) { + throw new Error(`Could not find scheme in link "${text}"`); + } + const scheme = text.substring(0, colonIndex); + if (this._terminalConfigurationService.config.allowedLinkSchemes.indexOf(scheme) === -1) { + this._notificationService.prompt(Severity.Warning, nls.localize('scheme', 'Opening URIs can be insecure, do you want to allow opening links with the scheme {0}?', scheme), [ + { + label: nls.localize('allow', 'Allow {0}', scheme), + run: () => { + const allowedLinkSchemes = [ + ...this._terminalConfigurationService.config.allowedLinkSchemes, + scheme + ]; + this._configurationService.updateValue(`terminal.integrated.allowedLinkSchemes`, allowedLinkSchemes); + } + } + ]); + } this._openers.get(TerminalBuiltinLinkType.Url)?.open({ type: TerminalBuiltinLinkType.Url, text, @@ -441,6 +467,6 @@ export interface ILineColumnInfo { export interface IDetectedLinks { wordLinks?: ILink[]; webLinks?: ILink[]; - fileLinks?: ILink[]; + fileLinks?: (ILink | TerminalLink)[]; folderLinks?: ILink[]; } diff --git a/src/vs/workbench/contrib/terminalContrib/links/browser/terminalLinkOpeners.ts b/src/vs/workbench/contrib/terminalContrib/links/browser/terminalLinkOpeners.ts index 6b5af7070127c..d4247bbd835f2 100644 --- a/src/vs/workbench/contrib/terminalContrib/links/browser/terminalLinkOpeners.ts +++ b/src/vs/workbench/contrib/terminalContrib/links/browser/terminalLinkOpeners.ts @@ -22,7 +22,7 @@ import { IHostService } from 'vs/workbench/services/host/browser/host'; import { QueryBuilder } from 'vs/workbench/services/search/common/queryBuilder'; import { ISearchService } from 'vs/workbench/services/search/common/search'; import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; -import { getLinkSuffix } from 'vs/workbench/contrib/terminalContrib/links/browser/terminalLinkParsing'; +import { detectLinks, getLinkSuffix } from 'vs/workbench/contrib/terminalContrib/links/browser/terminalLinkParsing'; import { ITerminalLogService } from 'vs/platform/terminal/common/terminal'; export class TerminalLocalFileLinkOpener implements ITerminalLinkOpener { @@ -98,10 +98,27 @@ export class TerminalSearchLinkOpener implements ITerminalLinkOpener { async open(link: ITerminalSimpleLink): Promise { const osPath = osPathModule(this._getOS()); const pathSeparator = osPath.sep; + // Remove file:/// and any leading ./ or ../ since quick access doesn't understand that format let text = link.text.replace(/^file:\/\/\/?/, ''); text = osPath.normalize(text).replace(/^(\.+[\\/])+/, ''); + // Try extract any trailing line and column numbers by matching the text against parsed + // links. This will give a search link `foo` on a line like `"foo", line 10` to open the + // quick pick with `foo:10` as the contents. + if (link.contextLine) { + const parsedLinks = detectLinks(link.contextLine, this._getOS()); + const matchingParsedLink = parsedLinks.find(parsedLink => parsedLink.suffix && link.text === parsedLink.path.text); + if (matchingParsedLink) { + if (matchingParsedLink.suffix?.row !== undefined) { + text += `:${matchingParsedLink.suffix.row}`; + if (matchingParsedLink.suffix?.col !== undefined) { + text += `:${matchingParsedLink.suffix.col}`; + } + } + } + } + // Remove `:` from the end of the link. // Examples: // - Ruby stack traces: :in ... diff --git a/src/vs/workbench/contrib/terminalContrib/links/browser/terminalLinkParsing.ts b/src/vs/workbench/contrib/terminalContrib/links/browser/terminalLinkParsing.ts index 8328315b9578b..94dbbd2f5cd62 100644 --- a/src/vs/workbench/contrib/terminalContrib/links/browser/terminalLinkParsing.ts +++ b/src/vs/workbench/contrib/terminalContrib/links/browser/terminalLinkParsing.ts @@ -277,6 +277,11 @@ function detectLinksViaSuffix(line: string): IParsedLink[] { }; path = path.substring(prefix.text.length); + // Don't allow suffix links to be returned when the link itself is the empty string + if (path.trim().length === 0) { + continue; + } + // If there are multiple characters in the prefix, trim the prefix if the _first_ // suffix character is the same as the last prefix character. For example, for the // text `echo "'foo' on line 1"`: diff --git a/src/vs/workbench/contrib/terminalContrib/links/browser/terminalLinkQuickpick.ts b/src/vs/workbench/contrib/terminalContrib/links/browser/terminalLinkQuickpick.ts index e954be537df57..80a6e670c4a09 100644 --- a/src/vs/workbench/contrib/terminalContrib/links/browser/terminalLinkQuickpick.ts +++ b/src/vs/workbench/contrib/terminalContrib/links/browser/terminalLinkQuickpick.ts @@ -6,32 +6,56 @@ import { EventType } from 'vs/base/browser/dom'; import { Emitter, Event } from 'vs/base/common/event'; import { localize } from 'vs/nls'; -import { QuickPickItem, IQuickInputService, IQuickPickItem } from 'vs/platform/quickinput/common/quickInput'; +import { QuickPickItem, IQuickInputService, IQuickPickItem, QuickInputHideReason } from 'vs/platform/quickinput/common/quickInput'; import { IDetectedLinks } from 'vs/workbench/contrib/terminalContrib/links/browser/terminalLinkManager'; -import { TerminalLinkQuickPickEvent } from 'vs/workbench/contrib/terminal/browser/terminal'; +import { TerminalLinkQuickPickEvent, type IDetachedTerminalInstance, type ITerminalInstance } from 'vs/workbench/contrib/terminal/browser/terminal'; import type { ILink } from '@xterm/xterm'; import { DisposableStore } from 'vs/base/common/lifecycle'; import { IAccessibleViewService } from 'vs/workbench/contrib/accessibility/browser/accessibleView'; import { AccessibleViewProviderId } from 'vs/workbench/contrib/accessibility/browser/accessibilityConfiguration'; +import type { TerminalLink } from 'vs/workbench/contrib/terminalContrib/links/browser/terminalLink'; +import { Sequencer, timeout } from 'vs/base/common/async'; +import { PickerEditorState } from 'vs/workbench/browser/quickaccess'; +import { getLinkSuffix } from 'vs/workbench/contrib/terminalContrib/links/browser/terminalLinkParsing'; +import { TerminalBuiltinLinkType } from 'vs/workbench/contrib/terminalContrib/links/browser/links'; +import { ILabelService } from 'vs/platform/label/common/label'; +import { basenameOrAuthority, dirname } from 'vs/base/common/resources'; +import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; export class TerminalLinkQuickpick extends DisposableStore { + private readonly _editorSequencer = new Sequencer(); + private readonly _editorViewState: PickerEditorState; + + private _instance: ITerminalInstance | IDetachedTerminalInstance | undefined; + private readonly _onDidRequestMoreLinks = this.add(new Emitter()); readonly onDidRequestMoreLinks = this._onDidRequestMoreLinks.event; constructor( + @ILabelService private readonly _labelService: ILabelService, @IQuickInputService private readonly _quickInputService: IQuickInputService, - @IAccessibleViewService private readonly _accessibleViewService: IAccessibleViewService + @IAccessibleViewService private readonly _accessibleViewService: IAccessibleViewService, + @IInstantiationService instantiationService: IInstantiationService ) { super(); + this._editorViewState = this.add(instantiationService.createInstance(PickerEditorState)); } - async show(links: { viewport: IDetectedLinks; all: Promise }): Promise { + async show(instance: ITerminalInstance | IDetachedTerminalInstance, links: { viewport: IDetectedLinks; all: Promise }): Promise { + this._instance = instance; + + // Allow all links a small amount of time to elapse to finish, if this is not done in this + // time they will be loaded upon the first filter. + const result = await Promise.race([links.all, timeout(500)]); + const usingAllLinks = typeof result === 'object'; + const resolvedLinks = usingAllLinks ? result : links.viewport; + // Get raw link picks - const wordPicks = links.viewport.wordLinks ? await this._generatePicks(links.viewport.wordLinks) : undefined; - const filePicks = links.viewport.fileLinks ? await this._generatePicks(links.viewport.fileLinks) : undefined; - const folderPicks = links.viewport.folderLinks ? await this._generatePicks(links.viewport.folderLinks) : undefined; - const webPicks = links.viewport.webLinks ? await this._generatePicks(links.viewport.webLinks) : undefined; + const wordPicks = resolvedLinks.wordLinks ? await this._generatePicks(resolvedLinks.wordLinks) : undefined; + const filePicks = resolvedLinks.fileLinks ? await this._generatePicks(resolvedLinks.fileLinks) : undefined; + const folderPicks = resolvedLinks.folderLinks ? await this._generatePicks(resolvedLinks.folderLinks) : undefined; + const webPicks = resolvedLinks.webLinks ? await this._generatePicks(resolvedLinks.webLinks) : undefined; const picks: LinkQuickPickItem[] = []; if (webPicks) { @@ -57,44 +81,72 @@ export class TerminalLinkQuickpick extends DisposableStore { pick.placeholder = localize('terminal.integrated.openDetectedLink', "Select the link to open, type to filter all links"); pick.sortByLabel = false; pick.show(); + if (pick.activeItems.length > 0) { + this._previewItem(pick.activeItems[0]); + } // Show all results only when filtering begins, this is done so the quick pick will show up // ASAP with only the viewport entries. let accepted = false; const disposables = new DisposableStore(); - disposables.add(Event.once(pick.onDidChangeValue)(async () => { - const allLinks = await links.all; - if (accepted) { - return; - } - const wordIgnoreLinks = [...(allLinks.fileLinks ?? []), ...(allLinks.folderLinks ?? []), ...(allLinks.webLinks ?? [])]; - - const wordPicks = allLinks.wordLinks ? await this._generatePicks(allLinks.wordLinks, wordIgnoreLinks) : undefined; - const filePicks = allLinks.fileLinks ? await this._generatePicks(allLinks.fileLinks) : undefined; - const folderPicks = allLinks.folderLinks ? await this._generatePicks(allLinks.folderLinks) : undefined; - const webPicks = allLinks.webLinks ? await this._generatePicks(allLinks.webLinks) : undefined; - const picks: LinkQuickPickItem[] = []; - if (webPicks) { - picks.push({ type: 'separator', label: localize('terminal.integrated.urlLinks', "Url") }); - picks.push(...webPicks); - } - if (filePicks) { - picks.push({ type: 'separator', label: localize('terminal.integrated.localFileLinks', "File") }); - picks.push(...filePicks); - } - if (folderPicks) { - picks.push({ type: 'separator', label: localize('terminal.integrated.localFolderLinks', "Folder") }); - picks.push(...folderPicks); - } - if (wordPicks) { - picks.push({ type: 'separator', label: localize('terminal.integrated.searchLinks', "Workspace Search") }); - picks.push(...wordPicks); - } - pick.items = picks; + if (!usingAllLinks) { + disposables.add(Event.once(pick.onDidChangeValue)(async () => { + const allLinks = await links.all; + if (accepted) { + return; + } + const wordIgnoreLinks = [...(allLinks.fileLinks ?? []), ...(allLinks.folderLinks ?? []), ...(allLinks.webLinks ?? [])]; + + const wordPicks = allLinks.wordLinks ? await this._generatePicks(allLinks.wordLinks, wordIgnoreLinks) : undefined; + const filePicks = allLinks.fileLinks ? await this._generatePicks(allLinks.fileLinks) : undefined; + const folderPicks = allLinks.folderLinks ? await this._generatePicks(allLinks.folderLinks) : undefined; + const webPicks = allLinks.webLinks ? await this._generatePicks(allLinks.webLinks) : undefined; + const picks: LinkQuickPickItem[] = []; + if (webPicks) { + picks.push({ type: 'separator', label: localize('terminal.integrated.urlLinks', "Url") }); + picks.push(...webPicks); + } + if (filePicks) { + picks.push({ type: 'separator', label: localize('terminal.integrated.localFileLinks', "File") }); + picks.push(...filePicks); + } + if (folderPicks) { + picks.push({ type: 'separator', label: localize('terminal.integrated.localFolderLinks', "Folder") }); + picks.push(...folderPicks); + } + if (wordPicks) { + picks.push({ type: 'separator', label: localize('terminal.integrated.searchLinks', "Workspace Search") }); + picks.push(...wordPicks); + } + pick.items = picks; + })); + } + + disposables.add(pick.onDidChangeActive(async () => { + const [item] = pick.activeItems; + this._previewItem(item); })); return new Promise(r => { - disposables.add(pick.onDidHide(() => { + disposables.add(pick.onDidHide(({ reason }) => { + + // Restore terminal scroll state + if (this._terminalScrollStateSaved) { + const markTracker = this._instance?.xterm?.markTracker; + if (markTracker) { + markTracker.restoreScrollState(); + markTracker.clear(); + this._terminalScrollStateSaved = false; + } + } + + // Restore view state upon cancellation if we changed it + // but only when the picker was closed via explicit user + // gesture and not e.g. when focus was lost because that + // could mean the user clicked into the editor directly. + if (reason === QuickInputHideReason.Gesture) { + this._editorViewState.restore(); + } disposables.dispose(); if (pick.selectedItems.length === 0) { this._accessibleViewService.showLastProvider(AccessibleViewProviderId.Terminal); @@ -102,6 +154,16 @@ export class TerminalLinkQuickpick extends DisposableStore { r(); })); disposables.add(Event.once(pick.onDidAccept)(() => { + // Restore terminal scroll state + if (this._terminalScrollStateSaved) { + const markTracker = this._instance?.xterm?.markTracker; + if (markTracker) { + markTracker.restoreScrollState(); + markTracker.clear(); + this._terminalScrollStateSaved = false; + } + } + accepted = true; const event = new TerminalLinkQuickPickEvent(EventType.CLICK); const activeItem = pick.activeItems?.[0]; @@ -117,25 +179,114 @@ export class TerminalLinkQuickpick extends DisposableStore { /** * @param ignoreLinks Links with labels to not include in the picks. */ - private async _generatePicks(links: ILink[], ignoreLinks?: ILink[]): Promise { + private async _generatePicks(links: (ILink | TerminalLink)[], ignoreLinks?: ILink[]): Promise { if (!links) { return; } - const linkKeys: Set = new Set(); + const linkTextKeys: Set = new Set(); + const linkUriKeys: Set = new Set(); const picks: ITerminalLinkQuickPickItem[] = []; for (const link of links) { - const label = link.text; - if (!linkKeys.has(label) && (!ignoreLinks || !ignoreLinks.some(e => e.text === label))) { - linkKeys.add(label); - picks.push({ label, link }); + let label = link.text; + if (!linkTextKeys.has(label) && (!ignoreLinks || !ignoreLinks.some(e => e.text === label))) { + linkTextKeys.add(label); + + // Add a consistently formatted resolved URI label to the description if applicable + let description: string | undefined; + if ('uri' in link && link.uri) { + // For local files and folders, mimic the presentation of go to file + if ( + link.type === TerminalBuiltinLinkType.LocalFile || + link.type === TerminalBuiltinLinkType.LocalFolderInWorkspace || + link.type === TerminalBuiltinLinkType.LocalFolderOutsideWorkspace + ) { + label = basenameOrAuthority(link.uri); + description = this._labelService.getUriLabel(dirname(link.uri), { relative: true }); + } + + // Add line and column numbers to the label if applicable + if (link.type === TerminalBuiltinLinkType.LocalFile) { + if (link.parsedLink?.suffix?.row !== undefined) { + label += `:${link.parsedLink.suffix.row}`; + if (link.parsedLink?.suffix?.rowEnd !== undefined) { + label += `-${link.parsedLink.suffix.rowEnd}`; + } + if (link.parsedLink?.suffix?.col !== undefined) { + label += `:${link.parsedLink.suffix.col}`; + if (link.parsedLink?.suffix?.colEnd !== undefined) { + label += `-${link.parsedLink.suffix.colEnd}`; + } + } + } + } + + // Skip the link if it's a duplicate URI + line/col + if (linkUriKeys.has(label + '|' + (description ?? ''))) { + continue; + } + linkUriKeys.add(label + '|' + (description ?? '')); + } + + picks.push({ label, link, description }); } } return picks.length > 0 ? picks : undefined; } + + private _previewItem(item: ITerminalLinkQuickPickItem | IQuickPickItem) { + if (!item || !('link' in item) || !item.link) { + return; + } + + // Any link can be previewed in the termninal + const link = item.link; + this._previewItemInTerminal(link); + + if (!('uri' in link) || !link.uri) { + return; + } + + if (link.type !== TerminalBuiltinLinkType.LocalFile) { + return; + } + + this._previewItemInEditor(link); + } + + private _previewItemInEditor(link: TerminalLink) { + const linkSuffix = link.parsedLink ? link.parsedLink.suffix : getLinkSuffix(link.text); + const selection = linkSuffix?.row === undefined ? undefined : { + startLineNumber: linkSuffix.row ?? 1, + startColumn: linkSuffix.col ?? 1, + endLineNumber: linkSuffix.rowEnd, + endColumn: linkSuffix.colEnd + }; + + this._editorViewState.set(); + this._editorSequencer.queue(async () => { + await this._editorViewState.openTransientEditor({ + resource: link.uri, + options: { preserveFocus: true, revealIfOpened: true, ignoreError: true, selection, } + }); + }); + } + + private _terminalScrollStateSaved: boolean = false; + private _previewItemInTerminal(link: ILink) { + const xterm = this._instance?.xterm; + if (!xterm) { + return; + } + if (!this._terminalScrollStateSaved) { + xterm.markTracker.saveScrollState(); + this._terminalScrollStateSaved = true; + } + xterm.markTracker.revealRange(link.range); + } } export interface ITerminalLinkQuickPickItem extends IQuickPickItem { - link: ILink; + link: ILink | TerminalLink; } type LinkQuickPickItem = ITerminalLinkQuickPickItem | QuickPickItem; diff --git a/src/vs/workbench/contrib/terminalContrib/links/browser/terminalLinkResolver.ts b/src/vs/workbench/contrib/terminalContrib/links/browser/terminalLinkResolver.ts index 6090c628f8acc..c3315837a51c6 100644 --- a/src/vs/workbench/contrib/terminalContrib/links/browser/terminalLinkResolver.ts +++ b/src/vs/workbench/contrib/terminalContrib/links/browser/terminalLinkResolver.ts @@ -25,6 +25,14 @@ export class TerminalLinkResolver implements ITerminalLinkResolver { } async resolveLink(processManager: Pick & { backend?: Pick }, link: string, uri?: URI): Promise { + // Correct scheme and authority for remote terminals + if (uri && uri.scheme === Schemas.file && processManager.remoteAuthority) { + uri = uri.with({ + scheme: Schemas.vscodeRemote, + authority: processManager.remoteAuthority + }); + } + // Get the link cache let cache = this._resolvedLinkCaches.get(processManager.remoteAuthority ?? ''); if (!cache) { diff --git a/src/vs/workbench/contrib/terminalContrib/links/browser/terminalLocalLinkDetector.ts b/src/vs/workbench/contrib/terminalContrib/links/browser/terminalLocalLinkDetector.ts index 259df08a2497d..adee3ee9f1f33 100644 --- a/src/vs/workbench/contrib/terminalContrib/links/browser/terminalLocalLinkDetector.ts +++ b/src/vs/workbench/contrib/terminalContrib/links/browser/terminalLocalLinkDetector.ts @@ -37,6 +37,8 @@ const enum Constants { const fallbackMatchers: RegExp[] = [ // Python style error: File "", line /^ *File (?"(?.+)"(, line (?\d+))?)/, + // Unknown tool #200166: FILE :: + /^ +FILE +(?(?.+)(?::(?\d+)(?::(?\d+))?)?)/, // Some C++ compile error formats: // C:\foo\bar baz(339) : error ... // C:\foo\bar baz(339,12) : error ... diff --git a/src/vs/workbench/contrib/terminalContrib/links/browser/terminalWordLinkDetector.ts b/src/vs/workbench/contrib/terminalContrib/links/browser/terminalWordLinkDetector.ts index 1675d0fac7354..02a9b2cc39fe6 100644 --- a/src/vs/workbench/contrib/terminalContrib/links/browser/terminalWordLinkDetector.ts +++ b/src/vs/workbench/contrib/terminalContrib/links/browser/terminalWordLinkDetector.ts @@ -103,7 +103,8 @@ export class TerminalWordLinkDetector extends Disposable implements ITerminalLin links.push({ text: word.text, bufferRange, - type: TerminalBuiltinLinkType.Search + type: TerminalBuiltinLinkType.Search, + contextLine: text }); } diff --git a/src/vs/workbench/contrib/terminalContrib/links/test/browser/terminalLinkParsing.test.ts b/src/vs/workbench/contrib/terminalContrib/links/test/browser/terminalLinkParsing.test.ts index 71d641d0739fd..04e4aeb5e9bca 100644 --- a/src/vs/workbench/contrib/terminalContrib/links/test/browser/terminalLinkParsing.test.ts +++ b/src/vs/workbench/contrib/terminalContrib/links/test/browser/terminalLinkParsing.test.ts @@ -706,5 +706,11 @@ suite('TerminalLinkParsing', () => { }); } }); + suite('should ignore links with suffixes when the path itself is the empty string', () => { + deepStrictEqual( + detectLinks('""",1', OperatingSystem.Linux), + [] as IParsedLink[] + ); + }); }); }); diff --git a/src/vs/workbench/contrib/terminalContrib/links/test/browser/terminalLocalLinkDetector.test.ts b/src/vs/workbench/contrib/terminalContrib/links/test/browser/terminalLocalLinkDetector.test.ts index faae5d685eeb7..785ad9658abc7 100644 --- a/src/vs/workbench/contrib/terminalContrib/links/test/browser/terminalLocalLinkDetector.test.ts +++ b/src/vs/workbench/contrib/terminalContrib/links/test/browser/terminalLocalLinkDetector.test.ts @@ -138,6 +138,10 @@ const supportedFallbackLinkFormats: LinkFormatInfo[] = [ // Python style error: File "", line { urlFormat: 'File "{0}"', linkCellStartOffset: 5 }, { urlFormat: 'File "{0}", line {1}', line: '5', linkCellStartOffset: 5 }, + // Unknown tool #200166: FILE :: + { urlFormat: ' FILE {0}', linkCellStartOffset: 7 }, + { urlFormat: ' FILE {0}:{1}', line: '5', linkCellStartOffset: 7 }, + { urlFormat: ' FILE {0}:{1}:{2}', line: '5', column: '3', linkCellStartOffset: 7 }, // Some C++ compile error formats { urlFormat: '{0}({1}) :', line: '5', linkCellEndOffset: -2 }, { urlFormat: '{0}({1},{2}) :', line: '5', column: '3', linkCellEndOffset: -2 }, diff --git a/src/vs/workbench/contrib/terminalContrib/quickFix/browser/quickFixAddon.ts b/src/vs/workbench/contrib/terminalContrib/quickFix/browser/quickFixAddon.ts index ea488b6fe0e9f..21c627770546a 100644 --- a/src/vs/workbench/contrib/terminalContrib/quickFix/browser/quickFixAddon.ts +++ b/src/vs/workbench/contrib/terminalContrib/quickFix/browser/quickFixAddon.ts @@ -20,7 +20,7 @@ import type { IDecoration, Terminal } from '@xterm/xterm'; import { ITelemetryService } from 'vs/platform/telemetry/common/telemetry'; import { CancellationTokenSource } from 'vs/base/common/cancellation'; import { IExtensionService } from 'vs/workbench/services/extensions/common/extensions'; -import { AudioCue, IAudioCueService } from 'vs/platform/audioCues/browser/audioCueService'; +import { AccessibilitySignal, IAccessibilitySignalService } from 'vs/platform/accessibilitySignal/browser/accessibilitySignalService'; import { IActionWidgetService } from 'vs/platform/actionWidget/browser/actionWidget'; import { ActionSet } from 'vs/platform/actionWidget/common/actionWidget'; import { getLinesForCommand } from 'vs/platform/terminal/common/capabilities/commandDetectionCapability'; @@ -77,7 +77,7 @@ export class TerminalQuickFixAddon extends Disposable implements ITerminalAddon, @ITerminalQuickFixService private readonly _quickFixService: ITerminalQuickFixService, @ICommandService private readonly _commandService: ICommandService, @IConfigurationService private readonly _configurationService: IConfigurationService, - @IAudioCueService private readonly _audioCueService: IAudioCueService, + @IAccessibilitySignalService private readonly _accessibilitySignalService: IAccessibilitySignalService, @IOpenerService private readonly _openerService: IOpenerService, @ITelemetryService private readonly _telemetryService: ITelemetryService, @IExtensionService private readonly _extensionService: IExtensionService, @@ -284,7 +284,7 @@ export class TerminalQuickFixAddon extends Disposable implements ITerminalAddon, e.classList.add(...ThemeIcon.asClassNameArray(isExplainOnly ? Codicon.sparkle : Codicon.lightBulb)); updateLayout(this._configurationService, e); - this._audioCueService.playAudioCue(AudioCue.terminalQuickFix); + this._accessibilitySignalService.playSignal(AccessibilitySignal.terminalQuickFix); const parentElement = (e.closest('.xterm') as HTMLElement).parentElement; if (!parentElement) { diff --git a/src/vs/workbench/contrib/terminalContrib/quickFix/browser/terminalQuickFixBuiltinActions.ts b/src/vs/workbench/contrib/terminalContrib/quickFix/browser/terminalQuickFixBuiltinActions.ts index 9998539f95d8c..f17970dd14bc5 100644 --- a/src/vs/workbench/contrib/terminalContrib/quickFix/browser/terminalQuickFixBuiltinActions.ts +++ b/src/vs/workbench/contrib/terminalContrib/quickFix/browser/terminalQuickFixBuiltinActions.ts @@ -238,7 +238,7 @@ export function gitCreatePr(): ITerminalQuickFixInternalOptions { }, commandExitResult: 'success', getQuickFixes: (matchResult: ITerminalCommandMatchResult) => { - const link = matchResult?.outputMatch?.regexMatch?.groups?.link; + const link = matchResult?.outputMatch?.regexMatch?.groups?.link?.trimEnd(); if (!link) { return; } diff --git a/src/vs/workbench/contrib/terminalContrib/stickyScroll/browser/terminalStickyScrollContribution.ts b/src/vs/workbench/contrib/terminalContrib/stickyScroll/browser/terminalStickyScrollContribution.ts index 38408b2a8d0b2..f2ade434b617c 100644 --- a/src/vs/workbench/contrib/terminalContrib/stickyScroll/browser/terminalStickyScrollContribution.ts +++ b/src/vs/workbench/contrib/terminalContrib/stickyScroll/browser/terminalStickyScrollContribution.ts @@ -28,10 +28,10 @@ export class TerminalStickyScrollContribution extends Disposable implements ITer private _xterm?: IXtermTerminal & { raw: RawXtermTerminal }; - private _overlay = this._register(new MutableDisposable()); + private readonly _overlay = this._register(new MutableDisposable()); - private _enableListeners = this._register(new MutableDisposable()); - private _disableListeners = this._register(new MutableDisposable()); + private readonly _enableListeners = this._register(new MutableDisposable()); + private readonly _disableListeners = this._register(new MutableDisposable()); constructor( private readonly _instance: ITerminalInstance, diff --git a/src/vs/workbench/contrib/terminalContrib/stickyScroll/browser/terminalStickyScrollOverlay.ts b/src/vs/workbench/contrib/terminalContrib/stickyScroll/browser/terminalStickyScrollOverlay.ts index 8868f63beef1a..3a42a3dbcd599 100644 --- a/src/vs/workbench/contrib/terminalContrib/stickyScroll/browser/terminalStickyScrollOverlay.ts +++ b/src/vs/workbench/contrib/terminalContrib/stickyScroll/browser/terminalStickyScrollOverlay.ts @@ -8,7 +8,7 @@ import type { IBufferLine, IMarker, ITerminalOptions, ITheme, Terminal as RawXte import { importAMDNodeModule } from 'vs/amdX'; import { $, addDisposableListener, addStandardDisposableListener, getWindow } from 'vs/base/browser/dom'; import { CancelablePromise, createCancelablePromise } from 'vs/base/common/async'; -import { debounce, memoize, throttle } from 'vs/base/common/decorators'; +import { memoize, throttle } from 'vs/base/common/decorators'; import { Event } from 'vs/base/common/event'; import { Disposable, MutableDisposable, combinedDisposable, toDisposable } from 'vs/base/common/lifecycle'; import { removeAnsiEscapeCodes } from 'vs/base/common/strings'; @@ -48,7 +48,7 @@ export class TerminalStickyScrollOverlay extends Disposable { private _stickyScrollOverlay?: RawXtermTerminal; private _serializeAddon?: SerializeAddonType; - private _canvasAddon = this._register(new MutableDisposable()); + private readonly _canvasAddon = this._register(new MutableDisposable()); private _pendingCanvasAddon?: CancelablePromise; private _element?: HTMLElement; @@ -56,9 +56,10 @@ export class TerminalStickyScrollOverlay extends Disposable { private _currentContent?: string; private _contextMenu: IMenu; - private _refreshListeners = this._register(new MutableDisposable()); + private readonly _refreshListeners = this._register(new MutableDisposable()); private _state: OverlayState = OverlayState.Off; + private _isRefreshQueued = false; private _rawMaxLineCount: number = 5; constructor( @@ -95,6 +96,9 @@ export class TerminalStickyScrollOverlay extends Disposable { // Eagerly create the overlay xtermCtor.then(ctor => { + if (this._store.isDisposed) { + return; + } this._stickyScrollOverlay = this._register(new ctor({ rows: 1, cols: this._xterm.raw.cols, @@ -109,6 +113,10 @@ export class TerminalStickyScrollOverlay extends Disposable { this._register(this._themeService.onDidColorThemeChange(() => { this._syncOptions(); })); + this._register(this._xterm.raw.onResize(() => { + this._syncOptions(); + this._refresh(); + })); this._getSerializeAddonConstructor().then(SerializeAddon => { this._serializeAddon = this._register(new SerializeAddon()); @@ -147,7 +155,7 @@ export class TerminalStickyScrollOverlay extends Disposable { this._xterm.raw.onLineFeed, // Rarely an update may be required after just a cursor move, like when // scrolling horizontally in a pager - this._xterm.raw.onCursorMove + this._xterm.raw.onCursorMove, )(() => this._refresh()), addStandardDisposableListener(this._xterm.raw.element!.querySelector('.xterm-viewport')!, 'scroll', () => this._refresh()), ); @@ -168,39 +176,18 @@ export class TerminalStickyScrollOverlay extends Disposable { this._element?.classList.toggle(CssClasses.Visible, isVisible); } - /** - * The entry point to refresh sticky scroll. This is synchronous and will call into the method - * that actually refreshes using either debouncing or throttling depending on the situation. - * - * The goal is that if the command has changed to update immediately (with throttling) and if - * the command is the same then update with debouncing as it's less likely updates will show up. - * This approach also helps with: - * - * - Cursor move only updates such as moving horizontally in pagers which without this may show - * the sticky scroll before hiding it again almost immediately due to everything not being - * parsed yet. - * - Improving performance due to deferring less important updates via debouncing. - * - Less flickering when scrolling, while still updating immediately when the command changes. - */ private _refresh(): void { - if (!this._xterm.raw.element?.parentElement || !this._stickyScrollOverlay || !this._serializeAddon) { + if (this._isRefreshQueued) { return; } - const command = this._commandDetection.getCommandForLine(this._xterm.raw.buffer.active.viewportY); - if (command && this._currentStickyCommand !== command) { - this._throttledRefresh(); - } else { - this._debouncedRefresh(); - } - } - - @debounce(20) - private _debouncedRefresh(): void { - this._throttledRefresh(); + this._isRefreshQueued = true; + queueMicrotask(() => { + this._refreshNow(); + this._isRefreshQueued = false; + }); } - @throttle(0) - private _throttledRefresh(): void { + private _refreshNow(): void { const command = this._commandDetection.getCommandForLine(this._xterm.raw.buffer.active.viewportY); // The command from viewportY + 1 is used because this one will not be obscured by sticky @@ -242,6 +229,12 @@ export class TerminalStickyScrollOverlay extends Disposable { return; } + // Hide sticky scroll if the prompt has been trimmed from the buffer + if (command.promptStartMarker?.line === -1) { + this._setVisible(false); + return; + } + // Determine sticky scroll line count const buffer = xterm.buffer.active; const promptRowCount = command.getPromptRowCount(); @@ -278,7 +271,7 @@ export class TerminalStickyScrollOverlay extends Disposable { } } - // Clear attrs, reset cursor position, clear right + // Get the line content of the command from the terminal const content = this._serializeAddon.serialize({ range: { start: stickyScrollLineStart + rowOffset, @@ -294,8 +287,13 @@ export class TerminalStickyScrollOverlay extends Disposable { } // Write content if it differs - if (content && this._currentContent !== content) { + if ( + content && this._currentContent !== content || + this._stickyScrollOverlay.cols !== xterm.cols || + this._stickyScrollOverlay.rows !== stickyScrollLineCount + ) { this._stickyScrollOverlay.resize(this._stickyScrollOverlay.cols, stickyScrollLineCount); + // Clear attrs, reset cursor position, clear right this._stickyScrollOverlay.write('\x1b[0m\x1b[H\x1b[2J'); this._stickyScrollOverlay.write(content); this._currentContent = content; @@ -314,7 +312,18 @@ export class TerminalStickyScrollOverlay extends Disposable { const termBox = xterm.element.getBoundingClientRect(); const rowHeight = termBox.height / xterm.rows; const overlayHeight = stickyScrollLineCount * rowHeight; - this._element.style.bottom = `${termBox.height - overlayHeight + 1}px`; + + // Adjust sticky scroll content if it would below the end of the command, obscuring the + // following command. + let endMarkerOffset = 0; + if (!isPartialCommand && command.endMarker && command.endMarker.line !== -1) { + if (buffer.viewportY + stickyScrollLineCount > command.endMarker.line) { + const diff = buffer.viewportY + stickyScrollLineCount - command.endMarker.line; + endMarkerOffset = diff * rowHeight; + } + } + + this._element.style.bottom = `${termBox.height - overlayHeight + 1 + endMarkerOffset}px`; } } else { this._setVisible(false); @@ -367,12 +376,15 @@ export class TerminalStickyScrollOverlay extends Disposable { // Scroll to the command on click this._register(addStandardDisposableListener(hoverOverlay, 'click', () => { - if (this._xterm && this._currentStickyCommand && 'getOutput' in this._currentStickyCommand) { + if (this._xterm && this._currentStickyCommand) { this._xterm.markTracker.revealCommand(this._currentStickyCommand); this._instance.focus(); } })); + // Forward mouse events to the terminal + this._register(addStandardDisposableListener(hoverOverlay, 'wheel', e => this._xterm?.raw.element?.dispatchEvent(new WheelEvent(e.type, e)))); + // Context menu - stop propagation on mousedown because rightClickBehavior listens on // mousedown, not contextmenu this._register(addDisposableListener(hoverOverlay, 'mousedown', e => { @@ -434,6 +446,7 @@ export class TerminalStickyScrollOverlay extends Disposable { private _getOptions(): ITerminalOptions { const o = this._xterm.raw.options; return { + allowTransparency: true, cursorInactiveStyle: 'none', scrollback: 0, logLevel: 'off', diff --git a/src/vs/workbench/contrib/terminalContrib/suggest/browser/terminal.suggest.contribution.ts b/src/vs/workbench/contrib/terminalContrib/suggest/browser/terminal.suggest.contribution.ts index 0cf5fabbe4ed6..f4a8f597439ac 100644 --- a/src/vs/workbench/contrib/terminalContrib/suggest/browser/terminal.suggest.contribution.ts +++ b/src/vs/workbench/contrib/terminalContrib/suggest/browser/terminal.suggest.contribution.ts @@ -4,13 +4,13 @@ *--------------------------------------------------------------------------------------------*/ import * as dom from 'vs/base/browser/dom'; -import { DisposableStore, toDisposable } from 'vs/base/common/lifecycle'; +import { DisposableStore, toDisposable, MutableDisposable } from 'vs/base/common/lifecycle'; import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; import { ITerminalContribution, ITerminalInstance, IXtermTerminal } from 'vs/workbench/contrib/terminal/browser/terminal'; import { registerTerminalContribution } from 'vs/workbench/contrib/terminal/browser/terminalExtensions'; import { TerminalWidgetManager } from 'vs/workbench/contrib/terminal/browser/widgets/widgetManager'; import { SuggestAddon } from 'vs/workbench/contrib/terminalContrib/suggest/browser/terminalSuggestAddon'; -import { ITerminalProcessManager, TerminalCommandId } from 'vs/workbench/contrib/terminal/common/terminal'; +import { ITerminalConfiguration, ITerminalProcessManager, TERMINAL_CONFIG_SECTION, TerminalCommandId } from 'vs/workbench/contrib/terminal/common/terminal'; import type { Terminal as RawXtermTerminal } from '@xterm/xterm'; import { ContextKeyExpr, IContextKey, IContextKeyService, IReadableSet } from 'vs/platform/contextkey/common/contextkey'; import { TerminalContextKeys } from 'vs/workbench/contrib/terminal/common/terminalContextKey'; @@ -18,6 +18,8 @@ import { registerActiveInstanceAction } from 'vs/workbench/contrib/terminal/brow import { localize2 } from 'vs/nls'; import { KeybindingWeight } from 'vs/platform/keybinding/common/keybindingsRegistry'; import { KeyCode } from 'vs/base/common/keyCodes'; +import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; +import { TerminalSettingId } from 'vs/platform/terminal/common/terminal'; class TerminalSuggestContribution extends DisposableStore implements ITerminalContribution { static readonly ID = 'terminal.suggest'; @@ -26,17 +28,18 @@ class TerminalSuggestContribution extends DisposableStore implements ITerminalCo return instance.getContribution(TerminalSuggestContribution.ID); } - private _addon: SuggestAddon | undefined; + private readonly _addon: MutableDisposable = new MutableDisposable(); private _terminalSuggestWidgetContextKeys: IReadableSet = new Set(TerminalContextKeys.suggestWidgetVisible.key); private _terminalSuggestWidgetVisibleContextKey: IContextKey; - get addon(): SuggestAddon | undefined { return this._addon; } + get addon(): SuggestAddon | undefined { return this._addon.value; } constructor( private readonly _instance: ITerminalInstance, _processManager: ITerminalProcessManager, widgetManager: TerminalWidgetManager, @IContextKeyService private readonly _contextKeyService: IContextKeyService, + @IConfigurationService private readonly _configurationService: IConfigurationService, @IInstantiationService private readonly _instantiationService: IInstantiationService ) { super(); @@ -51,21 +54,31 @@ class TerminalSuggestContribution extends DisposableStore implements ITerminalCo this._loadSuggestAddon(xterm.raw); } })); + this.add(this._configurationService.onDidChangeConfiguration(e => { + if (e.affectsConfiguration(TerminalSettingId.SendKeybindingsToShell)) { + this._loadSuggestAddon(xterm.raw); + } + })); } private _loadSuggestAddon(xterm: RawXtermTerminal): void { + const sendingKeybindingsToShell = this._configurationService.getValue(TERMINAL_CONFIG_SECTION).sendKeybindingsToShell; + if (sendingKeybindingsToShell) { + this._addon.dispose(); + return; + } if (this._terminalSuggestWidgetVisibleContextKey) { - this._addon = this._instantiationService.createInstance(SuggestAddon, this._terminalSuggestWidgetVisibleContextKey); - xterm.loadAddon(this._addon); - this._addon?.setPanel(dom.findParentWithClass(xterm.element!, 'panel')!); - this._addon?.setScreen(xterm.element!.querySelector('.xterm-screen')!); - this.add(this._instance.onDidBlur(() => this._addon?.hideSuggestWidget())); - this.add(this._addon.onAcceptedCompletion(async text => { + this._addon.value = this._instantiationService.createInstance(SuggestAddon, this._terminalSuggestWidgetVisibleContextKey); + xterm.loadAddon(this._addon.value); + this._addon.value.setPanel(dom.findParentWithClass(xterm.element!, 'panel')!); + this._addon.value.setScreen(xterm.element!.querySelector('.xterm-screen')!); + this.add(this._instance.onDidBlur(() => this._addon.value?.hideSuggestWidget())); + this.add(this._addon.value.onAcceptedCompletion(async text => { this._instance.focus(); this._instance.sendText(text, false); })); this.add(this._instance.onDidSendText((text) => { - this._addon?.handleNonXtermData(text); + this._addon.value?.handleNonXtermData(text); })); } } diff --git a/src/vs/workbench/contrib/terminalContrib/suggest/browser/terminalSuggestAddon.ts b/src/vs/workbench/contrib/terminalContrib/suggest/browser/terminalSuggestAddon.ts index 46ca6bda9d620..523911dd7c943 100644 --- a/src/vs/workbench/contrib/terminalContrib/suggest/browser/terminalSuggestAddon.ts +++ b/src/vs/workbench/contrib/terminalContrib/suggest/browser/terminalSuggestAddon.ts @@ -296,7 +296,8 @@ export class SuggestAddon extends Disposable implements ITerminalAddon, ISuggest // TODO: What do frozen and auto do? const xtermBox = this._screen!.getBoundingClientRect(); const panelBox = this._panel!.offsetParent!.getBoundingClientRect(); - suggestWidget.showSuggestions(model, 0, false, false, { + suggestWidget.setCompletionModel(model); + suggestWidget.showSuggestions(0, false, false, { left: (xtermBox.left - panelBox.left) + this._terminal.buffer.active.cursorX * dimensions.width, top: (xtermBox.top - panelBox.top) + this._terminal.buffer.active.cursorY * dimensions.height, height: dimensions.height @@ -425,9 +426,12 @@ export class SuggestAddon extends Disposable implements ITerminalAddon, ISuggest } // Right if (data === '\x1b[C') { - handled = true; - this._cursorIndexDelta += 1; - handledCursorDelta++; + // If right requests beyond where the completion was requested (potentially accepting a shell completion), hide + if (this._additionalInput?.length !== this._cursorIndexDelta) { + handled = true; + this._cursorIndexDelta++; + handledCursorDelta++; + } } if (data.match(/^[a-z0-9]$/i)) { @@ -464,7 +468,8 @@ export class SuggestAddon extends Disposable implements ITerminalAddon, ISuggest // TODO: What do frozen and auto do? const xtermBox = this._screen!.getBoundingClientRect(); const panelBox = this._panel!.offsetParent!.getBoundingClientRect(); - this._suggestWidget?.showSuggestions((this._suggestWidget as any)._completionModel, 0, false, false, { + + this._suggestWidget?.showSuggestions(0, false, false, { left: (xtermBox.left - panelBox.left) + (this._terminal.buffer.active.cursorX + handledCursorDelta) * dimensions.width, top: (xtermBox.top - panelBox.top) + this._terminal.buffer.active.cursorY * dimensions.height, height: dimensions.height diff --git a/src/vs/workbench/contrib/terminalContrib/zoom/browser/terminal.zoom.contribution.ts b/src/vs/workbench/contrib/terminalContrib/zoom/browser/terminal.zoom.contribution.ts index 0f4d1ad09f3ef..aed5f68e6bbeb 100644 --- a/src/vs/workbench/contrib/terminalContrib/zoom/browser/terminal.zoom.contribution.ts +++ b/src/vs/workbench/contrib/terminalContrib/zoom/browser/terminal.zoom.contribution.ts @@ -33,7 +33,7 @@ class TerminalMouseWheelZoomContribution extends Disposable implements ITerminal return instance.getContribution(TerminalMouseWheelZoomContribution.ID); } - private _listener = this._register(new MutableDisposable()); + private readonly _listener = this._register(new MutableDisposable()); constructor( instance: ITerminalInstance | IDetachedTerminalInstance, diff --git a/src/vs/workbench/contrib/testing/browser/codeCoverageDecorations.ts b/src/vs/workbench/contrib/testing/browser/codeCoverageDecorations.ts index 0c556853d8c5f..8b310a5d71b51 100644 --- a/src/vs/workbench/contrib/testing/browser/codeCoverageDecorations.ts +++ b/src/vs/workbench/contrib/testing/browser/codeCoverageDecorations.ts @@ -6,6 +6,7 @@ import * as dom from 'vs/base/browser/dom'; import { HoverWidget } from 'vs/base/browser/ui/hover/hoverWidget'; import { mapFindFirst } from 'vs/base/common/arraysFind'; +import { assertNever } from 'vs/base/common/assert'; import { CancellationTokenSource } from 'vs/base/common/cancellation'; import { IMarkdownString, MarkdownString } from 'vs/base/common/htmlContent'; import { KeyChord, KeyCode, KeyMod } from 'vs/base/common/keyCodes'; @@ -31,7 +32,7 @@ import { ILogService } from 'vs/platform/log/common/log'; import { testingCoverageMissingBranch } from 'vs/workbench/contrib/testing/browser/icons'; import { FileCoverage } from 'vs/workbench/contrib/testing/common/testCoverage'; import { ITestCoverageService } from 'vs/workbench/contrib/testing/common/testCoverageService'; -import { CoverageDetails, DetailType, IStatementCoverage } from 'vs/workbench/contrib/testing/common/testTypes'; +import { CoverageDetails, DetailType, IDeclarationCoverage, IStatementCoverage } from 'vs/workbench/contrib/testing/common/testTypes'; import { TestingContextKeys } from 'vs/workbench/contrib/testing/common/testingContextKeys'; const MAX_HOVERED_LINES = 30; @@ -81,7 +82,13 @@ export class CodeCoverageDecorations extends Disposable implements IEditorContri return; } - return report.getUri(model.uri); + const file = report.getUri(model.uri); + if (file) { + return file; + } + + report.didAddCoverage.read(reader); // re-read if changes when there's no report + return undefined; }); this._register(autorun(reader => { @@ -366,7 +373,7 @@ export class CoverageDetailsModel { //#region decoration generation // Coverage from a provider can have a range that contains smaller ranges, - // such as a function declarationt that has nested statements. In this we + // such as a function declaration that has nested statements. In this we // make sequential, non-overlapping ranges for each detail for display in // the editor without ugly overlaps. const detailRanges: DetailRange[] = details.map(detail => ({ @@ -445,33 +452,43 @@ export class CoverageDetailsModel { /** Gets the markdown description for the given detail */ public describe(detail: CoverageDetailsWithBranch, model: ITextModel): IMarkdownString | undefined { - if (detail.type === DetailType.Function) { - return new MarkdownString().appendMarkdown(localize('coverage.fnExecutedCount', 'Function `{0}` was executed {1} time(s).', detail.name, detail.count)); + if (detail.type === DetailType.Declaration) { + return namedDetailLabel(detail.name, detail); } else if (detail.type === DetailType.Statement) { const text = wrapName(model.getValueInRange(tidyLocation(detail.location)).trim() || ``); - const str = new MarkdownString(); if (detail.branches?.length) { const covered = detail.branches.filter(b => !!b.count).length; - str.appendMarkdown(localize('coverage.branches', '{0} of {1} of branches in {2} were covered.', covered, detail.branches.length, text)); + return new MarkdownString().appendMarkdown(localize('coverage.branches', '{0} of {1} of branches in {2} were covered.', covered, detail.branches.length, text)); } else { - str.appendMarkdown(localize('coverage.codeExecutedCount', '{0} was executed {1} time(s).', text, detail.count)); + return namedDetailLabel(text, detail); } - return str; } else if (detail.type === DetailType.Branch) { const text = wrapName(model.getValueInRange(tidyLocation(detail.detail.location)).trim() || ``); const { count, label } = detail.detail.branches![detail.branch]; const label2 = label ? wrapInBackticks(label) : `#${detail.branch + 1}`; - if (count === 0) { + if (!count) { return new MarkdownString().appendMarkdown(localize('coverage.branchNotCovered', 'Branch {0} in {1} was not covered.', label2, text)); + } else if (count === true) { + return new MarkdownString().appendMarkdown(localize('coverage.branchCoveredYes', 'Branch {0} in {1} was executed.', label2, text)); } else { return new MarkdownString().appendMarkdown(localize('coverage.branchCovered', 'Branch {0} in {1} was executed {2} time(s).', label2, text, count)); } } - return undefined; + assertNever(detail); } } +function namedDetailLabel(name: string, detail: IStatementCoverage | IDeclarationCoverage) { + return new MarkdownString().appendMarkdown( + !detail.count // 0 or false + ? localize('coverage.declExecutedNo', '`{0}` was not executed.', name) + : typeof detail.count === 'number' + ? localize('coverage.declExecutedCount', '`{0}` was executed {1} time(s).', name, detail.count) + : localize('coverage.declExecutedYes', '`{0}` was executed.', name) + ); +} + // 'tidies' the range by normalizing it into a range and removing leading // and trailing whitespace. function tidyLocation(location: Range | Position): Range { diff --git a/src/vs/workbench/contrib/testing/browser/explorerProjections/index.ts b/src/vs/workbench/contrib/testing/browser/explorerProjections/index.ts index e3e327ee8c074..29f03a52d113d 100644 --- a/src/vs/workbench/contrib/testing/browser/explorerProjections/index.ts +++ b/src/vs/workbench/contrib/testing/browser/explorerProjections/index.ts @@ -142,7 +142,15 @@ export type TestExplorerTreeElement = TestItemTreeElement | TestTreeErrorMessage export const testIdentityProvider: IIdentityProvider = { getId(element) { - return element.treeId + '\0' + (element instanceof TestTreeErrorMessage ? 'error' : element.test.expand); + // For "not expandable" elements, whether they have children is part of the + // ID so they're rerendered if that changes (#204805) + const expandComponent = element instanceof TestTreeErrorMessage + ? 'error' + : element.test.expand === TestItemExpandState.NotExpandable + ? !!element.children.size + : element.test.expand; + + return element.treeId + '\0' + expandComponent; } }; diff --git a/src/vs/workbench/contrib/testing/browser/explorerProjections/treeProjection.ts b/src/vs/workbench/contrib/testing/browser/explorerProjections/treeProjection.ts index 2e0374be9cd0f..33060d117ea11 100644 --- a/src/vs/workbench/contrib/testing/browser/explorerProjections/treeProjection.ts +++ b/src/vs/workbench/contrib/testing/browser/explorerProjections/treeProjection.ts @@ -253,21 +253,20 @@ export class TreeProjection extends Disposable implements ITestTreeProjection { * @inheritdoc */ public applyTo(tree: ObjectTree) { - for (const s of [this.changedParents, this.resortedParents]) { - for (const element of s) { - if (element && !tree.hasElement(element)) { - s.delete(element); - } - } - } - for (const parent of this.changedParents) { - tree.setChildren(parent, getChildrenForParent(this.lastState, this.rootsWithChildren, parent), { diffIdentityProvider: testIdentityProvider }); + if (!parent || tree.hasElement(parent)) { + tree.setChildren(parent, getChildrenForParent(this.lastState, this.rootsWithChildren, parent), { diffIdentityProvider: testIdentityProvider }); + } } for (const parent of this.resortedParents) { - tree.resort(parent, false); + if (!parent || tree.hasElement(parent)) { + tree.resort(parent, false); + } } + + this.changedParents.clear(); + this.resortedParents.clear(); } /** @@ -302,9 +301,14 @@ export class TreeProjection extends Disposable implements ITestTreeProjection { treeElement.parent?.children.add(treeElement); this.items.set(treeElement.test.item.extId, treeElement); - // The first element will cause the root to be shown - const affectsRootElement = treeElement.depth === 1 && treeElement.parent?.children.size === 1; - this.changedParents.add(affectsRootElement ? null : treeElement.parent); + // The first element will cause the root to be shown. The first element of + // a parent may need to re-render it for #204805. + const affectsParent = treeElement.parent?.children.size === 1; + const affectedParent = affectsParent ? treeElement.parent.parent : treeElement.parent; + this.changedParents.add(affectedParent); + if (affectedParent?.depth === 0) { + this.changedParents.add(null); + } if (treeElement.depth === 0 || isCollapsedInSerializedTestTree(this.lastState, treeElement.test.item.extId) === false) { this.expandElement(treeElement, 0); diff --git a/src/vs/workbench/contrib/testing/browser/icons.ts b/src/vs/workbench/contrib/testing/browser/icons.ts index 81b61817d3f51..428ba987914dc 100644 --- a/src/vs/workbench/contrib/testing/browser/icons.ts +++ b/src/vs/workbench/contrib/testing/browser/icons.ts @@ -8,7 +8,7 @@ import { localize } from 'vs/nls'; import { registerIcon, spinningLoading } from 'vs/platform/theme/common/iconRegistry'; import { registerThemingParticipant } from 'vs/platform/theme/common/themeService'; import { ThemeIcon } from 'vs/base/common/themables'; -import { testingColorRunAction, testStatesToIconColors } from 'vs/workbench/contrib/testing/browser/theme'; +import { testingColorRunAction, testStatesToIconColors, testStatesToRetiredIconColors } from 'vs/workbench/contrib/testing/browser/theme'; import { TestResultState } from 'vs/workbench/contrib/testing/common/testTypes'; export const testingViewIcon = registerIcon('test-view-icon', Codicon.beaker, localize('testViewIcon', 'View icon of the test view.')); @@ -52,12 +52,22 @@ export const testingStatesToIcons = new Map([ registerThemingParticipant((theme, collector) => { for (const [state, icon] of testingStatesToIcons.entries()) { const color = testStatesToIconColors[state]; + const retiredColor = testStatesToRetiredIconColors[state]; if (!color) { continue; } collector.addRule(`.monaco-workbench ${ThemeIcon.asCSSSelector(icon)} { color: ${theme.getColor(color)} !important; }`); + if (!retiredColor) { + continue; + } + collector.addRule(` + .test-explorer .computed-state.retired${ThemeIcon.asCSSSelector(icon)}, + .testing-run-glyph.retired${ThemeIcon.asCSSSelector(icon)}{ + color: ${theme.getColor(retiredColor)} !important; + } + `); } collector.addRule(` diff --git a/src/vs/workbench/contrib/testing/browser/media/testMessageColorizer.css b/src/vs/workbench/contrib/testing/browser/media/testMessageColorizer.css new file mode 100644 index 0000000000000..8c3dbab41628e --- /dev/null +++ b/src/vs/workbench/contrib/testing/browser/media/testMessageColorizer.css @@ -0,0 +1,55 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +.test-output-peek-message-container { + .tstm-ansidec-1 { + font-weight: bold; + } + .tstm-ansidec-2 { + opacity: 0.7 + } + .tstm-ansidec-3 { + font-style: italic; + } + .tstm-ansidec-4 { + text-decoration: underline; + } + + .tstm-ansidec-fg30 { color: var(--vscode-terminal-ansiBlack); } + .tstm-ansidec-fg31 { color: var(--vscode-terminal-ansiRed); } + .tstm-ansidec-fg32 { color: var(--vscode-terminal-ansiGreen); } + .tstm-ansidec-fg33 { color: var(--vscode-terminal-ansiYellow); } + .tstm-ansidec-fg34 { color: var(--vscode-terminal-ansiBlue); } + .tstm-ansidec-fg35 { color: var(--vscode-terminal-ansiMagenta); } + .tstm-ansidec-fg36 { color: var(--vscode-terminal-ansiCyan); } + .tstm-ansidec-fg37 { color: var(--vscode-terminal-ansiWhite); } + + .tstm-ansidec-fg90 { color: var(--vscode-terminal-ansiBrightBlack); } + .tstm-ansidec-fg91 { color: var(--vscode-terminal-ansiBrightRed); } + .tstm-ansidec-fg92 { color: var(--vscode-terminal-ansiBrightGreen); } + .tstm-ansidec-fg93 { color: var(--vscode-terminal-ansiBrightYellow); } + .tstm-ansidec-fg94 { color: var(--vscode-terminal-ansiBrightBlue); } + .tstm-ansidec-fg95 { color: var(--vscode-terminal-ansiBrightMagenta); } + .tstm-ansidec-fg96 { color: var(--vscode-terminal-ansiBrightCyan); } + .tstm-ansidec-fg97 { color: var(--vscode-terminal-ansiBrightWhite); } + + .tstm-ansidec-bg30 { background-color: var(--vscode-terminal-ansiBlack); } + .tstm-ansidec-bg31 { background-color: var(--vscode-terminal-ansiRed); } + .tstm-ansidec-bg32 { background-color: var(--vscode-terminal-ansiGreen); } + .tstm-ansidec-bg33 { background-color: var(--vscode-terminal-ansiYellow); } + .tstm-ansidec-bg34 { background-color: var(--vscode-terminal-ansiBlue); } + .tstm-ansidec-bg35 { background-color: var(--vscode-terminal-ansiMagenta); } + .tstm-ansidec-bg36 { background-color: var(--vscode-terminal-ansiCyan); } + .tstm-ansidec-bg37 { background-color: var(--vscode-terminal-ansiWhite); } + + .tstm-ansidec-bg100 { background-color: var(--vscode-terminal-ansiBrightBlack); } + .tstm-ansidec-bg101 { background-color: var(--vscode-terminal-ansiBrightRed); } + .tstm-ansidec-bg102 { background-color: var(--vscode-terminal-ansiBrightGreen); } + .tstm-ansidec-bg103 { background-color: var(--vscode-terminal-ansiBrightYellow); } + .tstm-ansidec-bg104 { background-color: var(--vscode-terminal-ansiBrightBlue); } + .tstm-ansidec-bg105 { background-color: var(--vscode-terminal-ansiBrightMagenta); } + .tstm-ansidec-bg106 { background-color: var(--vscode-terminal-ansiBrightCyan); } + .tstm-ansidec-bg107 { background-color: var(--vscode-terminal-ansiBrightWhite); } +} diff --git a/src/vs/workbench/contrib/testing/browser/media/testing.css b/src/vs/workbench/contrib/testing/browser/media/testing.css index 010fd1c5c75a2..fc0cecc4d6027 100644 --- a/src/vs/workbench/contrib/testing/browser/media/testing.css +++ b/src/vs/workbench/contrib/testing/browser/media/testing.css @@ -147,11 +147,6 @@ margin-right: 0.25em; } -.test-explorer .computed-state.retired, -.testing-run-glyph.retired { - opacity: 0.7 !important; -} - .test-explorer .test-is-hidden { opacity: 0.8; } diff --git a/src/vs/workbench/contrib/testing/browser/testCoverageBars.ts b/src/vs/workbench/contrib/testing/browser/testCoverageBars.ts index 9a1c2987b9d3e..10a01eab913e8 100644 --- a/src/vs/workbench/contrib/testing/browser/testCoverageBars.ts +++ b/src/vs/workbench/contrib/testing/browser/testCoverageBars.ts @@ -4,8 +4,10 @@ *--------------------------------------------------------------------------------------------*/ import { h } from 'vs/base/browser/dom'; +import type { IUpdatableHover, IUpdatableHoverTooltipMarkdownString } from 'vs/base/browser/ui/hover/hover'; +import { getDefaultHoverDelegate } from 'vs/base/browser/ui/hover/hoverDelegateFactory'; import { assertNever } from 'vs/base/common/assert'; -import { IMarkdownString, MarkdownString } from 'vs/base/common/htmlContent'; +import { MarkdownString } from 'vs/base/common/htmlContent'; import { Lazy } from 'vs/base/common/lazy'; import { Disposable, DisposableStore, toDisposable } from 'vs/base/common/lifecycle'; import { clamp } from 'vs/base/common/numbers'; @@ -14,13 +16,13 @@ import { isDefined } from 'vs/base/common/types'; import { URI } from 'vs/base/common/uri'; import { localize } from 'vs/nls'; import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; +import { IHoverService } from 'vs/platform/hover/browser/hover'; import { asCssVariableName, chartsGreen, chartsRed, chartsYellow } from 'vs/platform/theme/common/colorRegistry'; import { IExplorerFileContribution } from 'vs/workbench/contrib/files/browser/explorerFileContrib'; import { ITestingCoverageBarThresholds, TestingConfigKeys, TestingDisplayedCoveragePercent, getTestingConfiguration, observeTestingConfiguration } from 'vs/workbench/contrib/testing/common/configuration'; import { AbstractFileCoverage, getTotalCoveragePercent } from 'vs/workbench/contrib/testing/common/testCoverage'; import { ITestCoverageService } from 'vs/workbench/contrib/testing/common/testCoverageService'; -import { ICoveredCount } from 'vs/workbench/contrib/testing/common/testTypes'; -import { IHoverService } from 'vs/platform/hover/browser/hover'; +import { ICoverageCount } from 'vs/workbench/contrib/testing/common/testTypes'; export interface TestCoverageBarsOptions { /** @@ -35,7 +37,7 @@ export interface TestCoverageBarsOptions { } /** Type that can be used to render coverage bars */ -export type CoverageBarSource = Pick; +export type CoverageBarSource = Pick; export class ManagedTestCoverageBars extends Disposable { private _coverage?: CoverageBarSource; @@ -62,6 +64,7 @@ export class ManagedTestCoverageBars extends Disposable { }); private readonly visibleStore = this._register(new DisposableStore()); + private readonly customHovers: IUpdatableHover[] = []; /** Gets whether coverage is currently visible for the resource. */ public get visible() { @@ -70,36 +73,14 @@ export class ManagedTestCoverageBars extends Disposable { constructor( protected readonly options: TestCoverageBarsOptions, - @IHoverService private readonly hoverService: IHoverService, @IConfigurationService private readonly configurationService: IConfigurationService, + @IHoverService private readonly hoverService: IHoverService, ) { super(); } - private attachHover(target: HTMLElement, factory: (coverage: CoverageBarSource) => string | IMarkdownString | undefined) { - target.onmouseenter = () => { - if (!this._coverage) { - return; - } - - const content = factory(this._coverage); - if (!content) { - return; - } - - const hover = this.hoverService.showHover({ - content, - target, - appearance: { - showPointer: true, - compact: true, - skipFadeInAnimation: true, - } - }); - if (hover) { - this.visibleStore.add(hover); - } - }; + private attachHover(target: HTMLElement, factory: (coverage: CoverageBarSource) => string | IUpdatableHoverTooltipMarkdownString | undefined) { + this._register(this.hoverService.setupUpdatableHover(getDefaultHoverDelegate('element'), target, () => this._coverage && factory(this._coverage))); } public setCoverageInfo(coverage: CoverageBarSource | undefined) { @@ -107,6 +88,7 @@ export class ManagedTestCoverageBars extends Disposable { if (!coverage) { if (this._coverage) { this._coverage = undefined; + this.customHovers.forEach(c => c.hide()); ds.clear(); } return; @@ -142,13 +124,13 @@ export class ManagedTestCoverageBars extends Disposable { renderBar(el.tpcBar, overallStat, false, thresholds); } else { renderBar(el.statement, percent(coverage.statement), coverage.statement.total === 0, thresholds); - renderBar(el.function, coverage.function && percent(coverage.function), coverage.function?.total === 0, thresholds); + renderBar(el.function, coverage.declaration && percent(coverage.declaration), coverage.declaration?.total === 0, thresholds); renderBar(el.branch, coverage.branch && percent(coverage.branch), coverage.branch?.total === 0, thresholds); } } } -const percent = (cc: ICoveredCount) => clamp(cc.total === 0 ? 1 : cc.covered / cc.total, 0, 1); +const percent = (cc: ICoverageCount) => clamp(cc.total === 0 ? 1 : cc.covered / cc.total, 0, 1); const epsilon = 10e-8; const barWidth = 16; @@ -196,11 +178,11 @@ const calculateDisplayedStat = (coverage: CoverageBarSource, method: TestingDisp case TestingDisplayedCoveragePercent.Minimum: { let value = percent(coverage.statement); if (coverage.branch) { value = Math.min(value, percent(coverage.branch)); } - if (coverage.function) { value = Math.min(value, percent(coverage.function)); } + if (coverage.declaration) { value = Math.min(value, percent(coverage.declaration)); } return value; } case TestingDisplayedCoveragePercent.TotalCoverage: - return getTotalCoveragePercent(coverage.statement, coverage.branch, coverage.function); + return getTotalCoveragePercent(coverage.statement, coverage.branch, coverage.declaration); default: assertNever(method); } @@ -218,15 +200,23 @@ const displayPercent = (value: number, precision = 2) => { return `${display}%`; }; -const stmtCoverageText = (coverage: CoverageBarSource) => localize('statementCoverage', '{0}/{1} statements covered ({2})', coverage.statement.covered, coverage.statement.total, displayPercent(percent(coverage.statement))); -const fnCoverageText = (coverage: CoverageBarSource) => coverage.function && localize('functionCoverage', '{0}/{1} functions covered ({2})', coverage.function.covered, coverage.function.total, displayPercent(percent(coverage.function))); -const branchCoverageText = (coverage: CoverageBarSource) => coverage.branch && localize('branchCoverage', '{0}/{1} branches covered ({2})', coverage.branch.covered, coverage.branch.total, displayPercent(percent(coverage.branch))); - -const getOverallHoverText = (coverage: CoverageBarSource) => new MarkdownString([ - stmtCoverageText(coverage), - fnCoverageText(coverage), - branchCoverageText(coverage), -].filter(isDefined).join('\n\n')); +const nf = new Intl.NumberFormat(); +const stmtCoverageText = (coverage: CoverageBarSource) => localize('statementCoverage', '{0}/{1} statements covered ({2})', nf.format(coverage.statement.covered), nf.format(coverage.statement.total), displayPercent(percent(coverage.statement))); +const fnCoverageText = (coverage: CoverageBarSource) => coverage.declaration && localize('functionCoverage', '{0}/{1} functions covered ({2})', nf.format(coverage.declaration.covered), nf.format(coverage.declaration.total), displayPercent(percent(coverage.declaration))); +const branchCoverageText = (coverage: CoverageBarSource) => coverage.branch && localize('branchCoverage', '{0}/{1} branches covered ({2})', nf.format(coverage.branch.covered), nf.format(coverage.branch.total), displayPercent(percent(coverage.branch))); + +const getOverallHoverText = (coverage: CoverageBarSource): IUpdatableHoverTooltipMarkdownString => { + const str = [ + stmtCoverageText(coverage), + fnCoverageText(coverage), + branchCoverageText(coverage), + ].filter(isDefined).join('\n\n'); + + return { + markdown: new MarkdownString().appendText(str), + markdownNotSupportedFallback: str + }; +}; /** * Renders test coverage bars for a resource in the given container. It will @@ -237,11 +227,11 @@ export class ExplorerTestCoverageBars extends ManagedTestCoverageBars implements constructor( options: TestCoverageBarsOptions, - @IHoverService hoverService: IHoverService, @IConfigurationService configurationService: IConfigurationService, + @IHoverService hoverService: IHoverService, @ITestCoverageService testCoverageService: ITestCoverageService, ) { - super(options, hoverService, configurationService); + super(options, configurationService, hoverService); const isEnabled = observeTestingConfiguration(configurationService, TestingConfigKeys.ShowCoverageInExplorer); diff --git a/src/vs/workbench/contrib/testing/browser/testCoverageView.ts b/src/vs/workbench/contrib/testing/browser/testCoverageView.ts index 5b583bf832236..c4270ad9e2a1f 100644 --- a/src/vs/workbench/contrib/testing/browser/testCoverageView.ts +++ b/src/vs/workbench/contrib/testing/browser/testCoverageView.ts @@ -8,6 +8,7 @@ import { IIdentityProvider, IListVirtualDelegate } from 'vs/base/browser/ui/list import { ICompressedTreeElement, ICompressedTreeNode } from 'vs/base/browser/ui/tree/compressedObjectTreeModel'; import { ICompressibleTreeRenderer } from 'vs/base/browser/ui/tree/objectTree'; import { ITreeNode, ITreeSorter } from 'vs/base/browser/ui/tree/tree'; +import { findLast } from 'vs/base/common/arraysFind'; import { assertNever } from 'vs/base/common/assert'; import { Codicon } from 'vs/base/common/codicons'; import { memoize } from 'vs/base/common/decorators'; @@ -28,6 +29,7 @@ import { ContextKeyExpr, IContextKeyService } from 'vs/platform/contextkey/commo import { IContextMenuService } from 'vs/platform/contextview/browser/contextView'; import { EditorOpenSource, TextEditorSelectionRevealType } from 'vs/platform/editor/common/editor'; import { FileKind } from 'vs/platform/files/common/files'; +import { IHoverService } from 'vs/platform/hover/browser/hover'; import { IInstantiationService, ServicesAccessor } from 'vs/platform/instantiation/common/instantiation'; import { IKeybindingService } from 'vs/platform/keybinding/common/keybinding'; import { ILabelService } from 'vs/platform/label/common/label'; @@ -42,9 +44,10 @@ import { IViewDescriptorService } from 'vs/workbench/common/views'; import { testingStatesToIcons, testingWasCovered } from 'vs/workbench/contrib/testing/browser/icons'; import { CoverageBarSource, ManagedTestCoverageBars } from 'vs/workbench/contrib/testing/browser/testCoverageBars'; import { TestCommandId, Testing } from 'vs/workbench/contrib/testing/common/constants'; +import { onObservableChange } from 'vs/workbench/contrib/testing/common/observableUtils'; import { ComputedFileCoverage, FileCoverage, TestCoverage, getTotalCoveragePercent } from 'vs/workbench/contrib/testing/common/testCoverage'; import { ITestCoverageService } from 'vs/workbench/contrib/testing/common/testCoverageService'; -import { CoverageDetails, DetailType, ICoveredCount, IFunctionCoverage, TestResultState } from 'vs/workbench/contrib/testing/common/testTypes'; +import { CoverageDetails, DetailType, ICoverageCount, IDeclarationCoverage, TestResultState } from 'vs/workbench/contrib/testing/common/testTypes'; import { ACTIVE_GROUP, IEditorService, SIDE_GROUP } from 'vs/workbench/services/editor/common/editorService'; const enum CoverageSortOrder { @@ -68,9 +71,10 @@ export class TestCoverageView extends ViewPane { @IOpenerService openerService: IOpenerService, @IThemeService themeService: IThemeService, @ITelemetryService telemetryService: ITelemetryService, + @IHoverService hoverService: IHoverService, @ITestCoverageService private readonly coverageService: ITestCoverageService, ) { - super(options, keybindingService, contextMenuService, configurationService, contextKeyService, viewDescriptorService, instantiationService, openerService, themeService, telemetryService); + super(options, keybindingService, contextMenuService, configurationService, contextKeyService, viewDescriptorService, instantiationService, openerService, themeService, telemetryService, hoverService); } protected override renderBody(container: HTMLElement): void { @@ -97,10 +101,10 @@ export class TestCoverageView extends ViewPane { let fnNodeId = 0; -class FunctionCoverageNode { +class DeclarationCoverageNode { public readonly id = String(fnNodeId++); public readonly containedDetails = new Set(); - public readonly children: FunctionCoverageNode[] = []; + public readonly children: DeclarationCoverageNode[] = []; public get hits() { return this.data.count; @@ -121,7 +125,7 @@ class FunctionCoverageNode { constructor( public readonly uri: URI, - private readonly data: IFunctionCoverage, + private readonly data: IDeclarationCoverage, details: readonly CoverageDetails[], ) { if (data.location instanceof Range) { @@ -151,8 +155,8 @@ class FunctionCoverageNode { return; } - const statement: ICoveredCount = { covered: 0, total: 0 }; - const branch: ICoveredCount = { covered: 0, total: 0 }; + const statement: ICoverageCount = { covered: 0, total: 0 }; + const branch: ICoverageCount = { covered: 0, total: 0 }; for (const detail of this.containedDetails) { if (detail.type !== DetailType.Statement) { continue; @@ -172,11 +176,11 @@ class FunctionCoverageNode { } } -class RevealUncoveredFunctions { +class RevealUncoveredDeclarations { public readonly id = String(fnNodeId++); public get label() { - return localize('functionsWithoutCoverage', "{0} functions without coverage...", this.n); + return localize('functionsWithoutCoverage', "{0} declarations without coverage...", this.n); } constructor(public readonly n: number) { } @@ -189,15 +193,16 @@ class LoadingDetails { /** Type of nodes returned from {@link TestCoverage}. Note: value is *always* defined. */ type TestCoverageFileNode = IPrefixTreeNode; -type CoverageTreeElement = TestCoverageFileNode | FunctionCoverageNode | LoadingDetails | RevealUncoveredFunctions; +type CoverageTreeElement = TestCoverageFileNode | DeclarationCoverageNode | LoadingDetails | RevealUncoveredDeclarations; const isFileCoverage = (c: CoverageTreeElement): c is TestCoverageFileNode => typeof c === 'object' && 'value' in c; -const isFunctionCoverage = (c: CoverageTreeElement): c is FunctionCoverageNode => c instanceof FunctionCoverageNode; -const shouldShowFunctionDetailsOnExpand = (c: CoverageTreeElement): c is IPrefixTreeNode => - isFileCoverage(c) && c.value instanceof FileCoverage && !!c.value.function?.total; +const isDeclarationCoverage = (c: CoverageTreeElement): c is DeclarationCoverageNode => c instanceof DeclarationCoverageNode; +const shouldShowDeclDetailsOnExpand = (c: CoverageTreeElement): c is IPrefixTreeNode => + isFileCoverage(c) && c.value instanceof FileCoverage && !!c.value.declaration?.total; class TestCoverageTree extends Disposable { private readonly tree: WorkbenchCompressibleObjectTree; + private readonly inputDisposables = this._register(new DisposableStore()); constructor( container: HTMLElement, @@ -215,7 +220,7 @@ class TestCoverageTree extends Disposable { new TestCoverageTreeListDelegate(), [ instantiationService.createInstance(FileCoverageRenderer, labels), - instantiationService.createInstance(FunctionCoverageRenderer), + instantiationService.createInstance(DeclarationCoverageRenderer), instantiationService.createInstance(BasicRenderer), ], { @@ -256,7 +261,7 @@ class TestCoverageTree extends Disposable { this._register(this.tree); this._register(this.tree.onDidChangeCollapseState(e => { const el = e.node.element; - if (!e.node.collapsed && !e.node.children.length && el && shouldShowFunctionDetailsOnExpand(el)) { + if (!e.node.collapsed && !e.node.children.length && el && shouldShowDeclDetailsOnExpand(el)) { if (el.value!.hasSynchronousDetails) { this.tree.setChildren(el, [{ element: new LoadingDetails(), incompressible: true }]); } @@ -270,7 +275,7 @@ class TestCoverageTree extends Disposable { if (e.element) { if (isFileCoverage(e.element) && !e.element.children?.size) { resource = e.element.value!.uri; - } else if (isFunctionCoverage(e.element)) { + } else if (isDeclarationCoverage(e.element)) { resource = e.element.uri; selection = e.element.location; } @@ -294,6 +299,8 @@ class TestCoverageTree extends Disposable { } public setInput(coverage: TestCoverage) { + this.inputDisposables.clear(); + const files = []; for (let node of coverage.tree.nodes) { // when showing initial children, only show from the first file or tee @@ -310,11 +317,22 @@ class TestCoverageTree extends Disposable { incompressible: isFile, collapsed: isFile, // directories can be expanded, and items with function info can be expanded - collapsible: !isFile || !!file.value?.function?.total, + collapsible: !isFile || !!file.value?.declaration?.total, children: file.children && Iterable.map(file.children?.values(), toChild) }; }; + this.inputDisposables.add(onObservableChange(coverage.didAddCoverage, nodes => { + const toRender = findLast(nodes, n => this.tree.hasElement(n)); + if (toRender) { + this.tree.setChildren( + toRender, + Iterable.map(toRender.children?.values() || [], toChild), + { diffIdentityProvider: { getId: el => (el as TestCoverageFileNode).value!.id } } + ); + } + })); + this.tree.setChildren(null, Iterable.map(files, toChild)); } @@ -327,13 +345,13 @@ class TestCoverageTree extends Disposable { return; // avoid any issues if the tree changes in the meanwhile } - const functions: FunctionCoverageNode[] = []; + const decl: DeclarationCoverageNode[] = []; for (const fn of details) { - if (fn.type !== DetailType.Function) { + if (fn.type !== DetailType.Declaration) { continue; } - let arr = functions; + let arr = decl; while (true) { const parent = arr.find(p => p.containedDetails.has(fn)); if (parent) { @@ -343,10 +361,10 @@ class TestCoverageTree extends Disposable { } } - arr.push(new FunctionCoverageNode(el.value!.uri, fn, details)); + arr.push(new DeclarationCoverageNode(el.value!.uri, fn, details)); } - const makeChild = (fn: FunctionCoverageNode): ICompressedTreeElement => ({ + const makeChild = (fn: DeclarationCoverageNode): ICompressedTreeElement => ({ element: fn, incompressible: true, collapsed: true, @@ -354,7 +372,7 @@ class TestCoverageTree extends Disposable { children: fn.children.map(makeChild) }); - this.tree.setChildren(el, functions.map(makeChild)); + this.tree.setChildren(el, decl.map(makeChild)); } } @@ -367,10 +385,10 @@ class TestCoverageTreeListDelegate implements IListVirtualDelegate { case CoverageSortOrder.Coverage: return b.value!.tpc - a.value!.tpc; } - } else if (isFunctionCoverage(a) && isFunctionCoverage(b)) { + } else if (isDeclarationCoverage(a) && isDeclarationCoverage(b)) { switch (order) { case CoverageSortOrder.Location: return Position.compare( @@ -416,6 +434,7 @@ interface FileTemplateData { container: HTMLElement; bars: ManagedTestCoverageBars; templateDisposables: DisposableStore; + elementsDisposables: DisposableStore; label: IResourceLabel; } @@ -440,6 +459,7 @@ class FileCoverageRenderer implements ICompressibleTreeRenderer basenameOrAuthority((e as TestCoverageFileNode).value!.uri)) : basenameOrAuthority(file.uri); + templateData.elementsDisposables.add(autorun(reader => { + stat.value?.didChange.read(reader); + templateData.bars.setCoverageInfo(file); + })); templateData.bars.setCoverageInfo(file); templateData.label.setResource({ resource: file.uri, name }, { @@ -474,7 +500,7 @@ class FileCoverageRenderer implements ICompressibleTreeRenderer { +class DeclarationCoverageRenderer implements ICompressibleTreeRenderer { public static readonly ID = 'N'; - public readonly templateId = FunctionCoverageRenderer.ID; + public readonly templateId = DeclarationCoverageRenderer.ID; constructor( @IInstantiationService private readonly instantiationService: IInstantiationService, ) { } /** @inheritdoc */ - public renderTemplate(container: HTMLElement): FunctionTemplateData { + public renderTemplate(container: HTMLElement): DeclarationTemplateData { const templateDisposables = new DisposableStore(); container.classList.add('test-coverage-list-item'); const icon = dom.append(container, dom.$('.state')); @@ -507,21 +533,21 @@ class FunctionCoverageRenderer implements ICompressibleTreeRenderer, _index: number, templateData: FunctionTemplateData): void { - this.doRender(node.element as FunctionCoverageNode, templateData, node.filterData); + public renderElement(node: ITreeNode, _index: number, templateData: DeclarationTemplateData): void { + this.doRender(node.element as DeclarationCoverageNode, templateData, node.filterData); } /** @inheritdoc */ - public renderCompressedElements(node: ITreeNode, FuzzyScore>, _index: number, templateData: FunctionTemplateData): void { - this.doRender(node.element.elements[node.element.elements.length - 1] as FunctionCoverageNode, templateData, node.filterData); + public renderCompressedElements(node: ITreeNode, FuzzyScore>, _index: number, templateData: DeclarationTemplateData): void { + this.doRender(node.element.elements[node.element.elements.length - 1] as DeclarationCoverageNode, templateData, node.filterData); } - public disposeTemplate(templateData: FunctionTemplateData) { + public disposeTemplate(templateData: DeclarationTemplateData) { templateData.templateDisposables.dispose(); } /** @inheritdoc */ - private doRender(element: FunctionCoverageNode, templateData: FunctionTemplateData, _filterData: FuzzyScore | undefined) { + private doRender(element: DeclarationCoverageNode, templateData: DeclarationTemplateData, _filterData: FuzzyScore | undefined) { const covered = !!element.hits; const icon = covered ? testingWasCovered : testingStatesToIcons.get(TestResultState.Unset); templateData.container.classList.toggle('not-covered', !covered); @@ -552,7 +578,7 @@ class BasicRenderer implements ICompressibleTreeRenderer()); const items: Item[] = [ - { label: localize('testing.coverageSortByLocation', 'Sort by Location'), value: CoverageSortOrder.Location, description: localize('testing.coverageSortByLocationDescription', 'Files are sorted alphabetically, functions are sorted by position') }, - { label: localize('testing.coverageSortByCoverage', 'Sort by Coverage'), value: CoverageSortOrder.Coverage, description: localize('testing.coverageSortByCoverageDescription', 'Files and functions are sorted by total coverage') }, - { label: localize('testing.coverageSortByName', 'Sort by Name'), value: CoverageSortOrder.Name, description: localize('testing.coverageSortByNameDescription', 'Files and functions are sorted alphabetically') }, + { label: localize('testing.coverageSortByLocation', 'Sort by Location'), value: CoverageSortOrder.Location, description: localize('testing.coverageSortByLocationDescription', 'Files are sorted alphabetically, declarations are sorted by position') }, + { label: localize('testing.coverageSortByCoverage', 'Sort by Coverage'), value: CoverageSortOrder.Coverage, description: localize('testing.coverageSortByCoverageDescription', 'Files and declarations are sorted by total coverage') }, + { label: localize('testing.coverageSortByName', 'Sort by Name'), value: CoverageSortOrder.Name, description: localize('testing.coverageSortByNameDescription', 'Files and declarations are sorted alphabetically') }, ]; quickInput.placeholder = localize('testing.coverageSortPlaceholder', 'Sort the Test Coverage view...'); diff --git a/src/vs/workbench/contrib/testing/browser/testExplorerActions.ts b/src/vs/workbench/contrib/testing/browser/testExplorerActions.ts index f6f451bcd0d52..6851abd333bdd 100644 --- a/src/vs/workbench/contrib/testing/browser/testExplorerActions.ts +++ b/src/vs/workbench/contrib/testing/browser/testExplorerActions.ts @@ -9,7 +9,8 @@ import { Iterable } from 'vs/base/common/iterator'; import { KeyChord, KeyCode, KeyMod } from 'vs/base/common/keyCodes'; import { isDefined } from 'vs/base/common/types'; import { URI } from 'vs/base/common/uri'; -import { isCodeEditor } from 'vs/editor/browser/editorBrowser'; +import { ICodeEditorService } from 'vs/editor/browser/services/codeEditorService'; +import { EmbeddedCodeEditorWidget } from 'vs/editor/browser/widget/codeEditor/embeddedCodeEditorWidget'; import { Position } from 'vs/editor/common/core/position'; import { Range } from 'vs/editor/common/core/range'; import { EditorContextKeys } from 'vs/editor/common/editorContextKeys'; @@ -83,7 +84,7 @@ export class HideTestAction extends Action2 { constructor() { super({ id: TestCommandId.HideTestAction, - title: localize('hideTest', 'Hide Test'), + title: localize2('hideTest', 'Hide Test'), menu: { id: MenuId.TestItem, group: 'builtin@2', @@ -105,7 +106,7 @@ export class UnhideTestAction extends Action2 { constructor() { super({ id: TestCommandId.UnhideTestAction, - title: localize('unhideTest', 'Unhide Test'), + title: localize2('unhideTest', 'Unhide Test'), menu: { id: MenuId.TestItem, order: ActionOrder.HideTest, @@ -129,7 +130,7 @@ export class UnhideAllTestsAction extends Action2 { constructor() { super({ id: TestCommandId.UnhideAllTestsAction, - title: localize('unhideAllTests', 'Unhide All Tests'), + title: localize2('unhideAllTests', 'Unhide All Tests'), }); } @@ -179,7 +180,7 @@ export class DebugAction extends RunVisibleAction { constructor() { super(TestRunProfileBitset.Debug, { id: TestCommandId.DebugAction, - title: localize('debug test', 'Debug Test'), + title: localize2('debug test', 'Debug Test'), icon: icons.testingDebugIcon, menu: testItemInlineAndInContext(ActionOrder.Debug, TestingContextKeys.hasDebuggableTests.isEqualTo(true)), }); @@ -190,7 +191,7 @@ export class CoverageAction extends RunVisibleAction { constructor() { super(TestRunProfileBitset.Coverage, { id: TestCommandId.RunWithCoverageAction, - title: localize('run with cover test', 'Run Test with Coverage'), + title: localize2('run with cover test', 'Run Test with Coverage'), icon: icons.testingCoverageIcon, menu: testItemInlineAndInContext(ActionOrder.Coverage, TestingContextKeys.hasCoverableTests.isEqualTo(true)), }); @@ -201,7 +202,7 @@ export class RunUsingProfileAction extends Action2 { constructor() { super({ id: TestCommandId.RunUsingProfileAction, - title: localize('testing.runUsing', 'Execute Using Profile...'), + title: localize2('testing.runUsing', 'Execute Using Profile...'), icon: icons.testingDebugIcon, menu: { id: MenuId.TestItem, @@ -237,7 +238,7 @@ export class RunAction extends RunVisibleAction { constructor() { super(TestRunProfileBitset.Run, { id: TestCommandId.RunAction, - title: localize('run test', 'Run Test'), + title: localize2('run test', 'Run Test'), icon: icons.testingRunIcon, menu: testItemInlineAndInContext(ActionOrder.Run, TestingContextKeys.hasRunnableTests.isEqualTo(true)), }); @@ -248,7 +249,7 @@ export class SelectDefaultTestProfiles extends Action2 { constructor() { super({ id: TestCommandId.SelectDefaultTestProfiles, - title: localize('testing.selectDefaultTestProfiles', 'Select Default Profile'), + title: localize2('testing.selectDefaultTestProfiles', 'Select Default Profile'), icon: icons.testingUpdateProfiles, category, }); @@ -273,7 +274,7 @@ export class ContinuousRunTestAction extends Action2 { constructor() { super({ id: TestCommandId.ToggleContinousRunForTest, - title: localize('testing.toggleContinuousRunOn', 'Turn on Continuous Run'), + title: localize2('testing.toggleContinuousRunOn', 'Turn on Continuous Run'), icon: icons.testingTurnContinuousRunOn, precondition: ContextKeyExpr.or( TestingContextKeys.isContinuousModeOn.isEqualTo(true), @@ -306,7 +307,7 @@ export class ContinuousRunUsingProfileTestAction extends Action2 { constructor() { super({ id: TestCommandId.ContinousRunUsingForTest, - title: localize('testing.startContinuousRunUsing', 'Start Continous Run Using...'), + title: localize2('testing.startContinuousRunUsing', 'Start Continous Run Using...'), icon: icons.testingDebugIcon, menu: [ { @@ -529,7 +530,7 @@ abstract class ExecuteSelectedAction extends ViewAction { export class GetSelectedProfiles extends Action2 { constructor() { - super({ id: TestCommandId.GetSelectedProfiles, title: localize('getSelectedProfiles', 'Get Selected Profiles') }); + super({ id: TestCommandId.GetSelectedProfiles, title: localize2('getSelectedProfiles', 'Get Selected Profiles') }); } /** @@ -555,7 +556,7 @@ export class GetSelectedProfiles extends Action2 { export class GetExplorerSelection extends ViewAction { constructor() { - super({ id: TestCommandId.GetExplorerSelection, title: localize('getExplorerSelection', 'Get Explorer Selection'), viewId: Testing.ExplorerViewId }); + super({ id: TestCommandId.GetExplorerSelection, title: localize2('getExplorerSelection', 'Get Explorer Selection'), viewId: Testing.ExplorerViewId }); } /** @@ -639,7 +640,7 @@ export class RunAllAction extends RunOrDebugAllTestsAction { super( { id: TestCommandId.RunAllAction, - title: localize('runAllTests', 'Run All Tests'), + title: localize2('runAllTests', 'Run All Tests'), icon: icons.testingRunAllIcon, keybinding: { weight: KeybindingWeight.WorkbenchContrib, @@ -657,7 +658,7 @@ export class DebugAllAction extends RunOrDebugAllTestsAction { super( { id: TestCommandId.DebugAllAction, - title: localize('debugAllTests', 'Debug All Tests'), + title: localize2('debugAllTests', 'Debug All Tests'), icon: icons.testingDebugIcon, keybinding: { weight: KeybindingWeight.WorkbenchContrib, @@ -675,7 +676,7 @@ export class CoverageAllAction extends RunOrDebugAllTestsAction { super( { id: TestCommandId.RunAllWithCoverageAction, - title: localize('runAllWithCoverage', 'Run All Tests with Coverage'), + title: localize2('runAllWithCoverage', 'Run All Tests with Coverage'), icon: icons.testingCoverageIcon, keybinding: { weight: KeybindingWeight.WorkbenchContrib, @@ -981,15 +982,20 @@ abstract class ExecuteTestAtCursor extends Action2 { * @override */ public async run(accessor: ServicesAccessor) { + const codeEditorService = accessor.get(ICodeEditorService); const editorService = accessor.get(IEditorService); const activeEditorPane = editorService.activeEditorPane; - const activeControl = editorService.activeTextEditorControl; - if (!activeEditorPane || !activeControl) { + let editor = codeEditorService.getActiveCodeEditor(); + if (!activeEditorPane || !editor) { return; } - const position = activeControl?.getPosition(); - const model = activeControl?.getModel(); + if (editor instanceof EmbeddedCodeEditorWidget) { + editor = editor.getParentEditor(); + } + + const position = editor?.getPosition(); + const model = editor?.getModel(); if (!position || !model || !('uri' in model)) { return; } @@ -1053,8 +1059,8 @@ abstract class ExecuteTestAtCursor extends Action2 { group: this.group, tests: bestNodes.length ? bestNodes : bestNodesBefore, }); - } else if (isCodeEditor(activeControl)) { - MessageController.get(activeControl)?.showMessage(localize('noTestsAtCursor', "No tests found here"), position); + } else if (editor) { + MessageController.get(editor)?.showMessage(localize('noTestsAtCursor', "No tests found here"), position); } } } @@ -1186,9 +1192,15 @@ abstract class ExecuteTestsInCurrentFile extends Action2 { * @override */ public run(accessor: ServicesAccessor) { - const control = accessor.get(IEditorService).activeTextEditorControl; - const position = control?.getPosition(); - const model = control?.getModel(); + let editor = accessor.get(ICodeEditorService).getActiveCodeEditor(); + if (!editor) { + return; + } + if (editor instanceof EmbeddedCodeEditorWidget) { + editor = editor.getParentEditor(); + } + const position = editor?.getPosition(); + const model = editor?.getModel(); if (!position || !model || !('uri' in model)) { return; } @@ -1218,8 +1230,8 @@ abstract class ExecuteTestsInCurrentFile extends Action2 { }); } - if (isCodeEditor(control)) { - MessageController.get(control)?.showMessage(localize('noTestsInFile', "No tests found in this file"), position); + if (editor) { + MessageController.get(editor)?.showMessage(localize('noTestsInFile', "No tests found in this file"), position); } return undefined; diff --git a/src/vs/workbench/contrib/testing/browser/testMessageColorizer.ts b/src/vs/workbench/contrib/testing/browser/testMessageColorizer.ts new file mode 100644 index 0000000000000..527d6e5cc32f5 --- /dev/null +++ b/src/vs/workbench/contrib/testing/browser/testMessageColorizer.ts @@ -0,0 +1,100 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { renderStringAsPlaintext } from 'vs/base/browser/markdownRenderer'; +import { IMarkdownString } from 'vs/base/common/htmlContent'; +import { IDisposable, toDisposable } from 'vs/base/common/lifecycle'; +import { GraphemeIterator, forAnsiStringParts, removeAnsiEscapeCodes } from 'vs/base/common/strings'; +import 'vs/css!./media/testMessageColorizer'; +import { CodeEditorWidget } from 'vs/editor/browser/widget/codeEditor/codeEditorWidget'; +import { Position } from 'vs/editor/common/core/position'; +import { Range } from 'vs/editor/common/core/range'; + +const colorAttrRe = /^\x1b\[([0-9]+)m$/; + +const enum Classes { + Prefix = 'tstm-ansidec-', + ForegroundPrefix = Classes.Prefix + 'fg', + BackgroundPrefix = Classes.Prefix + 'bg', + Bold = Classes.Prefix + '1', + Faint = Classes.Prefix + '2', + Italic = Classes.Prefix + '3', + Underline = Classes.Prefix + '4', +} + +export const renderTestMessageAsText = (tm: string | IMarkdownString) => + typeof tm === 'string' ? removeAnsiEscapeCodes(tm) : renderStringAsPlaintext(tm); + + +/** + * Applies decorations based on ANSI styles from the test message in the editor. + * ANSI sequences are stripped from the text displayed in editor, and this + * re-applies their colorization. + * + * This uses decorations rather than language features because the string + * rendered in the editor lacks the ANSI codes needed to actually apply the + * colorization. + * + * Note: does not support TrueColor. + */ +export const colorizeTestMessageInEditor = (message: string, editor: CodeEditorWidget): IDisposable => { + const decos: string[] = []; + + editor.changeDecorations(changeAccessor => { + let start = new Position(1, 1); + let cls: string[] = []; + for (const part of forAnsiStringParts(message)) { + if (part.isCode) { + const colorAttr = colorAttrRe.exec(part.str)?.[1]; + if (!colorAttr) { + continue; + } + + const n = Number(colorAttr); + if (n === 0) { + cls.length = 0; + } else if (n === 22) { + cls = cls.filter(c => c !== Classes.Bold && c !== Classes.Italic); + } else if (n === 23) { + cls = cls.filter(c => c !== Classes.Italic); + } else if (n === 24) { + cls = cls.filter(c => c !== Classes.Underline); + } else if ((n >= 30 && n <= 39) || (n >= 90 && n <= 99)) { + cls = cls.filter(c => !c.startsWith(Classes.ForegroundPrefix)); + cls.push(Classes.ForegroundPrefix + colorAttr); + } else if ((n >= 40 && n <= 49) || (n >= 100 && n <= 109)) { + cls = cls.filter(c => !c.startsWith(Classes.BackgroundPrefix)); + cls.push(Classes.BackgroundPrefix + colorAttr); + } else { + cls.push(Classes.Prefix + colorAttr); + } + } else { + let line = start.lineNumber; + let col = start.column; + + const graphemes = new GraphemeIterator(part.str); + for (let i = 0; !graphemes.eol(); i += graphemes.nextGraphemeLength()) { + if (part.str[i] === '\n') { + line++; + col = 1; + } else { + col++; + } + } + + const end = new Position(line, col); + if (cls.length) { + decos.push(changeAccessor.addDecoration(Range.fromPositions(start, end), { + inlineClassName: cls.join(' '), + description: 'test-message-colorized', + })); + } + start = end; + } + } + }); + + return toDisposable(() => editor.removeDecorations(decos)); +}; diff --git a/src/vs/workbench/contrib/testing/browser/testingDecorations.ts b/src/vs/workbench/contrib/testing/browser/testingDecorations.ts index 6083c9af89665..33befec1e7494 100644 --- a/src/vs/workbench/contrib/testing/browser/testingDecorations.ts +++ b/src/vs/workbench/contrib/testing/browser/testingDecorations.ts @@ -4,7 +4,6 @@ *--------------------------------------------------------------------------------------------*/ import * as dom from 'vs/base/browser/dom'; -import { renderStringAsPlaintext } from 'vs/base/browser/markdownRenderer'; import { Action, IAction, Separator, SubmenuAction } from 'vs/base/common/actions'; import { equals } from 'vs/base/common/arrays'; import { RunOnceScheduler } from 'vs/base/common/async'; @@ -41,6 +40,7 @@ import { IUriIdentityService } from 'vs/platform/uriIdentity/common/uriIdentity' import { EditorLineNumberContextMenu, GutterActionsRegistry } from 'vs/workbench/contrib/codeEditor/browser/editorLineNumberMenu'; import { getTestItemContextOverlay } from 'vs/workbench/contrib/testing/browser/explorerProjections/testItemContextOverlay'; import { testingRunAllIcon, testingRunIcon, testingStatesToIcons } from 'vs/workbench/contrib/testing/browser/icons'; +import { renderTestMessageAsText } from 'vs/workbench/contrib/testing/browser/testMessageColorizer'; import { DefaultGutterClickAction, TestingConfigKeys, getTestingConfiguration } from 'vs/workbench/contrib/testing/common/configuration'; import { Testing, labelForTestInState } from 'vs/workbench/contrib/testing/common/constants'; import { TestId } from 'vs/workbench/contrib/testing/common/testId'; @@ -174,7 +174,7 @@ export class TestingDecorationService extends Disposable implements ITestingDeco super(); codeEditorService.registerDecorationType('test-message-decoration', TestMessageDecoration.decorationId, {}, undefined); - modelService.onModelRemoved(e => this.decorationCache.delete(e.uri)); + this._register(modelService.onModelRemoved(e => this.decorationCache.delete(e.uri))); const debounceInvalidate = this._register(new RunOnceScheduler(() => this.invalidate(), 100)); @@ -1085,7 +1085,7 @@ class TestMessageDecoration implements ITestDecoration { options.stickiness = TrackedRangeStickiness.NeverGrowsWhenTypingAtEdges; options.collapseOnReplaceEdit = true; - let inlineText = renderStringAsPlaintext(message).replace(lineBreakRe, ' '); + let inlineText = renderTestMessageAsText(message).replace(lineBreakRe, ' '); if (inlineText.length > MAX_INLINE_MESSAGE_LENGTH) { inlineText = inlineText.slice(0, MAX_INLINE_MESSAGE_LENGTH - 1) + '…'; } diff --git a/src/vs/workbench/contrib/testing/browser/testingExplorerFilter.ts b/src/vs/workbench/contrib/testing/browser/testingExplorerFilter.ts index ba9050a610bc9..16b2f5f1e92e9 100644 --- a/src/vs/workbench/contrib/testing/browser/testingExplorerFilter.ts +++ b/src/vs/workbench/contrib/testing/browser/testingExplorerFilter.ts @@ -5,7 +5,7 @@ import * as dom from 'vs/base/browser/dom'; import { ActionBar } from 'vs/base/browser/ui/actionbar/actionbar'; -import { BaseActionViewItem } from 'vs/base/browser/ui/actionbar/actionViewItems'; +import { BaseActionViewItem, IActionViewItemOptions, IBaseActionViewItemOptions } from 'vs/base/browser/ui/actionbar/actionViewItems'; import { AnchorAlignment } from 'vs/base/browser/ui/contextview/contextview'; import { DropdownMenuActionViewItem } from 'vs/base/browser/ui/dropdown/dropdownActionViewItem'; import { Action, IAction, IActionRunner, Separator } from 'vs/base/common/actions'; @@ -46,11 +46,12 @@ export class TestingExplorerFilter extends BaseActionViewItem { constructor( action: IAction, + options: IBaseActionViewItemOptions, @ITestExplorerFilterState private readonly state: ITestExplorerFilterState, @IInstantiationService private readonly instantiationService: IInstantiationService, @ITestService private readonly testService: ITestService, ) { - super(null, action); + super(null, action, options); this.updateFilterActiveState(); this._register(testService.excluded.onTestExclusionsChanged(this.updateFilterActiveState, this)); } @@ -120,9 +121,9 @@ export class TestingExplorerFilter extends BaseActionViewItem { }))); const actionbar = this._register(new ActionBar(container, { - actionViewItemProvider: action => { + actionViewItemProvider: (action, options) => { if (action.id === this.filtersAction.id) { - return this.instantiationService.createInstance(FiltersDropdownMenuActionViewItem, action, this.state, this.actionRunner); + return this.instantiationService.createInstance(FiltersDropdownMenuActionViewItem, action, options, this.state, this.actionRunner); } return undefined; }, @@ -175,6 +176,7 @@ class FiltersDropdownMenuActionViewItem extends DropdownMenuActionViewItem { constructor( action: IAction, + options: IActionViewItemOptions, private readonly filters: ITestExplorerFilterState, actionRunner: IActionRunner, @IContextMenuService contextMenuService: IContextMenuService, diff --git a/src/vs/workbench/contrib/testing/browser/testingExplorerView.ts b/src/vs/workbench/contrib/testing/browser/testingExplorerView.ts index 4110388332e11..5c5572d1dc0a0 100644 --- a/src/vs/workbench/contrib/testing/browser/testingExplorerView.ts +++ b/src/vs/workbench/contrib/testing/browser/testingExplorerView.ts @@ -5,8 +5,11 @@ import * as dom from 'vs/base/browser/dom'; import { IKeyboardEvent } from 'vs/base/browser/keyboardEvent'; +import { IActionViewItemOptions } from 'vs/base/browser/ui/actionbar/actionViewItems'; import { ActionBar, IActionViewItem } from 'vs/base/browser/ui/actionbar/actionbar'; import { Button } from 'vs/base/browser/ui/button/button'; +import type { IUpdatableHover } from 'vs/base/browser/ui/hover/hover'; +import { getDefaultHoverDelegate } from 'vs/base/browser/ui/hover/hoverDelegateFactory'; import { renderLabelWithIcons } from 'vs/base/browser/ui/iconLabel/iconLabels'; import { IIdentityProvider, IKeyboardNavigationLabelProvider, IListVirtualDelegate } from 'vs/base/browser/ui/list/list'; import { DefaultKeyboardNavigationDelegate, IListAccessibilityProvider } from 'vs/base/browser/ui/list/listWidget'; @@ -33,6 +36,7 @@ import { ICommandService } from 'vs/platform/commands/common/commands'; import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; import { IContextKeyService } from 'vs/platform/contextkey/common/contextkey'; import { IContextMenuService } from 'vs/platform/contextview/browser/contextView'; +import { IHoverService } from 'vs/platform/hover/browser/hover'; import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; import { IKeybindingService } from 'vs/platform/keybinding/common/keybinding'; import { IOpenerService } from 'vs/platform/opener/common/opener'; @@ -83,10 +87,10 @@ const enum LastFocusState { export class TestingExplorerView extends ViewPane { public viewModel!: TestingExplorerViewModel; - private filterActionBar = this._register(new MutableDisposable()); + private readonly filterActionBar = this._register(new MutableDisposable()); private container!: HTMLElement; private treeHeader!: HTMLElement; - private discoveryProgress = this._register(new MutableDisposable()); + private readonly discoveryProgress = this._register(new MutableDisposable()); private readonly filter = this._register(new MutableDisposable()); private readonly filterFocusListener = this._register(new MutableDisposable()); private readonly dimensions = { width: 0, height: 0 }; @@ -108,10 +112,11 @@ export class TestingExplorerView extends ViewPane { @IThemeService themeService: IThemeService, @ITestService private readonly testService: ITestService, @ITelemetryService telemetryService: ITelemetryService, + @IHoverService hoverService: IHoverService, @ITestProfileService private readonly testProfileService: ITestProfileService, @ICommandService private readonly commandService: ICommandService, ) { - super(options, keybindingService, contextMenuService, configurationService, contextKeyService, viewDescriptorService, instantiationService, openerService, themeService, telemetryService); + super(options, keybindingService, contextMenuService, configurationService, contextKeyService, viewDescriptorService, instantiationService, openerService, themeService, telemetryService, hoverService); const relayout = this._register(new RunOnceScheduler(() => this.layoutBody(), 1)); this._register(this.onDidChangeViewWelcomeState(() => { @@ -249,6 +254,7 @@ export class TestingExplorerView extends ViewPane { override render(): void { super.render(); this._register(registerNavigableContainer({ + name: 'testingExplorerView', focusNotifiers: [this], focusNextWidget: () => { if (!this.viewModel.tree.isDOMFocused()) { @@ -285,18 +291,18 @@ export class TestingExplorerView extends ViewPane { } /** @override */ - public override getActionViewItem(action: IAction): IActionViewItem | undefined { + public override getActionViewItem(action: IAction, options: IActionViewItemOptions): IActionViewItem | undefined { switch (action.id) { case TestCommandId.FilterAction: - this.filter.value = this.instantiationService.createInstance(TestingExplorerFilter, action); + this.filter.value = this.instantiationService.createInstance(TestingExplorerFilter, action, options); this.filterFocusListener.value = this.filter.value.onDidFocus(() => this.lastFocusState = LastFocusState.Input); return this.filter.value; case TestCommandId.RunSelectedAction: - return this.getRunGroupDropdown(TestRunProfileBitset.Run, action); + return this.getRunGroupDropdown(TestRunProfileBitset.Run, action, options); case TestCommandId.DebugSelectedAction: - return this.getRunGroupDropdown(TestRunProfileBitset.Debug, action); + return this.getRunGroupDropdown(TestRunProfileBitset.Debug, action, options); default: - return super.getActionViewItem(action); + return super.getActionViewItem(action, options); } } @@ -380,10 +386,10 @@ export class TestingExplorerView extends ViewPane { super.saveState(); } - private getRunGroupDropdown(group: TestRunProfileBitset, defaultAction: IAction) { + private getRunGroupDropdown(group: TestRunProfileBitset, defaultAction: IAction, options: IActionViewItemOptions) { const dropdownActions = this.getTestConfigGroupActions(group); if (dropdownActions.length < 2) { - return super.getActionViewItem(defaultAction); + return super.getActionViewItem(defaultAction, options); } const primaryAction = this.instantiationService.createInstance(MenuItemAction, { @@ -392,7 +398,7 @@ export class TestingExplorerView extends ViewPane { icon: group === TestRunProfileBitset.Run ? icons.testingRunAllIcon : icons.testingDebugAllIcon, - }, undefined, undefined, undefined); + }, undefined, undefined, undefined, undefined); const dropdownAction = new Action('selectRunConfig', 'Select Configuration...', 'codicon-chevron-down', true); @@ -401,13 +407,13 @@ export class TestingExplorerView extends ViewPane { primaryAction, dropdownAction, dropdownActions, '', this.contextMenuService, - {} + options ); } private createFilterActionBar() { const bar = new ActionBar(this.treeHeader, { - actionViewItemProvider: action => this.getActionViewItem(action), + actionViewItemProvider: (action, options) => this.getActionViewItem(action, options), triggerKeys: { keyDown: false, keys: [] }, }); bar.push(new Action(TestCommandId.FilterAction)); @@ -442,6 +448,7 @@ class ResultSummaryView extends Disposable { private elementsWereAttached = false; private badgeType: TestingCountBadge; private lastBadge?: NumberBadge | IconBadge; + private countHover: IUpdatableHover; private readonly badgeDisposable = this._register(new MutableDisposable()); private readonly renderLoop = this._register(new RunOnceScheduler(() => this.render(), SUMMARY_RENDER_INTERVAL)); private readonly elements = dom.h('div.result-summary', [ @@ -460,6 +467,7 @@ class ResultSummaryView extends Disposable { @ITestingContinuousRunService private readonly crService: ITestingContinuousRunService, @IConfigurationService configurationService: IConfigurationService, @IInstantiationService instantiationService: IInstantiationService, + @IHoverService hoverService: IHoverService, ) { super(); @@ -472,14 +480,16 @@ class ResultSummaryView extends Disposable { } })); + this.countHover = this._register(hoverService.setupUpdatableHover(getDefaultHoverDelegate('mouse'), this.elements.count, '')); + const ab = this._register(new ActionBar(this.elements.rerun, { - actionViewItemProvider: action => createActionViewItem(instantiationService, action), + actionViewItemProvider: (action, options) => createActionViewItem(instantiationService, action, options), })); ab.push(instantiationService.createInstance(MenuItemAction, { ...new ReRunLastRun().desc, icon: icons.testingRerunIcon }, { ...new DebugLastRun().desc, icon: icons.testingDebugIcon }, {}, - undefined, + undefined, undefined ), { icon: true, label: false }); this.render(); @@ -518,7 +528,7 @@ class ResultSummaryView extends Disposable { } count.textContent = `${counts.passed}/${counts.totalWillBeRun}`; - count.title = getTestProgressText(counts); + this.countHover.update(getTestProgressText(counts)); this.renderActivityBadge(counts); if (!this.elementsWereAttached) { @@ -573,7 +583,7 @@ const enum WelcomeExperience { class TestingExplorerViewModel extends Disposable { public tree: TestingObjectTree; private filter: TestsFilter; - public projection = this._register(new MutableDisposable()); + public readonly projection = this._register(new MutableDisposable()); private readonly revealTimeout = new MutableDisposable(); private readonly _viewMode = TestingContextKeys.viewMode.bindTo(this.contextKeyService); @@ -1301,6 +1311,7 @@ class IdentityProvider implements IIdentityProvider { interface IErrorTemplateData { label: HTMLElement; + disposable: DisposableStore; } class ErrorRenderer implements ITreeRenderer { @@ -1308,7 +1319,10 @@ class ErrorRenderer implements ITreeRenderer, _: number, data: IErrorTemplateData): void { @@ -1330,12 +1344,11 @@ class ErrorRenderer implements ITreeRenderer + actionViewItemProvider: (action, options) => action instanceof MenuItemAction - ? this.instantiationService.createInstance(MenuEntryActionViewItem, action, undefined) + ? this.instantiationService.createInstance(MenuEntryActionViewItem, action, { hoverDelegate: options.hoverDelegate }) : undefined })); @@ -1451,7 +1465,7 @@ class TestItemRenderer extends Disposable data.icon.className += ' retired'; } - data.label.title = getLabelForTestTreeElement(node.element); + data.elementDisposable.add(this.hoverService.setupUpdatableHover(getDefaultHoverDelegate('mouse'), data.label, getLabelForTestTreeElement(node.element))); if (node.element.test.item.label.trim()) { dom.reset(data.label, ...renderLabelWithIcons(node.element.test.item.label)); } else { diff --git a/src/vs/workbench/contrib/testing/browser/testingOutputPeek.ts b/src/vs/workbench/contrib/testing/browser/testingOutputPeek.ts index a64fc0274f345..2bd0e5f3243c9 100644 --- a/src/vs/workbench/contrib/testing/browser/testingOutputPeek.ts +++ b/src/vs/workbench/contrib/testing/browser/testingOutputPeek.ts @@ -4,7 +4,6 @@ *--------------------------------------------------------------------------------------------*/ import * as dom from 'vs/base/browser/dom'; -import { renderStringAsPlaintext } from 'vs/base/browser/markdownRenderer'; import { ActionBar } from 'vs/base/browser/ui/actionbar/actionbar'; import { alert } from 'vs/base/browser/ui/aria/aria'; import { renderLabelWithIcons } from 'vs/base/browser/ui/iconLabel/iconLabels'; @@ -37,16 +36,17 @@ import 'vs/css!./testingOutputPeek'; import { ICodeEditor, IDiffEditorConstructionOptions, isCodeEditor } from 'vs/editor/browser/editorBrowser'; import { EditorAction2 } from 'vs/editor/browser/editorExtensions'; import { ICodeEditorService } from 'vs/editor/browser/services/codeEditorService'; -import { CodeEditorWidget } from 'vs/editor/browser/widget/codeEditorWidget'; +import { CodeEditorWidget } from 'vs/editor/browser/widget/codeEditor/codeEditorWidget'; +import { EmbeddedCodeEditorWidget } from 'vs/editor/browser/widget/codeEditor/embeddedCodeEditorWidget'; import { DiffEditorWidget } from 'vs/editor/browser/widget/diffEditor/diffEditorWidget'; -import { EmbeddedCodeEditorWidget, EmbeddedDiffEditorWidget } from 'vs/editor/browser/widget/embeddedCodeEditorWidget'; +import { EmbeddedDiffEditorWidget } from 'vs/editor/browser/widget/diffEditor/embeddedDiffEditorWidget'; +import { MarkdownRenderer } from 'vs/editor/browser/widget/markdownRenderer/browser/markdownRenderer'; import { IDiffEditorOptions, IEditorOptions } from 'vs/editor/common/config/editorOptions'; import { Position } from 'vs/editor/common/core/position'; import { Range } from 'vs/editor/common/core/range'; import { IEditor, IEditorContribution, ScrollType } from 'vs/editor/common/editorCommon'; import { EditorContextKeys } from 'vs/editor/common/editorContextKeys'; import { IResolvedTextEditorModel, ITextModelService } from 'vs/editor/common/services/resolverService'; -import { MarkdownRenderer } from 'vs/editor/browser/widget/markdownRenderer/browser/markdownRenderer'; import { IPeekViewService, PeekViewWidget, peekViewResultsBackground, peekViewTitleForeground, peekViewTitleInfoForeground } from 'vs/editor/contrib/peekView/browser/peekView'; import { localize, localize2 } from 'vs/nls'; import { Categories } from 'vs/platform/action/common/actionCommonCategories'; @@ -58,6 +58,7 @@ import { IConfigurationService } from 'vs/platform/configuration/common/configur import { ContextKeyExpr, IContextKey, IContextKeyService } from 'vs/platform/contextkey/common/contextkey'; import { IContextMenuService } from 'vs/platform/contextview/browser/contextView'; import { ITextEditorOptions, TextEditorSelectionRevealType } from 'vs/platform/editor/common/editor'; +import { IHoverService } from 'vs/platform/hover/browser/hover'; import { IInstantiationService, ServicesAccessor } from 'vs/platform/instantiation/common/instantiation'; import { ServiceCollection } from 'vs/platform/instantiation/common/serviceCollection'; import { IKeybindingService } from 'vs/platform/keybinding/common/keybinding'; @@ -79,13 +80,13 @@ import { IViewPaneOptions, ViewPane } from 'vs/workbench/browser/parts/views/vie import { EditorModel } from 'vs/workbench/common/editor/editorModel'; import { PANEL_BACKGROUND, SIDE_BAR_BACKGROUND } from 'vs/workbench/common/theme'; import { IViewDescriptorService, ViewContainerLocation } from 'vs/workbench/common/views'; -import { IViewsService } from 'vs/workbench/services/views/common/viewsService'; import { DetachedProcessInfo } from 'vs/workbench/contrib/terminal/browser/detachedTerminal'; import { IDetachedTerminalInstance, ITerminalService } from 'vs/workbench/contrib/terminal/browser/terminal'; import { getXtermScaledDimensions } from 'vs/workbench/contrib/terminal/browser/xterm/xtermTerminal'; import { TERMINAL_BACKGROUND_COLOR } from 'vs/workbench/contrib/terminal/common/terminalColorRegistry'; import { getTestItemContextOverlay } from 'vs/workbench/contrib/testing/browser/explorerProjections/testItemContextOverlay'; import * as icons from 'vs/workbench/contrib/testing/browser/icons'; +import { colorizeTestMessageInEditor, renderTestMessageAsText } from 'vs/workbench/contrib/testing/browser/testMessageColorizer'; import { testingMessagePeekBorder, testingPeekBorder, testingPeekHeaderBackground, testingPeekMessageHeaderBackground } from 'vs/workbench/contrib/testing/browser/theme'; import { AutoOpenPeekViewWhen, TestingConfigKeys, getTestingConfiguration } from 'vs/workbench/contrib/testing/common/configuration'; import { Testing } from 'vs/workbench/contrib/testing/common/constants'; @@ -97,12 +98,19 @@ import { ITestProfileService } from 'vs/workbench/contrib/testing/common/testPro import { ITaskRawOutput, ITestResult, ITestRunTaskResults, LiveTestResult, TestResultItemChange, TestResultItemChangeReason, maxCountPriority, resultItemParents } from 'vs/workbench/contrib/testing/common/testResult'; import { ITestResultService, ResultChangeEvent } from 'vs/workbench/contrib/testing/common/testResultService'; import { ITestService } from 'vs/workbench/contrib/testing/common/testService'; -import { IRichLocation, ITestErrorMessage, ITestItem, ITestMessage, ITestMessageMenuArgs, ITestRunTask, ITestTaskState, TestMessageType, TestResultItem, TestResultState, TestRunProfileBitset, getMarkId } from 'vs/workbench/contrib/testing/common/testTypes'; +import { IRichLocation, ITestErrorMessage, ITestItem, ITestItemContext, ITestMessage, ITestMessageMenuArgs, ITestRunTask, ITestTaskState, InternalTestItem, TestMessageType, TestResultItem, TestResultState, TestRunProfileBitset, getMarkId, testResultStateToContextValues } from 'vs/workbench/contrib/testing/common/testTypes'; import { TestingContextKeys } from 'vs/workbench/contrib/testing/common/testingContextKeys'; import { IShowResultOptions, ITestingPeekOpener } from 'vs/workbench/contrib/testing/common/testingPeekOpener'; import { cmpPriority, isFailedState } from 'vs/workbench/contrib/testing/common/testingStates'; import { ParsedTestUri, TestUriType, buildTestUri, parseTestUri } from 'vs/workbench/contrib/testing/common/testingUri'; import { IEditorService } from 'vs/workbench/services/editor/common/editorService'; +import { IViewsService } from 'vs/workbench/services/views/common/viewsService'; + +const getMessageArgs = (test: TestResultItem, message: ITestMessage): ITestMessageMenuArgs => ({ + $mid: MarshalledId.TestMessageMenuArgs, + test: InternalTestItem.serialize(test), + message: ITestMessage.serialize(message), +}); class MessageSubject { public readonly test: ITestItem; @@ -111,6 +119,7 @@ class MessageSubject { public readonly actualUri: URI; public readonly messageUri: URI; public readonly revealLocation: IRichLocation | undefined; + public readonly context: ITestMessageMenuArgs | undefined; public get isDiffable() { return this.message.type === TestMessageType.Error && isDiffable(this.message); @@ -120,14 +129,6 @@ class MessageSubject { return this.message.type === TestMessageType.Error ? this.message.contextValue : undefined; } - public get context(): ITestMessageMenuArgs { - return { - $mid: MarshalledId.TestMessageMenuArgs, - extId: this.test.extId, - message: ITestMessage.serialize(this.message), - }; - } - constructor(public readonly result: ITestResult, test: TestResultItem, public readonly taskIndex: number, public readonly messageIndex: number) { this.test = test.item; const messages = test.tasks[taskIndex].messages; @@ -139,6 +140,7 @@ class MessageSubject { this.messageUri = buildTestUri({ ...parts, type: TestUriType.ResultMessage }); const message = this.message = messages[this.messageIndex]; + this.context = getMessageArgs(test, message); this.revealLocation = message.location ?? (test.item.uri && test.item.range ? { uri: test.item.uri, range: Range.lift(test.item.range) } : undefined); } } @@ -590,7 +592,7 @@ export class TestingOutputPeekController extends Disposable implements IEditorCo } if (subject instanceof MessageSubject) { - alert(renderStringAsPlaintext(subject.message.message)); + alert(renderTestMessageAsText(subject.message.message)); } this.peek.value.setModel(subject); @@ -815,7 +817,7 @@ class TestResultsViewContent extends Disposable { this._register(this.instantiationService.createInstance(PlainTextMessagePeek, this.editor, messageContainer)), ]; - this.messageContextKeyService = this._register(this.contextKeyService.createScoped(this.messageContainer)); + this.messageContextKeyService = this._register(this.contextKeyService.createScoped(containerElement)); this.contextKeyTestMessage = TestingContextKeys.testMessageContext.bindTo(this.messageContextKeyService); this.contextKeyResultOutdated = TestingContextKeys.testResultOutdated.bindTo(this.messageContextKeyService); @@ -996,11 +998,11 @@ class TestResultsPeek extends PeekViewWidget { protected override _fillBody(containerElement: HTMLElement): void { this.content.fillBody(containerElement); - this.content.onDidRequestReveal(sub => { + this._disposables.add(this.content.onDidRequestReveal(sub => { TestingOutputPeekController.get(this.editor)?.show(sub instanceof MessageSubject ? sub.messageUri : sub.outputUri); - }); + })); } /** @@ -1037,7 +1039,7 @@ class TestResultsPeek extends PeekViewWidget { public async showInPlace(subject: InspectSubject) { if (subject instanceof MessageSubject) { const message = subject.message; - this.setTitle(firstLine(renderStringAsPlaintext(message.message)), stripIcons(subject.test.label)); + this.setTitle(firstLine(renderTestMessageAsText(message.message)), stripIcons(subject.test.label)); } else { this.setTitle(localize('testOutputTitle', 'Test Output')); } @@ -1085,9 +1087,10 @@ export class TestResultsView extends ViewPane { @IOpenerService openerService: IOpenerService, @IThemeService themeService: IThemeService, @ITelemetryService telemetryService: ITelemetryService, + @IHoverService hoverService: IHoverService, @ITestResultService private readonly resultService: ITestResultService, ) { - super(options, keybindingService, contextMenuService, configurationService, contextKeyService, viewDescriptorService, instantiationService, openerService, themeService, telemetryService); + super(options, keybindingService, contextMenuService, configurationService, contextKeyService, viewDescriptorService, instantiationService, openerService, themeService, telemetryService, hoverService); } public get subject() { @@ -1317,6 +1320,7 @@ class MarkdownTestMessagePeek extends Disposable implements IPeekOutputRenderer } class PlainTextMessagePeek extends Disposable implements IPeekOutputRenderer { + private readonly widgetDecorations = this._register(new MutableDisposable()); private readonly widget = this._register(new MutableDisposable()); private readonly model = this._register(new MutableDisposable()); private dimension?: dom.IDimension; @@ -1362,11 +1366,13 @@ class PlainTextMessagePeek extends Disposable implements IPeekOutputRenderer { this.widget.value.setModel(modelRef.object.textEditorModel); this.widget.value.updateOptions(commonEditorOptions); + this.widgetDecorations.value = colorizeTestMessageInEditor(message.message, this.widget.value); } private clear() { - this.model.clear(); + this.widgetDecorations.clear(); this.widget.clear(); + this.model.clear(); } public layout(dimensions: dom.IDimension) { @@ -1663,7 +1669,7 @@ export class CloseTestPeek extends EditorAction2 { constructor() { super({ id: 'editor.closeTestPeek', - title: localize('close', 'Close'), + title: localize2('close', 'Close'), icon: Codicon.close, precondition: ContextKeyExpr.or(TestingContextKeys.isInPeek, TestingContextKeys.isPeekVisible), keybinding: { @@ -1744,7 +1750,10 @@ class CoverageElement implements ITreeElement { class TestCaseElement implements ITreeElement { public readonly type = 'test'; - public readonly context = this.test.item.extId; + public readonly context: ITestItemContext = { + $mid: MarshalledId.TestItemContext, + tests: [InternalTestItem.serialize(this.test)], + }; public readonly id = `${this.results.id}/${this.test.item.extId}`; public readonly description?: string; @@ -1825,13 +1834,12 @@ class TestMessageElement implements ITreeElement { } public get context(): ITestMessageMenuArgs { - return { - $mid: MarshalledId.TestMessageMenuArgs, - extId: this.test.item.extId, - message: ITestMessage.serialize(this.message), - }; + return getMessageArgs(this.test, this.message); } + public get outputSubject() { + return new TestOutputSubject(this.result, this.taskIndex, this.test); + } constructor( public readonly result: ITestResult, @@ -1853,7 +1861,7 @@ class TestMessageElement implements ITreeElement { this.id = this.uri.toString(); - const asPlaintext = renderStringAsPlaintext(m.message); + const asPlaintext = renderTestMessageAsText(m.message); const lines = count(asPlaintext.trimEnd(), '\n'); this.label = firstLine(asPlaintext); if (lines > 0) { @@ -1941,6 +1949,7 @@ class OutputPeekTree extends Disposable { result = Iterable.concat( Iterable.single>({ element: new CoverageElement(results, task, coverageService), + incompressible: true, }), result, ); @@ -2226,9 +2235,9 @@ class TestRunElementRenderer implements ICompressibleTreeRenderer + actionViewItemProvider: (action, options) => action instanceof MenuItemAction - ? this.instantiationService.createInstance(MenuEntryActionViewItem, action, undefined) + ? this.instantiationService.createInstance(MenuEntryActionViewItem, action, { hoverDelegate: options.hoverDelegate }) : undefined }); @@ -2356,11 +2365,10 @@ class TreeActionsProvider { if (element instanceof TestCaseElement || element instanceof TestMessageElement) { contextKeys.push( [TestingContextKeys.testResultOutdated.key, element.test.retired], + [TestingContextKeys.testResultState.key, testResultStateToContextValues[element.test.ownComputedState]], ...getTestItemContextOverlay(element.test, capabilities), ); - } - if (element instanceof TestCaseElement) { const extId = element.test.item.extId; if (element.test.tasks[element.taskIndex].messages.some(m => m.type === TestMessageType.Output)) { primary.push(new Action( @@ -2400,12 +2408,15 @@ class TreeActionsProvider { )); } + } + + if (element instanceof TestMessageElement) { primary.push(new Action( 'testing.outputPeek.goToFile', localize('testing.goToFile', "Go to Source"), ThemeIcon.asClassName(Codicon.goToFile), undefined, - () => this.commandService.executeCommand('vscode.revealTest', extId), + () => this.commandService.executeCommand('vscode.revealTest', element.test.item.extId), )); } @@ -2483,6 +2494,9 @@ export class GoToNextMessageAction extends Action2 { id: GoToNextMessageAction.ID, f1: true, title: localize2('testing.goToNextMessage', 'Go to Next Test Failure'), + metadata: { + description: localize2('testing.goToNextMessage.description', 'Shows the next failure message in your file') + }, icon: Codicon.arrowDown, category: Categories.Test, keybinding: { @@ -2516,6 +2530,9 @@ export class GoToPreviousMessageAction extends Action2 { id: GoToPreviousMessageAction.ID, f1: true, title: localize2('testing.goToPreviousMessage', 'Go to Previous Test Failure'), + metadata: { + description: localize2('testing.goToPreviousMessage.description', 'Shows the previous failure message in your file') + }, icon: Codicon.arrowUp, category: Categories.Test, keybinding: { @@ -2567,6 +2584,9 @@ export class ToggleTestingPeekHistory extends Action2 { id: ToggleTestingPeekHistory.ID, f1: true, title: localize2('testing.toggleTestingPeekHistory', 'Toggle Test History in Peek'), + metadata: { + description: localize2('testing.toggleTestingPeekHistory.description', 'Shows or hides the history of test runs in the peek view') + }, icon: Codicon.history, category: Categories.Test, menu: [{ diff --git a/src/vs/workbench/contrib/testing/browser/theme.ts b/src/vs/workbench/contrib/testing/browser/theme.ts index 4e23240541171..536c03da5f99f 100644 --- a/src/vs/workbench/contrib/testing/browser/theme.ts +++ b/src/vs/workbench/contrib/testing/browser/theme.ts @@ -190,6 +190,57 @@ export const testStatesToIconColors: { [K in TestResultState]?: string } = { [TestResultState.Skipped]: testingColorIconSkipped, }; +export const testingRetiredColorIconErrored = registerColor('testing.iconErrored.retired', { + dark: transparent(testingColorIconErrored, 0.7), + light: transparent(testingColorIconErrored, 0.7), + hcDark: transparent(testingColorIconErrored, 0.7), + hcLight: transparent(testingColorIconErrored, 0.7) +}, localize('testing.iconErrored.retired', "Retired color for the 'Errored' icon in the test explorer.")); + +export const testingRetiredColorIconFailed = registerColor('testing.iconFailed.retired', { + dark: transparent(testingColorIconFailed, 0.7), + light: transparent(testingColorIconFailed, 0.7), + hcDark: transparent(testingColorIconFailed, 0.7), + hcLight: transparent(testingColorIconFailed, 0.7) +}, localize('testing.iconFailed.retired', "Retired color for the 'failed' icon in the test explorer.")); + +export const testingRetiredColorIconPassed = registerColor('testing.iconPassed.retired', { + dark: transparent(testingColorIconPassed, 0.7), + light: transparent(testingColorIconPassed, 0.7), + hcDark: transparent(testingColorIconPassed, 0.7), + hcLight: transparent(testingColorIconPassed, 0.7) +}, localize('testing.iconPassed.retired', "Retired color for the 'passed' icon in the test explorer.")); + +export const testingRetiredColorIconQueued = registerColor('testing.iconQueued.retired', { + dark: transparent(testingColorIconQueued, 0.7), + light: transparent(testingColorIconQueued, 0.7), + hcDark: transparent(testingColorIconQueued, 0.7), + hcLight: transparent(testingColorIconQueued, 0.7) +}, localize('testing.iconQueued.retired', "Retired color for the 'Queued' icon in the test explorer.")); + +export const testingRetiredColorIconUnset = registerColor('testing.iconUnset.retired', { + dark: transparent(testingColorIconUnset, 0.7), + light: transparent(testingColorIconUnset, 0.7), + hcDark: transparent(testingColorIconUnset, 0.7), + hcLight: transparent(testingColorIconUnset, 0.7) +}, localize('testing.iconUnset.retired', "Retired color for the 'Unset' icon in the test explorer.")); + +export const testingRetiredColorIconSkipped = registerColor('testing.iconSkipped.retired', { + dark: transparent(testingColorIconSkipped, 0.7), + light: transparent(testingColorIconSkipped, 0.7), + hcDark: transparent(testingColorIconSkipped, 0.7), + hcLight: transparent(testingColorIconSkipped, 0.7) +}, localize('testing.iconSkipped.retired', "Retired color for the 'Skipped' icon in the test explorer.")); + +export const testStatesToRetiredIconColors: { [K in TestResultState]?: string } = { + [TestResultState.Errored]: testingRetiredColorIconErrored, + [TestResultState.Failed]: testingRetiredColorIconFailed, + [TestResultState.Passed]: testingRetiredColorIconPassed, + [TestResultState.Queued]: testingRetiredColorIconQueued, + [TestResultState.Unset]: testingRetiredColorIconUnset, + [TestResultState.Skipped]: testingRetiredColorIconSkipped, +}; + registerThemingParticipant((theme, collector) => { const editorBg = theme.getColor(editorBackground); diff --git a/src/vs/workbench/contrib/testing/common/constants.ts b/src/vs/workbench/contrib/testing/common/constants.ts index 35d9eb5e5521e..8f6bb09e32af6 100644 --- a/src/vs/workbench/contrib/testing/common/constants.ts +++ b/src/vs/workbench/contrib/testing/common/constants.ts @@ -18,6 +18,8 @@ export const enum Testing { ResultsPanelId = 'workbench.panel.testResults', ResultsViewId = 'workbench.panel.testResults.view', + + MessageLanguageId = 'vscodeInternalTestMessage' } export const enum TestExplorerViewMode { diff --git a/src/vs/workbench/contrib/testing/common/observableUtils.ts b/src/vs/workbench/contrib/testing/common/observableUtils.ts new file mode 100644 index 0000000000000..26c6c087d7891 --- /dev/null +++ b/src/vs/workbench/contrib/testing/common/observableUtils.ts @@ -0,0 +1,27 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IDisposable } from 'vs/base/common/lifecycle'; +import { IObservable, IObserver } from 'vs/base/common/observable'; + +export function onObservableChange(observable: IObservable, callback: (value: T) => void): IDisposable { + const o: IObserver = { + beginUpdate() { }, + endUpdate() { }, + handlePossibleChange(observable) { + observable.reportChanges(); + }, + handleChange(_observable: IObservable, change: TChange) { + callback(change as any as T); + } + }; + + observable.addObserver(o); + return { + dispose() { + observable.removeObserver(o); + } + }; +} diff --git a/src/vs/workbench/contrib/testing/common/testCoverage.ts b/src/vs/workbench/contrib/testing/common/testCoverage.ts index 7545c7c3da668..10d4f23cea351 100644 --- a/src/vs/workbench/contrib/testing/common/testCoverage.ts +++ b/src/vs/workbench/contrib/testing/common/testCoverage.ts @@ -5,43 +5,79 @@ import { CancellationToken } from 'vs/base/common/cancellation'; import { ResourceMap } from 'vs/base/common/map'; +import { deepClone } from 'vs/base/common/objects'; +import { ITransaction, observableSignal } from 'vs/base/common/observable'; import { IPrefixTreeNode, WellDefinedPrefixTree } from 'vs/base/common/prefixTree'; import { URI } from 'vs/base/common/uri'; import { IUriIdentityService } from 'vs/platform/uriIdentity/common/uriIdentity'; -import { CoverageDetails, ICoveredCount, IFileCoverage } from 'vs/workbench/contrib/testing/common/testTypes'; +import { CoverageDetails, ICoverageCount, IFileCoverage } from 'vs/workbench/contrib/testing/common/testTypes'; export interface ICoverageAccessor { - provideFileCoverage: (token: CancellationToken) => Promise; - resolveFileCoverage: (fileIndex: number, token: CancellationToken) => Promise; + getCoverageDetails: (id: string, token: CancellationToken) => Promise; } +let incId = 0; + /** * Class that exposese coverage information for a run. */ export class TestCoverage { - private _tree?: WellDefinedPrefixTree; - - public static async load(taskId: string, accessor: ICoverageAccessor, uriIdentityService: IUriIdentityService, token: CancellationToken) { - const files = await accessor.provideFileCoverage(token); - const map = new ResourceMap(); - for (const [i, file] of files.entries()) { - map.set(file.uri, new FileCoverage(file, i, accessor)); - } - return new TestCoverage(taskId, map, uriIdentityService); - } - - public get tree() { - return this._tree ??= this.buildCoverageTree(); - } + private readonly fileCoverage = new ResourceMap(); + public readonly didAddCoverage = observableSignal[]>(this); + public readonly tree = new WellDefinedPrefixTree(); public readonly associatedData = new Map(); constructor( public readonly fromTaskId: string, - private readonly fileCoverage: ResourceMap, private readonly uriIdentityService: IUriIdentityService, + private readonly accessor: ICoverageAccessor, ) { } + public append(rawCoverage: IFileCoverage, tx: ITransaction | undefined) { + const coverage = new FileCoverage(rawCoverage, this.accessor); + const previous = this.getComputedForUri(coverage.uri); + const applyDelta = (kind: 'statement' | 'branch' | 'declaration', node: ComputedFileCoverage) => { + if (!node[kind]) { + if (coverage[kind]) { + node[kind] = { ...coverage[kind]! }; + } + } else { + node[kind]!.covered += (coverage[kind]?.covered || 0) - (previous?.[kind]?.covered || 0); + node[kind]!.total += (coverage[kind]?.total || 0) - (previous?.[kind]?.total || 0); + } + }; + + // We insert using the non-canonical path to normalize for casing differences + // between URIs, but when inserting an intermediate node always use 'a' canonical + // version. + const canonical = [...this.treePathForUri(coverage.uri, /* canonical = */ true)]; + const chain: IPrefixTreeNode[] = []; + this.tree.insert(this.treePathForUri(coverage.uri, /* canonical = */ false), coverage, node => { + chain.push(node); + + if (chain.length === canonical.length) { + node.value = coverage; + } else if (!node.value) { + // clone because later intersertions can modify the counts: + const intermediate = deepClone(rawCoverage); + intermediate.id = String(incId++); + intermediate.uri = this.treePathToUri(canonical.slice(0, chain.length)); + node.value = new ComputedFileCoverage(intermediate); + } else { + applyDelta('statement', node.value); + applyDelta('branch', node.value); + applyDelta('declaration', node.value); + node.value.didChange.trigger(tx); + } + }); + + this.fileCoverage.set(coverage.uri, coverage); + if (chain) { + this.didAddCoverage.trigger(tx, chain); + } + } + /** * Gets coverage information for all files. */ @@ -64,54 +100,6 @@ export class TestCoverage { return this.tree.find(this.treePathForUri(uri, /* canonical = */ false)); } - private buildCoverageTree() { - const tree = new WellDefinedPrefixTree(); - const nodeCanonicalSegments = new Map, string>(); - - // 1. Initial iteration. We insert based on the case-erased file path, and - // then tag the nodes with their 'canonical' path segment preserving the - // original casing we were given, to avoid #200604 - for (const file of this.fileCoverage.values()) { - const keyPath = this.treePathForUri(file.uri, /* canonical = */ false); - const canonicalPath = this.treePathForUri(file.uri, /* canonical = */ true); - tree.insert(keyPath, file, node => { - nodeCanonicalSegments.set(node, canonicalPath.next().value as string); - }); - } - - // 2. Depth-first iteration to create computed nodes - const calculateComputed = (path: string[], node: IPrefixTreeNode): AbstractFileCoverage => { - if (node.value) { - return node.value; - } - - const fileCoverage: IFileCoverage = { - uri: this.treePathToUri(path), - statement: ICoveredCount.empty(), - }; - - if (node.children) { - for (const [prefix, child] of node.children) { - path.push(nodeCanonicalSegments.get(child) || prefix); - const v = calculateComputed(path, child); - path.pop(); - - ICoveredCount.sum(fileCoverage.statement, v.statement); - if (v.branch) { ICoveredCount.sum(fileCoverage.branch ??= ICoveredCount.empty(), v.branch); } - if (v.function) { ICoveredCount.sum(fileCoverage.function ??= ICoveredCount.empty(), v.function); } - } - } - - return node.value = new ComputedFileCoverage(fileCoverage); - }; - - for (const node of tree.nodes) { - calculateComputed([], node); - } - - return tree; - } - private *treePathForUri(uri: URI, canconicalPath: boolean) { yield uri.scheme; yield uri.authority; @@ -125,7 +113,7 @@ export class TestCoverage { } } -export const getTotalCoveragePercent = (statement: ICoveredCount, branch: ICoveredCount | undefined, function_: ICoveredCount | undefined) => { +export const getTotalCoveragePercent = (statement: ICoverageCount, branch: ICoverageCount | undefined, function_: ICoverageCount | undefined) => { let numerator = statement.covered; let denominator = statement.total; @@ -143,24 +131,27 @@ export const getTotalCoveragePercent = (statement: ICoveredCount, branch: ICover }; export abstract class AbstractFileCoverage { + public readonly id: string; public readonly uri: URI; - public readonly statement: ICoveredCount; - public readonly branch?: ICoveredCount; - public readonly function?: ICoveredCount; + public statement: ICoverageCount; + public branch?: ICoverageCount; + public declaration?: ICoverageCount; + public readonly didChange = observableSignal(this); /** * Gets the total coverage percent based on information provided. * This is based on the Clover total coverage formula */ public get tpc() { - return getTotalCoveragePercent(this.statement, this.branch, this.function); + return getTotalCoveragePercent(this.statement, this.branch, this.declaration); } constructor(coverage: IFileCoverage) { + this.id = coverage.id; this.uri = coverage.uri; this.statement = coverage.statement; this.branch = coverage.branch; - this.function = coverage.function; + this.declaration = coverage.declaration; } } @@ -171,7 +162,7 @@ export abstract class AbstractFileCoverage { export class ComputedFileCoverage extends AbstractFileCoverage { } export class FileCoverage extends AbstractFileCoverage { - private _details?: CoverageDetails[] | Promise; + private _details?: Promise; private resolved?: boolean; /** Gets whether details are synchronously available */ @@ -179,16 +170,15 @@ export class FileCoverage extends AbstractFileCoverage { return this._details instanceof Array || this.resolved; } - constructor(coverage: IFileCoverage, private readonly index: number, private readonly accessor: ICoverageAccessor) { + constructor(coverage: IFileCoverage, private readonly accessor: ICoverageAccessor) { super(coverage); - this._details = coverage.details; } /** * Gets per-line coverage details. */ public async details(token = CancellationToken.None) { - this._details ??= this.accessor.resolveFileCoverage(this.index, token); + this._details ??= this.accessor.getCoverageDetails(this.id, token); try { const d = await this._details; diff --git a/src/vs/workbench/contrib/testing/common/testCoverageService.ts b/src/vs/workbench/contrib/testing/common/testCoverageService.ts index 57c0832fdfdea..0bf62937458f3 100644 --- a/src/vs/workbench/contrib/testing/common/testCoverageService.ts +++ b/src/vs/workbench/contrib/testing/common/testCoverageService.ts @@ -6,16 +6,14 @@ import { CancellationTokenSource } from 'vs/base/common/cancellation'; import { Disposable, MutableDisposable } from 'vs/base/common/lifecycle'; import { IObservable, observableValue } from 'vs/base/common/observable'; -import { localize } from 'vs/nls'; import { IContextKey, IContextKeyService } from 'vs/platform/contextkey/common/contextkey'; import { createDecorator } from 'vs/platform/instantiation/common/instantiation'; -import { INotificationService } from 'vs/platform/notification/common/notification'; -import { IViewsService } from 'vs/workbench/services/views/common/viewsService'; import { Testing } from 'vs/workbench/contrib/testing/common/constants'; import { TestCoverage } from 'vs/workbench/contrib/testing/common/testCoverage'; import { ITestRunTaskResults } from 'vs/workbench/contrib/testing/common/testResult'; import { ITestResultService } from 'vs/workbench/contrib/testing/common/testResultService'; import { TestingContextKeys } from 'vs/workbench/contrib/testing/common/testingContextKeys'; +import { IViewsService } from 'vs/workbench/services/views/common/viewsService'; export const ITestCoverageService = createDecorator('testCoverageService'); @@ -50,7 +48,6 @@ export class TestCoverageService extends Disposable implements ITestCoverageServ @IContextKeyService contextKeyService: IContextKeyService, @ITestResultService resultService: ITestResultService, @IViewsService private readonly viewsService: IViewsService, - @INotificationService private readonly notificationService: INotificationService, ) { super(); this._isOpenKey = TestingContextKeys.isTestCoverageOpen.bindTo(contextKeyService); @@ -76,21 +73,13 @@ export class TestCoverageService extends Disposable implements ITestCoverageServ public async openCoverage(task: ITestRunTaskResults, focus = true) { this.lastOpenCts.value?.cancel(); const cts = this.lastOpenCts.value = new CancellationTokenSource(); - const getCoverage = task.coverage.get(); - if (!getCoverage) { + const coverage = task.coverage.get(); + if (!coverage) { return; } - try { - const coverage = await getCoverage(cts.token); - this.selected.set(coverage, undefined); - this._isOpenKey.set(true); - } catch (e) { - if (!cts.token.isCancellationRequested) { - this.notificationService.error(localize('testCoverageError', 'Failed to load test coverage: {0}', String(e))); - } - return; - } + this.selected.set(coverage, undefined); + this._isOpenKey.set(true); if (focus && !cts.token.isCancellationRequested) { this.viewsService.openView(Testing.CoverageViewId, true); diff --git a/src/vs/workbench/contrib/testing/common/testItemCollection.ts b/src/vs/workbench/contrib/testing/common/testItemCollection.ts index 42e4c2fa36b76..b048c72640255 100644 --- a/src/vs/workbench/contrib/testing/common/testItemCollection.ts +++ b/src/vs/workbench/contrib/testing/common/testItemCollection.ts @@ -396,6 +396,7 @@ export class TestItemCollection extends Disposable { this.options.getApiFor(oldActual).listener = undefined; internal.actual = actual; + internal.resolveBarrier = undefined; internal.expand = TestItemExpandState.NotExpandable; // updated by `connectItemAndChildren` if (update) { @@ -416,6 +417,19 @@ export class TestItemCollection extends Disposable { } } + // Re-expand the element if it was previous expanded (#207574) + const expandLevels = internal.expandLevels; + if (expandLevels !== undefined) { + // Wait until a microtask to allow the extension to finish setting up + // properties of the element and children before we ask it to expand. + queueMicrotask(() => { + if (internal.expand === TestItemExpandState.Expandable) { + internal.expandLevels = undefined; + this.expand(fullId.toString(), expandLevels); + } + }); + } + // Mark ranges in the document as synced (#161320) this.documentSynced(internal.actual.uri); } diff --git a/src/vs/workbench/contrib/testing/common/testResult.ts b/src/vs/workbench/contrib/testing/common/testResult.ts index e6056621bf2b7..6bbff4a7c9410 100644 --- a/src/vs/workbench/contrib/testing/common/testResult.ts +++ b/src/vs/workbench/contrib/testing/common/testResult.ts @@ -5,14 +5,12 @@ import { DeferredPromise } from 'vs/base/common/async'; import { VSBuffer } from 'vs/base/common/buffer'; -import { CancellationToken } from 'vs/base/common/cancellation'; import { Emitter, Event } from 'vs/base/common/event'; import { Lazy } from 'vs/base/common/lazy'; import { Disposable } from 'vs/base/common/lifecycle'; import { IObservable, observableValue } from 'vs/base/common/observable'; import { language } from 'vs/base/common/platform'; import { WellDefinedPrefixTree } from 'vs/base/common/prefixTree'; -import { removeAnsiEscapeCodes } from 'vs/base/common/strings'; import { localize } from 'vs/nls'; import { IUriIdentityService } from 'vs/platform/uriIdentity/common/uriIdentity'; import { IComputedStateAccessor, refreshComputedState } from 'vs/workbench/contrib/testing/common/getComputedState'; @@ -25,7 +23,7 @@ export interface ITestRunTaskResults extends ITestRunTask { /** * Contains test coverage for the result, if it's available. */ - readonly coverage: IObservable Promise)>; + readonly coverage: IObservable; /** * Messages from the task not associated with any specific test. @@ -366,7 +364,7 @@ export class LiveTestResult extends Disposable implements ITestResult { const { offset, length } = task.output.append(output, marker); const message: ITestOutputMessage = { location, - message: removeAnsiEscapeCodes(preview), + message: preview, offset, length, marker, diff --git a/src/vs/workbench/contrib/testing/common/testTypes.ts b/src/vs/workbench/contrib/testing/common/testTypes.ts index ed4ff1c93ea34..9aa9db27bcb1e 100644 --- a/src/vs/workbench/contrib/testing/common/testTypes.ts +++ b/src/vs/workbench/contrib/testing/common/testTypes.ts @@ -21,6 +21,16 @@ export const enum TestResultState { Errored = 6 } +export const testResultStateToContextValues: { [K in TestResultState]: string } = { + [TestResultState.Unset]: 'unset', + [TestResultState.Queued]: 'queued', + [TestResultState.Running]: 'running', + [TestResultState.Passed]: 'passed', + [TestResultState.Failed]: 'failed', + [TestResultState.Skipped]: 'skipped', + [TestResultState.Errored]: 'errored', +}; + /** note: keep in sync with TestRunProfileKind in vscode.d.ts */ export const enum ExtTestRunProfileKind { Run = 1, @@ -532,50 +542,49 @@ export interface ITestCoverage { files: IFileCoverage[]; } -export interface ICoveredCount { +export interface ICoverageCount { covered: number; total: number; } -export namespace ICoveredCount { - export const empty = (): ICoveredCount => ({ covered: 0, total: 0 }); - export const sum = (target: ICoveredCount, src: Readonly) => { +export namespace ICoverageCount { + export const empty = (): ICoverageCount => ({ covered: 0, total: 0 }); + export const sum = (target: ICoverageCount, src: Readonly) => { target.covered += src.covered; target.total += src.total; }; } export interface IFileCoverage { + id: string; uri: URI; - statement: ICoveredCount; - branch?: ICoveredCount; - function?: ICoveredCount; - details?: CoverageDetails[]; + statement: ICoverageCount; + branch?: ICoverageCount; + declaration?: ICoverageCount; } - export namespace IFileCoverage { export interface Serialized { + id: string; uri: UriComponents; - statement: ICoveredCount; - branch?: ICoveredCount; - function?: ICoveredCount; - details?: CoverageDetails.Serialized[]; + statement: ICoverageCount; + branch?: ICoverageCount; + declaration?: ICoverageCount; } export const serialize = (original: Readonly): Serialized => ({ + id: original.id, statement: original.statement, branch: original.branch, - function: original.function, - details: original.details?.map(CoverageDetails.serialize), + declaration: original.declaration, uri: original.uri.toJSON(), }); export const deserialize = (uriIdentity: ITestUriCanonicalizer, serialized: Serialized): IFileCoverage => ({ + id: serialized.id, statement: serialized.statement, branch: serialized.branch, - function: serialized.function, - details: serialized.details?.map(CoverageDetails.deserialize), + declaration: serialized.declaration, uri: uriIdentity.asCanonicalUri(URI.revive(serialized.uri)), }); } @@ -596,21 +605,21 @@ function deserializeThingWithLocation): Serialized => - original.type === DetailType.Function ? IFunctionCoverage.serialize(original) : IStatementCoverage.serialize(original); + original.type === DetailType.Declaration ? IDeclarationCoverage.serialize(original) : IStatementCoverage.serialize(original); export const deserialize = (serialized: Serialized): CoverageDetails => - serialized.type === DetailType.Function ? IFunctionCoverage.deserialize(serialized) : IStatementCoverage.deserialize(serialized); + serialized.type === DetailType.Declaration ? IDeclarationCoverage.deserialize(serialized) : IStatementCoverage.deserialize(serialized); } export interface IBranchCoverage { @@ -630,23 +639,23 @@ export namespace IBranchCoverage { export const deserialize: (original: Serialized) => IBranchCoverage = deserializeThingWithLocation; } -export interface IFunctionCoverage { - type: DetailType.Function; +export interface IDeclarationCoverage { + type: DetailType.Declaration; name: string; count: number | boolean; location: Range | Position; } -export namespace IFunctionCoverage { +export namespace IDeclarationCoverage { export interface Serialized { - type: DetailType.Function; + type: DetailType.Declaration; name: string; count: number | boolean; location: IRange | IPosition; } - export const serialize: (original: IFunctionCoverage) => Serialized = serializeThingWithLocation; - export const deserialize: (original: Serialized) => IFunctionCoverage = deserializeThingWithLocation; + export const serialize: (original: IDeclarationCoverage) => Serialized = serializeThingWithLocation; + export const deserialize: (original: Serialized) => IDeclarationCoverage = deserializeThingWithLocation; } export interface IStatementCoverage { @@ -755,7 +764,7 @@ export interface ITestMessageMenuArgs { /** Marshalling marker */ $mid: MarshalledId.TestMessageMenuArgs; /** Tests ext ID */ - extId: string; + test: InternalTestItem.Serialized; /** Serialized test message */ message: ITestMessage.Serialized; } diff --git a/src/vs/workbench/contrib/testing/common/testingContentProvider.ts b/src/vs/workbench/contrib/testing/common/testingContentProvider.ts index 2509bbd2c124d..8f4dab17dc1fd 100644 --- a/src/vs/workbench/contrib/testing/common/testingContentProvider.ts +++ b/src/vs/workbench/contrib/testing/common/testingContentProvider.ts @@ -119,7 +119,7 @@ export class TestingContentProvider implements IWorkbenchContribution, ITextMode const content = result.tasks[parsed.taskIndex].output.getRange(message.offset, message.length); text = removeAnsiEscapeCodes(content.toString()); } else if (typeof message.message === 'string') { - text = message.message; + text = removeAnsiEscapeCodes(message.message); } else { text = message.message.value; language = this.languageService.createById('markdown'); diff --git a/src/vs/workbench/contrib/testing/common/testingContextKeys.ts b/src/vs/workbench/contrib/testing/common/testingContextKeys.ts index cc7821a4e2764..ddef4fcdc15b4 100644 --- a/src/vs/workbench/contrib/testing/common/testingContextKeys.ts +++ b/src/vs/workbench/contrib/testing/common/testingContextKeys.ts @@ -67,4 +67,8 @@ export namespace TestingContextKeys { type: 'boolean', description: localize('testing.testResultOutdated', 'Value available in editor/content and testing/message/context when the result is outdated') }); + export const testResultState = new RawContextKey('testResultState', undefined, { + type: 'string', + description: localize('testing.testResultState', 'Value available testing/item/result indicating the state of the item.') + }); } diff --git a/src/vs/workbench/contrib/testing/test/browser/explorerProjections/hierarchalByName.test.ts b/src/vs/workbench/contrib/testing/test/browser/explorerProjections/nameProjection.test.ts similarity index 100% rename from src/vs/workbench/contrib/testing/test/browser/explorerProjections/hierarchalByName.test.ts rename to src/vs/workbench/contrib/testing/test/browser/explorerProjections/nameProjection.test.ts diff --git a/src/vs/workbench/contrib/testing/test/browser/explorerProjections/hierarchalByLocation.test.ts b/src/vs/workbench/contrib/testing/test/browser/explorerProjections/treeProjection.test.ts similarity index 86% rename from src/vs/workbench/contrib/testing/test/browser/explorerProjections/hierarchalByLocation.test.ts rename to src/vs/workbench/contrib/testing/test/browser/explorerProjections/treeProjection.test.ts index 97b6ef85520b9..cebf9e01a5dac 100644 --- a/src/vs/workbench/contrib/testing/test/browser/explorerProjections/hierarchalByLocation.test.ts +++ b/src/vs/workbench/contrib/testing/test/browser/explorerProjections/treeProjection.test.ts @@ -229,5 +229,43 @@ suite('Workbench - Testing Explorer Hierarchal by Location Projection', () => { }); + test('fixes #204805', async () => { + harness.flush(); + harness.pushDiff({ + op: TestDiffOpType.Remove, + itemId: 'ctrlId', + }, { + op: TestDiffOpType.Add, + item: { controllerId: 'ctrlId', expand: TestItemExpandState.NotExpandable, item: new TestTestItem(new TestId(['ctrlId']), 'ctrl').toTestItem() }, + }, { + op: TestDiffOpType.Add, + item: { controllerId: 'ctrlId', expand: TestItemExpandState.NotExpandable, item: new TestTestItem(new TestId(['ctrlId', 'a']), 'a').toTestItem() }, + }); + + assert.deepStrictEqual(harness.flush(), [ + { e: 'a' } + ]); + + harness.pushDiff({ + op: TestDiffOpType.Add, + item: { controllerId: 'ctrlId', expand: TestItemExpandState.NotExpandable, item: new TestTestItem(new TestId(['ctrlId', 'a', 'b']), 'b').toTestItem() }, + }); + harness.flush(); + harness.tree.expandAll(); + assert.deepStrictEqual(harness.tree.getRendered(), [ + { e: 'a', children: [{ e: 'b' }] } + ]); + + harness.pushDiff({ + op: TestDiffOpType.Add, + item: { controllerId: 'ctrlId', expand: TestItemExpandState.NotExpandable, item: new TestTestItem(new TestId(['ctrlId', 'a', 'b', 'c']), 'c').toTestItem() }, + }); + harness.flush(); + harness.tree.expandAll(); + assert.deepStrictEqual(harness.tree.getRendered(), [ + { e: 'a', children: [{ e: 'b', children: [{ e: 'c' }] }] } + ]); + }); + }); diff --git a/src/vs/workbench/contrib/testing/test/browser/testObjectTree.ts b/src/vs/workbench/contrib/testing/test/browser/testObjectTree.ts index 569661d627dcd..e0923164c97cd 100644 --- a/src/vs/workbench/contrib/testing/test/browser/testObjectTree.ts +++ b/src/vs/workbench/contrib/testing/test/browser/testObjectTree.ts @@ -5,14 +5,14 @@ import { ObjectTree } from 'vs/base/browser/ui/tree/objectTree'; import { Emitter } from 'vs/base/common/event'; -import { Disposable } from 'vs/base/common/lifecycle'; +import { Disposable, DisposableStore } from 'vs/base/common/lifecycle'; import { IWorkspaceFoldersChangeEvent } from 'vs/platform/workspace/common/workspace'; import { ITestTreeProjection, TestExplorerTreeElement, TestItemTreeElement, TestTreeErrorMessage } from 'vs/workbench/contrib/testing/browser/explorerProjections/index'; import { MainThreadTestCollection } from 'vs/workbench/contrib/testing/common/mainThreadTestCollection'; import { TestsDiff, TestsDiffOp } from 'vs/workbench/contrib/testing/common/testTypes'; import { ITestService } from 'vs/workbench/contrib/testing/common/testService'; import { testStubs } from 'vs/workbench/contrib/testing/test/common/testStubs'; -import { ITreeSorter } from 'vs/base/browser/ui/tree/tree'; +import { ITreeRenderer, ITreeSorter } from 'vs/base/browser/ui/tree/tree'; type SerializedTree = { e: string; children?: SerializedTree[]; data?: string }; @@ -31,14 +31,22 @@ class TestObjectTree extends ObjectTree { }, [ { - disposeTemplate: () => undefined, - renderElement: (node, _index, container: HTMLElement) => { - Object.assign(container.dataset, node.element); - container.textContent = `${node.depth}:${serializer(node.element)}`; + disposeTemplate: ({ store }) => store.dispose(), + renderElement: ({ depth, element }, _index, { container, store }) => { + const render = () => { + container.textContent = `${depth}:${serializer(element)}`; + Object.assign(container.dataset, element); + }; + render(); + + if (element instanceof TestItemTreeElement) { + store.add(element.onChange(render)); + } }, - renderTemplate: c => c, + disposeElement: (_el, _index, { store }) => store.clear(), + renderTemplate: container => ({ container, store: new DisposableStore() }), templateId: 'default' - } + } as ITreeRenderer ], { sorter: sorter ?? { diff --git a/src/vs/workbench/contrib/timeline/browser/timelinePane.ts b/src/vs/workbench/contrib/timeline/browser/timelinePane.ts index d20b31e06410d..cdf025f4a5ffd 100644 --- a/src/vs/workbench/contrib/timeline/browser/timelinePane.ts +++ b/src/vs/workbench/contrib/timeline/browser/timelinePane.ts @@ -49,13 +49,14 @@ import { API_OPEN_DIFF_EDITOR_COMMAND_ID, API_OPEN_EDITOR_COMMAND_ID } from 'vs/ import { MarshalledId } from 'vs/base/common/marshallingIds'; import { isString } from 'vs/base/common/types'; import { renderMarkdownAsPlaintext } from 'vs/base/browser/markdownRenderer'; -import { IHoverService } from 'vs/platform/hover/browser/hover'; -import { IHoverDelegate, IHoverDelegateOptions } from 'vs/base/browser/ui/iconLabel/iconHoverDelegate'; +import { IHoverDelegate } from 'vs/base/browser/ui/hover/hoverDelegate'; import { IUriIdentityService } from 'vs/platform/uriIdentity/common/uriIdentity'; import { IExtensionService } from 'vs/workbench/services/extensions/common/extensions'; import { IStorageService, StorageScope, StorageTarget } from 'vs/platform/storage/common/storage'; import { AriaRole } from 'vs/base/browser/ui/aria/aria'; import { ILocalizedString } from 'vs/platform/action/common/action'; +import { getDefaultHoverDelegate } from 'vs/base/browser/ui/hover/hoverDelegateFactory'; +import { IHoverService } from 'vs/platform/hover/browser/hover'; const ItemHeight = 22; @@ -268,11 +269,12 @@ export class TimelinePane extends ViewPane { @IOpenerService openerService: IOpenerService, @IThemeService themeService: IThemeService, @ITelemetryService telemetryService: ITelemetryService, + @IHoverService hoverService: IHoverService, @ILabelService private readonly labelService: ILabelService, @IUriIdentityService private readonly uriIdentityService: IUriIdentityService, @IExtensionService private readonly extensionService: IExtensionService, ) { - super({ ...options, titleMenuId: MenuId.TimelineTitle }, keybindingService, contextMenuService, configurationService, contextKeyService, viewDescriptorService, instantiationService, openerService, themeService, telemetryService); + super({ ...options, titleMenuId: MenuId.TimelineTitle }, keybindingService, contextMenuService, configurationService, contextKeyService, viewDescriptorService, instantiationService, openerService, themeService, telemetryService, hoverService); this.commands = this._register(this.instantiationService.createInstance(TimelinePaneCommands, this)); @@ -747,7 +749,7 @@ export class TimelinePane extends ViewPane { } const iterator = timeline.items[Symbol.iterator](); - sources.push({ timeline: timeline, iterator: iterator, nextItem: iterator.next() }); + sources.push({ timeline, iterator, nextItem: iterator.next() }); } this._visibleItemCount = hasAnyItems ? 1 : 0; @@ -935,9 +937,7 @@ export class TimelinePane extends ViewPane { }, keyboardNavigationLabelProvider: new TimelineKeyboardNavigationLabelProvider(), multipleSelectionSupport: false, - overrideStyles: { - listBackground: this.getBackgroundColor() - } + overrideStyles: this.getLocationBasedColors().listOverrideStyles, }); this._register(this.tree.onContextMenu(e => this.onContextMenu(this.commands, e))); @@ -1046,7 +1046,7 @@ export class TimelinePane extends ViewPane { this.tree.domFocus(); } }, - getActionsContext: (): TimelineActionContext => ({ uri: this.uri, item: item }), + getActionsContext: (): TimelineActionContext => ({ uri: this.uri, item }), actionRunner: new TimelineActionRunner() }); } @@ -1068,13 +1068,13 @@ class TimelineElementTemplate implements IDisposable { container.classList.add('custom-view-tree-node-item'); this.icon = DOM.append(container, DOM.$('.custom-view-tree-node-item-icon')); - this.iconLabel = new IconLabel(container, { supportHighlights: true, supportIcons: true, hoverDelegate: hoverDelegate }); + this.iconLabel = new IconLabel(container, { supportHighlights: true, supportIcons: true, hoverDelegate }); const timestampContainer = DOM.append(this.iconLabel.element, DOM.$('.timeline-timestamp-container')); this.timestamp = DOM.append(timestampContainer, DOM.$('span.timeline-timestamp')); const actionsContainer = DOM.append(this.iconLabel.element, DOM.$('.actions')); - this.actionBar = new ActionBar(actionsContainer, { actionViewItemProvider: actionViewItemProvider }); + this.actionBar = new ActionBar(actionsContainer, { actionViewItemProvider }); } dispose() { @@ -1109,7 +1109,7 @@ class TimelineActionRunner extends ActionRunner { $mid: MarshalledId.TimelineActionContext, handle: item.handle, source: item.source, - uri: uri + uri }, uri, item.source, @@ -1147,14 +1147,9 @@ class TimelineTreeRenderer implements ITreeRenderer this.hoverService.showHover(options), - delay: this.configurationService.getValue('workbench.hover.delay') - }; + this._hoverDelegate = getDefaultHoverDelegate('mouse'); } private uri: URI | undefined; @@ -1212,7 +1207,7 @@ class TimelineTreeRenderer implements ITreeRenderer>(); private _currentReleaseNotes: WebviewInput | undefined = undefined; @@ -48,26 +52,33 @@ export class ReleaseNotesManager { @IConfigurationService private readonly _configurationService: IConfigurationService, @IEditorService private readonly _editorService: IEditorService, @IEditorGroupsService private readonly _editorGroupService: IEditorGroupsService, + @ICodeEditorService private readonly _codeEditorService: ICodeEditorService, @IWebviewWorkbenchService private readonly _webviewWorkbenchService: IWebviewWorkbenchService, @IExtensionService private readonly _extensionService: IExtensionService, - @IProductService private readonly _productService: IProductService + @IProductService private readonly _productService: IProductService, + @IInstantiationService private readonly _instantiationService: IInstantiationService, ) { - TokenizationRegistry.onDidChange(async () => { - if (!this._currentReleaseNotes || !this._lastText) { - return; - } - const html = await this.renderBody(this._lastText); - if (this._currentReleaseNotes) { - this._currentReleaseNotes.webview.setHtml(html); - } + TokenizationRegistry.onDidChange(() => { + return this.updateHtml(); }); _configurationService.onDidChangeConfiguration(this.onDidChangeConfiguration, this, this.disposables); _webviewWorkbenchService.onDidChangeActiveWebviewEditor(this.onDidChangeActiveWebviewEditor, this, this.disposables); + this._simpleSettingRenderer = this._instantiationService.createInstance(SimpleSettingRenderer); + } + + private async updateHtml() { + if (!this._currentReleaseNotes || !this._lastText) { + return; + } + const html = await this.renderBody(this._lastText); + if (this._currentReleaseNotes) { + this._currentReleaseNotes.webview.setHtml(html); + } } - public async show(version: string): Promise { - const releaseNoteText = await this.loadReleaseNotes(version); + public async show(version: string, useCurrentFile: boolean): Promise { + const releaseNoteText = await this.loadReleaseNotes(version, useCurrentFile); this._lastText = releaseNoteText; const html = await this.renderBody(releaseNoteText); const title = nls.localize('releaseNotesInputName', "Release Notes: {0}", version); @@ -102,6 +113,10 @@ export class ReleaseNotesManager { disposables.add(this._currentReleaseNotes.webview.onMessage(e => { if (e.message.type === 'showReleaseNotes') { this._configurationService.updateValue('update.showReleaseNotes', e.message.value); + } else if (e.message.type === 'clickSetting') { + const x = this._currentReleaseNotes?.webview.container.offsetLeft + e.message.value.x; + const y = this._currentReleaseNotes?.webview.container.offsetTop + e.message.value.y; + this._simpleSettingRenderer.updateSetting(URI.parse(e.message.value.uri), x, y); } })); @@ -116,7 +131,7 @@ export class ReleaseNotesManager { return true; } - private async loadReleaseNotes(version: string): Promise { + private async loadReleaseNotes(version: string, useCurrentFile: boolean): Promise { const match = /^(\d+\.\d+)\./.exec(version); if (!match) { throw new Error('not found'); @@ -178,7 +193,12 @@ export class ReleaseNotesManager { const fetchReleaseNotes = async () => { let text; try { - text = await asTextOrError(await this._requestService.request({ url }, CancellationToken.None)); + if (useCurrentFile) { + const file = this._codeEditorService.getActiveCodeEditor()?.getModel()?.getValue(); + text = file ? file.substring(file.indexOf('#')) : undefined; + } else { + text = await asTextOrError(await this._requestService.request({ url }, CancellationToken.None)); + } } catch { throw new Error('Failed to fetch release notes'); } @@ -190,6 +210,10 @@ export class ReleaseNotesManager { return patchKeybindings(text); }; + // Don't cache the current file + if (useCurrentFile) { + return fetchReleaseNotes(); + } if (!this._releaseNotesCache.has(version)) { this._releaseNotesCache.set(version, (async () => { try { @@ -204,10 +228,14 @@ export class ReleaseNotesManager { return this._releaseNotesCache.get(version)!; } - private onDidClickLink(uri: URI) { - this.addGAParameters(uri, 'ReleaseNotes') - .then(updated => this._openerService.open(updated)) - .then(undefined, onUnexpectedError); + private async onDidClickLink(uri: URI) { + if (uri.scheme === Schemas.codeSetting) { + // handled in receive message + } else { + this.addGAParameters(uri, 'ReleaseNotes') + .then(updated => this._openerService.open(updated, { allowCommands: ['workbench.action.openSettings'] })) + .then(undefined, onUnexpectedError); + } } private async addGAParameters(uri: URI, origin: string, experiment = '1'): Promise { @@ -221,7 +249,7 @@ export class ReleaseNotesManager { private async renderBody(text: string) { const nonce = generateUuid(); - const content = await renderMarkdownDocument(text, this._extensionService, this._languageService, false); + const content = await renderMarkdownDocument(text, this._extensionService, this._languageService, false, undefined, undefined, this._simpleSettingRenderer); const colorMap = TokenizationRegistry.getColorMap(); const css = colorMap ? generateTokensCSSForColorMap(colorMap) : ''; const showReleaseNotes = Boolean(this._configurationService.getValue('update.showReleaseNotes')); @@ -235,6 +263,91 @@ export class ReleaseNotesManager { @@ -270,6 +383,22 @@ export class ReleaseNotesManager { } }); + window.addEventListener('click', event => { + const href = event.target.href ?? event.target.parentElement.href ?? event.target.parentElement.parentElement?.href; + if (href && (href.startsWith('${Schemas.codeSetting}'))) { + vscode.postMessage({ type: 'clickSetting', value: { uri: href, x: event.clientX, y: event.clientY }}); + } + }); + + window.addEventListener('keypress', event => { + if (event.keyCode === 13) { + if (event.target.children.length > 0 && event.target.children[0].href) { + const clientRect = event.target.getBoundingClientRect(); + vscode.postMessage({ type: 'clickSetting', value: { uri: event.target.children[0].href, x: clientRect.right , y: clientRect.bottom }}); + } + } + }); + input.addEventListener('change', event => { vscode.postMessage({ type: 'showReleaseNotes', value: input.checked }, '*'); }); @@ -280,17 +409,17 @@ export class ReleaseNotesManager { private onDidChangeConfiguration(e: IConfigurationChangeEvent): void { if (e.affectsConfiguration('update.showReleaseNotes')) { - this.updateWebview(); + this.updateCheckboxWebview(); } } private onDidChangeActiveWebviewEditor(input: WebviewInput | undefined): void { if (input && input === this._currentReleaseNotes) { - this.updateWebview(); + this.updateCheckboxWebview(); } } - private updateWebview() { + private updateCheckboxWebview() { if (this._currentReleaseNotes) { this._currentReleaseNotes.webview.postMessage({ type: 'showReleaseNotes', diff --git a/src/vs/workbench/contrib/update/browser/update.contribution.ts b/src/vs/workbench/contrib/update/browser/update.contribution.ts index 46b1d76365abb..fa3edab7d3acc 100644 --- a/src/vs/workbench/contrib/update/browser/update.contribution.ts +++ b/src/vs/workbench/contrib/update/browser/update.contribution.ts @@ -17,7 +17,7 @@ import { IInstantiationService, ServicesAccessor } from 'vs/platform/instantiati import { isWindows } from 'vs/base/common/platform'; import { IFileDialogService } from 'vs/platform/dialogs/common/dialogs'; import { mnemonicButtonLabel } from 'vs/base/common/labels'; -import { ShowCurrentReleaseNotesActionId } from 'vs/workbench/contrib/update/common/update'; +import { ShowCurrentReleaseNotesActionId, ShowCurrentReleaseNotesFromCurrentFileActionId } from 'vs/workbench/contrib/update/common/update'; import { IsWebContext } from 'vs/platform/contextkey/common/contextkeys'; import { IOpenerService } from 'vs/platform/opener/common/opener'; import { IProductService } from 'vs/platform/product/common/productService'; @@ -59,7 +59,7 @@ export class ShowCurrentReleaseNotesAction extends Action2 { const openerService = accessor.get(IOpenerService); try { - await showReleaseNotesInEditor(instantiationService, productService.version); + await showReleaseNotesInEditor(instantiationService, productService.version, false); } catch (err) { if (productService.releaseNotesUrl) { await openerService.open(URI.parse(productService.releaseNotesUrl)); @@ -70,7 +70,35 @@ export class ShowCurrentReleaseNotesAction extends Action2 { } } +export class ShowCurrentReleaseNotesFromCurrentFileAction extends Action2 { + + constructor() { + super({ + id: ShowCurrentReleaseNotesFromCurrentFileActionId, + title: { + ...localize2('showReleaseNotesCurrentFile', "Open Current File as Release Notes"), + mnemonicTitle: localize({ key: 'mshowReleaseNotes', comment: ['&& denotes a mnemonic'] }, "Show &&Release Notes"), + }, + category: localize2('developerCategory', "Developer"), + f1: true, + precondition: RELEASE_NOTES_URL + }); + } + + async run(accessor: ServicesAccessor): Promise { + const instantiationService = accessor.get(IInstantiationService); + const productService = accessor.get(IProductService); + + try { + await showReleaseNotesInEditor(instantiationService, productService.version, true); + } catch (err) { + throw new Error(localize('releaseNotesFromFileNone', "Cannot open the current file as Release Notes")); + } + } +} + registerAction2(ShowCurrentReleaseNotesAction); +registerAction2(ShowCurrentReleaseNotesFromCurrentFileAction); // Update diff --git a/src/vs/workbench/contrib/update/browser/update.ts b/src/vs/workbench/contrib/update/browser/update.ts index d57afcd149a5f..af1b3d507c244 100644 --- a/src/vs/workbench/contrib/update/browser/update.ts +++ b/src/vs/workbench/contrib/update/browser/update.ts @@ -17,7 +17,7 @@ import { INotificationService, Severity } from 'vs/platform/notification/common/ import { IDialogService } from 'vs/platform/dialogs/common/dialogs'; import { IBrowserWorkbenchEnvironmentService } from 'vs/workbench/services/environment/browser/environmentService'; import { ReleaseNotesManager } from 'vs/workbench/contrib/update/browser/releaseNotesEditor'; -import { isWeb, isWindows } from 'vs/base/common/platform'; +import { isMacintosh, isWeb, isWindows } from 'vs/base/common/platform'; import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; import { RawContextKey, IContextKey, IContextKeyService, ContextKeyExpr } from 'vs/platform/contextkey/common/contextkey'; import { MenuRegistry, MenuId, registerAction2, Action2 } from 'vs/platform/actions/common/actions'; @@ -38,12 +38,12 @@ export const DOWNLOAD_URL = new RawContextKey('downloadUrl', ''); let releaseNotesManager: ReleaseNotesManager | undefined = undefined; -export function showReleaseNotesInEditor(instantiationService: IInstantiationService, version: string) { +export function showReleaseNotesInEditor(instantiationService: IInstantiationService, version: string, useCurrentFile: boolean) { if (!releaseNotesManager) { releaseNotesManager = instantiationService.createInstance(ReleaseNotesManager); } - return releaseNotesManager.show(version); + return releaseNotesManager.show(version, useCurrentFile); } async function openLatestReleaseNotesInBrowser(accessor: ServicesAccessor) { @@ -61,7 +61,7 @@ async function openLatestReleaseNotesInBrowser(accessor: ServicesAccessor) { async function showReleaseNotes(accessor: ServicesAccessor, version: string) { const instantiationService = accessor.get(IInstantiationService); try { - await showReleaseNotesInEditor(instantiationService, version); + await showReleaseNotesInEditor(instantiationService, version, false); } catch (err) { try { await instantiationService.invokeFunction(openLatestReleaseNotesInBrowser); @@ -135,7 +135,7 @@ export class ProductContribution implements IWorkbenchContribution { // was there a major/minor update? if so, open release notes if (shouldShowReleaseNotes && !environmentService.skipReleaseNotes && releaseNotesUrl && lastVersion && currentVersion && isMajorMinorUpdate(lastVersion, currentVersion)) { - showReleaseNotesInEditor(instantiationService, productService.version) + showReleaseNotesInEditor(instantiationService, productService.version, false) .then(undefined, () => { notificationService.prompt( severity.Info, @@ -173,6 +173,7 @@ export class UpdateContribution extends Disposable implements IWorkbenchContribu @IContextKeyService private readonly contextKeyService: IContextKeyService, @IProductService private readonly productService: IProductService, @IOpenerService private readonly openerService: IOpenerService, + @IConfigurationService private readonly configurationService: IConfigurationService, @IHostService private readonly hostService: IHostService ) { super(); @@ -241,10 +242,13 @@ export class UpdateContribution extends Disposable implements IWorkbenchContribu break; case StateType.Ready: { - const currentVersion = parseVersion(this.productService.version); - const nextVersion = parseVersion(state.update.productVersion); - this.majorMinorUpdateAvailableContextKey.set(Boolean(currentVersion && nextVersion && isMajorMinorUpdate(currentVersion, nextVersion))); - this.onUpdateReady(state.update); + const productVersion = state.update.productVersion; + if (productVersion) { + const currentVersion = parseVersion(this.productService.version); + const nextVersion = parseVersion(productVersion); + this.majorMinorUpdateAvailableContextKey.set(Boolean(currentVersion && nextVersion && isMajorMinorUpdate(currentVersion, nextVersion))); + this.onUpdateReady(state.update); + } break; } } @@ -298,6 +302,11 @@ export class UpdateContribution extends Disposable implements IWorkbenchContribu return; } + const productVersion = update.productVersion; + if (!productVersion) { + return; + } + this.notificationService.prompt( severity.Info, nls.localize('thereIsUpdateAvailable', "There is an available update."), @@ -310,21 +319,33 @@ export class UpdateContribution extends Disposable implements IWorkbenchContribu }, { label: nls.localize('releaseNotes', "Release Notes"), run: () => { - this.instantiationService.invokeFunction(accessor => showReleaseNotes(accessor, update.productVersion)); + this.instantiationService.invokeFunction(accessor => showReleaseNotes(accessor, productVersion)); } }] ); } - // windows fast updates (target === system) + // windows fast updates private onUpdateDownloaded(update: IUpdate): void { + if (isMacintosh) { + return; + } + if (this.configurationService.getValue('update.enableWindowsBackgroundUpdates') && this.productService.target === 'user') { + return; + } + if (!this.shouldShowNotification()) { return; } + const productVersion = update.productVersion; + if (!productVersion) { + return; + } + this.notificationService.prompt( severity.Info, - nls.localize('updateAvailable', "There's an update available: {0} {1}", this.productService.nameLong, update.productVersion), + nls.localize('updateAvailable', "There's an update available: {0} {1}", this.productService.nameLong, productVersion), [{ label: nls.localize('installUpdate', "Install Update"), run: () => this.updateService.applyUpdate() @@ -334,7 +355,7 @@ export class UpdateContribution extends Disposable implements IWorkbenchContribu }, { label: nls.localize('releaseNotes', "Release Notes"), run: () => { - this.instantiationService.invokeFunction(accessor => showReleaseNotes(accessor, update.productVersion)); + this.instantiationService.invokeFunction(accessor => showReleaseNotes(accessor, productVersion)); } }] ); @@ -354,12 +375,12 @@ export class UpdateContribution extends Disposable implements IWorkbenchContribu run: () => { } }]; - // TODO@joao check why snap updates send `update` as falsy - if (update.productVersion) { + const productVersion = update.productVersion; + if (productVersion) { actions.push({ label: nls.localize('releaseNotes', "Release Notes"), run: () => { - this.instantiationService.invokeFunction(accessor => showReleaseNotes(accessor, update.productVersion)); + this.instantiationService.invokeFunction(accessor => showReleaseNotes(accessor, productVersion)); } }); } @@ -460,8 +481,11 @@ export class UpdateContribution extends Disposable implements IWorkbenchContribu return; } - const version = this.updateService.state.update.version; - this.instantiationService.invokeFunction(accessor => showReleaseNotes(accessor, version)); + const productVersion = this.updateService.state.update.productVersion; + if (productVersion) { + this.instantiationService.invokeFunction(accessor => showReleaseNotes(accessor, productVersion)); + } + }); MenuRegistry.appendMenuItem(MenuId.GlobalActivity, { group: '7_update', @@ -509,7 +533,7 @@ export class SwitchProductQualityContribution extends Disposable implements IWor const newQuality = quality === 'stable' ? 'insider' : 'stable'; const commandId = `update.switchQuality.${newQuality}`; const isSwitchingToInsiders = newQuality === 'insider'; - registerAction2(class SwitchQuality extends Action2 { + this._register(registerAction2(class SwitchQuality extends Action2 { constructor() { super({ id: commandId, @@ -604,7 +628,7 @@ export class SwitchProductQualityContribution extends Disposable implements IWor }); return result; } - }); + })); } } } diff --git a/src/vs/workbench/contrib/update/common/update.ts b/src/vs/workbench/contrib/update/common/update.ts index c224d76703ab8..a5798049ce0e9 100644 --- a/src/vs/workbench/contrib/update/common/update.ts +++ b/src/vs/workbench/contrib/update/common/update.ts @@ -4,3 +4,4 @@ *--------------------------------------------------------------------------------------------*/ export const ShowCurrentReleaseNotesActionId = 'update.showCurrentReleaseNotes'; +export const ShowCurrentReleaseNotesFromCurrentFileActionId = 'developer.showCurrentFileAsReleaseNotes'; diff --git a/src/vs/workbench/contrib/userDataProfile/browser/userDataProfile.ts b/src/vs/workbench/contrib/userDataProfile/browser/userDataProfile.ts index e86a7c07e96a0..0c5405c87b009 100644 --- a/src/vs/workbench/contrib/userDataProfile/browser/userDataProfile.ts +++ b/src/vs/workbench/contrib/userDataProfile/browser/userDataProfile.ts @@ -425,7 +425,7 @@ export class UserDataProfilesWorkbenchContribution extends Disposable implements } private registerDeleteProfileAction(): void { - registerAction2(class DeleteProfileAction extends Action2 { + this._register(registerAction2(class DeleteProfileAction extends Action2 { constructor() { super({ id: 'workbench.profiles.actions.deleteProfile', @@ -473,7 +473,7 @@ export class UserDataProfilesWorkbenchContribution extends Disposable implements } } } - }); + })); } private registerHelpAction(): void { @@ -513,7 +513,7 @@ export class UserDataProfilesWorkbenchContribution extends Disposable implements owner: 'sandy081'; comment: 'Report profile information of the current workspace'; workspaceId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'A UUID given to a workspace to identify it.' }; - defaultProfile: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Whether the profile of the workspace is default or not.' }; + defaultProfile: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether the profile of the workspace is default or not.' }; }; type WorkspaceProfileInfoEvent = { workspaceId: string | undefined; diff --git a/src/vs/workbench/contrib/userDataSync/browser/userDataSync.ts b/src/vs/workbench/contrib/userDataSync/browser/userDataSync.ts index 0787368edba0a..632ee6ad4e380 100644 --- a/src/vs/workbench/contrib/userDataSync/browser/userDataSync.ts +++ b/src/vs/workbench/contrib/userDataSync/browser/userDataSync.ts @@ -60,8 +60,8 @@ type ConfigureSyncQuickPickItem = { id: SyncResource; label: string; description type SyncConflictsClassification = { owner: 'sandy081'; comment: 'Response information when conflict happens during settings sync'; - source: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'settings sync resource. eg., settings, keybindings...' }; - action?: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'action taken while resolving conflicts. Eg: acceptLocal, acceptRemote' }; + source: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'settings sync resource. eg., settings, keybindings...' }; + action?: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'action taken while resolving conflicts. Eg: acceptLocal, acceptRemote' }; }; const turnOffSyncCommand = { id: 'workbench.userDataSync.actions.turnOff', title: localize2('stop sync', 'Turn Off') }; @@ -838,7 +838,7 @@ export class UserDataSyncWorkbenchContribution extends Disposable implements IWo return localize2('resolveConflicts_global', "Show Conflicts ({0})", this.getConflictsCount()); } - private conflictsActionDisposable = this._register(new MutableDisposable()); + private readonly conflictsActionDisposable = this._register(new MutableDisposable()); private registerShowConflictsAction(): void { this.conflictsActionDisposable.value = undefined; const that = this; @@ -918,7 +918,7 @@ export class UserDataSyncWorkbenchContribution extends Disposable implements IWo items.push({ id: syncNowCommand.id, label: `${SYNC_TITLE.value}: ${syncNowCommand.title.original}`, description: syncNowCommand.description(that.userDataSyncService) }); if (that.userDataSyncEnablementService.canToggleEnablement()) { const account = that.userDataSyncWorkbenchService.current; - items.push({ id: turnOffSyncCommand.id, label: `${SYNC_TITLE.value}: ${turnOffSyncCommand.title.original}`, description: account ? `${account.accountName} (${that.authenticationService.getLabel(account.authenticationProviderId)})` : undefined }); + items.push({ id: turnOffSyncCommand.id, label: `${SYNC_TITLE.value}: ${turnOffSyncCommand.title.original}`, description: account ? `${account.accountName} (${that.authenticationService.getProvider(account.authenticationProviderId).label})` : undefined }); } quickPick.items = items; disposables.add(quickPick.onDidAccept(() => { diff --git a/src/vs/workbench/contrib/userDataSync/browser/userDataSyncConflictsView.ts b/src/vs/workbench/contrib/userDataSync/browser/userDataSyncConflictsView.ts index f1b8ea91185e4..15bfa4d03eeff 100644 --- a/src/vs/workbench/contrib/userDataSync/browser/userDataSyncConflictsView.ts +++ b/src/vs/workbench/contrib/userDataSync/browser/userDataSyncConflictsView.ts @@ -26,6 +26,7 @@ import { INotificationService } from 'vs/platform/notification/common/notificati import { Codicon } from 'vs/base/common/codicons'; import { IUserDataProfile, IUserDataProfilesService, reviveProfile } from 'vs/platform/userDataProfile/common/userDataProfile'; import { DEFAULT_EDITOR_ASSOCIATION } from 'vs/workbench/common/editor'; +import { IHoverService } from 'vs/platform/hover/browser/hover'; type UserDataSyncConflictResource = IUserDataSyncResource & IResourcePreview; @@ -44,12 +45,13 @@ export class UserDataSyncConflictsViewPane extends TreeViewPane implements IUser @IThemeService themeService: IThemeService, @ITelemetryService telemetryService: ITelemetryService, @INotificationService notificationService: INotificationService, + @IHoverService hoverService: IHoverService, @IUserDataSyncService private readonly userDataSyncService: IUserDataSyncService, @IUserDataSyncWorkbenchService private readonly userDataSyncWorkbenchService: IUserDataSyncWorkbenchService, @IUserDataSyncEnablementService private readonly userDataSyncEnablementService: IUserDataSyncEnablementService, @IUserDataProfilesService private readonly userDataProfilesService: IUserDataProfilesService, ) { - super(options, keybindingService, contextMenuService, configurationService, contextKeyService, viewDescriptorService, instantiationService, openerService, themeService, telemetryService, notificationService); + super(options, keybindingService, contextMenuService, configurationService, contextKeyService, viewDescriptorService, instantiationService, openerService, themeService, telemetryService, notificationService, hoverService); this._register(this.userDataSyncService.onDidChangeConflicts(() => this.treeView.refresh())); this.registerActions(); } diff --git a/src/vs/workbench/contrib/userDataSync/browser/userDataSyncViews.ts b/src/vs/workbench/contrib/userDataSync/browser/userDataSyncViews.ts index 248d8c0756ff0..34da3b15c2a42 100644 --- a/src/vs/workbench/contrib/userDataSync/browser/userDataSyncViews.ts +++ b/src/vs/workbench/contrib/userDataSync/browser/userDataSyncViews.ts @@ -97,7 +97,7 @@ export class UserDataSyncDataViews extends Disposable { order: 300, }], container); - registerAction2(class extends Action2 { + this._register(registerAction2(class extends Action2 { constructor() { super({ id: `workbench.actions.sync.editMachineName`, @@ -116,9 +116,9 @@ export class UserDataSyncDataViews extends Disposable { await treeView.refresh(); } } - }); + })); - registerAction2(class extends Action2 { + this._register(registerAction2(class extends Action2 { constructor() { super({ id: `workbench.actions.sync.turnOffSyncOnMachine`, @@ -134,7 +134,7 @@ export class UserDataSyncDataViews extends Disposable { await treeView.refresh(); } } - }); + })); } @@ -221,7 +221,7 @@ export class UserDataSyncDataViews extends Disposable { } private registerDataViewActions(viewId: string) { - registerAction2(class extends Action2 { + this._register(registerAction2(class extends Action2 { constructor() { super({ id: `workbench.actions.sync.${viewId}.resolveResource`, @@ -237,9 +237,9 @@ export class UserDataSyncDataViews extends Disposable { const editorService = accessor.get(IEditorService); await editorService.openEditor({ resource: URI.parse(resource), options: { pinned: true } }); } - }); + })); - registerAction2(class extends Action2 { + this._register(registerAction2(class extends Action2 { constructor() { super({ id: `workbench.actions.sync.${viewId}.compareWithLocal`, @@ -262,9 +262,9 @@ export class UserDataSyncDataViews extends Disposable { undefined ); } - }); + })); - registerAction2(class extends Action2 { + this._register(registerAction2(class extends Action2 { constructor() { super({ id: `workbench.actions.sync.${viewId}.replaceCurrent`, @@ -272,7 +272,7 @@ export class UserDataSyncDataViews extends Disposable { icon: Codicon.discard, menu: { id: MenuId.ViewItemContext, - when: ContextKeyExpr.and(ContextKeyExpr.equals('view', viewId), ContextKeyExpr.regex('viewItem', /sync-resource-.*/i)), + when: ContextKeyExpr.and(ContextKeyExpr.equals('view', viewId), ContextKeyExpr.regex('viewItem', /sync-resource-.*/i), ContextKeyExpr.notEquals('viewItem', `sync-resource-${SyncResource.Profiles}`)), group: 'inline', }, }); @@ -290,7 +290,7 @@ export class UserDataSyncDataViews extends Disposable { return userDataSyncService.replace({ created: syncResourceHandle.created, uri: URI.revive(syncResourceHandle.uri) }); } } - }); + })); } diff --git a/src/vs/workbench/contrib/webview/browser/overlayWebview.ts b/src/vs/workbench/contrib/webview/browser/overlayWebview.ts index 0c60dbd1e6825..6b6be89f393d5 100644 --- a/src/vs/workbench/contrib/webview/browser/overlayWebview.ts +++ b/src/vs/workbench/contrib/webview/browser/overlayWebview.ts @@ -3,16 +3,17 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { Dimension } from 'vs/base/browser/dom'; +import { Dimension, getWindowById } from 'vs/base/browser/dom'; import { FastDomNode } from 'vs/base/browser/fastDomNode'; import { IMouseWheelEvent } from 'vs/base/browser/mouseEvent'; +import { CodeWindow } from 'vs/base/browser/window'; import { Emitter } from 'vs/base/common/event'; import { Disposable, DisposableStore, MutableDisposable } from 'vs/base/common/lifecycle'; import { URI } from 'vs/base/common/uri'; import { generateUuid } from 'vs/base/common/uuid'; import { IContextKey, IContextKeyService, IScopedContextKeyService } from 'vs/platform/contextkey/common/contextkey'; import { ExtensionIdentifier } from 'vs/platform/extensions/common/extensions'; -import { ILayoutService } from 'vs/platform/layout/browser/layoutService'; +import { IWorkbenchLayoutService } from 'vs/workbench/services/layout/browser/layoutService'; import { IOverlayWebview, IWebview, IWebviewElement, IWebviewService, KEYBINDING_CONTEXT_WEBVIEW_FIND_WIDGET_ENABLED, KEYBINDING_CONTEXT_WEBVIEW_FIND_WIDGET_VISIBLE, WebviewContentOptions, WebviewExtensionDescription, WebviewInitInfo, WebviewMessageReceivedEvent, WebviewOptions } from 'vs/workbench/contrib/webview/browser/webview'; /** @@ -36,6 +37,9 @@ export class OverlayWebview extends Disposable implements IOverlayWebview { private _owner: any = undefined; + private _windowId: number | undefined = undefined; + private get window() { return getWindowById(this._windowId, true).window; } + private readonly _scopedContextKeyService = this._register(new MutableDisposable()); private _findWidgetVisible: IContextKey | undefined; private _findWidgetEnabled: IContextKey | undefined; @@ -49,7 +53,7 @@ export class OverlayWebview extends Disposable implements IOverlayWebview { public constructor( initInfo: WebviewInitInfo, - @ILayoutService private readonly _layoutService: ILayoutService, + @IWorkbenchLayoutService private readonly _layoutService: IWorkbenchLayoutService, @IWebviewService private readonly _webviewService: IWebviewService, @IContextKeyService private readonly _baseContextKeyService: IContextKeyService ) { @@ -103,21 +107,32 @@ export class OverlayWebview extends Disposable implements IOverlayWebview { // Webviews cannot be reparented in the dom as it will destroy their contents. // Mount them to a high level node to avoid this. - this._layoutService.mainContainer.appendChild(node); + this._layoutService.getContainer(this.window).appendChild(node); } return this._container.domNode; } - public claim(owner: any, scopedContextKeyService: IContextKeyService | undefined) { + public claim(owner: any, targetWindow: CodeWindow, scopedContextKeyService: IContextKeyService | undefined) { if (this._isDisposed) { return; } const oldOwner = this._owner; + if (this._windowId !== targetWindow.vscodeWindowId) { + // moving to a new window + this.release(oldOwner); + // since we are moving to a new window, we need to dispose the webview and recreate + this._webview.clear(); + this._webviewEvents.clear(); + this._container?.domNode.remove(); + this._container = undefined; + } + this._owner = owner; - this._show(); + this._windowId = targetWindow.vscodeWindowId; + this._show(targetWindow); if (oldOwner !== owner) { const contextKeyService = (scopedContextKeyService || this._baseContextKeyService); @@ -168,6 +183,22 @@ export class OverlayWebview extends Disposable implements IOverlayWebview { return; } + const whenContainerStylesLoaded = this._layoutService.whenContainerStylesLoaded(this.window); + if (whenContainerStylesLoaded) { + // In floating windows, we need to ensure that the + // container is ready for us to compute certain + // layout related properties. + whenContainerStylesLoaded.then(() => this.doLayoutWebviewOverElement(element, dimension, clippingContainer)); + } else { + this.doLayoutWebviewOverElement(element, dimension, clippingContainer); + } + } + + private doLayoutWebviewOverElement(element: HTMLElement, dimension?: Dimension, clippingContainer?: HTMLElement) { + if (!this._container || !this._container.domNode.parentElement) { + return; + } + const frameRect = element.getBoundingClientRect(); const containerRect = this._container.domNode.parentElement.getBoundingClientRect(); const parentBorderTop = (containerRect.height - this._container.domNode.parentElement.clientHeight) / 2.0; @@ -184,7 +215,7 @@ export class OverlayWebview extends Disposable implements IOverlayWebview { } } - private _show() { + private _show(targetWindow: CodeWindow) { if (this._isDisposed) { throw new Error('OverlayWebview is disposed'); } @@ -215,7 +246,7 @@ export class OverlayWebview extends Disposable implements IOverlayWebview { this._findWidgetEnabled?.set(!!this.options.enableFindWidget); - webview.mountTo(this.container); + webview.mountTo(this.container, targetWindow); // Forward events from inner webview to outer listeners this._webviewEvents.clear(); diff --git a/src/vs/workbench/contrib/webview/browser/pre/index-no-csp.html b/src/vs/workbench/contrib/webview/browser/pre/index-no-csp.html index 8b22da1420429..5e094fc4ccc40 100644 --- a/src/vs/workbench/contrib/webview/browser/pre/index-no-csp.html +++ b/src/vs/workbench/contrib/webview/browser/pre/index-no-csp.html @@ -46,7 +46,8 @@ const interval = 250; let isFocused = document.hasFocus(); setInterval(() => { - const isCurrentlyFocused = document.hasFocus(); + const target = getActiveFrame(); + const isCurrentlyFocused = document.hasFocus() || !!(target && target.contentDocument && target.contentDocument.body.classList.contains('vscode-context-menu-visible')); if (isCurrentlyFocused === isFocused) { return; } @@ -128,6 +129,10 @@ border-radius: 4px; } + pre code { + padding: 0; + } + blockquote { background: var(--vscode-textBlockQuote-background); border-color: var(--vscode-textBlockQuote-border); diff --git a/src/vs/workbench/contrib/webview/browser/pre/index.html b/src/vs/workbench/contrib/webview/browser/pre/index.html index 277a619ddc7a0..fa7b15e39c854 100644 --- a/src/vs/workbench/contrib/webview/browser/pre/index.html +++ b/src/vs/workbench/contrib/webview/browser/pre/index.html @@ -5,7 +5,7 @@ + content="default-src 'none'; script-src 'sha256-bQPwjO6bLiyf6v9eDVtAI67LrfonA1w49aFkRXBy4/g=' 'self'; frame-src 'self'; style-src 'unsafe-inline';"> { - const isCurrentlyFocused = document.hasFocus(); + const target = getActiveFrame(); + const isCurrentlyFocused = document.hasFocus() || !!(target && target.contentDocument && target.contentDocument.body.classList.contains('vscode-context-menu-visible')); if (isCurrentlyFocused === isFocused) { return; } @@ -129,6 +130,10 @@ border-radius: 4px; } + pre code { + padding: 0; + } + blockquote { background: var(--vscode-textBlockQuote-background); border-color: var(--vscode-textBlockQuote-border); diff --git a/src/vs/workbench/contrib/webview/browser/themeing.ts b/src/vs/workbench/contrib/webview/browser/themeing.ts index 4fd074d18aeec..b63bfffca2d4c 100644 --- a/src/vs/workbench/contrib/webview/browser/themeing.ts +++ b/src/vs/workbench/contrib/webview/browser/themeing.ts @@ -3,14 +3,14 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { DEFAULT_FONT_FAMILY } from 'vs/base/browser/fonts'; import { Emitter } from 'vs/base/common/event'; import { Disposable } from 'vs/base/common/lifecycle'; import { EDITOR_FONT_DEFAULTS, IEditorOptions } from 'vs/editor/common/config/editorOptions'; import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; import * as colorRegistry from 'vs/platform/theme/common/colorRegistry'; import { ColorScheme } from 'vs/platform/theme/common/theme'; -import { IWorkbenchThemeService, IWorkbenchColorTheme } from 'vs/workbench/services/themes/common/workbenchThemeService'; -import { DEFAULT_FONT_FAMILY } from 'vs/workbench/browser/style'; +import { IWorkbenchColorTheme, IWorkbenchThemeService } from 'vs/workbench/services/themes/common/workbenchThemeService'; import { WebviewStyles } from 'vs/workbench/contrib/webview/browser/webview'; interface WebviewThemeData { diff --git a/src/vs/workbench/contrib/webview/browser/webview.ts b/src/vs/workbench/contrib/webview/browser/webview.ts index dfd7c6fc5b47b..14e925969a14e 100644 --- a/src/vs/workbench/contrib/webview/browser/webview.ts +++ b/src/vs/workbench/contrib/webview/browser/webview.ts @@ -78,7 +78,6 @@ export interface WebviewInitInfo { readonly contentOptions: WebviewContentOptions; readonly extension: WebviewExtensionDescription | undefined; - readonly codeWindow?: CodeWindow; } export const enum WebviewContentPurpose { @@ -278,7 +277,7 @@ export interface IWebviewElement extends IWebview { * * @param parent Element to append the webview to. */ - mountTo(parent: HTMLElement): void; + mountTo(parent: HTMLElement, targetWindow: CodeWindow): void; } /** @@ -308,7 +307,7 @@ export interface IOverlayWebview extends IWebview { * @param claimant Identifier for the object claiming the webview. * This must match the `claimant` passed to {@link IOverlayWebview.release}. */ - claim(claimant: any, scopedContextKeyService: IContextKeyService | undefined): void; + claim(claimant: any, targetWindow: CodeWindow, scopedContextKeyService: IContextKeyService | undefined): void; /** * Release ownership of the webview. diff --git a/src/vs/workbench/contrib/webview/browser/webviewElement.ts b/src/vs/workbench/contrib/webview/browser/webviewElement.ts index 6514979da3dc8..3d5d21f080f7f 100644 --- a/src/vs/workbench/contrib/webview/browser/webviewElement.ts +++ b/src/vs/workbench/contrib/webview/browser/webviewElement.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { isFirefox } from 'vs/base/browser/browser'; -import { addDisposableListener, EventType, getActiveWindow } from 'vs/base/browser/dom'; +import { addDisposableListener, EventType, getWindowById } from 'vs/base/browser/dom'; import { IMouseWheelEvent } from 'vs/base/browser/mouseEvent'; import { promiseWithResolvers, ThrottledDelayer } from 'vs/base/common/async'; import { streamToBuffer, VSBufferReadableStream } from 'vs/base/common/buffer'; @@ -37,7 +37,7 @@ import { WebviewFindDelegate, WebviewFindWidget } from 'vs/workbench/contrib/web import { FromWebviewMessage, KeyEvent, ToWebviewMessage } from 'vs/workbench/contrib/webview/browser/webviewMessages'; import { decodeAuthority, webviewGenericCspSource, webviewRootResourceAuthority } from 'vs/workbench/contrib/webview/common/webview'; import { IWorkbenchEnvironmentService } from 'vs/workbench/services/environment/common/environmentService'; -import { $window } from 'vs/base/browser/window'; +import { CodeWindow } from 'vs/base/browser/window'; interface WebviewContent { readonly html: string; @@ -88,7 +88,10 @@ export class WebviewElement extends Disposable implements IWebview, WebviewFindD */ public readonly origin: string; - private readonly _encodedWebviewOriginPromise: Promise; + private _windowId: number | undefined = undefined; + private get window() { return typeof this._windowId === 'number' ? getWindowById(this._windowId)?.window : undefined; } + + private _encodedWebviewOriginPromise?: Promise; private _encodedWebviewOrigin: string | undefined; protected get platform(): string { return 'browser'; } @@ -103,7 +106,12 @@ export class WebviewElement extends Disposable implements IWebview, WebviewFindD if (!this._focused) { return false; } - if ($window.document.activeElement && $window.document.activeElement !== this.element) { + // code window is only available after the webview is mounted. + if (!this.window) { + return false; + } + + if (this.window.document.activeElement && this.window.document.activeElement !== this.element) { // looks like https://github.com/microsoft/vscode/issues/132641 // where the focus is actually not in the `