diff --git a/.github/workflows/monaco-editor.yml b/.github/workflows/monaco-editor.yml index 426999ce43b..1f5694faec2 100644 --- a/.github/workflows/monaco-editor.yml +++ b/.github/workflows/monaco-editor.yml @@ -45,11 +45,11 @@ jobs: path: ${{ steps.npmCacheDirPath.outputs.dir }} key: ${{ runner.os }}-npmCacheDir-${{ steps.nodeModulesCacheKey.outputs.value }} restore-keys: ${{ runner.os }}-npmCacheDir- - - name: Install libkrb5-dev + - name: Install system dependencies if: ${{ steps.cacheNodeModules.outputs.cache-hit != 'true' }} run: | sudo apt update - sudo apt install -y libkrb5-dev + sudo apt install -y libxkbfile-dev pkg-config libkrb5-dev libxss1 - name: Execute npm if: ${{ steps.cacheNodeModules.outputs.cache-hit != 'true' }} env: diff --git a/.vscode/notebooks/api.github-issues b/.vscode/notebooks/api.github-issues index 0423e9e3afc..d29f2bc441d 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:\"November 2024\"" + "value": "$REPO=repo:microsoft/vscode\n$MILESTONE=milestone:\"January 2025\"" }, { "kind": 1, diff --git a/extensions/git/package.json b/extensions/git/package.json index cdb7c77a03a..66e8dd4981d 100644 --- a/extensions/git/package.json +++ b/extensions/git/package.json @@ -912,13 +912,6 @@ "category": "Git", "enablement": "!operationInProgress" }, - { - "command": "git.viewAllChanges", - "title": "%command.viewAllChanges%", - "icon": "$(diff-multiple)", - "category": "Git", - "enablement": "!operationInProgress" - }, { "command": "git.copyCommitId", "title": "%command.timelineCopyCommitId%", @@ -1444,10 +1437,6 @@ "command": "git.viewCommit", "when": "false" }, - { - "command": "git.viewAllChanges", - "when": "false" - }, { "command": "git.stageFile", "when": "false" @@ -3229,6 +3218,14 @@ "type": "string", "default": "${authorName} (${authorDateAgo})", "markdownDescription": "%config.blameStatusBarItem.template%" + }, + "git.commitShortHashLength": { + "type": "number", + "default": 7, + "minimum": 7, + "maximum": 40, + "markdownDescription": "%config.commitShortHashLength%", + "scope": "resource" } } }, diff --git a/extensions/git/package.nls.json b/extensions/git/package.nls.json index 6db253137e1..e7020f41890 100644 --- a/extensions/git/package.nls.json +++ b/extensions/git/package.nls.json @@ -125,7 +125,6 @@ "command.viewChanges": "View Changes", "command.viewStagedChanges": "View Staged Changes", "command.viewUntrackedChanges": "View Untracked Changes", - "command.viewAllChanges": "View All Changes", "command.viewCommit": "View Commit", "command.api.getRepositories": "Get Repositories", "command.api.getRepositoryState": "Get Repository State", @@ -278,9 +277,10 @@ "config.publishBeforeContinueOn.prompt": "Prompt to publish unpublished Git state when using Continue Working On from a Git repository", "config.similarityThreshold": "Controls the threshold of the similarity index (the amount of additions/deletions compared to the file's size) for changes in a pair of added/deleted files to be considered a rename. **Note:** Requires Git version `2.18.0` or later.", "config.blameEditorDecoration.enabled": "Controls whether to show blame information in the editor using editor decorations.", - "config.blameEditorDecoration.template": "Template for the blame information editor decoration. Supported variables:\n\n* `hash`: Commit hash\n\n* `hashShort`: First 8 characters of the commit hash\n\n* `subject`: First line of the commit message\n\n* `authorName`: Author name\n\n* `authorEmail`: Author email\n\n* `authorDate`: Author date\n\n* `authorDateAgo`: Time difference between now and the author date\n\n", + "config.blameEditorDecoration.template": "Template for the blame information editor decoration. Supported variables:\n\n* `hash`: Commit hash\n\n* `hashShort`: First N characters of the commit hash according to `#git.commitShortHashLength#`\n\n* `subject`: First line of the commit message\n\n* `authorName`: Author name\n\n* `authorEmail`: Author email\n\n* `authorDate`: Author date\n\n* `authorDateAgo`: Time difference between now and the author date\n\n", "config.blameStatusBarItem.enabled": "Controls whether to show blame information in the status bar.", - "config.blameStatusBarItem.template": "Template for the blame information status bar item. Supported variables:\n\n* `hash`: Commit hash\n\n* `hashShort`: First 8 characters of the commit hash\n\n* `subject`: First line of the commit message\n\n* `authorName`: Author name\n\n* `authorEmail`: Author email\n\n* `authorDate`: Author date\n\n* `authorDateAgo`: Time difference between now and the author date\n\n", + "config.blameStatusBarItem.template": "Template for the blame information status bar item. Supported variables:\n\n* `hash`: Commit hash\n\n* `hashShort`: First N characters of the commit hash according to `#git.commitShortHashLength#`\n\n* `subject`: First line of the commit message\n\n* `authorName`: Author name\n\n* `authorEmail`: Author email\n\n* `authorDate`: Author date\n\n* `authorDateAgo`: Time difference between now and the author date\n\n", + "config.commitShortHashLength": "Controls the length of the commit short hash.", "submenu.explorer": "Git", "submenu.commit": "Commit", "submenu.commit.amend": "Amend", diff --git a/extensions/git/src/blame.ts b/extensions/git/src/blame.ts index 3c7d61dd1e1..c8f15ffdf94 100644 --- a/extensions/git/src/blame.ts +++ b/extensions/git/src/blame.ts @@ -5,7 +5,7 @@ import { DecorationOptions, l10n, Position, Range, TextEditor, TextEditorChange, TextEditorDecorationType, TextEditorChangeKind, ThemeColor, Uri, window, workspace, EventEmitter, ConfigurationChangeEvent, StatusBarItem, StatusBarAlignment, Command, MarkdownString, languages, HoverProvider, CancellationToken, Hover, TextDocument } from 'vscode'; import { Model } from './model'; -import { dispose, fromNow, IDisposable } from './util'; +import { dispose, fromNow, getCommitShortHash, IDisposable } from './util'; import { Repository } from './repository'; import { throttle } from './decorators'; import { BlameInformation, Commit } from './git'; @@ -186,14 +186,14 @@ export class GitBlameController { this._onDidChangeConfiguration(); } - formatBlameInformationMessage(template: string, blameInformation: BlameInformation): string { + formatBlameInformationMessage(documentUri: Uri, template: string, blameInformation: BlameInformation): string { const subject = blameInformation.subject && blameInformation.subject.length > this._subjectMaxLength ? `${blameInformation.subject.substring(0, this._subjectMaxLength)}\u2026` : blameInformation.subject; const templateTokens = { hash: blameInformation.hash, - hashShort: blameInformation.hash.substring(0, 8), + hashShort: getCommitShortHash(documentUri, blameInformation.hash), subject: emojify(subject ?? ''), authorName: blameInformation.authorName ?? '', authorEmail: blameInformation.authorEmail ?? '', @@ -227,7 +227,12 @@ export class GitBlameController { markdownString.supportThemeIcons = true; if (blameInformationOrCommit.authorName) { - markdownString.appendMarkdown(`$(account) **${blameInformationOrCommit.authorName}**`); + if (blameInformationOrCommit.authorEmail) { + const emailTitle = l10n.t('Email'); + markdownString.appendMarkdown(`$(account) [**${blameInformationOrCommit.authorName}**](mailto:${blameInformationOrCommit.authorEmail} "${emailTitle} ${blameInformationOrCommit.authorName}")`); + } else { + markdownString.appendMarkdown(`$(account) **${blameInformationOrCommit.authorName}**`); + } if (blameInformationOrCommit.authorDate) { const dateString = new Date(blameInformationOrCommit.authorDate).toLocaleString(undefined, { year: 'numeric', month: 'long', day: 'numeric', hour: 'numeric', minute: 'numeric' }); @@ -260,9 +265,9 @@ export class GitBlameController { markdownString.appendMarkdown(`\n\n---\n\n`); } - markdownString.appendMarkdown(`[\`$(git-commit) ${blameInformationOrCommit.hash.substring(0, 8)} \`](command:git.blameStatusBarItem.viewCommit?${encodeURIComponent(JSON.stringify([documentUri, blameInformationOrCommit.hash]))} "${l10n.t('View Commit')}")`); + markdownString.appendMarkdown(`[\`$(git-commit) ${getCommitShortHash(documentUri, blameInformationOrCommit.hash)} \`](command:git.viewCommit?${encodeURIComponent(JSON.stringify([documentUri, blameInformationOrCommit.hash]))} "${l10n.t('View Commit')}")`); markdownString.appendMarkdown(' '); - markdownString.appendMarkdown(`[$(copy)](command:git.blameStatusBarItem.copyContent?${encodeURIComponent(JSON.stringify(blameInformationOrCommit.hash))} "${l10n.t('Copy Commit Hash')}")`); + markdownString.appendMarkdown(`[$(copy)](command:git.copyContentToClipboard?${encodeURIComponent(JSON.stringify(blameInformationOrCommit.hash))} "${l10n.t('Copy Commit Hash')}")`); markdownString.appendMarkdown('  |  '); markdownString.appendMarkdown(`[$(gear)](command:workbench.action.openSettings?%5B%22git.blame%22%5D "${l10n.t('Open Settings')}")`); @@ -571,7 +576,9 @@ class GitBlameEditorDecoration implements HoverProvider { } private _onDidChangeConfiguration(e?: ConfigurationChangeEvent): void { - if (e && !e.affectsConfiguration('git.blame.editorDecoration.template')) { + if (e && + !e.affectsConfiguration('git.commitShortHashLength') && + !e.affectsConfiguration('git.blame.editorDecoration.template')) { return; } @@ -610,7 +617,7 @@ class GitBlameEditorDecoration implements HoverProvider { const decorations = blameInformation.map(blame => { const contentText = typeof blame.blameInformation !== 'string' - ? this._controller.formatBlameInformationMessage(template, blame.blameInformation) + ? this._controller.formatBlameInformationMessage(textEditor.document.uri, template, blame.blameInformation) : blame.blameInformation; return this._createDecoration(blame.lineNumber, contentText); @@ -663,7 +670,8 @@ class GitBlameStatusBarItem { } private _onDidChangeConfiguration(e: ConfigurationChangeEvent): void { - if (!e.affectsConfiguration('git.blame.statusBarItem.template')) { + if (!e.affectsConfiguration('git.commitShortHashLength') && + !e.affectsConfiguration('git.blame.statusBarItem.template')) { return; } @@ -690,11 +698,11 @@ class GitBlameStatusBarItem { const config = workspace.getConfiguration('git'); const template = config.get('blame.statusBarItem.template', '${authorName} (${authorDateAgo})'); - this._statusBarItem.text = `$(git-commit) ${this._controller.formatBlameInformationMessage(template, blameInformation[0].blameInformation)}`; + this._statusBarItem.text = `$(git-commit) ${this._controller.formatBlameInformationMessage(window.activeTextEditor.document.uri, template, blameInformation[0].blameInformation)}`; this._statusBarItem.tooltip = this._controller.getBlameInformationHover(window.activeTextEditor.document.uri, blameInformation[0].blameInformation); this._statusBarItem.command = { title: l10n.t('View Commit'), - command: 'git.blameStatusBarItem.viewCommit', + command: 'git.viewCommit', arguments: [window.activeTextEditor.document.uri, blameInformation[0].blameInformation.hash] } satisfies Command; } diff --git a/extensions/git/src/commands.ts b/extensions/git/src/commands.ts index 95ab9ada071..b8fa9009e1f 100644 --- a/extensions/git/src/commands.ts +++ b/extensions/git/src/commands.ts @@ -14,7 +14,7 @@ import { Model } from './model'; import { GitResourceGroup, Repository, Resource, ResourceGroupType } from './repository'; import { DiffEditorSelectionHunkToolbarContext, applyLineChanges, getModifiedRange, getWorkingTreeAndIndexDiffInformation, getWorkingTreeDiffInformation, intersectDiffWithRange, invertLineChange, toLineChanges, toLineRanges } from './staging'; import { fromGitUri, toGitUri, isGitUri, toMergeUris, toMultiFileDiffEditorUris } from './uri'; -import { dispose, grep, isDefined, isDescendant, pathEquals, relativePath, truncate } from './util'; +import { dispose, getCommitShortHash, grep, isDefined, isDescendant, pathEquals, relativePath, truncate } from './util'; import { GitTimelineItem } from './timelineProvider'; import { ApiRepository } from './api/api1'; import { getRemoteSourceActions, pickRemoteSource } from './remoteSource'; @@ -4271,61 +4271,6 @@ export class CommandCenter { }); } - @command('git.viewCommit', { repository: true }) - async viewCommit(repository: Repository, historyItem1: SourceControlHistoryItem, historyItem2?: SourceControlHistoryItem): Promise { - if (!repository || !historyItem1) { - return; - } - - if (historyItem2) { - const mergeBase = await repository.getMergeBase(historyItem1.id, historyItem2.id); - if (!mergeBase || (mergeBase !== historyItem1.id && mergeBase !== historyItem2.id)) { - return; - } - } - - let title: string | undefined; - let historyItemParentId: string | undefined; - - // If historyItem2 is not provided, we are viewing a single commit. If historyItem2 is - // provided, we are viewing a range and we have to include both start and end commits. - // TODO@lszomoru - handle the case when historyItem2 is the first commit in the repository - if (!historyItem2) { - const commit = await repository.getCommit(historyItem1.id); - title = `${historyItem1.id.substring(0, 8)} - ${truncate(commit.message)}`; - historyItemParentId = historyItem1.parentIds.length > 0 ? historyItem1.parentIds[0] : `${historyItem1.id}^`; - } else { - title = l10n.t('All Changes ({0} ↔ {1})', historyItem2.id.substring(0, 8), historyItem1.id.substring(0, 8)); - historyItemParentId = historyItem2.parentIds.length > 0 ? historyItem2.parentIds[0] : `${historyItem2.id}^`; - } - - const multiDiffSourceUri = Uri.from({ scheme: 'scm-history-item', path: `${repository.root}/${historyItemParentId}..${historyItem1.id}` }); - - await this._viewChanges(repository, historyItem1.id, historyItemParentId, multiDiffSourceUri, title); - } - - @command('git.viewAllChanges', { repository: true }) - async viewAllChanges(repository: Repository, historyItem: SourceControlHistoryItem): Promise { - if (!repository || !historyItem) { - return; - } - - const modifiedShortRef = historyItem.id.substring(0, 8); - const originalShortRef = historyItem.parentIds.length > 0 ? historyItem.parentIds[0].substring(0, 8) : `${modifiedShortRef}^`; - const title = l10n.t('All Changes ({0} ↔ {1})', originalShortRef, modifiedShortRef); - - const multiDiffSourceUri = toGitUri(Uri.file(repository.root), historyItem.id, { scheme: 'git-changes' }); - - await this._viewChanges(repository, modifiedShortRef, originalShortRef, multiDiffSourceUri, title); - } - - async _viewChanges(repository: Repository, historyItemId: string, historyItemParentId: string, multiDiffSourceUri: Uri, title: string): Promise { - const changes = await repository.diffBetween(historyItemParentId, historyItemId); - const resources = changes.map(c => toMultiFileDiffEditorUris(c, historyItemParentId, historyItemId)); - - await commands.executeCommand('_workbench.openMultiDiffEditor', { multiDiffSourceUri, title, resources }); - } - @command('git.copyCommitId', { repository: true }) async copyCommitId(repository: Repository, historyItem: SourceControlHistoryItem): Promise { if (!repository || !historyItem) { @@ -4344,14 +4289,15 @@ export class CommandCenter { env.clipboard.writeText(historyItem.message); } - @command('git.blameStatusBarItem.viewCommit', { repository: true }) - async viewStatusBarCommit(repository: Repository, historyItemId: string): Promise { + @command('git.viewCommit', { repository: true }) + async viewCommit(repository: Repository, historyItemId: string): Promise { if (!repository || !historyItemId) { return; } + const rootUri = Uri.file(repository.root); const commit = await repository.getCommit(historyItemId); - const title = `${historyItemId.substring(0, 8)} - ${truncate(commit.message)}`; + const title = `${getCommitShortHash(rootUri, historyItemId)} - ${truncate(commit.message)}`; const historyItemParentId = commit.parents.length > 0 ? commit.parents[0] : `${historyItemId}^`; const multiDiffSourceUri = Uri.from({ scheme: 'scm-history-item', path: `${repository.root}/${historyItemParentId}..${historyItemId}` }); @@ -4362,8 +4308,8 @@ export class CommandCenter { await commands.executeCommand('_workbench.openMultiDiffEditor', { multiDiffSourceUri, title, resources }); } - @command('git.blameStatusBarItem.copyContent') - async blameStatusBarCopyContent(content: string): Promise { + @command('git.copyContentToClipboard') + async copyContentToClipboard(content: string): Promise { if (typeof content !== 'string') { return; } diff --git a/extensions/git/src/git.ts b/extensions/git/src/git.ts index 5bdfd655dbc..0ca691fda64 100644 --- a/extensions/git/src/git.ts +++ b/extensions/git/src/git.ts @@ -62,6 +62,7 @@ export interface LogFileOptions { /** Optional. Specifies whether to start retrieving log entries in reverse order. */ readonly reverse?: boolean; readonly sortByAuthorDate?: boolean; + readonly shortStats?: boolean; } function parseVersion(raw: string): string { @@ -1290,6 +1291,10 @@ export class Repository { } } + if (options?.shortStats) { + args.push('--shortstat'); + } + if (options?.sortByAuthorDate) { args.push('--author-date-order'); } diff --git a/extensions/git/src/historyProvider.ts b/extensions/git/src/historyProvider.ts index 896c46851c4..b18923d56b7 100644 --- a/extensions/git/src/historyProvider.ts +++ b/extensions/git/src/historyProvider.ts @@ -6,20 +6,22 @@ import { Disposable, Event, EventEmitter, FileDecoration, FileDecorationProvider, SourceControlHistoryItem, SourceControlHistoryItemChange, SourceControlHistoryOptions, SourceControlHistoryProvider, ThemeIcon, Uri, window, LogOutputChannel, SourceControlHistoryItemRef, l10n, SourceControlHistoryItemRefsChangeEvent } from 'vscode'; import { Repository, Resource } from './repository'; -import { IDisposable, deltaHistoryItemRefs, dispose, filterEvent } from './util'; +import { IDisposable, deltaHistoryItemRefs, dispose, filterEvent, getCommitShortHash } from './util'; import { toGitUri } from './uri'; import { Branch, LogOptions, Ref, RefType } from './api/git'; import { emojify, ensureEmojis } from './emoji'; import { Commit } from './git'; import { OperationKind, OperationResult } from './operation'; -function toSourceControlHistoryItemRef(ref: Ref): SourceControlHistoryItemRef { +function toSourceControlHistoryItemRef(repository: Repository, ref: Ref): SourceControlHistoryItemRef { + const rootUri = Uri.file(repository.root); + switch (ref.type) { case RefType.RemoteHead: return { id: `refs/remotes/${ref.name}`, name: ref.name ?? '', - description: ref.commit ? l10n.t('Remote branch at {0}', ref.commit.substring(0, 8)) : undefined, + description: ref.commit ? l10n.t('Remote branch at {0}', getCommitShortHash(rootUri, ref.commit)) : undefined, revision: ref.commit, icon: new ThemeIcon('cloud'), category: l10n.t('remote branches') @@ -28,7 +30,7 @@ function toSourceControlHistoryItemRef(ref: Ref): SourceControlHistoryItemRef { return { id: `refs/tags/${ref.name}`, name: ref.name ?? '', - description: ref.commit ? l10n.t('Tag at {0}', ref.commit.substring(0, 8)) : undefined, + description: ref.commit ? l10n.t('Tag at {0}', getCommitShortHash(rootUri, ref.commit)) : undefined, revision: ref.commit, icon: new ThemeIcon('tag'), category: l10n.t('tags') @@ -37,7 +39,7 @@ function toSourceControlHistoryItemRef(ref: Ref): SourceControlHistoryItemRef { return { id: `refs/heads/${ref.name}`, name: ref.name ?? '', - description: ref.commit ? ref.commit.substring(0, 8) : undefined, + description: ref.commit ? getCommitShortHash(rootUri, ref.commit) : undefined, revision: ref.commit, icon: new ThemeIcon('git-branch'), category: l10n.t('branches') @@ -178,7 +180,7 @@ export class GitHistoryProvider implements SourceControlHistoryProvider, FileDec // Refs (alphabetically) const historyItemRefs = this.repository.refs - .map(ref => toSourceControlHistoryItemRef(ref)) + .map(ref => toSourceControlHistoryItemRef(this.repository, ref)) .sort((a, b) => a.id.localeCompare(b.id)); // Auto-fetch @@ -207,13 +209,13 @@ export class GitHistoryProvider implements SourceControlHistoryProvider, FileDec for (const ref of refs) { switch (ref.type) { case RefType.RemoteHead: - remoteBranches.push(toSourceControlHistoryItemRef(ref)); + remoteBranches.push(toSourceControlHistoryItemRef(this.repository, ref)); break; case RefType.Tag: - tags.push(toSourceControlHistoryItemRef(ref)); + tags.push(toSourceControlHistoryItemRef(this.repository, ref)); break; default: - branches.push(toSourceControlHistoryItemRef(ref)); + branches.push(toSourceControlHistoryItemRef(this.repository, ref)); break; } } @@ -258,8 +260,9 @@ export class GitHistoryProvider implements SourceControlHistoryProvider, FileDec parentIds: commit.parents, message: emojify(commit.message), author: commit.authorName, + authorEmail: commit.authorEmail, icon: new ThemeIcon('git-commit'), - displayId: commit.hash.substring(0, 8), + displayId: getCommitShortHash(Uri.file(this.repository.root), commit.hash), timestamp: commit.authorDate?.getTime(), statistics: commit.shortStat ?? { files: 0, insertions: 0, deletions: 0 }, references: references.length !== 0 ? references : undefined diff --git a/extensions/git/src/repository.ts b/extensions/git/src/repository.ts index 4bf7fa32ef0..e1b71d87f0a 100644 --- a/extensions/git/src/repository.ts +++ b/extensions/git/src/repository.ts @@ -23,7 +23,7 @@ import { IPushErrorHandlerRegistry } from './pushError'; import { IRemoteSourcePublisherRegistry } from './remotePublisher'; import { StatusBarCommands } from './statusbar'; import { toGitUri } from './uri'; -import { anyEvent, combinedDisposable, debounceEvent, dispose, EmptyDisposable, eventToPromise, filterEvent, find, IDisposable, isDescendant, onceEvent, pathEquals, relativePath } from './util'; +import { anyEvent, combinedDisposable, debounceEvent, dispose, EmptyDisposable, eventToPromise, filterEvent, find, getCommitShortHash, IDisposable, isDescendant, onceEvent, pathEquals, relativePath } from './util'; import { IFileWatcher, watch } from './watch'; import { detectEncoding } from './encoding'; @@ -1657,7 +1657,7 @@ export class Repository implements Disposable { } async checkout(treeish: string, opts?: { detached?: boolean; pullBeforeCheckout?: boolean }): Promise { - const refLabel = opts?.detached ? treeish.substring(0, 8) : treeish; + const refLabel = opts?.detached ? getCommitShortHash(Uri.file(this.root), treeish) : treeish; await this.run(Operation.Checkout(refLabel), async () => { @@ -1675,7 +1675,7 @@ export class Repository implements Disposable { } async checkoutTracking(treeish: string, opts: { detached?: boolean } = {}): Promise { - const refLabel = opts.detached ? treeish.substring(0, 8) : treeish; + const refLabel = opts.detached ? getCommitShortHash(Uri.file(this.root), treeish) : treeish; await this.run(Operation.CheckoutTracking(refLabel), () => this.repository.checkout(treeish, [], { ...opts, track: true })); } diff --git a/extensions/git/src/timelineProvider.ts b/extensions/git/src/timelineProvider.ts index 5788ecc53dd..2b3da08f82e 100644 --- a/extensions/git/src/timelineProvider.ts +++ b/extensions/git/src/timelineProvider.ts @@ -10,6 +10,8 @@ import { debounce } from './decorators'; import { emojify, ensureEmojis } from './emoji'; import { CommandCenter } from './commands'; import { OperationKind, OperationResult } from './operation'; +import { getCommitShortHash } from './util'; +import { CommitShortStat } from './git'; export class GitTimelineItem extends TimelineItem { static is(item: TimelineItem): item is GitTimelineItem { @@ -48,18 +50,46 @@ export class GitTimelineItem extends TimelineItem { return this.shortenRef(this.previousRef); } - setItemDetails(author: string, email: string | undefined, date: string, message: string): void { + setItemDetails(uri: Uri, hash: string | undefined, author: string, email: string | undefined, date: string, message: string, shortStat?: CommitShortStat): void { this.tooltip = new MarkdownString('', true); + this.tooltip.isTrusted = true; + this.tooltip.supportHtml = true; if (email) { const emailTitle = l10n.t('Email'); - this.tooltip.appendMarkdown(`$(account) [**${author}**](mailto:${email} "${emailTitle} ${author}")\n\n`); + this.tooltip.appendMarkdown(`$(account) [**${author}**](mailto:${email} "${emailTitle} ${author}")`); } else { - this.tooltip.appendMarkdown(`$(account) **${author}**\n\n`); + this.tooltip.appendMarkdown(`$(account) **${author}**`); } - this.tooltip.appendMarkdown(`$(history) ${date}\n\n`); - this.tooltip.appendMarkdown(message); + this.tooltip.appendMarkdown(`, $(history) ${date}\n\n`); + this.tooltip.appendMarkdown(`${message}\n\n`); + + if (shortStat) { + this.tooltip.appendMarkdown(`---\n\n`); + + if (shortStat.insertions) { + this.tooltip.appendMarkdown(`${shortStat.insertions === 1 ? + l10n.t('{0} insertion{1}', shortStat.insertions, '(+)') : + l10n.t('{0} insertions{1}', shortStat.insertions, '(+)')}`); + } + + if (shortStat.deletions) { + this.tooltip.appendMarkdown(`, ${shortStat.deletions === 1 ? + l10n.t('{0} deletion{1}', shortStat.deletions, '(-)') : + l10n.t('{0} deletions{1}', shortStat.deletions, '(-)')}`); + } + + this.tooltip.appendMarkdown(`\n\n`); + } + + if (hash) { + this.tooltip.appendMarkdown(`---\n\n`); + + this.tooltip.appendMarkdown(`[\`$(git-commit) ${getCommitShortHash(uri, hash)} \`](command:git.viewCommit?${encodeURIComponent(JSON.stringify([uri, hash]))} "${l10n.t('View Commit')}")`); + this.tooltip.appendMarkdown(' '); + this.tooltip.appendMarkdown(`[$(copy)](command:git.copyContentToClipboard?${encodeURIComponent(JSON.stringify(hash))} "${l10n.t('Copy Commit Hash')}")`); + } } private shortenRef(ref: string): string { @@ -153,6 +183,7 @@ export class GitTimelineProvider implements TimelineProvider { maxEntries: limit, hash: options.cursor, follow: true, + shortStats: true, // sortByAuthorDate: true }); @@ -184,7 +215,7 @@ export class GitTimelineProvider implements TimelineProvider { item.description = c.authorName; } - item.setItemDetails(c.authorName!, c.authorEmail, dateFormatter.format(date), message); + item.setItemDetails(uri, c.hash, c.authorName!, c.authorEmail, dateFormatter.format(date), message, c.shortStat); const cmd = this.commands.resolveTimelineOpenDiffCommand(item, uri); if (cmd) { @@ -209,7 +240,7 @@ export class GitTimelineProvider implements TimelineProvider { // TODO@eamodio: Replace with a better icon -- reflecting its status maybe? item.iconPath = new ThemeIcon('git-commit'); item.description = ''; - item.setItemDetails(you, undefined, dateFormatter.format(date), Resource.getStatusText(index.type)); + item.setItemDetails(uri, undefined, you, undefined, dateFormatter.format(date), Resource.getStatusText(index.type)); const cmd = this.commands.resolveTimelineOpenDiffCommand(item, uri); if (cmd) { @@ -231,7 +262,7 @@ export class GitTimelineProvider implements TimelineProvider { const item = new GitTimelineItem('', index ? '~' : 'HEAD', l10n.t('Uncommitted Changes'), date.getTime(), 'working', 'git:file:working'); item.iconPath = new ThemeIcon('circle-outline'); item.description = ''; - item.setItemDetails(you, undefined, dateFormatter.format(date), Resource.getStatusText(working.type)); + item.setItemDetails(uri, undefined, you, undefined, dateFormatter.format(date), Resource.getStatusText(working.type)); const cmd = this.commands.resolveTimelineOpenDiffCommand(item, uri); if (cmd) { diff --git a/extensions/git/src/util.ts b/extensions/git/src/util.ts index 8fb85493a9b..759ccdf82de 100644 --- a/extensions/git/src/util.ts +++ b/extensions/git/src/util.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { Event, Disposable, EventEmitter, SourceControlHistoryItemRef, l10n } from 'vscode'; +import { Event, Disposable, EventEmitter, SourceControlHistoryItemRef, l10n, workspace, Uri } from 'vscode'; import { dirname, sep, relative } from 'path'; import { Readable } from 'stream'; import { promises as fs, createReadStream } from 'fs'; @@ -766,3 +766,9 @@ export function fromNow(date: number | Date, appendAgoLabel?: boolean, useFullTi } } } + +export function getCommitShortHash(scope: Uri, hash: string): string { + const config = workspace.getConfiguration('git', scope); + const shortHashLength = config.get('commitShortHashLength', 7); + return hash.substring(0, shortHashLength); +} diff --git a/extensions/ipynb/src/deserializers.ts b/extensions/ipynb/src/deserializers.ts index de467f66077..ad99a4fcea8 100644 --- a/extensions/ipynb/src/deserializers.ts +++ b/extensions/ipynb/src/deserializers.ts @@ -149,8 +149,12 @@ function getNotebookCellMetadata(cell: nbformat.IBaseCell): { // 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.cell_type === 'code') { + if (typeof cell['execution_count'] === 'number') { + cellMetadata.execution_count = cell['execution_count']; + } else { + cellMetadata.execution_count = null; + } } if (cell['metadata']) { diff --git a/extensions/ipynb/src/notebookModelStoreSync.ts b/extensions/ipynb/src/notebookModelStoreSync.ts index dc1bae1de2f..836e1c8afc5 100644 --- a/extensions/ipynb/src/notebookModelStoreSync.ts +++ b/extensions/ipynb/src/notebookModelStoreSync.ts @@ -165,6 +165,13 @@ function onDidChangeNotebookCells(e: NotebookDocumentChangeEventEx) { metadata.execution_count = null; metadataUpdated = true; pendingCellUpdates.delete(e.cell); + } else if (!e.executionSummary?.executionOrder && !e.executionSummary?.success && !e.executionSummary?.timing + && !e.metadata && !e.outputs && currentMetadata.execution_count && !pendingCellUpdates.has(e.cell)) { + // This is a result of the cell without outupts but has execution count being cleared + // Create two cells, one that produces output and one that doesn't. Run both and then clear the output or all cells. + // This condition will be satisfied for first cell without outputs. + metadata.execution_count = null; + metadataUpdated = true; } if (e.document?.languageId && e.document?.languageId !== preferredCellLanguage && e.document?.languageId !== languageIdInMetadata) { diff --git a/extensions/ipynb/src/test/clearOutputs.test.ts b/extensions/ipynb/src/test/clearOutputs.test.ts new file mode 100644 index 00000000000..0437e9838bb --- /dev/null +++ b/extensions/ipynb/src/test/clearOutputs.test.ts @@ -0,0 +1,736 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * 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'; +import { jupyterNotebookModelToNotebookData } from '../deserializers'; +import { activate } from '../notebookModelStoreSync'; + + +suite(`ipynb Clear Outputs`, () => { + const disposables: vscode.Disposable[] = []; + const context = { subscriptions: disposables } as vscode.ExtensionContext; + setup(() => { + disposables.length = 0; + activate(context); + }); + teardown(async () => { + disposables.forEach(d => d.dispose()); + disposables.length = 0; + sinon.restore(); + await vscode.commands.executeCommand('workbench.action.closeAllEditors'); + }); + + test('Clear outputs after opening Notebook', async () => { + const cells: nbformat.ICell[] = [ + { + cell_type: 'code', + execution_count: 10, + outputs: [{ output_type: 'stream', name: 'stdout', text: ['Hello'] }], + source: 'print(1)', + metadata: {} + }, + { + cell_type: 'code', + outputs: [], + source: 'print(2)', + metadata: {} + }, + { + cell_type: 'markdown', + source: '# HEAD', + metadata: {} + } + ]; + const notebook = jupyterNotebookModelToNotebookData({ cells }, 'python'); + + const notebookDocument = await vscode.workspace.openNotebookDocument('jupyter-notebook', notebook); + await vscode.window.showNotebookDocument(notebookDocument); + + assert.strictEqual(notebookDocument.cellCount, 3); + assert.strictEqual(notebookDocument.cellAt(0).metadata.execution_count, 10); + assert.strictEqual(notebookDocument.cellAt(1).metadata.execution_count, null); + assert.strictEqual(notebookDocument.cellAt(2).metadata.execution_count, undefined); + + // Clear all outputs + await vscode.commands.executeCommand('notebook.clearAllCellsOutputs'); + + // Wait for all changes to be applied, could take a few ms. + const verifyMetadataChanges = () => { + assert.strictEqual(notebookDocument.cellAt(0).metadata.execution_count, null); + assert.strictEqual(notebookDocument.cellAt(1).metadata.execution_count, null); + assert.strictEqual(notebookDocument.cellAt(2).metadata.execution_count, undefined); + }; + + vscode.workspace.onDidChangeNotebookDocument(() => verifyMetadataChanges(), undefined, disposables); + + await new Promise((resolve, reject) => { + const interval = setInterval(() => { + try { + verifyMetadataChanges(); + clearInterval(interval); + resolve(); + } catch { + // Ignore + } + }, 50); + disposables.push({ dispose: () => clearInterval(interval) }); + const timeout = setTimeout(() => { + try { + verifyMetadataChanges(); + resolve(); + } catch (ex) { + reject(ex); + } + }, 1000); + disposables.push({ dispose: () => clearTimeout(timeout) }); + }); + }); + + + // test('Serialize', async () => { + // const markdownCell = new vscode.NotebookCellData(vscode.NotebookCellKind.Markup, '# header1', 'markdown'); + // markdownCell.metadata = { + // attachments: { + // 'image.png': { + // 'image/png': 'abc' + // } + // }, + // id: '123', + // metadata: { + // foo: 'bar' + // } + // }; + + // 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 = { + // id: '123', + // metadata: { + // foo: 'bar' + // }, + // attachments: { + // 'image.png': { + // 'image/png': 'abc' + // } + // } + // }; + + // 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' + // }); + // }); + + // 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); + // } + + // 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', + // '...\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', + // 'Epoch 3/5\n', + // '...\n', + // 'Epoch 4/5\n', + // '...\n', + // 'Epoch 5/5\n', + // '...\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', + // '...\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', + // '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' + // }) + // ] + // ); + // }); + + // 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')], + // { + // outputType: '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' + // }) + // ] + // ); + // }); + + // 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')], + // { + // 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') + // }) + // ], + // { + // outputType: 'error', + // originalError: { + // ename: 'Error Name', + // evalue: 'Error Value', + // traceback: ['stack1', 'stack2', 'stack3'], + // output_type: 'error' + // } + // } + // ) + // ] + // ); + // }); + + // ['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( + // [ + // { + // data: { + // 'text/plain': 'Hello World!' + // }, + // output_type, + // metadata: {}, + // execution_count: 1 + // } + // ], + // [ + // 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') + // ], + // { + // 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')], + // { + // 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')], + // { + // 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')], + // { + // 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')], + // { + // 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' + // }, + // metadata: {}, + // output_type: 'display_data' + // }, + // 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' + // }, + // metadata: {}, + // output_type: 'display_data' + // }, + // 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' + // }, + // metadata: {}, + // output_type: 'display_data' + // }, + // expectedMimeTypesOrder: [ + // 'text/html', + // 'application/javascript', + // 'text/plain', + // 'application/vnd.vegalite.v4+json' + // ] + // }, + // { + // output: { + // data: { + // 'text/plain': 'some text', + // 'text/html': 'Hello' + // }, + // metadata: {}, + // output_type: 'display_data' + // }, + // expectedMimeTypesOrder: ['text/html', 'text/plain'] + // }, + // { + // output: { + // data: { + // 'application/javascript': 'some js', + // 'text/plain': 'some text' + // }, + // metadata: {}, + // output_type: 'display_data' + // }, + // expectedMimeTypesOrder: ['application/javascript', 'text/plain'] + // }, + // { + // output: { + // data: { + // 'image/svg+xml': 'some svg', + // 'text/plain': 'some text' + // }, + // metadata: {}, + // output_type: 'display_data' + // }, + // expectedMimeTypesOrder: ['image/svg+xml', 'text/plain'] + // }, + // { + // output: { + // data: { + // 'text/latex': 'some latex', + // 'text/plain': 'some text' + // }, + // metadata: {}, + // output_type: 'display_data' + // }, + // expectedMimeTypesOrder: ['text/latex', 'text/plain'] + // }, + // { + // output: { + // data: { + // 'application/vnd.jupyter.widget-view+json': 'some widget', + // 'text/plain': 'some text' + // }, + // metadata: {}, + // output_type: 'display_data' + // }, + // expectedMimeTypesOrder: ['application/vnd.jupyter.widget-view+json', 'text/plain'] + // }, + // { + // output: { + // data: { + // 'text/plain': 'some text', + // 'image/svg+xml': 'some svg', + // 'image/png': 'some png' + // }, + // metadata: {}, + // output_type: 'display_data' + // }, + // 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(',')); + // }); + // }); + // }); +}); diff --git a/extensions/ipynb/src/test/serializers.test.ts b/extensions/ipynb/src/test/serializers.test.ts index cb461539c5d..e132b6b2b1d 100644 --- a/extensions/ipynb/src/test/serializers.test.ts +++ b/extensions/ipynb/src/test/serializers.test.ts @@ -41,6 +41,12 @@ suite(`ipynb serializer`, () => { source: 'print(1)', metadata: {} }, + { + cell_type: 'code', + outputs: [], + source: 'print(2)', + metadata: {} + }, { cell_type: 'markdown', source: '# HEAD', @@ -55,13 +61,18 @@ suite(`ipynb serializer`, () => { expectedCodeCell.metadata = { execution_count: 10, metadata: {} }; expectedCodeCell.executionSummary = { executionOrder: 10 }; + const expectedCodeCell2 = new vscode.NotebookCellData(vscode.NotebookCellKind.Code, 'print(2)', 'python'); + expectedCodeCell2.outputs = []; + expectedCodeCell2.metadata = { execution_count: null, metadata: {} }; + expectedCodeCell2.executionSummary = {}; + const expectedMarkdownCell = new vscode.NotebookCellData(vscode.NotebookCellKind.Markup, '# HEAD', 'markdown'); expectedMarkdownCell.outputs = []; expectedMarkdownCell.metadata = { metadata: {} }; - assert.deepStrictEqual(notebook.cells, [expectedCodeCell, expectedMarkdownCell]); + assert.deepStrictEqual(notebook.cells, [expectedCodeCell, expectedCodeCell2, expectedMarkdownCell]); }); diff --git a/package-lock.json b/package-lock.json index 23a24c728bd..7750736d839 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,15 +27,15 @@ "@vscode/windows-mutex": "^0.5.0", "@vscode/windows-process-tree": "^0.6.0", "@vscode/windows-registry": "^1.1.0", - "@xterm/addon-clipboard": "^0.2.0-beta.53", - "@xterm/addon-image": "^0.9.0-beta.70", - "@xterm/addon-ligatures": "^0.10.0-beta.70", - "@xterm/addon-search": "^0.16.0-beta.70", - "@xterm/addon-serialize": "^0.14.0-beta.70", - "@xterm/addon-unicode11": "^0.9.0-beta.70", - "@xterm/addon-webgl": "^0.19.0-beta.70", - "@xterm/headless": "^5.6.0-beta.70", - "@xterm/xterm": "^5.6.0-beta.70", + "@xterm/addon-clipboard": "^0.2.0-beta.57", + "@xterm/addon-image": "^0.9.0-beta.74", + "@xterm/addon-ligatures": "^0.10.0-beta.74", + "@xterm/addon-search": "^0.16.0-beta.74", + "@xterm/addon-serialize": "^0.14.0-beta.74", + "@xterm/addon-unicode11": "^0.9.0-beta.74", + "@xterm/addon-webgl": "^0.19.0-beta.74", + "@xterm/headless": "^5.6.0-beta.74", + "@xterm/xterm": "^5.6.0-beta.74", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.2", "jschardet": "3.1.4", @@ -3457,30 +3457,30 @@ } }, "node_modules/@xterm/addon-clipboard": { - "version": "0.2.0-beta.53", - "resolved": "https://registry.npmjs.org/@xterm/addon-clipboard/-/addon-clipboard-0.2.0-beta.53.tgz", - "integrity": "sha512-kCcBuGvF8mwzExU+Tm9eylPvp1kXTkvm+kO0V4qP7HI3ZCw5vfKmnlRn41FvNIylsK2hnmrFtxauPHEGBy/dfA==", + "version": "0.2.0-beta.57", + "resolved": "https://registry.npmjs.org/@xterm/addon-clipboard/-/addon-clipboard-0.2.0-beta.57.tgz", + "integrity": "sha512-/GSI8Fkmb8s/V1t2EGc2U2PUfSqge6f9gAeob65EwarsfBf66cmCxMG0ZSPE8+nti1pGIsrJA8XfeEaJt4clcA==", "license": "MIT", "dependencies": { "js-base64": "^3.7.5" }, "peerDependencies": { - "@xterm/xterm": "^5.6.0-beta.70" + "@xterm/xterm": "^5.6.0-beta.74" } }, "node_modules/@xterm/addon-image": { - "version": "0.9.0-beta.70", - "resolved": "https://registry.npmjs.org/@xterm/addon-image/-/addon-image-0.9.0-beta.70.tgz", - "integrity": "sha512-QLhy77i0sjnffkLuxj1yB/mBUJI64bbL86eMW+1g5XEsZnSevY8YwU/cEJg02PAGK0ggwQNNfiRwoO6VBCdYFg==", + "version": "0.9.0-beta.74", + "resolved": "https://registry.npmjs.org/@xterm/addon-image/-/addon-image-0.9.0-beta.74.tgz", + "integrity": "sha512-mJPWNPov2mqrUkYZCs6UCn5p6DBLeN6xjpLu5mLh8cmXr544VWfqEVNAYPQ9+8uNgXdzSsKInBv9ZGtbXV0SfA==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^5.6.0-beta.70" + "@xterm/xterm": "^5.6.0-beta.74" } }, "node_modules/@xterm/addon-ligatures": { - "version": "0.10.0-beta.70", - "resolved": "https://registry.npmjs.org/@xterm/addon-ligatures/-/addon-ligatures-0.10.0-beta.70.tgz", - "integrity": "sha512-BPDHHOybUWO6mjHf/RMDBjSKDl9QdyyGTyHvmlyhuI/2sma3lu98bA4U03F8nBnvj/6otzEFARuOeoN7rkfRng==", + "version": "0.10.0-beta.74", + "resolved": "https://registry.npmjs.org/@xterm/addon-ligatures/-/addon-ligatures-0.10.0-beta.74.tgz", + "integrity": "sha512-/X3OemVqPSgdely8OgdQb0cJqv9HqiMaBLeLe2QHfTWdXDBOLG/O4g8n/lChqR9rulEMwPCt2LqvtyIMA3iZsw==", "license": "MIT", "dependencies": { "font-finder": "^1.1.0", @@ -3490,55 +3490,55 @@ "node": ">8.0.0" }, "peerDependencies": { - "@xterm/xterm": "^5.6.0-beta.70" + "@xterm/xterm": "^5.6.0-beta.74" } }, "node_modules/@xterm/addon-search": { - "version": "0.16.0-beta.70", - "resolved": "https://registry.npmjs.org/@xterm/addon-search/-/addon-search-0.16.0-beta.70.tgz", - "integrity": "sha512-uaNBf77cr5Jikj69TDkTfe3V3wHA+4tDTFcv8DwJ/KGORPLUfBk8cn9HpbIJ+0jc1kOZr5xDrEjaYNwEVDkGeQ==", + "version": "0.16.0-beta.74", + "resolved": "https://registry.npmjs.org/@xterm/addon-search/-/addon-search-0.16.0-beta.74.tgz", + "integrity": "sha512-KMOeOu3EvtDWcH/HCs6fe4KyaMMdjoUjP1C7R3AtZwJVdm5GaFxASxdol+9evfGhUUei4qt+zVsaFraNKyFvsA==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^5.6.0-beta.70" + "@xterm/xterm": "^5.6.0-beta.74" } }, "node_modules/@xterm/addon-serialize": { - "version": "0.14.0-beta.70", - "resolved": "https://registry.npmjs.org/@xterm/addon-serialize/-/addon-serialize-0.14.0-beta.70.tgz", - "integrity": "sha512-4ijqHU7xDRcZ4Gm6yN/tKsZzovUwzttE9/pPfhxpWIgSdm9c8i5R4rGyOAlAZCnuU3DT5vPplQO7W/0zwAmJsQ==", + "version": "0.14.0-beta.74", + "resolved": "https://registry.npmjs.org/@xterm/addon-serialize/-/addon-serialize-0.14.0-beta.74.tgz", + "integrity": "sha512-zBhQAoXagBMhKFGYQ6n52sKapY61Jt3hKT8awhG/49f04JkaX1Jhc6xohD+aZs6J2ABHI7EWW/y0xTN9BDLLBw==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^5.6.0-beta.70" + "@xterm/xterm": "^5.6.0-beta.74" } }, "node_modules/@xterm/addon-unicode11": { - "version": "0.9.0-beta.70", - "resolved": "https://registry.npmjs.org/@xterm/addon-unicode11/-/addon-unicode11-0.9.0-beta.70.tgz", - "integrity": "sha512-9UE4v2SpWtqd85Hqvk8LW4QA9Phe93RMVltrNtt9jCUmkAok/QLFOEbLqoq2JUOvlmfsYfXmIl11Wg4CYIsvwA==", + "version": "0.9.0-beta.74", + "resolved": "https://registry.npmjs.org/@xterm/addon-unicode11/-/addon-unicode11-0.9.0-beta.74.tgz", + "integrity": "sha512-1AyuDST77Xg33ed+3neNrQfsfZVwa+C16uWP9eTdJ1rO48ylqPcFTDnahCfIZGPHGjPqLl90ZKZ8tt1zWSefmA==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^5.6.0-beta.70" + "@xterm/xterm": "^5.6.0-beta.74" } }, "node_modules/@xterm/addon-webgl": { - "version": "0.19.0-beta.70", - "resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.19.0-beta.70.tgz", - "integrity": "sha512-3BkmF24i66SHCfAkcHy1VC6H715qyelfOIjd6n7sTqHon56J1bUO782Q1al/MK0vSvplElBRKVdR31SDvITzpg==", + "version": "0.19.0-beta.74", + "resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.19.0-beta.74.tgz", + "integrity": "sha512-1wKiuv6WHMqOcSlLeIRU7UF8zkU4KU35rnPvLw2G45aAJSr9B3f7EVIaJ4IjXGeY/C+WCM3wWuybPN5VdB8qAQ==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^5.6.0-beta.70" + "@xterm/xterm": "^5.6.0-beta.74" } }, "node_modules/@xterm/headless": { - "version": "5.6.0-beta.70", - "resolved": "https://registry.npmjs.org/@xterm/headless/-/headless-5.6.0-beta.70.tgz", - "integrity": "sha512-npSJzmtJq8LLAzV3nwD9KkBQLNWSusi+cOBPyc3zlYYE63Vkqbtbp/iQS27zH2GxJ95rzCzDwnX6VOn0OoUPYQ==", + "version": "5.6.0-beta.74", + "resolved": "https://registry.npmjs.org/@xterm/headless/-/headless-5.6.0-beta.74.tgz", + "integrity": "sha512-6PSNk1/CaLGNZLhXULswjfbf/rJrG1EomT9hR2nNbX6Osm9LVbhgIzg/mYHPu9wX5Pz7Gpd1VWp4/1NcKpN53w==", "license": "MIT" }, "node_modules/@xterm/xterm": { - "version": "5.6.0-beta.70", - "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.6.0-beta.70.tgz", - "integrity": "sha512-qviQMVWhRtgPn4z8PHNH6D/ffSKkNBHmUX1HyJxl325QM2xF8M8met83uFv7JZm7a5OQYScnLGsFAoTreSgdew==", + "version": "5.6.0-beta.74", + "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.6.0-beta.74.tgz", + "integrity": "sha512-gVu7+4Cfd7O/6cQ/UK0sZM+TJBaI/VgQ/JFAhuAnNFj29wts2MzxSH3fIp3KUG1kqlJBWEUShCTwB8nKwtbCjQ==", "license": "MIT" }, "node_modules/@xtuc/ieee754": { diff --git a/package.json b/package.json index ba5004eed3e..66df5510d21 100644 --- a/package.json +++ b/package.json @@ -85,15 +85,15 @@ "@vscode/windows-mutex": "^0.5.0", "@vscode/windows-process-tree": "^0.6.0", "@vscode/windows-registry": "^1.1.0", - "@xterm/addon-clipboard": "^0.2.0-beta.53", - "@xterm/addon-image": "^0.9.0-beta.70", - "@xterm/addon-ligatures": "^0.10.0-beta.70", - "@xterm/addon-search": "^0.16.0-beta.70", - "@xterm/addon-serialize": "^0.14.0-beta.70", - "@xterm/addon-unicode11": "^0.9.0-beta.70", - "@xterm/addon-webgl": "^0.19.0-beta.70", - "@xterm/headless": "^5.6.0-beta.70", - "@xterm/xterm": "^5.6.0-beta.70", + "@xterm/addon-clipboard": "^0.2.0-beta.57", + "@xterm/addon-image": "^0.9.0-beta.74", + "@xterm/addon-ligatures": "^0.10.0-beta.74", + "@xterm/addon-search": "^0.16.0-beta.74", + "@xterm/addon-serialize": "^0.14.0-beta.74", + "@xterm/addon-unicode11": "^0.9.0-beta.74", + "@xterm/addon-webgl": "^0.19.0-beta.74", + "@xterm/headless": "^5.6.0-beta.74", + "@xterm/xterm": "^5.6.0-beta.74", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.2", "jschardet": "3.1.4", diff --git a/remote/package-lock.json b/remote/package-lock.json index 3cf22939861..53a1dddc292 100644 --- a/remote/package-lock.json +++ b/remote/package-lock.json @@ -20,15 +20,15 @@ "@vscode/vscode-languagedetection": "1.0.21", "@vscode/windows-process-tree": "^0.6.0", "@vscode/windows-registry": "^1.1.0", - "@xterm/addon-clipboard": "^0.2.0-beta.53", - "@xterm/addon-image": "^0.9.0-beta.70", - "@xterm/addon-ligatures": "^0.10.0-beta.70", - "@xterm/addon-search": "^0.16.0-beta.70", - "@xterm/addon-serialize": "^0.14.0-beta.70", - "@xterm/addon-unicode11": "^0.9.0-beta.70", - "@xterm/addon-webgl": "^0.19.0-beta.70", - "@xterm/headless": "^5.6.0-beta.70", - "@xterm/xterm": "^5.6.0-beta.70", + "@xterm/addon-clipboard": "^0.2.0-beta.57", + "@xterm/addon-image": "^0.9.0-beta.74", + "@xterm/addon-ligatures": "^0.10.0-beta.74", + "@xterm/addon-search": "^0.16.0-beta.74", + "@xterm/addon-serialize": "^0.14.0-beta.74", + "@xterm/addon-unicode11": "^0.9.0-beta.74", + "@xterm/addon-webgl": "^0.19.0-beta.74", + "@xterm/headless": "^5.6.0-beta.74", + "@xterm/xterm": "^5.6.0-beta.74", "cookie": "^0.7.0", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.2", @@ -520,30 +520,30 @@ "hasInstallScript": true }, "node_modules/@xterm/addon-clipboard": { - "version": "0.2.0-beta.53", - "resolved": "https://registry.npmjs.org/@xterm/addon-clipboard/-/addon-clipboard-0.2.0-beta.53.tgz", - "integrity": "sha512-kCcBuGvF8mwzExU+Tm9eylPvp1kXTkvm+kO0V4qP7HI3ZCw5vfKmnlRn41FvNIylsK2hnmrFtxauPHEGBy/dfA==", + "version": "0.2.0-beta.57", + "resolved": "https://registry.npmjs.org/@xterm/addon-clipboard/-/addon-clipboard-0.2.0-beta.57.tgz", + "integrity": "sha512-/GSI8Fkmb8s/V1t2EGc2U2PUfSqge6f9gAeob65EwarsfBf66cmCxMG0ZSPE8+nti1pGIsrJA8XfeEaJt4clcA==", "license": "MIT", "dependencies": { "js-base64": "^3.7.5" }, "peerDependencies": { - "@xterm/xterm": "^5.6.0-beta.70" + "@xterm/xterm": "^5.6.0-beta.74" } }, "node_modules/@xterm/addon-image": { - "version": "0.9.0-beta.70", - "resolved": "https://registry.npmjs.org/@xterm/addon-image/-/addon-image-0.9.0-beta.70.tgz", - "integrity": "sha512-QLhy77i0sjnffkLuxj1yB/mBUJI64bbL86eMW+1g5XEsZnSevY8YwU/cEJg02PAGK0ggwQNNfiRwoO6VBCdYFg==", + "version": "0.9.0-beta.74", + "resolved": "https://registry.npmjs.org/@xterm/addon-image/-/addon-image-0.9.0-beta.74.tgz", + "integrity": "sha512-mJPWNPov2mqrUkYZCs6UCn5p6DBLeN6xjpLu5mLh8cmXr544VWfqEVNAYPQ9+8uNgXdzSsKInBv9ZGtbXV0SfA==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^5.6.0-beta.70" + "@xterm/xterm": "^5.6.0-beta.74" } }, "node_modules/@xterm/addon-ligatures": { - "version": "0.10.0-beta.70", - "resolved": "https://registry.npmjs.org/@xterm/addon-ligatures/-/addon-ligatures-0.10.0-beta.70.tgz", - "integrity": "sha512-BPDHHOybUWO6mjHf/RMDBjSKDl9QdyyGTyHvmlyhuI/2sma3lu98bA4U03F8nBnvj/6otzEFARuOeoN7rkfRng==", + "version": "0.10.0-beta.74", + "resolved": "https://registry.npmjs.org/@xterm/addon-ligatures/-/addon-ligatures-0.10.0-beta.74.tgz", + "integrity": "sha512-/X3OemVqPSgdely8OgdQb0cJqv9HqiMaBLeLe2QHfTWdXDBOLG/O4g8n/lChqR9rulEMwPCt2LqvtyIMA3iZsw==", "license": "MIT", "dependencies": { "font-finder": "^1.1.0", @@ -553,55 +553,55 @@ "node": ">8.0.0" }, "peerDependencies": { - "@xterm/xterm": "^5.6.0-beta.70" + "@xterm/xterm": "^5.6.0-beta.74" } }, "node_modules/@xterm/addon-search": { - "version": "0.16.0-beta.70", - "resolved": "https://registry.npmjs.org/@xterm/addon-search/-/addon-search-0.16.0-beta.70.tgz", - "integrity": "sha512-uaNBf77cr5Jikj69TDkTfe3V3wHA+4tDTFcv8DwJ/KGORPLUfBk8cn9HpbIJ+0jc1kOZr5xDrEjaYNwEVDkGeQ==", + "version": "0.16.0-beta.74", + "resolved": "https://registry.npmjs.org/@xterm/addon-search/-/addon-search-0.16.0-beta.74.tgz", + "integrity": "sha512-KMOeOu3EvtDWcH/HCs6fe4KyaMMdjoUjP1C7R3AtZwJVdm5GaFxASxdol+9evfGhUUei4qt+zVsaFraNKyFvsA==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^5.6.0-beta.70" + "@xterm/xterm": "^5.6.0-beta.74" } }, "node_modules/@xterm/addon-serialize": { - "version": "0.14.0-beta.70", - "resolved": "https://registry.npmjs.org/@xterm/addon-serialize/-/addon-serialize-0.14.0-beta.70.tgz", - "integrity": "sha512-4ijqHU7xDRcZ4Gm6yN/tKsZzovUwzttE9/pPfhxpWIgSdm9c8i5R4rGyOAlAZCnuU3DT5vPplQO7W/0zwAmJsQ==", + "version": "0.14.0-beta.74", + "resolved": "https://registry.npmjs.org/@xterm/addon-serialize/-/addon-serialize-0.14.0-beta.74.tgz", + "integrity": "sha512-zBhQAoXagBMhKFGYQ6n52sKapY61Jt3hKT8awhG/49f04JkaX1Jhc6xohD+aZs6J2ABHI7EWW/y0xTN9BDLLBw==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^5.6.0-beta.70" + "@xterm/xterm": "^5.6.0-beta.74" } }, "node_modules/@xterm/addon-unicode11": { - "version": "0.9.0-beta.70", - "resolved": "https://registry.npmjs.org/@xterm/addon-unicode11/-/addon-unicode11-0.9.0-beta.70.tgz", - "integrity": "sha512-9UE4v2SpWtqd85Hqvk8LW4QA9Phe93RMVltrNtt9jCUmkAok/QLFOEbLqoq2JUOvlmfsYfXmIl11Wg4CYIsvwA==", + "version": "0.9.0-beta.74", + "resolved": "https://registry.npmjs.org/@xterm/addon-unicode11/-/addon-unicode11-0.9.0-beta.74.tgz", + "integrity": "sha512-1AyuDST77Xg33ed+3neNrQfsfZVwa+C16uWP9eTdJ1rO48ylqPcFTDnahCfIZGPHGjPqLl90ZKZ8tt1zWSefmA==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^5.6.0-beta.70" + "@xterm/xterm": "^5.6.0-beta.74" } }, "node_modules/@xterm/addon-webgl": { - "version": "0.19.0-beta.70", - "resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.19.0-beta.70.tgz", - "integrity": "sha512-3BkmF24i66SHCfAkcHy1VC6H715qyelfOIjd6n7sTqHon56J1bUO782Q1al/MK0vSvplElBRKVdR31SDvITzpg==", + "version": "0.19.0-beta.74", + "resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.19.0-beta.74.tgz", + "integrity": "sha512-1wKiuv6WHMqOcSlLeIRU7UF8zkU4KU35rnPvLw2G45aAJSr9B3f7EVIaJ4IjXGeY/C+WCM3wWuybPN5VdB8qAQ==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^5.6.0-beta.70" + "@xterm/xterm": "^5.6.0-beta.74" } }, "node_modules/@xterm/headless": { - "version": "5.6.0-beta.70", - "resolved": "https://registry.npmjs.org/@xterm/headless/-/headless-5.6.0-beta.70.tgz", - "integrity": "sha512-npSJzmtJq8LLAzV3nwD9KkBQLNWSusi+cOBPyc3zlYYE63Vkqbtbp/iQS27zH2GxJ95rzCzDwnX6VOn0OoUPYQ==", + "version": "5.6.0-beta.74", + "resolved": "https://registry.npmjs.org/@xterm/headless/-/headless-5.6.0-beta.74.tgz", + "integrity": "sha512-6PSNk1/CaLGNZLhXULswjfbf/rJrG1EomT9hR2nNbX6Osm9LVbhgIzg/mYHPu9wX5Pz7Gpd1VWp4/1NcKpN53w==", "license": "MIT" }, "node_modules/@xterm/xterm": { - "version": "5.6.0-beta.70", - "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.6.0-beta.70.tgz", - "integrity": "sha512-qviQMVWhRtgPn4z8PHNH6D/ffSKkNBHmUX1HyJxl325QM2xF8M8met83uFv7JZm7a5OQYScnLGsFAoTreSgdew==", + "version": "5.6.0-beta.74", + "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.6.0-beta.74.tgz", + "integrity": "sha512-gVu7+4Cfd7O/6cQ/UK0sZM+TJBaI/VgQ/JFAhuAnNFj29wts2MzxSH3fIp3KUG1kqlJBWEUShCTwB8nKwtbCjQ==", "license": "MIT" }, "node_modules/agent-base": { diff --git a/remote/package.json b/remote/package.json index 5a61a15c051..09dc79f4b0a 100644 --- a/remote/package.json +++ b/remote/package.json @@ -15,15 +15,15 @@ "@vscode/vscode-languagedetection": "1.0.21", "@vscode/windows-process-tree": "^0.6.0", "@vscode/windows-registry": "^1.1.0", - "@xterm/addon-clipboard": "^0.2.0-beta.53", - "@xterm/addon-image": "^0.9.0-beta.70", - "@xterm/addon-ligatures": "^0.10.0-beta.70", - "@xterm/addon-search": "^0.16.0-beta.70", - "@xterm/addon-serialize": "^0.14.0-beta.70", - "@xterm/addon-unicode11": "^0.9.0-beta.70", - "@xterm/addon-webgl": "^0.19.0-beta.70", - "@xterm/headless": "^5.6.0-beta.70", - "@xterm/xterm": "^5.6.0-beta.70", + "@xterm/addon-clipboard": "^0.2.0-beta.57", + "@xterm/addon-image": "^0.9.0-beta.74", + "@xterm/addon-ligatures": "^0.10.0-beta.74", + "@xterm/addon-search": "^0.16.0-beta.74", + "@xterm/addon-serialize": "^0.14.0-beta.74", + "@xterm/addon-unicode11": "^0.9.0-beta.74", + "@xterm/addon-webgl": "^0.19.0-beta.74", + "@xterm/headless": "^5.6.0-beta.74", + "@xterm/xterm": "^5.6.0-beta.74", "cookie": "^0.7.0", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.2", diff --git a/remote/web/package-lock.json b/remote/web/package-lock.json index 3e45e62a14d..6712d40c628 100644 --- a/remote/web/package-lock.json +++ b/remote/web/package-lock.json @@ -13,14 +13,14 @@ "@vscode/iconv-lite-umd": "0.7.0", "@vscode/tree-sitter-wasm": "^0.0.4", "@vscode/vscode-languagedetection": "1.0.21", - "@xterm/addon-clipboard": "^0.2.0-beta.53", - "@xterm/addon-image": "^0.9.0-beta.70", - "@xterm/addon-ligatures": "^0.10.0-beta.70", - "@xterm/addon-search": "^0.16.0-beta.70", - "@xterm/addon-serialize": "^0.14.0-beta.70", - "@xterm/addon-unicode11": "^0.9.0-beta.70", - "@xterm/addon-webgl": "^0.19.0-beta.70", - "@xterm/xterm": "^5.6.0-beta.70", + "@xterm/addon-clipboard": "^0.2.0-beta.57", + "@xterm/addon-image": "^0.9.0-beta.74", + "@xterm/addon-ligatures": "^0.10.0-beta.74", + "@xterm/addon-search": "^0.16.0-beta.74", + "@xterm/addon-serialize": "^0.14.0-beta.74", + "@xterm/addon-unicode11": "^0.9.0-beta.74", + "@xterm/addon-webgl": "^0.19.0-beta.74", + "@xterm/xterm": "^5.6.0-beta.74", "jschardet": "3.1.4", "tas-client-umd": "0.2.0", "vscode-oniguruma": "1.7.0", @@ -88,30 +88,30 @@ } }, "node_modules/@xterm/addon-clipboard": { - "version": "0.2.0-beta.53", - "resolved": "https://registry.npmjs.org/@xterm/addon-clipboard/-/addon-clipboard-0.2.0-beta.53.tgz", - "integrity": "sha512-kCcBuGvF8mwzExU+Tm9eylPvp1kXTkvm+kO0V4qP7HI3ZCw5vfKmnlRn41FvNIylsK2hnmrFtxauPHEGBy/dfA==", + "version": "0.2.0-beta.57", + "resolved": "https://registry.npmjs.org/@xterm/addon-clipboard/-/addon-clipboard-0.2.0-beta.57.tgz", + "integrity": "sha512-/GSI8Fkmb8s/V1t2EGc2U2PUfSqge6f9gAeob65EwarsfBf66cmCxMG0ZSPE8+nti1pGIsrJA8XfeEaJt4clcA==", "license": "MIT", "dependencies": { "js-base64": "^3.7.5" }, "peerDependencies": { - "@xterm/xterm": "^5.6.0-beta.70" + "@xterm/xterm": "^5.6.0-beta.74" } }, "node_modules/@xterm/addon-image": { - "version": "0.9.0-beta.70", - "resolved": "https://registry.npmjs.org/@xterm/addon-image/-/addon-image-0.9.0-beta.70.tgz", - "integrity": "sha512-QLhy77i0sjnffkLuxj1yB/mBUJI64bbL86eMW+1g5XEsZnSevY8YwU/cEJg02PAGK0ggwQNNfiRwoO6VBCdYFg==", + "version": "0.9.0-beta.74", + "resolved": "https://registry.npmjs.org/@xterm/addon-image/-/addon-image-0.9.0-beta.74.tgz", + "integrity": "sha512-mJPWNPov2mqrUkYZCs6UCn5p6DBLeN6xjpLu5mLh8cmXr544VWfqEVNAYPQ9+8uNgXdzSsKInBv9ZGtbXV0SfA==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^5.6.0-beta.70" + "@xterm/xterm": "^5.6.0-beta.74" } }, "node_modules/@xterm/addon-ligatures": { - "version": "0.10.0-beta.70", - "resolved": "https://registry.npmjs.org/@xterm/addon-ligatures/-/addon-ligatures-0.10.0-beta.70.tgz", - "integrity": "sha512-BPDHHOybUWO6mjHf/RMDBjSKDl9QdyyGTyHvmlyhuI/2sma3lu98bA4U03F8nBnvj/6otzEFARuOeoN7rkfRng==", + "version": "0.10.0-beta.74", + "resolved": "https://registry.npmjs.org/@xterm/addon-ligatures/-/addon-ligatures-0.10.0-beta.74.tgz", + "integrity": "sha512-/X3OemVqPSgdely8OgdQb0cJqv9HqiMaBLeLe2QHfTWdXDBOLG/O4g8n/lChqR9rulEMwPCt2LqvtyIMA3iZsw==", "license": "MIT", "dependencies": { "font-finder": "^1.1.0", @@ -121,49 +121,49 @@ "node": ">8.0.0" }, "peerDependencies": { - "@xterm/xterm": "^5.6.0-beta.70" + "@xterm/xterm": "^5.6.0-beta.74" } }, "node_modules/@xterm/addon-search": { - "version": "0.16.0-beta.70", - "resolved": "https://registry.npmjs.org/@xterm/addon-search/-/addon-search-0.16.0-beta.70.tgz", - "integrity": "sha512-uaNBf77cr5Jikj69TDkTfe3V3wHA+4tDTFcv8DwJ/KGORPLUfBk8cn9HpbIJ+0jc1kOZr5xDrEjaYNwEVDkGeQ==", + "version": "0.16.0-beta.74", + "resolved": "https://registry.npmjs.org/@xterm/addon-search/-/addon-search-0.16.0-beta.74.tgz", + "integrity": "sha512-KMOeOu3EvtDWcH/HCs6fe4KyaMMdjoUjP1C7R3AtZwJVdm5GaFxASxdol+9evfGhUUei4qt+zVsaFraNKyFvsA==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^5.6.0-beta.70" + "@xterm/xterm": "^5.6.0-beta.74" } }, "node_modules/@xterm/addon-serialize": { - "version": "0.14.0-beta.70", - "resolved": "https://registry.npmjs.org/@xterm/addon-serialize/-/addon-serialize-0.14.0-beta.70.tgz", - "integrity": "sha512-4ijqHU7xDRcZ4Gm6yN/tKsZzovUwzttE9/pPfhxpWIgSdm9c8i5R4rGyOAlAZCnuU3DT5vPplQO7W/0zwAmJsQ==", + "version": "0.14.0-beta.74", + "resolved": "https://registry.npmjs.org/@xterm/addon-serialize/-/addon-serialize-0.14.0-beta.74.tgz", + "integrity": "sha512-zBhQAoXagBMhKFGYQ6n52sKapY61Jt3hKT8awhG/49f04JkaX1Jhc6xohD+aZs6J2ABHI7EWW/y0xTN9BDLLBw==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^5.6.0-beta.70" + "@xterm/xterm": "^5.6.0-beta.74" } }, "node_modules/@xterm/addon-unicode11": { - "version": "0.9.0-beta.70", - "resolved": "https://registry.npmjs.org/@xterm/addon-unicode11/-/addon-unicode11-0.9.0-beta.70.tgz", - "integrity": "sha512-9UE4v2SpWtqd85Hqvk8LW4QA9Phe93RMVltrNtt9jCUmkAok/QLFOEbLqoq2JUOvlmfsYfXmIl11Wg4CYIsvwA==", + "version": "0.9.0-beta.74", + "resolved": "https://registry.npmjs.org/@xterm/addon-unicode11/-/addon-unicode11-0.9.0-beta.74.tgz", + "integrity": "sha512-1AyuDST77Xg33ed+3neNrQfsfZVwa+C16uWP9eTdJ1rO48ylqPcFTDnahCfIZGPHGjPqLl90ZKZ8tt1zWSefmA==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^5.6.0-beta.70" + "@xterm/xterm": "^5.6.0-beta.74" } }, "node_modules/@xterm/addon-webgl": { - "version": "0.19.0-beta.70", - "resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.19.0-beta.70.tgz", - "integrity": "sha512-3BkmF24i66SHCfAkcHy1VC6H715qyelfOIjd6n7sTqHon56J1bUO782Q1al/MK0vSvplElBRKVdR31SDvITzpg==", + "version": "0.19.0-beta.74", + "resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.19.0-beta.74.tgz", + "integrity": "sha512-1wKiuv6WHMqOcSlLeIRU7UF8zkU4KU35rnPvLw2G45aAJSr9B3f7EVIaJ4IjXGeY/C+WCM3wWuybPN5VdB8qAQ==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^5.6.0-beta.70" + "@xterm/xterm": "^5.6.0-beta.74" } }, "node_modules/@xterm/xterm": { - "version": "5.6.0-beta.70", - "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.6.0-beta.70.tgz", - "integrity": "sha512-qviQMVWhRtgPn4z8PHNH6D/ffSKkNBHmUX1HyJxl325QM2xF8M8met83uFv7JZm7a5OQYScnLGsFAoTreSgdew==", + "version": "5.6.0-beta.74", + "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.6.0-beta.74.tgz", + "integrity": "sha512-gVu7+4Cfd7O/6cQ/UK0sZM+TJBaI/VgQ/JFAhuAnNFj29wts2MzxSH3fIp3KUG1kqlJBWEUShCTwB8nKwtbCjQ==", "license": "MIT" }, "node_modules/font-finder": { diff --git a/remote/web/package.json b/remote/web/package.json index 8c1decd65bb..31f552e7f5c 100644 --- a/remote/web/package.json +++ b/remote/web/package.json @@ -8,14 +8,14 @@ "@vscode/iconv-lite-umd": "0.7.0", "@vscode/tree-sitter-wasm": "^0.0.4", "@vscode/vscode-languagedetection": "1.0.21", - "@xterm/addon-clipboard": "^0.2.0-beta.53", - "@xterm/addon-image": "^0.9.0-beta.70", - "@xterm/addon-ligatures": "^0.10.0-beta.70", - "@xterm/addon-search": "^0.16.0-beta.70", - "@xterm/addon-serialize": "^0.14.0-beta.70", - "@xterm/addon-unicode11": "^0.9.0-beta.70", - "@xterm/addon-webgl": "^0.19.0-beta.70", - "@xterm/xterm": "^5.6.0-beta.70", + "@xterm/addon-clipboard": "^0.2.0-beta.57", + "@xterm/addon-image": "^0.9.0-beta.74", + "@xterm/addon-ligatures": "^0.10.0-beta.74", + "@xterm/addon-search": "^0.16.0-beta.74", + "@xterm/addon-serialize": "^0.14.0-beta.74", + "@xterm/addon-unicode11": "^0.9.0-beta.74", + "@xterm/addon-webgl": "^0.19.0-beta.74", + "@xterm/xterm": "^5.6.0-beta.74", "jschardet": "3.1.4", "tas-client-umd": "0.2.0", "vscode-oniguruma": "1.7.0", diff --git a/src/typings/editContext.d.ts b/src/typings/editContext.d.ts index 5b5da0ac7e9..09585848667 100644 --- a/src/typings/editContext.d.ts +++ b/src/typings/editContext.d.ts @@ -58,8 +58,8 @@ interface EditContextEventHandlersEventMap { type EventHandler = (event: TEvent) => void; -interface TextUpdateEvent extends Event { - new(type: DOMString, options?: TextUpdateEventInit): TextUpdateEvent; +declare class TextUpdateEvent extends Event { + constructor(type: DOMString, options?: TextUpdateEventInit); readonly updateRangeStart: number; readonly updateRangeEnd: number; diff --git a/src/vs/code/electron-main/app.ts b/src/vs/code/electron-main/app.ts index 11ca66e8767..7c7fc636e21 100644 --- a/src/vs/code/electron-main/app.ts +++ b/src/vs/code/electron-main/app.ts @@ -83,7 +83,7 @@ import { NativeURLService } from '../../platform/url/common/urlService.js'; import { ElectronURLListener } from '../../platform/url/electron-main/electronUrlListener.js'; import { IWebviewManagerService } from '../../platform/webview/common/webviewManagerService.js'; import { WebviewMainService } from '../../platform/webview/electron-main/webviewMainService.js'; -import { isFolderToOpen, isWorkspaceToOpen, IWindowOpenable } from '../../platform/window/common/window.js'; +import { isFolderToOpen, isWorkspaceToOpen, IWindowOpenable, TitlebarStyle, overrideDefaultTitlebarStyle } from '../../platform/window/common/window.js'; import { IWindowsMainService, OpenContext } from '../../platform/windows/electron-main/windows.js'; import { ICodeWindow } from '../../platform/window/electron-main/window.js'; import { WindowsMainService } from '../../platform/windows/electron-main/windowsMainService.js'; @@ -593,6 +593,14 @@ export class CodeApplication extends Disposable { // Services const appInstantiationService = await this.initServices(machineId, sqmId, devDeviceId, sharedProcessReady); + // Linux (stable only): custom title default style override + if (isLinux && this.productService.quality === 'stable') { + const titleBarDefaultStyleOverride = this.stateService.getItem('window.titleBarStyleOverride'); + if (titleBarDefaultStyleOverride === TitlebarStyle.CUSTOM || titleBarDefaultStyleOverride === TitlebarStyle.NATIVE) { + overrideDefaultTitlebarStyle(titleBarDefaultStyleOverride); + } + } + // Auth Handler appInstantiationService.invokeFunction(accessor => accessor.get(IProxyAuthService)); @@ -605,7 +613,7 @@ export class CodeApplication extends Disposable { // Setup Protocol URL Handlers const initialProtocolUrls = await appInstantiationService.invokeFunction(accessor => this.setupProtocolUrlHandlers(accessor, mainProcessElectronServer)); - // Setup vscode-remote-resource protocol handler. + // Setup vscode-remote-resource protocol handler this.setupManagedRemoteResourceUrlHandler(mainProcessElectronServer); // Signal phase: ready - before opening first window diff --git a/src/vs/editor/browser/editorDom.ts b/src/vs/editor/browser/editorDom.ts index 38100ab8407..e42959d81b6 100644 --- a/src/vs/editor/browser/editorDom.ts +++ b/src/vs/editor/browser/editorDom.ts @@ -150,13 +150,13 @@ export class EditorMouseEventFactory { } public onContextMenu(target: HTMLElement, callback: (e: EditorMouseEvent) => void): IDisposable { - return dom.addDisposableListener(target, 'contextmenu', (e: MouseEvent) => { + return dom.addDisposableListener(target, dom.EventType.CONTEXT_MENU, (e: MouseEvent) => { callback(this._create(e)); }); } public onMouseUp(target: HTMLElement, callback: (e: EditorMouseEvent) => void): IDisposable { - return dom.addDisposableListener(target, 'mouseup', (e: MouseEvent) => { + return dom.addDisposableListener(target, dom.EventType.MOUSE_UP, (e: MouseEvent) => { callback(this._create(e)); }); } @@ -180,7 +180,7 @@ export class EditorMouseEventFactory { } public onMouseMove(target: HTMLElement, callback: (e: EditorMouseEvent) => void): IDisposable { - return dom.addDisposableListener(target, 'mousemove', (e) => callback(this._create(e))); + return dom.addDisposableListener(target, dom.EventType.MOUSE_MOVE, (e) => callback(this._create(e))); } } diff --git a/src/vs/editor/common/config/editorOptions.ts b/src/vs/editor/common/config/editorOptions.ts index 5fe2028e366..1847c2ff345 100644 --- a/src/vs/editor/common/config/editorOptions.ts +++ b/src/vs/editor/common/config/editorOptions.ts @@ -16,6 +16,7 @@ import { USUAL_WORD_SEPARATORS } from '../core/wordHelper.js'; import * as nls from '../../../nls.js'; import { AccessibilitySupport } from '../../../platform/accessibility/common/accessibility.js'; import { IConfigurationPropertySchema } from '../../../platform/configuration/common/configurationRegistry.js'; +import product from '../../../platform/product/common/product.js'; //#region typed options @@ -5822,7 +5823,7 @@ export const EditorOptions = { emptySelectionClipboard: register(new EditorEmptySelectionClipboard()), dropIntoEditor: register(new EditorDropIntoEditor()), experimentalEditContextEnabled: register(new EditorBooleanOption( - EditorOption.experimentalEditContextEnabled, 'experimentalEditContextEnabled', false, + EditorOption.experimentalEditContextEnabled, 'experimentalEditContextEnabled', product.quality !== 'stable', { description: nls.localize('experimentalEditContextEnabled', "Sets whether the new experimental edit context should be used instead of the text area."), included: platform.isChrome || platform.isEdge || platform.isNative diff --git a/src/vs/editor/common/core/editorColorRegistry.ts b/src/vs/editor/common/core/editorColorRegistry.ts index 1bcf7252443..15679ff7ba9 100644 --- a/src/vs/editor/common/core/editorColorRegistry.ts +++ b/src/vs/editor/common/core/editorColorRegistry.ts @@ -80,7 +80,7 @@ export const editorBracketHighlightingForeground4 = registerColor('editorBracket export const editorBracketHighlightingForeground5 = registerColor('editorBracketHighlight.foreground5', '#00000000', nls.localize('editorBracketHighlightForeground5', 'Foreground color of brackets (5). Requires enabling bracket pair colorization.')); export const editorBracketHighlightingForeground6 = registerColor('editorBracketHighlight.foreground6', '#00000000', nls.localize('editorBracketHighlightForeground6', 'Foreground color of brackets (6). Requires enabling bracket pair colorization.')); -export const editorBracketHighlightingUnexpectedBracketForeground = registerColor('editorBracketHighlight.unexpectedBracket.foreground', { dark: new Color(new RGBA(255, 18, 18, 0.8)), light: new Color(new RGBA(255, 18, 18, 0.8)), hcDark: 'new Color(new RGBA(255, 50, 50, 1))', hcLight: '#B5200D' }, nls.localize('editorBracketHighlightUnexpectedBracketForeground', 'Foreground color of unexpected brackets.')); +export const editorBracketHighlightingUnexpectedBracketForeground = registerColor('editorBracketHighlight.unexpectedBracket.foreground', { dark: new Color(new RGBA(255, 18, 18, 0.8)), light: new Color(new RGBA(255, 18, 18, 0.8)), hcDark: new Color(new RGBA(255, 50, 50, 1)), hcLight: '#B5200D' }, nls.localize('editorBracketHighlightUnexpectedBracketForeground', 'Foreground color of unexpected brackets.')); export const editorBracketPairGuideBackground1 = registerColor('editorBracketPairGuide.background1', '#00000000', nls.localize('editorBracketPairGuide.background1', 'Background color of inactive bracket pair guides (1). Requires enabling bracket pair guides.')); export const editorBracketPairGuideBackground2 = registerColor('editorBracketPairGuide.background2', '#00000000', nls.localize('editorBracketPairGuide.background2', 'Background color of inactive bracket pair guides (2). Requires enabling bracket pair guides.')); diff --git a/src/vs/platform/extensionManagement/common/extensionsProfileScannerService.ts b/src/vs/platform/extensionManagement/common/extensionsProfileScannerService.ts index ced131827be..162f11b5c96 100644 --- a/src/vs/platform/extensionManagement/common/extensionsProfileScannerService.ts +++ b/src/vs/platform/extensionManagement/common/extensionsProfileScannerService.ts @@ -85,7 +85,7 @@ export interface IExtensionsProfileScannerService { scanProfileExtensions(profileLocation: URI, options?: IProfileExtensionsScanOptions): 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; + removeExtensionsFromProfile(extensions: IExtensionIdentifier[], profileLocation: URI): Promise; } export abstract class AbstractExtensionsProfileScannerService extends Disposable implements IExtensionsProfileScannerService { @@ -193,13 +193,13 @@ export abstract class AbstractExtensionsProfileScannerService extends Disposable return updatedExtensions; } - async removeExtensionFromProfile(extension: IExtension, profileLocation: URI): Promise { + async removeExtensionsFromProfile(extensions: IExtensionIdentifier[], profileLocation: URI): Promise { const extensionsToRemove: IScannedProfileExtension[] = []; try { await this.withProfileExtensions(profileLocation, profileExtensions => { const result: IScannedProfileExtension[] = []; for (const e of profileExtensions) { - if (areSameExtensions(e.identifier, extension.identifier)) { + if (extensions.some(extension => areSameExtensions(e.identifier, extension))) { extensionsToRemove.push(e); } else { result.push(e); diff --git a/src/vs/platform/extensionManagement/common/extensionsScannerService.ts b/src/vs/platform/extensionManagement/common/extensionsScannerService.ts index a186b6fa045..0800126329d 100644 --- a/src/vs/platform/extensionManagement/common/extensionsScannerService.ts +++ b/src/vs/platform/extensionManagement/common/extensionsScannerService.ts @@ -100,16 +100,24 @@ interface IBuiltInExtensionControl { [name: string]: 'marketplace' | 'disabled' | string; } -export type ScanOptions = { - readonly profileLocation?: URI; - readonly includeInvalid?: boolean; - readonly includeAllVersions?: boolean; +export type SystemExtensionsScanOptions = { readonly checkControlFile?: boolean; readonly language?: string; +}; + +export type UserExtensionsScanOptions = { + readonly profileLocation: URI; + readonly includeInvalid?: boolean; + readonly language?: string; readonly useCache?: boolean; readonly productVersion?: IProductVersion; }; +export type ScanOptions = { + readonly includeInvalid?: boolean; + readonly language?: string; +}; + export const IExtensionsScannerService = createDecorator('IExtensionsScannerService'); export interface IExtensionsScannerService { readonly _serviceBrand: undefined; @@ -118,17 +126,16 @@ export interface IExtensionsScannerService { readonly userExtensionsLocation: URI; readonly onDidChangeCache: Event; - getTargetPlatform(): Promise; + scanAllExtensions(systemScanOptions: SystemExtensionsScanOptions, userScanOptions: UserExtensionsScanOptions): Promise; + scanSystemExtensions(scanOptions: SystemExtensionsScanOptions): Promise; + scanUserExtensions(scanOptions: UserExtensionsScanOptions): Promise; + scanAllUserExtensions(): Promise; - scanAllExtensions(systemScanOptions: ScanOptions, userScanOptions: ScanOptions, includeExtensionsUnderDev: boolean): Promise; - scanSystemExtensions(scanOptions: ScanOptions): Promise; - scanUserExtensions(scanOptions: ScanOptions): Promise; - scanExtensionsUnderDevelopment(scanOptions: ScanOptions, existingExtensions: IScannedExtension[]): Promise; + scanExtensionsUnderDevelopment(existingExtensions: IScannedExtension[], scanOptions: ScanOptions): 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; + scanOneOrMultipleExtensions(extensionLocation: URI, extensionType: ExtensionType, scanOptions: ScanOptions): Promise; - scanMetadata(extensionLocation: URI): Promise; updateMetadata(extensionLocation: URI, metadata: Partial): Promise; initializeDefaultProfileExtensions(): Promise; } @@ -167,35 +174,33 @@ export abstract class AbstractExtensionsScannerService extends Disposable implem } private _targetPlatformPromise: Promise | undefined; - getTargetPlatform(): Promise { + private getTargetPlatform(): Promise { if (!this._targetPlatformPromise) { this._targetPlatformPromise = computeTargetPlatform(this.fileService, this.logService); } return this._targetPlatformPromise; } - async scanAllExtensions(systemScanOptions: ScanOptions, userScanOptions: ScanOptions, includeExtensionsUnderDev: boolean): Promise { + async scanAllExtensions(systemScanOptions: SystemExtensionsScanOptions, userScanOptions: UserExtensionsScanOptions): Promise { const [system, user] = await Promise.all([ this.scanSystemExtensions(systemScanOptions), this.scanUserExtensions(userScanOptions), ]); - const development = includeExtensionsUnderDev ? await this.scanExtensionsUnderDevelopment(systemScanOptions, [...system, ...user]) : []; - return this.dedupExtensions(system, user, development, await this.getTargetPlatform(), true); + return this.dedupExtensions(system, user, [], await this.getTargetPlatform(), true); } - async scanSystemExtensions(scanOptions: ScanOptions): Promise { + async scanSystemExtensions(scanOptions: SystemExtensionsScanOptions): Promise { const promises: Promise[] = []; - promises.push(this.scanDefaultSystemExtensions(!!scanOptions.useCache, scanOptions.language)); + promises.push(this.scanDefaultSystemExtensions(scanOptions.language)); promises.push(this.scanDevSystemExtensions(scanOptions.language, !!scanOptions.checkControlFile)); const [defaultSystemExtensions, devSystemExtensions] = await Promise.all(promises); - return this.applyScanOptions([...defaultSystemExtensions, ...devSystemExtensions], ExtensionType.System, scanOptions, false); + return this.applyScanOptions([...defaultSystemExtensions, ...devSystemExtensions], ExtensionType.System, { pickLatest: false }); } - async scanUserExtensions(scanOptions: ScanOptions): Promise { - const location = scanOptions.profileLocation ?? this.userExtensionsLocation; - this.logService.trace('Started scanning user extensions', location); + async scanUserExtensions(scanOptions: UserExtensionsScanOptions): Promise { + this.logService.trace('Started scanning user extensions', scanOptions.profileLocation); 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.language, true, profileScanOptions, scanOptions.productVersion ?? this.getProductVersion()); + const extensionsScannerInput = await this.createExtensionScannerInput(scanOptions.profileLocation, true, ExtensionType.User, scanOptions.language, true, profileScanOptions, scanOptions.productVersion ?? this.getProductVersion()); const extensionsScanner = scanOptions.useCache && !extensionsScannerInput.devMode ? this.userExtensionsCachedScanner : this.extensionsScanner; let extensions: IRelaxedScannedExtension[]; try { @@ -208,16 +213,22 @@ export abstract class AbstractExtensionsScannerService extends Disposable implem throw error; } } - extensions = await this.applyScanOptions(extensions, ExtensionType.User, scanOptions, true); + extensions = await this.applyScanOptions(extensions, ExtensionType.User, { includeInvalid: scanOptions.includeInvalid, pickLatest: true }); this.logService.trace('Scanned user extensions:', extensions.length); return extensions; } - async scanExtensionsUnderDevelopment(scanOptions: ScanOptions, existingExtensions: IScannedExtension[]): Promise { + async scanAllUserExtensions(scanOptions: { includeAllVersions?: boolean; includeInvalid: boolean } = { includeInvalid: true, includeAllVersions: true }): Promise { + const extensionsScannerInput = await this.createExtensionScannerInput(this.userExtensionsLocation, false, ExtensionType.User, undefined, true, undefined, this.getProductVersion()); + const extensions = await this.extensionsScanner.scanExtensions(extensionsScannerInput); + return this.applyScanOptions(extensions, ExtensionType.User, { includeAllVersions: scanOptions.includeAllVersions, includeInvalid: scanOptions.includeInvalid }); + } + + async scanExtensionsUnderDevelopment(existingExtensions: IScannedExtension[], scanOptions: ScanOptions): Promise { 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, scanOptions.language, false /* do not validate */, undefined, scanOptions.productVersion ?? this.getProductVersion()); + const input = await this.createExtensionScannerInput(extensionDevelopmentLocationURI, false, ExtensionType.User, scanOptions.language, false /* do not validate */, undefined, this.getProductVersion()); const extensions = await this.extensionsScanner.scanOneOrMultipleExtensions(input); return extensions.map(extension => { // Override the extension type from the existing extensions @@ -227,13 +238,13 @@ export abstract class AbstractExtensionsScannerService extends Disposable implem }); }))) .flat(); - return this.applyScanOptions(extensions, 'development', scanOptions, true); + return this.applyScanOptions(extensions, 'development', { includeInvalid: scanOptions.includeInvalid, pickLatest: true }); } return []; } async scanExistingExtension(extensionLocation: URI, extensionType: ExtensionType, scanOptions: ScanOptions): Promise { - const extensionsScannerInput = await this.createExtensionScannerInput(extensionLocation, false, extensionType, scanOptions.language, true, undefined, scanOptions.productVersion ?? this.getProductVersion()); + const extensionsScannerInput = await this.createExtensionScannerInput(extensionLocation, false, extensionType, scanOptions.language, true, undefined, this.getProductVersion()); const extension = await this.extensionsScanner.scanExtension(extensionsScannerInput); if (!extension) { return null; @@ -245,9 +256,9 @@ export abstract class AbstractExtensionsScannerService extends Disposable implem } async scanOneOrMultipleExtensions(extensionLocation: URI, extensionType: ExtensionType, scanOptions: ScanOptions): Promise { - const extensionsScannerInput = await this.createExtensionScannerInput(extensionLocation, false, extensionType, scanOptions.language, true, undefined, scanOptions.productVersion ?? this.getProductVersion()); + const extensionsScannerInput = await this.createExtensionScannerInput(extensionLocation, false, extensionType, scanOptions.language, true, undefined, this.getProductVersion()); const extensions = await this.extensionsScanner.scanOneOrMultipleExtensions(extensionsScannerInput); - return this.applyScanOptions(extensions, extensionType, scanOptions, true); + return this.applyScanOptions(extensions, extensionType, { includeInvalid: scanOptions.includeInvalid, pickLatest: true }); } async scanMultipleExtensions(extensionLocations: URI[], extensionType: ExtensionType, scanOptions: ScanOptions): Promise { @@ -256,14 +267,7 @@ export abstract class AbstractExtensionsScannerService extends Disposable implem 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(); - const manifest: IScannedExtensionManifest = JSON.parse(content); - return manifest.__metadata; + return this.applyScanOptions(extensions, extensionType, { includeInvalid: scanOptions.includeInvalid, pickLatest: true }); } async updateMetadata(extensionLocation: URI, metaData: Partial): Promise { @@ -301,7 +305,7 @@ export abstract class AbstractExtensionsScannerService extends Disposable implem this.initializeDefaultProfileExtensionsPromise = (async () => { try { this.logService.info('Started initializing default profile extensions in extensions installation folder.', this.userExtensionsLocation.toString()); - const userExtensions = await this.scanUserExtensions({ includeInvalid: true }); + const userExtensions = await this.scanAllUserExtensions({ includeInvalid: true }); if (userExtensions.length) { await this.extensionsProfileScannerService.addExtensionsToProfile(userExtensions.map(e => [e, e.metadata]), this.userDataProfilesService.defaultProfile.extensionsResource); } else { @@ -324,9 +328,9 @@ export abstract class AbstractExtensionsScannerService extends Disposable implem return this.initializeDefaultProfileExtensionsPromise; } - private async applyScanOptions(extensions: IRelaxedScannedExtension[], type: ExtensionType | 'development', scanOptions: ScanOptions, pickLatest: boolean): Promise { + private async applyScanOptions(extensions: IRelaxedScannedExtension[], type: ExtensionType | 'development', scanOptions: { includeAllVersions?: boolean; includeInvalid?: boolean; pickLatest?: boolean } = {}): Promise { if (!scanOptions.includeAllVersions) { - extensions = this.dedupExtensions(type === ExtensionType.System ? extensions : undefined, type === ExtensionType.User ? extensions : undefined, type === 'development' ? extensions : undefined, await this.getTargetPlatform(), pickLatest); + extensions = this.dedupExtensions(type === ExtensionType.System ? extensions : undefined, type === ExtensionType.User ? extensions : undefined, type === 'development' ? extensions : undefined, await this.getTargetPlatform(), !!scanOptions.pickLatest); } if (!scanOptions.includeInvalid) { extensions = extensions.filter(extension => extension.isValid); @@ -399,10 +403,10 @@ export abstract class AbstractExtensionsScannerService extends Disposable implem return [...result.values()]; } - private async scanDefaultSystemExtensions(useCache: boolean, language: string | undefined): Promise { + private async scanDefaultSystemExtensions(language: string | undefined): Promise { this.logService.trace('Started scanning system extensions'); const extensionsScannerInput = await this.createExtensionScannerInput(this.systemExtensionsLocation, false, ExtensionType.System, language, true, undefined, this.getProductVersion()); - const extensionsScanner = useCache && !extensionsScannerInput.devMode ? this.systemExtensionsCachedScanner : this.extensionsScanner; + const extensionsScanner = !extensionsScannerInput.devMode ? this.systemExtensionsCachedScanner : this.extensionsScanner; const result = await extensionsScanner.scanExtensions(extensionsScannerInput); this.logService.trace('Scanned system extensions:', result.length); return result; @@ -605,15 +609,16 @@ class ExtensionsScanner extends Disposable { if (!scannedProfileExtensions.length) { return []; } - const extensions = await Promise.all( - scannedProfileExtensions.map(async extensionInfo => { - if (filter(extensionInfo)) { - const extensionScannerInput = new ExtensionScannerInput(extensionInfo.location, input.mtime, input.applicationExtensionslocation, input.applicationExtensionslocationMtime, input.profile, input.profileScanOptions, input.type, input.validate, input.productVersion, input.productDate, input.productCommit, input.devMode, input.language, input.translations); - return this.scanExtension(extensionScannerInput, extensionInfo.metadata); - } - return null; - })); - return coalesce(extensions); + const extensions: IRelaxedScannedExtension[] = []; + await Promise.all(scannedProfileExtensions.map(async extensionInfo => { + if (!filter(extensionInfo)) { + return; + } + const extensionScannerInput = new ExtensionScannerInput(extensionInfo.location, input.mtime, input.applicationExtensionslocation, input.applicationExtensionslocationMtime, input.profile, input.profileScanOptions, input.type, input.validate, input.productVersion, input.productDate, input.productCommit, input.devMode, input.language, input.translations); + const extension = await this.scanExtension(extensionScannerInput, extensionInfo); + extensions.push(extension); + })); + return extensions; } async scanOneOrMultipleExtensions(input: ExtensionScannerInput): Promise { @@ -630,56 +635,76 @@ class ExtensionsScanner extends Disposable { } } - async scanExtension(input: ExtensionScannerInput, metadata?: Metadata): Promise { + async scanExtension(input: ExtensionScannerInput): Promise; + async scanExtension(input: ExtensionScannerInput, scannedProfileExtension: IScannedProfileExtension): Promise; + async scanExtension(input: ExtensionScannerInput, scannedProfileExtension?: IScannedProfileExtension): Promise { + const validations: [Severity, string][] = []; + let isValid = true; + let manifest: IScannedExtensionManifest; try { - let manifest = await this.scanExtensionManifest(input.location); - if (manifest) { - // allow publisher to be undefined to make the initial extension authoring experience smoother - if (!manifest.publisher) { - manifest.publisher = UNDEFINED_PUBLISHER; - } - metadata = metadata ?? manifest.__metadata; - if (metadata && !metadata?.size && manifest.__metadata?.size) { - metadata.size = manifest.__metadata?.size; - } - delete manifest.__metadata; - const id = getGalleryExtensionId(manifest.publisher, manifest.name); - const identifier = metadata?.id ? { id, uuid: metadata.id } : { id }; - const type = metadata?.isSystem ? ExtensionType.System : input.type; - const isBuiltin = type === ExtensionType.System || !!metadata?.isBuiltin; - manifest = await this.translateManifest(input.location, manifest, ExtensionScannerInput.createNlsConfiguration(input)); - let extension: IRelaxedScannedExtension = { - type, - identifier, - manifest, - location: input.location, - isBuiltin, - targetPlatform: metadata?.targetPlatform ?? TargetPlatform.UNDEFINED, - publisherDisplayName: metadata?.publisherDisplayName, - metadata, - isValid: true, - validations: [], - preRelease: !!metadata?.preRelease, - }; - if (input.validate) { - extension = this.validate(extension, input); - } - if (manifest.enabledApiProposals && (!this.environmentService.isBuilt || this.extensionsEnabledWithApiProposalVersion.includes(id.toLowerCase()))) { - manifest.originalEnabledApiProposals = manifest.enabledApiProposals; - manifest.enabledApiProposals = parseEnabledApiProposalNames([...manifest.enabledApiProposals]); - } - return extension; - } + manifest = await this.scanExtensionManifest(input.location); } catch (e) { - if (input.type !== ExtensionType.System) { - this.logService.error(e); + if (scannedProfileExtension) { + validations.push([Severity.Error, getErrorMessage(e)]); + isValid = false; + const [publisher, name] = scannedProfileExtension.identifier.id.split('.'); + manifest = { + name, + publisher, + version: scannedProfileExtension.version, + engines: { vscode: '' } + }; + } else { + if (input.type !== ExtensionType.System) { + this.logService.error(e); + } + return null; } } - return null; + + // allow publisher to be undefined to make the initial extension authoring experience smoother + if (!manifest.publisher) { + manifest.publisher = UNDEFINED_PUBLISHER; + } + const metadata = scannedProfileExtension?.metadata ?? manifest.__metadata; + if (metadata && !metadata?.size && manifest.__metadata?.size) { + metadata.size = manifest.__metadata?.size; + } + delete manifest.__metadata; + const id = getGalleryExtensionId(manifest.publisher, manifest.name); + const identifier = metadata?.id ? { id, uuid: metadata.id } : { id }; + const type = metadata?.isSystem ? ExtensionType.System : input.type; + const isBuiltin = type === ExtensionType.System || !!metadata?.isBuiltin; + try { + manifest = await this.translateManifest(input.location, manifest, ExtensionScannerInput.createNlsConfiguration(input)); + } catch (error) { + this.logService.warn('Failed to translate manifest', getErrorMessage(error)); + } + let extension: IRelaxedScannedExtension = { + type, + identifier, + manifest, + location: input.location, + isBuiltin, + targetPlatform: metadata?.targetPlatform ?? TargetPlatform.UNDEFINED, + publisherDisplayName: metadata?.publisherDisplayName, + metadata, + isValid, + validations, + preRelease: !!metadata?.preRelease, + }; + if (input.validate) { + extension = this.validate(extension, input); + } + if (manifest.enabledApiProposals && (!this.environmentService.isBuilt || this.extensionsEnabledWithApiProposalVersion.includes(id.toLowerCase()))) { + manifest.originalEnabledApiProposals = manifest.enabledApiProposals; + manifest.enabledApiProposals = parseEnabledApiProposalNames([...manifest.enabledApiProposals]); + } + return extension; } validate(extension: IRelaxedScannedExtension, input: ExtensionScannerInput): IRelaxedScannedExtension { - let isValid = true; + let isValid = extension.isValid; const validateApiVersion = this.environmentService.isBuilt && this.extensionsEnabledWithApiProposalVersion.includes(extension.identifier.id.toLowerCase()); const validations = validateExtensionManifest(input.productVersion, input.productDate, input.location, extension.manifest, extension.isBuiltin, validateApiVersion); for (const [severity, message] of validations) { @@ -689,11 +714,11 @@ class ExtensionsScanner extends Disposable { } } extension.isValid = isValid; - extension.validations = validations; + extension.validations = [...extension.validations, ...validations]; return extension; } - private async scanExtensionManifest(extensionLocation: URI): Promise { + private async scanExtensionManifest(extensionLocation: URI): Promise { const manifestLocation = joinPath(extensionLocation, 'package.json'); let content; try { @@ -702,7 +727,7 @@ class ExtensionsScanner extends Disposable { if (toFileOperationResult(error) !== FileOperationResult.FILE_NOT_FOUND) { this.logService.error(this.formatMessage(extensionLocation, localize('fileReadFail', "Cannot read file {0}: {1}.", manifestLocation.path, error.message))); } - return null; + throw error; } let manifest: IScannedExtensionManifest; try { @@ -714,11 +739,12 @@ class ExtensionsScanner extends Disposable { for (const e of errors) { this.logService.error(this.formatMessage(extensionLocation, localize('jsonParseFail', "Failed to parse {0}: [{1}, {2}] {3}.", manifestLocation.path, e.offset, e.length, getParseErrorMessage(e.error)))); } - return null; + throw err; } if (getNodeType(manifest) !== 'object') { - this.logService.error(this.formatMessage(extensionLocation, localize('jsonParseInvalidType', "Invalid manifest file {0}: Not a JSON object.", manifestLocation.path))); - return null; + const errorMessage = this.formatMessage(extensionLocation, localize('jsonParseInvalidType', "Invalid manifest file {0}: Not a JSON object.", manifestLocation.path)); + this.logService.error(errorMessage); + throw new Error(errorMessage); } return manifest; } diff --git a/src/vs/platform/extensionManagement/node/extensionManagementService.ts b/src/vs/platform/extensionManagement/node/extensionManagementService.ts index 762da10967f..e954abf073b 100644 --- a/src/vs/platform/extensionManagement/node/extensionManagementService.ts +++ b/src/vs/platform/extensionManagement/node/extensionManagementService.ts @@ -17,7 +17,7 @@ import { Schemas } from '../../../base/common/network.js'; import * as path from '../../../base/common/path.js'; import { joinPath } from '../../../base/common/resources.js'; import * as semver from '../../../base/common/semver/semver.js'; -import { isBoolean } from '../../../base/common/types.js'; +import { isBoolean, isDefined, isUndefined } from '../../../base/common/types.js'; import { URI } from '../../../base/common/uri.js'; import { generateUuid } from '../../../base/common/uuid.js'; import * as pfs from '../../../base/node/pfs.js'; @@ -37,7 +37,7 @@ import { } from '../common/extensionManagement.js'; import { areSameExtensions, computeTargetPlatform, ExtensionKey, getGalleryExtensionId, groupByExtension } from '../common/extensionManagementUtil.js'; import { IExtensionsProfileScannerService, IScannedProfileExtension } from '../common/extensionsProfileScannerService.js'; -import { IExtensionsScannerService, IScannedExtension, ScanOptions } from '../common/extensionsScannerService.js'; +import { IExtensionsScannerService, IScannedExtension, UserExtensionsScanOptions } from '../common/extensionsScannerService.js'; import { ExtensionsDownloader } from './extensionDownloader.js'; import { ExtensionsLifecycle } from './extensionLifecycle.js'; import { fromExtractError, getManifest } from './extensionManagementUtil.js'; @@ -45,7 +45,7 @@ import { ExtensionsManifestCache } from './extensionsManifestCache.js'; import { DidChangeProfileExtensionsEvent, ExtensionsWatcher } from './extensionsWatcher.js'; import { ExtensionType, IExtension, IExtensionManifest, TargetPlatform } from '../../extensions/common/extensions.js'; import { isEngineValid } from '../../extensions/common/extensionValidator.js'; -import { FileChangesEvent, FileChangeType, FileOperationResult, IFileService, toFileOperationResult } from '../../files/common/files.js'; +import { FileChangesEvent, FileChangeType, FileOperationResult, IFileService, IFileStat, toFileOperationResult } from '../../files/common/files.js'; import { IInstantiationService, refineServiceDecorator } from '../../instantiation/common/instantiation.js'; import { ILogService } from '../../log/common/log.js'; import { IProductService } from '../../product/common/productService.js'; @@ -132,7 +132,7 @@ export class ExtensionManagementService extends AbstractExtensionManagementServi } scanAllUserInstalledExtensions(): Promise { - return this.extensionsScanner.scanAllUserExtensions(false); + return this.extensionsScanner.scanAllUserExtensions(); } scanInstalledExtensionAtLocation(location: URI): Promise { @@ -298,23 +298,6 @@ export class ExtensionManagementService extends AbstractExtensionManagementServi const local = await this.extensionsScanner.extractUserExtension( extensionKey, location.fsPath, - { - id: gallery.identifier.uuid, - publisherId: gallery.publisherId, - publisherDisplayName: gallery.publisherDisplayName, - targetPlatform: gallery.properties.targetPlatform, - isApplicationScoped: options.isApplicationScoped, - isMachineScoped: options.isMachineScoped, - isBuiltin: options.isBuiltin, - isPreReleaseVersion: gallery.properties.isPreReleaseVersion, - hasPreReleaseVersion: gallery.properties.isPreReleaseVersion, - installedTimestamp: Date.now(), - pinned: options.installGivenVersion ? true : !!options.pinned, - preRelease: isBoolean(options.preRelease) - ? options.preRelease - : options.installPreReleaseVersion || gallery.properties.isPreReleaseVersion, - source: 'gallery', - }, false, token); @@ -382,14 +365,6 @@ export class ExtensionManagementService extends AbstractExtensionManagementServi const local = await this.extensionsScanner.extractUserExtension( extensionKey, path.resolve(location.fsPath), - { - isApplicationScoped: options.isApplicationScoped, - isMachineScoped: options.isMachineScoped, - isBuiltin: options.isBuiltin, - installedTimestamp: Date.now(), - pinned: options.installGivenVersion ? true : !!options.pinned, - source: 'vsix', - }, isBoolean(options.keepExisting) ? !options.keepExisting : true, token); return { local }; @@ -561,17 +536,18 @@ export class ExtensionsScanner extends Disposable { async cleanUp(): Promise { await this.removeTemporarilyDeletedFolders(); await this.deleteExtensionsMarkedForRemoval(); - await this.initializeMetadata(); + //TODO: Remove this initiialization after coupe of releases + await this.initializeExtensionSize(); } async scanExtensions(type: ExtensionType | null, profileLocation: URI, productVersion: IProductVersion): Promise { try { - const userScanOptions: ScanOptions = { includeInvalid: true, profileLocation, productVersion }; + const userScanOptions: UserExtensionsScanOptions = { includeInvalid: true, profileLocation, productVersion }; let scannedExtensions: IScannedExtension[] = []; if (type === null || type === ExtensionType.System) { let scanAllExtensionsPromise = this.scanAllExtensionPromise.get(profileLocation); if (!scanAllExtensionsPromise) { - scanAllExtensionsPromise = this.extensionsScannerService.scanAllExtensions({ includeInvalid: true, useCache: true }, userScanOptions, false) + scanAllExtensionsPromise = this.extensionsScannerService.scanAllExtensions({}, userScanOptions) .finally(() => this.scanAllExtensionPromise.delete(profileLocation)); this.scanAllExtensionPromise.set(profileLocation, scanAllExtensionsPromise); } @@ -592,9 +568,9 @@ export class ExtensionsScanner extends Disposable { } } - async scanAllUserExtensions(excludeOutdated: boolean): Promise { + async scanAllUserExtensions(): Promise { try { - const scannedExtensions = await this.extensionsScannerService.scanUserExtensions({ includeAllVersions: !excludeOutdated, includeInvalid: true }); + const scannedExtensions = await this.extensionsScannerService.scanAllUserExtensions(); return await Promise.all(scannedExtensions.map(extension => this.toLocalExtension(extension))); } catch (error) { throw toExtensionManagementError(error, ExtensionManagementErrorCode.Scanning); @@ -613,7 +589,7 @@ export class ExtensionsScanner extends Disposable { return null; } - async extractUserExtension(extensionKey: ExtensionKey, zipPath: string, metadata: Metadata, removeIfExists: boolean, token: CancellationToken): Promise { + async extractUserExtension(extensionKey: ExtensionKey, zipPath: string, removeIfExists: boolean, token: CancellationToken): Promise { const folderName = extensionKey.toString(); const tempLocation = URI.file(path.join(this.extensionsScannerService.userExtensionsLocation.fsPath, `.${generateUuid()}`)); const extensionLocation = URI.file(path.join(this.extensionsScannerService.userExtensionsLocation.fsPath, folderName)); @@ -648,6 +624,7 @@ export class ExtensionsScanner extends Disposable { throw fromExtractError(e); } + const metadata: Metadata = { installedTimestamp: Date.now() }; try { metadata.size = await computeSize(tempLocation, this.fileService); } catch (error) { @@ -691,13 +668,9 @@ export class ExtensionsScanner extends Disposable { return this.scanLocalExtension(extensionLocation, ExtensionType.User); } - async scanMetadata(local: ILocalExtension, profileLocation?: URI): Promise { - if (profileLocation) { - const extension = await this.getScannedExtension(local, profileLocation); - return extension?.metadata; - } else { - return this.extensionsScannerService.scanMetadata(local.location); - } + async scanMetadata(local: ILocalExtension, profileLocation: URI): Promise { + const extension = await this.getScannedExtension(local, profileLocation); + return extension?.metadata; } private async getScannedExtension(local: ILocalExtension, profileLocation: URI): Promise { @@ -763,7 +736,7 @@ export class ExtensionsScanner extends Disposable { await this.extensionsProfileScannerService.updateMetadata([[extension, { ...target.metadata, ...metadata }]], toProfileLocation); } else { const targetExtension = await this.scanLocalExtension(target.location, extension.type, toProfileLocation); - await this.extensionsProfileScannerService.removeExtensionFromProfile(targetExtension, toProfileLocation); + await this.extensionsProfileScannerService.removeExtensionsFromProfile([targetExtension.identifier], toProfileLocation); await this.extensionsProfileScannerService.addExtensionsToProfile([[extension, { ...target.metadata, ...metadata }]], toProfileLocation); } } else { @@ -856,10 +829,14 @@ export class ExtensionsScanner extends Disposable { } private async toLocalExtension(extension: IScannedExtension): Promise { - const stat = await this.fileService.resolve(extension.location); + let stat: IFileStat | undefined; + try { + stat = await this.fileService.resolve(extension.location); + } catch (error) {/* ignore */ } + let readmeUrl: URI | undefined; let changelogUrl: URI | undefined; - if (stat.children) { + if (stat?.children) { readmeUrl = stat.children.find(({ name }) => /^readme(\.txt|\.md|)$/i.test(name))?.resource; changelogUrl = stat.children.find(({ name }) => /^changelog(\.txt|\.md|)$/i.test(name))?.resource; } @@ -890,11 +867,11 @@ export class ExtensionsScanner extends Disposable { }; } - private async initializeMetadata(): Promise { - const extensions = await this.extensionsScannerService.scanUserExtensions({ includeInvalid: true }); + private async initializeExtensionSize(): Promise { + const extensions = await this.extensionsScannerService.scanAllUserExtensions(); await Promise.all(extensions.map(async extension => { // set size if not set before - if (!extension.metadata?.size && extension.metadata?.source !== 'resource') { + if (isDefined(extension.metadata?.installedTimestamp) && isUndefined(extension.metadata?.size)) { const size = await computeSize(extension.location, this.fileService); await this.extensionsScannerService.updateMetadata(extension.location, { size }); } @@ -916,7 +893,7 @@ export class ExtensionsScanner extends Disposable { this.logService.debug(`Deleting extensions marked as removed:`, Object.keys(removed)); - const extensions = await this.extensionsScannerService.scanUserExtensions({ includeAllVersions: true, includeInvalid: true }); // All user extensions + const extensions = await this.scanAllUserExtensions(); const installed: Set = new Set(); for (const e of extensions) { if (!removed[ExtensionKey.create(e).toString()]) { @@ -930,14 +907,14 @@ export class ExtensionsScanner extends Disposable { await Promises.settled(byExtension.map(async e => { const latest = e.sort((a, b) => semver.rcompare(a.manifest.version, b.manifest.version))[0]; if (!installed.has(latest.identifier.id.toLowerCase())) { - await this.beforeRemovingExtension(await this.toLocalExtension(latest)); + await this.beforeRemovingExtension(latest); } })); } catch (error) { this.logService.error(error); } - const toRemove = extensions.filter(e => e.metadata /* Installed by System */ && removed[ExtensionKey.create(e).toString()]); + const toRemove = extensions.filter(e => e.installedTimestamp /* Installed by System */ && removed[ExtensionKey.create(e).toString()]); await Promise.allSettled(toRemove.map(e => this.deleteExtension(e, 'marked for removal'))); } @@ -1115,7 +1092,7 @@ class InstallExtensionInProfileTask extends AbstractExtensionTask ExtensionKey.create(i).equals(extensionKey)); } return undefined; @@ -1155,7 +1132,7 @@ class UninstallExtensionInProfileTask extends AbstractExtensionTask implem } protected doRun(token: CancellationToken): Promise { - return this.extensionsProfileScannerService.removeExtensionFromProfile(this.extension, this.options.profileLocation); + return this.extensionsProfileScannerService.removeExtensionsFromProfile([this.extension.identifier], this.options.profileLocation); } } diff --git a/src/vs/platform/extensionManagement/test/common/extensionsProfileScannerService.test.ts b/src/vs/platform/extensionManagement/test/common/extensionsProfileScannerService.test.ts index 0329b90263b..a21b64a95a5 100644 --- a/src/vs/platform/extensionManagement/test/common/extensionsProfileScannerService.test.ts +++ b/src/vs/platform/extensionManagement/test/common/extensionsProfileScannerService.test.ts @@ -356,7 +356,7 @@ suite('ExtensionsProfileScannerService', () => { assert.deepStrictEqual(((target2.args[0][0])).extensions[0].location.toString(), extension.location.toString()); }); - test('remove extension trigger events', async () => { + test('remove extensions trigger events', async () => { const testObject = disposables.add(instantiationService.createInstance(TestObject, extensionsLocation)); const target1 = sinon.stub(); const target2 = sinon.stub(); @@ -364,26 +364,33 @@ suite('ExtensionsProfileScannerService', () => { disposables.add(testObject.onDidRemoveExtensions(target2)); const extensionsManifest = joinPath(extensionsLocation, 'extensions.json'); - const extension = aExtension('pub.a', joinPath(ROOT, 'foo', 'pub.a-1.0.0')); - await testObject.addExtensionsToProfile([[extension, undefined]], extensionsManifest); - await testObject.removeExtensionFromProfile(extension, extensionsManifest); + const extension1 = aExtension('pub.a', joinPath(ROOT, 'foo', 'pub.a-1.0.0')); + const extension2 = aExtension('pub.b', joinPath(ROOT, 'foo', 'pub.b-1.0.0')); + await testObject.addExtensionsToProfile([[extension1, undefined], [extension2, undefined]], extensionsManifest); + await testObject.removeExtensionsFromProfile([extension1.identifier, extension2.identifier], extensionsManifest); const actual = await testObject.scanProfileExtensions(extensionsManifest); assert.deepStrictEqual(actual.length, 0); assert.ok(target1.calledOnce); assert.deepStrictEqual(((target1.args[0][0])).profileLocation.toString(), extensionsManifest.toString()); - assert.deepStrictEqual(((target1.args[0][0])).extensions.length, 1); - assert.deepStrictEqual(((target1.args[0][0])).extensions[0].identifier, extension.identifier); - assert.deepStrictEqual(((target1.args[0][0])).extensions[0].version, extension.manifest.version); - assert.deepStrictEqual(((target1.args[0][0])).extensions[0].location.toString(), extension.location.toString()); + assert.deepStrictEqual(((target1.args[0][0])).extensions.length, 2); + assert.deepStrictEqual(((target1.args[0][0])).extensions[0].identifier, extension1.identifier); + assert.deepStrictEqual(((target1.args[0][0])).extensions[0].version, extension1.manifest.version); + assert.deepStrictEqual(((target1.args[0][0])).extensions[0].location.toString(), extension1.location.toString()); + assert.deepStrictEqual(((target1.args[0][0])).extensions[1].identifier, extension2.identifier); + assert.deepStrictEqual(((target1.args[0][0])).extensions[1].version, extension2.manifest.version); + assert.deepStrictEqual(((target1.args[0][0])).extensions[1].location.toString(), extension2.location.toString()); assert.ok(target2.calledOnce); assert.deepStrictEqual(((target2.args[0][0])).profileLocation.toString(), extensionsManifest.toString()); - assert.deepStrictEqual(((target2.args[0][0])).extensions.length, 1); - assert.deepStrictEqual(((target2.args[0][0])).extensions[0].identifier, extension.identifier); - assert.deepStrictEqual(((target2.args[0][0])).extensions[0].version, extension.manifest.version); - assert.deepStrictEqual(((target2.args[0][0])).extensions[0].location.toString(), extension.location.toString()); + assert.deepStrictEqual(((target2.args[0][0])).extensions.length, 2); + assert.deepStrictEqual(((target2.args[0][0])).extensions[0].identifier, extension1.identifier); + assert.deepStrictEqual(((target2.args[0][0])).extensions[0].version, extension1.manifest.version); + assert.deepStrictEqual(((target2.args[0][0])).extensions[0].location.toString(), extension1.location.toString()); + assert.deepStrictEqual(((target2.args[0][0])).extensions[1].identifier, extension2.identifier); + assert.deepStrictEqual(((target2.args[0][0])).extensions[1].version, extension2.manifest.version); + assert.deepStrictEqual(((target2.args[0][0])).extensions[1].location.toString(), extension2.location.toString()); }); test('add extension with same id but different version', async () => { diff --git a/src/vs/platform/extensionManagement/test/node/extensionsScannerService.test.ts b/src/vs/platform/extensionManagement/test/node/extensionsScannerService.test.ts index 551ba576d44..ea4108b2c17 100644 --- a/src/vs/platform/extensionManagement/test/node/extensionsScannerService.test.ts +++ b/src/vs/platform/extensionManagement/test/node/extensionsScannerService.test.ts @@ -105,12 +105,12 @@ suite('NativeExtensionsScanerService Test', () => { assert.deepStrictEqual(actual[0].manifest, manifest); }); - test('scan user extension', async () => { + test('scan user extensions', async () => { const manifest: Partial = anExtensionManifest({ 'name': 'name', 'publisher': 'pub', __metadata: { id: 'uuid' } }); const extensionLocation = await aUserExtension(manifest); const testObject: IExtensionsScannerService = disposables.add(instantiationService.createInstance(ExtensionsScannerService)); - const actual = await testObject.scanUserExtensions({}); + const actual = await testObject.scanAllUserExtensions(); assert.deepStrictEqual(actual.length, 1); assert.deepStrictEqual(actual[0].identifier, { id: 'pub.name', uuid: 'uuid' }); @@ -175,24 +175,24 @@ suite('NativeExtensionsScanerService Test', () => { assert.deepStrictEqual(actual[1].identifier, { id: 'pub.name2' }); }); - test('scan user extension with different versions', async () => { + test('scan all user extensions with different versions', async () => { await aUserExtension(anExtensionManifest({ 'name': 'name', 'publisher': 'pub', version: '1.0.1' })); await aUserExtension(anExtensionManifest({ 'name': 'name', 'publisher': 'pub', version: '1.0.2' })); - const testObject: IExtensionsScannerService = disposables.add(instantiationService.createInstance(ExtensionsScannerService)); + const testObject = disposables.add(instantiationService.createInstance(ExtensionsScannerService)); - const actual = await testObject.scanUserExtensions({}); + const actual = await testObject.scanAllUserExtensions({ includeAllVersions: false, includeInvalid: false }); assert.deepStrictEqual(actual.length, 1); assert.deepStrictEqual(actual[0].identifier, { id: 'pub.name' }); assert.deepStrictEqual(actual[0].manifest.version, '1.0.2'); }); - test('scan user extension include all versions', async () => { + test('scan all user extensions include all versions', async () => { await aUserExtension(anExtensionManifest({ 'name': 'name', 'publisher': 'pub', version: '1.0.1' })); await aUserExtension(anExtensionManifest({ 'name': 'name', 'publisher': 'pub', version: '1.0.2' })); - const testObject: IExtensionsScannerService = disposables.add(instantiationService.createInstance(ExtensionsScannerService)); + const testObject = disposables.add(instantiationService.createInstance(ExtensionsScannerService)); - const actual = await testObject.scanUserExtensions({ includeAllVersions: true }); + const actual = await testObject.scanAllUserExtensions(); assert.deepStrictEqual(actual.length, 2); assert.deepStrictEqual(actual[0].identifier, { id: 'pub.name' }); @@ -201,35 +201,35 @@ suite('NativeExtensionsScanerService Test', () => { assert.deepStrictEqual(actual[1].manifest.version, '1.0.2'); }); - test('scan user extension with different versions and higher version is not compatible', async () => { + test('scan all user extensions with different versions and higher version is not compatible', async () => { await aUserExtension(anExtensionManifest({ 'name': 'name', 'publisher': 'pub', version: '1.0.1' })); await aUserExtension(anExtensionManifest({ 'name': 'name', 'publisher': 'pub', version: '1.0.2', engines: { vscode: '^1.67.0' } })); - const testObject: IExtensionsScannerService = disposables.add(instantiationService.createInstance(ExtensionsScannerService)); + const testObject = disposables.add(instantiationService.createInstance(ExtensionsScannerService)); - const actual = await testObject.scanUserExtensions({}); + const actual = await testObject.scanAllUserExtensions({ includeAllVersions: false, includeInvalid: false }); assert.deepStrictEqual(actual.length, 1); assert.deepStrictEqual(actual[0].identifier, { id: 'pub.name' }); assert.deepStrictEqual(actual[0].manifest.version, '1.0.1'); }); - test('scan exclude invalid extensions', async () => { + test('scan all user extensions exclude invalid extensions', async () => { await aUserExtension(anExtensionManifest({ 'name': 'name', 'publisher': 'pub' })); await aUserExtension(anExtensionManifest({ 'name': 'name2', 'publisher': 'pub', engines: { vscode: '^1.67.0' } })); - const testObject: IExtensionsScannerService = disposables.add(instantiationService.createInstance(ExtensionsScannerService)); + const testObject = disposables.add(instantiationService.createInstance(ExtensionsScannerService)); - const actual = await testObject.scanUserExtensions({}); + const actual = await testObject.scanAllUserExtensions({ includeAllVersions: false, includeInvalid: false }); assert.deepStrictEqual(actual.length, 1); assert.deepStrictEqual(actual[0].identifier, { id: 'pub.name' }); }); - test('scan include invalid extensions', async () => { + test('scan all user extensions include invalid extensions', async () => { await aUserExtension(anExtensionManifest({ 'name': 'name', 'publisher': 'pub' })); await aUserExtension(anExtensionManifest({ 'name': 'name2', 'publisher': 'pub', engines: { vscode: '^1.67.0' } })); - const testObject: IExtensionsScannerService = disposables.add(instantiationService.createInstance(ExtensionsScannerService)); + const testObject = disposables.add(instantiationService.createInstance(ExtensionsScannerService)); - const actual = await testObject.scanUserExtensions({ includeInvalid: true }); + const actual = await testObject.scanAllUserExtensions({ includeAllVersions: false, includeInvalid: true }); assert.deepStrictEqual(actual.length, 2); assert.deepStrictEqual(actual[0].identifier, { id: 'pub.name' }); @@ -257,12 +257,12 @@ suite('NativeExtensionsScanerService Test', () => { assert.deepStrictEqual(actual[0].manifest.version, '1.0.0'); }); - test('scan extension with default nls replacements', async () => { + test('scan all user extensions with default nls replacements', async () => { const extensionLocation = await aUserExtension(anExtensionManifest({ 'name': 'name', 'publisher': 'pub', displayName: '%displayName%' })); await instantiationService.get(IFileService).writeFile(joinPath(extensionLocation, 'package.nls.json'), VSBuffer.fromString(JSON.stringify({ displayName: 'Hello World' }))); - const testObject: IExtensionsScannerService = disposables.add(instantiationService.createInstance(ExtensionsScannerService)); + const testObject = disposables.add(instantiationService.createInstance(ExtensionsScannerService)); - const actual = await testObject.scanUserExtensions({}); + const actual = await testObject.scanAllUserExtensions(); assert.deepStrictEqual(actual.length, 1); assert.deepStrictEqual(actual[0].identifier, { id: 'pub.name' }); @@ -277,11 +277,11 @@ suite('NativeExtensionsScanerService Test', () => { const testObject: IExtensionsScannerService = disposables.add(instantiationService.createInstance(ExtensionsScannerService)); translations = { 'pub.name': nlsLocation.fsPath }; - const actual = await testObject.scanUserExtensions({ language: 'en' }); + const actual = await testObject.scanExistingExtension(extensionLocation, ExtensionType.User, { language: 'en' }); - assert.deepStrictEqual(actual.length, 1); - assert.deepStrictEqual(actual[0].identifier, { id: 'pub.name' }); - assert.deepStrictEqual(actual[0].manifest.displayName, 'Hello World EN'); + assert.ok(actual !== null); + assert.deepStrictEqual(actual!.identifier, { id: 'pub.name' }); + assert.deepStrictEqual(actual!.manifest.displayName, 'Hello World EN'); }); test('scan extension falls back to default nls replacements', async () => { @@ -292,11 +292,11 @@ suite('NativeExtensionsScanerService Test', () => { const testObject: IExtensionsScannerService = disposables.add(instantiationService.createInstance(ExtensionsScannerService)); translations = { 'pub.name2': nlsLocation.fsPath }; - const actual = await testObject.scanUserExtensions({ language: 'en' }); + const actual = await testObject.scanExistingExtension(extensionLocation, ExtensionType.User, { language: 'en' }); - assert.deepStrictEqual(actual.length, 1); - assert.deepStrictEqual(actual[0].identifier, { id: 'pub.name' }); - assert.deepStrictEqual(actual[0].manifest.displayName, 'Hello World'); + assert.ok(actual !== null); + assert.deepStrictEqual(actual!.identifier, { id: 'pub.name' }); + assert.deepStrictEqual(actual!.manifest.displayName, 'Hello World'); }); async function aUserExtension(manifest: Partial): Promise { diff --git a/src/vs/platform/native/common/native.ts b/src/vs/platform/native/common/native.ts index 2fdfb63e0b5..1f8d0adabe7 100644 --- a/src/vs/platform/native/common/native.ts +++ b/src/vs/platform/native/common/native.ts @@ -113,6 +113,9 @@ export interface ICommonNativeHostService { */ focusWindow(options?: INativeHostOptions & { force?: boolean }): Promise; + // Titlebar default style override + overrideDefaultTitlebarStyle(style: 'native' | 'custom' | undefined): Promise; + // Dialogs showMessageBox(options: MessageBoxOptions & INativeHostOptions): Promise; showSaveDialog(options: SaveDialogOptions & INativeHostOptions): Promise; @@ -143,10 +146,6 @@ export interface ICommonNativeHostService { hasWSLFeatureInstalled(): Promise; // Screenshots - - /** - * Gets a screenshot of the currently active Electron window. - */ getScreenshot(): Promise; // Process @@ -199,7 +198,7 @@ export interface ICommonNativeHostService { loadCertificates(): Promise; findFreePort(startPort: number, giveUpAfter: number, timeout: number, stride?: number): Promise; - // Registry (windows only) + // Registry (Windows only) windowsGetStringRegKey(hive: 'HKEY_CURRENT_USER' | 'HKEY_LOCAL_MACHINE' | 'HKEY_CLASSES_ROOT' | 'HKEY_USERS' | 'HKEY_CURRENT_CONFIG', path: string, name: string): Promise; } diff --git a/src/vs/platform/native/electron-main/nativeHostMainService.ts b/src/vs/platform/native/electron-main/nativeHostMainService.ts index 794c5aed575..8e5c129ea4f 100644 --- a/src/vs/platform/native/electron-main/nativeHostMainService.ts +++ b/src/vs/platform/native/electron-main/nativeHostMainService.ts @@ -33,7 +33,7 @@ import { IProductService } from '../../product/common/productService.js'; import { IPartsSplash } from '../../theme/common/themeService.js'; import { IThemeMainService } from '../../theme/electron-main/themeMainService.js'; import { defaultWindowState, ICodeWindow } from '../../window/electron-main/window.js'; -import { IColorScheme, IOpenedAuxiliaryWindow, IOpenedMainWindow, IOpenEmptyWindowOptions, IOpenWindowOptions, IPoint, IRectangle, IWindowOpenable } from '../../window/common/window.js'; +import { IColorScheme, IOpenedAuxiliaryWindow, IOpenedMainWindow, IOpenEmptyWindowOptions, IOpenWindowOptions, IPoint, IRectangle, IWindowOpenable, overrideDefaultTitlebarStyle } from '../../window/common/window.js'; import { defaultBrowserWindowOptions, IWindowsMainService, OpenContext } from '../../windows/electron-main/windows.js'; import { isWorkspaceIdentifier, toWorkspaceIdentifier } from '../../workspace/common/workspace.js'; import { IWorkspacesManagementMainService } from '../../workspaces/electron-main/workspacesManagementMainService.js'; @@ -48,6 +48,7 @@ import { IConfigurationService } from '../../configuration/common/configuration. import { IProxyAuthService } from './auth.js'; import { AuthInfo, Credentials, IRequestService } from '../../request/common/request.js'; import { randomPath } from '../../../base/common/extpath.js'; +import { IStateService } from '../../state/node/state.js'; export interface INativeHostMainService extends AddFirstParameterToFunctions /* only methods, not events */, number | undefined /* window ID */> { } @@ -70,7 +71,8 @@ export class NativeHostMainService extends Disposable implements INativeHostMain @IConfigurationService private readonly configurationService: IConfigurationService, @IRequestService private readonly requestService: IRequestService, @IProxyAuthService private readonly proxyAuthService: IProxyAuthService, - @IInstantiationService private readonly instantiationService: IInstantiationService + @IInstantiationService private readonly instantiationService: IInstantiationService, + @IStateService private readonly stateService: IStateService ) { super(); } @@ -324,6 +326,15 @@ export class NativeHostMainService extends Disposable implements INativeHostMain this.themeMainService.saveWindowSplash(windowId, splash); } + async overrideDefaultTitlebarStyle(windowId: number | undefined, style: 'native' | 'custom' | undefined): Promise { + if (typeof style === 'string') { + this.stateService.setItem('window.titleBarStyleOverride', style); + } else { + this.stateService.removeItem('window.titleBarStyleOverride'); + } + overrideDefaultTitlebarStyle(style); + } + //#endregion @@ -697,6 +708,7 @@ export class NativeHostMainService extends Disposable implements INativeHostMain async getScreenshot(windowId: number | undefined, options?: INativeHostOptions): Promise { const window = this.windowById(options?.targetWindowId, windowId); const captured = await window?.win?.webContents.capturePage(); + return captured?.toJPEG(95); } diff --git a/src/vs/platform/window/common/window.ts b/src/vs/platform/window/common/window.ts index 3a03481e55a..02e4152376d 100644 --- a/src/vs/platform/window/common/window.ts +++ b/src/vs/platform/window/common/window.ts @@ -187,10 +187,23 @@ export const enum CustomTitleBarVisibility { NEVER = 'never', } +export let titlebarStyleDefaultOverride: TitlebarStyle | undefined = undefined; +export function overrideDefaultTitlebarStyle(style: 'native' | 'custom' | undefined): void { + switch (style) { + case 'native': + titlebarStyleDefaultOverride = TitlebarStyle.NATIVE; + break; + case 'custom': + titlebarStyleDefaultOverride = TitlebarStyle.CUSTOM; + break; + default: + titlebarStyleDefaultOverride = undefined; + } +} + export function hasCustomTitlebar(configurationService: IConfigurationService, titleBarStyle?: TitlebarStyle): boolean { // Returns if it possible to have a custom title bar in the curren session // Does not imply that the title bar is visible - return true; } @@ -198,6 +211,7 @@ export function hasNativeTitlebar(configurationService: IConfigurationService, t if (!titleBarStyle) { titleBarStyle = getTitleBarStyle(configurationService); } + return titleBarStyle === TitlebarStyle.NATIVE; } @@ -224,6 +238,10 @@ export function getTitleBarStyle(configurationService: IConfigurationService): T } } + if (titlebarStyleDefaultOverride) { + return titlebarStyleDefaultOverride; + } + return isLinux && product.quality === 'stable' ? TitlebarStyle.NATIVE : TitlebarStyle.CUSTOM; // default to custom on all OS except Linux stable (for now) } diff --git a/src/vs/server/node/remoteExtensionsScanner.ts b/src/vs/server/node/remoteExtensionsScanner.ts index ee94de1090d..3855e37ce94 100644 --- a/src/vs/server/node/remoteExtensionsScanner.ts +++ b/src/vs/server/node/remoteExtensionsScanner.ts @@ -139,7 +139,7 @@ export class RemoteExtensionsScannerService implements IRemoteExtensionsScannerS } private async _scanBuiltinExtensions(language: string): Promise { - const scannedExtensions = await this._extensionsScannerService.scanSystemExtensions({ language, useCache: true }); + const scannedExtensions = await this._extensionsScannerService.scanSystemExtensions({ language }); return scannedExtensions.map(e => toExtensionDescription(e, false)); } diff --git a/src/vs/workbench/api/common/extHost.api.impl.ts b/src/vs/workbench/api/common/extHost.api.impl.ts index 868906908ea..a2fc5b81c0f 100644 --- a/src/vs/workbench/api/common/extHost.api.impl.ts +++ b/src/vs/workbench/api/common/extHost.api.impl.ts @@ -1500,10 +1500,10 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I return extHostLanguageModelTools.registerTool(extension, name, tool); }, invokeTool(name: string, parameters: vscode.LanguageModelToolInvocationOptions, token?: vscode.CancellationToken) { - return extHostLanguageModelTools.invokeTool(name, parameters, token); + return extHostLanguageModelTools.invokeTool(extension, name, parameters, token); }, get tools() { - return extHostLanguageModelTools.tools; + return extHostLanguageModelTools.getTools(extension); }, fileIsIgnored(uri: vscode.Uri, token: vscode.CancellationToken) { return extHostLanguageModels.fileIsIgnored(extension, uri, token); diff --git a/src/vs/workbench/api/common/extHost.protocol.ts b/src/vs/workbench/api/common/extHost.protocol.ts index 36e385c3e6e..7bccc00f5b9 100644 --- a/src/vs/workbench/api/common/extHost.protocol.ts +++ b/src/vs/workbench/api/common/extHost.protocol.ts @@ -1606,6 +1606,7 @@ export interface SCMHistoryItemDto { readonly message: string; readonly displayId?: string; readonly author?: string; + readonly authorEmail?: string; readonly timestamp?: number; readonly statistics?: { readonly files: number; diff --git a/src/vs/workbench/api/common/extHostLanguageModelTools.ts b/src/vs/workbench/api/common/extHostLanguageModelTools.ts index f078897e24a..b2f25fb152f 100644 --- a/src/vs/workbench/api/common/extHostLanguageModelTools.ts +++ b/src/vs/workbench/api/common/extHostLanguageModelTools.ts @@ -14,6 +14,7 @@ import { IExtensionDescription } from '../../../platform/extensions/common/exten import { IPreparedToolInvocation, isToolInvocationContext, IToolInvocation, IToolInvocationContext, IToolResult } from '../../contrib/chat/common/languageModelToolsService.js'; import { ExtHostLanguageModelToolsShape, IMainContext, IToolDataDto, MainContext, MainThreadLanguageModelToolsShape } from './extHost.protocol.js'; import * as typeConvert from './extHostTypeConverters.js'; +import { isProposedApiEnabled } from '../../services/extensions/common/extensions.js'; @@ -45,17 +46,22 @@ export class ExtHostLanguageModelTools implements ExtHostLanguageModelToolsShape return await fn(input, token); } - async invokeTool(toolId: string, options: vscode.LanguageModelToolInvocationOptions, token?: CancellationToken): Promise { + async invokeTool(extension: IExtensionDescription, toolId: string, options: vscode.LanguageModelToolInvocationOptions, token?: CancellationToken): Promise { const callId = generateUuid(); if (options.tokenizationOptions) { this._tokenCountFuncs.set(callId, options.tokenizationOptions.countTokens); } - if (options.toolInvocationToken && !isToolInvocationContext(options.toolInvocationToken)) { - throw new Error(`Invalid tool invocation token`); - } - try { + if (options.toolInvocationToken && !isToolInvocationContext(options.toolInvocationToken)) { + throw new Error(`Invalid tool invocation token`); + } + + const tool = this._allTools.get(toolId); + if (tool?.tags?.includes('vscode_editing') && !isProposedApiEnabled(extension, 'chatParticipantPrivate')) { + throw new Error(`Invalid tool: ${toolId}`); + } + // Making the round trip here because not all tools were necessarily registered in this EH const result = await this._proxy.$invokeTool({ toolId, @@ -77,9 +83,16 @@ export class ExtHostLanguageModelTools implements ExtHostLanguageModelToolsShape } } - get tools(): vscode.LanguageModelToolInformation[] { + getTools(extension: IExtensionDescription): vscode.LanguageModelToolInformation[] { return Array.from(this._allTools.values()) - .map(tool => typeConvert.LanguageModelToolDescription.to(tool)); + .map(tool => typeConvert.LanguageModelToolDescription.to(tool)) + .filter(tool => { + if (tool.tags.includes('vscode_editing')) { + return isProposedApiEnabled(extension, 'chatParticipantPrivate'); + } + + return true; + }); } async $invokeTool(dto: IToolInvocation, token: CancellationToken): Promise { diff --git a/src/vs/workbench/browser/parts/statusbar/statusbarItem.ts b/src/vs/workbench/browser/parts/statusbar/statusbarItem.ts index 3de50cb8937..149ee4cbff5 100644 --- a/src/vs/workbench/browser/parts/statusbar/statusbarItem.ts +++ b/src/vs/workbench/browser/parts/statusbar/statusbarItem.ts @@ -8,7 +8,7 @@ import { Disposable, MutableDisposable } from '../../../../base/common/lifecycle import { SimpleIconLabel } from '../../../../base/browser/ui/iconLabel/simpleIconLabel.js'; import { ICommandService } from '../../../../platform/commands/common/commands.js'; import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; -import { IStatusbarEntry, ShowTooltipCommand, StatusbarEntryKinds } from '../../../services/statusbar/browser/statusbar.js'; +import { IStatusbarEntry, isTooltipWithCommands, ShowTooltipCommand, StatusbarEntryKinds, TooltipContent } from '../../../services/statusbar/browser/statusbar.js'; import { WorkbenchActionExecutedEvent, WorkbenchActionExecutedClassification } from '../../../../base/common/actions.js'; import { IThemeService } from '../../../../platform/theme/common/themeService.js'; import { ThemeColor } from '../../../../base/common/themables.js'; @@ -24,7 +24,7 @@ import { spinningLoading, syncing } from '../../../../platform/theme/common/icon import { isMarkdownString, markdownStringEqual } from '../../../../base/common/htmlContent.js'; import { IHoverDelegate } from '../../../../base/browser/ui/hover/hoverDelegate.js'; import { Gesture, EventType as TouchEventType } from '../../../../base/browser/touch.js'; -import type { IManagedHover } from '../../../../base/browser/ui/hover/hover.js'; +import { IManagedHover, IManagedHoverOptions } from '../../../../base/browser/ui/hover/hover.js'; import { IHoverService } from '../../../../platform/hover/browser/hover.js'; export class StatusbarEntryItem extends Disposable { @@ -116,11 +116,26 @@ export class StatusbarEntryItem extends Disposable { // Update: Hover if (!this.entry || !this.isEqualTooltip(this.entry, entry)) { - const hoverContents = isMarkdownString(entry.tooltip) ? { markdown: entry.tooltip, markdownNotSupportedFallback: undefined } : entry.tooltip; - if (this.hover) { - this.hover.update(hoverContents); + let hoverOptions: IManagedHoverOptions | undefined; + let hoverTooltip: TooltipContent | undefined; + if (isTooltipWithCommands(entry.tooltip)) { + hoverTooltip = entry.tooltip.content; + hoverOptions = { + actions: entry.tooltip.commands.map(command => ({ + commandId: command.id, + label: command.title, + run: () => this.executeCommand(command) + })) + }; } else { - this.hover = this._register(this.hoverService.setupManagedHover(this.hoverDelegate, this.container, hoverContents)); + hoverTooltip = entry.tooltip; + } + + const hoverContents = isMarkdownString(hoverTooltip) ? { markdown: hoverTooltip, markdownNotSupportedFallback: undefined } : hoverTooltip; + if (this.hover) { + this.hover.update(hoverContents, hoverOptions); + } else { + this.hover = this._register(this.hoverService.setupManagedHover(this.hoverDelegate, this.container, hoverContents, hoverOptions)); } } diff --git a/src/vs/workbench/common/editor/textEditorModel.ts b/src/vs/workbench/common/editor/textEditorModel.ts index 482364fda50..53b900ec6ed 100644 --- a/src/vs/workbench/common/editor/textEditorModel.ts +++ b/src/vs/workbench/common/editor/textEditorModel.ts @@ -75,13 +75,22 @@ export class BaseTextEditorModel extends EditorModel implements ITextEditorModel return true; } - private _hasLanguageSetExplicitly: boolean = false; - get hasLanguageSetExplicitly(): boolean { return this._hasLanguageSetExplicitly; } + private _blockLanguageChangeListener = false; + private _languageChangeSource: 'user' | 'api' | undefined = undefined; + get languageChangeSource() { return this._languageChangeSource; } + get hasLanguageSetExplicitly() { + // This is technically not 100% correct, because 'api' can also be + // set as source if a model is resolved as text first and then + // transitions into the resolved language. But to preserve the current + // behaviour, we do not change this property. Rather, `languageChangeSource` + // can be used to get more fine grained information. + return typeof this._languageChangeSource === 'string'; + } setLanguageId(languageId: string, source?: string): void { // Remember that an explicit language was set - this._hasLanguageSetExplicitly = true; + this._languageChangeSource = 'user'; this.setLanguageIdInternal(languageId, source); } @@ -95,18 +104,26 @@ export class BaseTextEditorModel extends EditorModel implements ITextEditorModel return; } - this.textEditorModel.setLanguage(this.languageService.createById(languageId), source); + this._blockLanguageChangeListener = true; + try { + this.textEditorModel.setLanguage(this.languageService.createById(languageId), source); + } finally { + this._blockLanguageChangeListener = false; + } } protected installModelListeners(model: ITextModel): void { // Setup listener for lower level language changes - const disposable = this._register(model.onDidChangeLanguage((e) => { - if (e.source === LanguageDetectionLanguageEventSource) { + const disposable = this._register(model.onDidChangeLanguage(e => { + if ( + e.source === LanguageDetectionLanguageEventSource || + this._blockLanguageChangeListener + ) { return; } - this._hasLanguageSetExplicitly = true; + this._languageChangeSource = 'api'; disposable.dispose(); })); } diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts index 1b8f11d4851..6557d40e8c7 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts @@ -6,11 +6,11 @@ import { Codicon } from '../../../../../base/common/codicons.js'; import { KeyCode, KeyMod } from '../../../../../base/common/keyCodes.js'; import { URI } from '../../../../../base/common/uri.js'; -import { ServicesAccessor } from '../../../../../editor/browser/editorExtensions.js'; import { localize, localize2 } from '../../../../../nls.js'; import { Action2, MenuId, MenuRegistry, registerAction2 } from '../../../../../platform/actions/common/actions.js'; import { ContextKeyExpr } from '../../../../../platform/contextkey/common/contextkey.js'; import { IDialogService } from '../../../../../platform/dialogs/common/dialogs.js'; +import { ServicesAccessor } from '../../../../../platform/instantiation/common/instantiation.js'; import { KeybindingWeight } from '../../../../../platform/keybinding/common/keybindingsRegistry.js'; import { IViewsService } from '../../../../services/views/common/viewsService.js'; import { ChatAgentLocation, IChatAgentService } from '../../common/chatAgents.js'; @@ -75,6 +75,52 @@ export class ChatSubmitAction extends SubmitAction { } } +export const ToggleAgentModeActionId = 'workbench.action.chat.toggleAgentMode'; +export class ToggleAgentModeAction extends Action2 { + static readonly ID = ToggleAgentModeActionId; + + constructor() { + super({ + id: ToggleAgentModeAction.ID, + title: localize2('interactive.toggleAgent.label', "Toggle Agent Mode"), + f1: true, + category: CHAT_CATEGORY, + precondition: ContextKeyExpr.and( + ChatContextKeys.location.isEqualTo(ChatAgentLocation.EditingSession), + ChatContextKeys.Editing.hasToolsAgent), + icon: Codicon.edit, + toggled: { + condition: ChatContextKeys.Editing.agentMode, + icon: Codicon.tools, + tooltip: localize('agentEnabled', "Agent Mode Enabled"), + }, + tooltip: localize('agentDisabled', "Agent Mode Disabled"), + keybinding: { + when: ContextKeyExpr.and( + ChatContextKeys.inChatInput, + ChatContextKeys.location.isEqualTo(ChatAgentLocation.EditingSession)), + primary: KeyMod.CtrlCmd | KeyCode.Period, + weight: KeybindingWeight.EditorContrib + }, + menu: [ + { + id: MenuId.ChatExecute, + order: 1, + when: ContextKeyExpr.and( + ChatContextKeys.location.isEqualTo(ChatAgentLocation.EditingSession), + ChatContextKeys.Editing.hasToolsAgent), + group: 'navigation', + }, + ] + }); + } + + override run(accessor: ServicesAccessor, ...args: any[]): void { + const agentService = accessor.get(IChatAgentService); + agentService.toggleToolsAgentMode(); + } +} + export class ChatEditingSessionSubmitAction extends SubmitAction { static readonly ID = 'workbench.action.edits.submit'; @@ -388,4 +434,5 @@ export function registerChatExecuteActions() { registerAction2(SendToNewChatAction); registerAction2(ChatSubmitSecondaryAgentAction); registerAction2(SendToChatEditingAction); + registerAction2(ToggleAgentModeAction); } diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatGettingStarted.ts b/src/vs/workbench/contrib/chat/browser/actions/chatGettingStarted.ts index 8dc7d4dee91..ea35e44e016 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatGettingStarted.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatGettingStarted.ts @@ -16,6 +16,7 @@ import { IDefaultChatAgent } from '../../../../../base/common/product.js'; import { IViewDescriptorService } from '../../../../common/views.js'; import { IWorkbenchLayoutService } from '../../../../services/layout/browser/layoutService.js'; import { ensureSideBarChatViewSize } from '../chat.js'; +import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; export class ChatGettingStartedContribution extends Disposable implements IWorkbenchContribution { @@ -32,6 +33,7 @@ export class ChatGettingStartedContribution extends Disposable implements IWorkb @IStorageService private readonly storageService: IStorageService, @IViewDescriptorService private readonly viewDescriptorService: IViewDescriptorService, @IWorkbenchLayoutService private readonly layoutService: IWorkbenchLayoutService, + @IConfigurationService private readonly configurationService: IConfigurationService, ) { super(); @@ -60,14 +62,25 @@ export class ChatGettingStartedContribution extends Disposable implements IWorkb if (ExtensionIdentifier.equals(defaultChatAgent.extensionId, ext.value)) { const extensionStatus = this.extensionService.getExtensionsStatus(); if (extensionStatus[ext.value].activationTimes && this.recentlyInstalled) { - await this.commandService.executeCommand(CHAT_OPEN_ACTION_ID); - ensureSideBarChatViewSize(400, this.viewDescriptorService, this.layoutService); - this.storageService.store(ChatGettingStartedContribution.hideWelcomeView, true, StorageScope.APPLICATION, StorageTarget.MACHINE); - this.recentlyInstalled = false; + this.onDidInstallChat(); return; } } } })); } + + private async onDidInstallChat() { + + // Enable chat command center if previously disabled + this.configurationService.updateValue('chat.commandCenter.enabled', true); + + // Open and configure chat view + await this.commandService.executeCommand(CHAT_OPEN_ACTION_ID); + ensureSideBarChatViewSize(400, this.viewDescriptorService, this.layoutService); + + // Only do this once + this.storageService.store(ChatGettingStartedContribution.hideWelcomeView, true, StorageScope.APPLICATION, StorageTarget.MACHINE); + this.recentlyInstalled = false; + } } diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatMoveActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatMoveActions.ts index e927be89e77..e732c01efbb 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatMoveActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatMoveActions.ts @@ -18,6 +18,7 @@ import { IEditorGroupsService } from '../../../../services/editor/common/editorG import { ACTIVE_GROUP, AUX_WINDOW_GROUP, IEditorService } from '../../../../services/editor/common/editorService.js'; import { IViewsService } from '../../../../services/views/common/viewsService.js'; import { isChatViewTitleActionContext } from '../../common/chatActions.js'; +import { ChatAgentLocation } from '../../common/chatAgents.js'; enum MoveToNewLocation { Editor = 'Editor', @@ -99,7 +100,7 @@ async function executeMoveToAction(accessor: ServicesAccessor, moveTo: MoveToNew const widget = (_sessionId ? widgetService.getWidgetBySessionId(_sessionId) : undefined) ?? widgetService.lastFocusedWidget; - if (!widget || !('viewId' in widget.viewContext)) { + if (!widget || widget.location !== ChatAgentLocation.Panel) { await editorService.openEditor({ resource: ChatEditorInput.getNewEditorUri(), options: { pinned: true } }, moveTo === MoveToNewLocation.Window ? AUX_WINDOW_GROUP : ACTIVE_GROUP); return; } diff --git a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts index 456c8817cd4..86b780b350c 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts @@ -81,6 +81,7 @@ import { Extensions, IConfigurationMigrationRegistry } from '../../../common/con import { ChatEditorOverlayController } from './chatEditorOverlay.js'; import { ChatRelatedFilesContribution } from './contrib/chatInputRelatedFilesContrib.js'; import { ChatQuotasService, ChatQuotasStatusBarEntry, IChatQuotasService } from './chatQuotasService.js'; +import { BuiltinToolsContribution } from './tools/tools.js'; import { ChatSetupContribution } from './chatSetup.js'; // Register configuration @@ -319,6 +320,7 @@ registerWorkbenchContribution2(ChatViewsWelcomeHandler.ID, ChatViewsWelcomeHandl registerWorkbenchContribution2(ChatGettingStartedContribution.ID, ChatGettingStartedContribution, WorkbenchPhase.Eventually); registerWorkbenchContribution2(ChatSetupContribution.ID, ChatSetupContribution, WorkbenchPhase.BlockRestore); registerWorkbenchContribution2(ChatQuotasStatusBarEntry.ID, ChatQuotasStatusBarEntry, WorkbenchPhase.Eventually); +registerWorkbenchContribution2(BuiltinToolsContribution.ID, BuiltinToolsContribution, WorkbenchPhase.Eventually); registerChatActions(); registerChatCopyActions(); diff --git a/src/vs/workbench/contrib/chat/browser/chatContentParts/chatMarkdownContentPart.ts b/src/vs/workbench/contrib/chat/browser/chatContentParts/chatMarkdownContentPart.ts index a281df1c40c..aad084c2bb2 100644 --- a/src/vs/workbench/contrib/chat/browser/chatContentParts/chatMarkdownContentPart.ts +++ b/src/vs/workbench/contrib/chat/browser/chatContentParts/chatMarkdownContentPart.ts @@ -76,17 +76,22 @@ export class ChatMarkdownContentPart extends Disposable implements IChatContentP // We release editors in order so that it's more likely that the same editor will be assigned if this element is re-rendered right away, like it often is during progressive rendering const orderedDisposablesList: IDisposable[] = []; - let codeBlockIndex = codeBlockStartIndex; + + // Need to track the index of the codeblock within the response so it can have a unique ID, + // and within this part to find it within the codeblocks array + let globalCodeBlockIndexStart = codeBlockStartIndex; + let thisPartCodeBlockIndexStart = 0; const result = this._register(renderer.render(markdown.content, { fillInIncompleteTokens, codeBlockRendererSync: (languageId, text, raw) => { - const isCodeBlockComplete = !isResponseVM(context.element) || context.element.isComplete || !raw || raw?.endsWith('```'); + const isCodeBlockComplete = !isResponseVM(context.element) || context.element.isComplete || !raw || raw?.trim().endsWith('```'); if ((!text || (text.startsWith('') && !text.includes('\n'))) && !isCodeBlockComplete && rendererOptions.renderCodeBlockPills) { const hideEmptyCodeblock = $('div'); hideEmptyCodeblock.style.display = 'none'; return hideEmptyCodeblock; } - const index = codeBlockIndex++; + const globalIndex = globalCodeBlockIndexStart++; + const thisPartIndex = thisPartCodeBlockIndexStart++; let textModel: Promise; let range: Range | undefined; let vulns: readonly IMarkdownVulnerability[] | undefined; @@ -101,15 +106,15 @@ export class ChatMarkdownContentPart extends Disposable implements IChatContentP } } else { const sessionId = isResponseVM(element) || isRequestVM(element) ? element.sessionId : ''; - const modelEntry = this.codeBlockModelCollection.getOrCreate(sessionId, element, index); - const fastUpdateModelEntry = this.codeBlockModelCollection.updateSync(sessionId, element, index, { text, languageId, isComplete: isCodeBlockComplete }); + const modelEntry = this.codeBlockModelCollection.getOrCreate(sessionId, element, globalIndex); + const fastUpdateModelEntry = this.codeBlockModelCollection.updateSync(sessionId, element, globalIndex, { text, languageId, isComplete: isCodeBlockComplete }); vulns = modelEntry.vulns; codemapperUri = fastUpdateModelEntry.codemapperUri; textModel = modelEntry.model; } const hideToolbar = isResponseVM(element) && element.errorDetails?.responseIsFiltered; - const codeBlockInfo: ICodeBlockData = { languageId, textModel, codeBlockIndex: index, element, range, hideToolbar, parentContextKeyService: contextKeyService, vulns, codemapperUri }; + const codeBlockInfo: ICodeBlockData = { languageId, textModel, codeBlockIndex: globalIndex, codeBlockPartIndex: thisPartIndex, element, range, hideToolbar, parentContextKeyService: contextKeyService, vulns, codemapperUri }; if (!rendererOptions.renderCodeBlockPills || element.isCompleteAddedRequest || !codemapperUri) { const ref = this.renderCodeBlock(codeBlockInfo, text, isCodeBlockComplete, currentWidth); @@ -122,7 +127,7 @@ export class ChatMarkdownContentPart extends Disposable implements IChatContentP const ownerMarkdownPartId = this.id; const info: IChatCodeBlockInfo = new class { readonly ownerMarkdownPartId = ownerMarkdownPartId; - readonly codeBlockIndex = index; + readonly codeBlockIndex = globalIndex; readonly element = element; readonly isStreaming = !rendererOptions.renderCodeBlockPills; codemapperUri = undefined; // will be set async @@ -149,7 +154,7 @@ export class ChatMarkdownContentPart extends Disposable implements IChatContentP // TODO@joyceerhl: remove this code when we change the codeblockUri API to make the URI available synchronously this.codeBlockModelCollection.update(codeBlockInfo.element.sessionId, codeBlockInfo.element, codeBlockInfo.codeBlockIndex, { text, languageId: codeBlockInfo.languageId, isComplete: isCodeBlockComplete }).then((e) => { // Update the existing object's codemapperUri - this.codeblocks[codeBlockInfo.codeBlockIndex].codemapperUri = e.codemapperUri; + this.codeblocks[codeBlockInfo.codeBlockPartIndex].codemapperUri = e.codemapperUri; this._onDidChangeHeight.fire(); }); } @@ -157,7 +162,7 @@ export class ChatMarkdownContentPart extends Disposable implements IChatContentP const ownerMarkdownPartId = this.id; const info: IChatCodeBlockInfo = new class { readonly ownerMarkdownPartId = ownerMarkdownPartId; - readonly codeBlockIndex = index; + readonly codeBlockIndex = globalIndex; readonly element = element; readonly isStreaming = !isCodeBlockComplete; readonly codemapperUri = codemapperUri; @@ -205,7 +210,7 @@ export class ChatMarkdownContentPart extends Disposable implements IChatContentP if (isResponseVM(data.element)) { this.codeBlockModelCollection.update(data.element.sessionId, data.element, data.codeBlockIndex, { text, languageId: data.languageId, isComplete }).then((e) => { // Update the existing object's codemapperUri - this.codeblocks[data.codeBlockIndex].codemapperUri = e.codemapperUri; + this.codeblocks[data.codeBlockPartIndex].codemapperUri = e.codemapperUri; this._onDidChangeHeight.fire(); }); } diff --git a/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingModifiedFileEntry.ts b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingModifiedFileEntry.ts index aa105b2e767..37a6b9a2b68 100644 --- a/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingModifiedFileEntry.ts +++ b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingModifiedFileEntry.ts @@ -14,6 +14,7 @@ import { EditOperation, ISingleEditOperation } from '../../../../../editor/commo import { OffsetEdit } from '../../../../../editor/common/core/offsetEdit.js'; import { Range } from '../../../../../editor/common/core/range.js'; import { IDocumentDiff, nullDocumentDiff } from '../../../../../editor/common/diff/documentDiffProvider.js'; +import { DetailedLineRangeMapping } from '../../../../../editor/common/diff/rangeMapping.js'; import { TextEdit } from '../../../../../editor/common/languages.js'; import { ILanguageService } from '../../../../../editor/common/languages/language.js'; import { IModelDeltaDecoration, ITextModel, OverviewRulerLane } from '../../../../../editor/common/model.js'; @@ -343,6 +344,41 @@ export class ChatEditingModifiedFileEntry extends Disposable implements IModifie }); } + async acceptHunk(change: DetailedLineRangeMapping): Promise { + if (!this._diffInfo.get().changes.includes(change)) { + // diffInfo should have model version ids and check them (instead of the caller doing that) + return false; + } + const edits: ISingleEditOperation[] = []; + for (const edit of change.innerChanges ?? []) { + const newText = this.modifiedModel.getValueInRange(edit.modifiedRange); + edits.push(EditOperation.replace(edit.originalRange, newText)); + } + this.docSnapshot.pushEditOperations(null, edits, _ => null); + await this._updateDiffInfoSeq(); + if (this.diffInfo.get().identical) { + this._stateObs.set(WorkingSetEntryState.Accepted, undefined); + } + return true; + } + + async rejectHunk(change: DetailedLineRangeMapping): Promise { + if (!this._diffInfo.get().changes.includes(change)) { + return false; + } + const edits: ISingleEditOperation[] = []; + for (const edit of change.innerChanges ?? []) { + const newText = this.docSnapshot.getValueInRange(edit.originalRange); + edits.push(EditOperation.replace(edit.modifiedRange, newText)); + } + this.doc.pushEditOperations(null, edits, _ => null); + await this._updateDiffInfoSeq(); + if (this.diffInfo.get().identical) { + this._stateObs.set(WorkingSetEntryState.Rejected, undefined); + } + return true; + } + private _applyEdits(edits: ISingleEditOperation[]) { // make the actual edit this._isEditFromUs = true; @@ -358,13 +394,14 @@ export class ChatEditingModifiedFileEntry extends Disposable implements IModifie } } - private _updateDiffInfoSeq() { + private async _updateDiffInfoSeq() { const myDiffOperationId = ++this._diffOperationIds; - Promise.resolve(this._diffOperation).then(() => { - if (this._diffOperationIds === myDiffOperationId) { - this._diffOperation = this._updateDiffInfo(); - } - }); + await Promise.resolve(this._diffOperation); + if (this._diffOperationIds === myDiffOperationId) { + const thisDiffOperation = this._updateDiffInfo(); + this._diffOperation = thisDiffOperation; + await thisDiffOperation; + } } private async _updateDiffInfo(): Promise { diff --git a/src/vs/workbench/contrib/chat/browser/chatEditorActions.ts b/src/vs/workbench/contrib/chat/browser/chatEditorActions.ts index b7f3ae1d0b4..a8060733fff 100644 --- a/src/vs/workbench/contrib/chat/browser/chatEditorActions.ts +++ b/src/vs/workbench/contrib/chat/browser/chatEditorActions.ts @@ -217,6 +217,33 @@ class UndoHunkAction extends EditorAction2 { } } +class AcceptHunkAction extends EditorAction2 { + constructor() { + super({ + id: 'chatEditor.action.acceptHunk', + title: localize2('acceptHunk', 'Accept this Change'), + shortTitle: localize2('acceptHunk2', 'Accept'), + category: CHAT_CATEGORY, + precondition: ContextKeyExpr.and(ChatContextKeys.requestInProgress.negate(), hasUndecidedChatEditingResourceContextKey), + icon: Codicon.check, + f1: true, + keybinding: { + when: EditorContextKeys.focus, + weight: KeybindingWeight.WorkbenchContrib, + primary: KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.Enter + }, + menu: { + id: MenuId.ChatEditingEditorHunk, + order: 0 + } + }); + } + + override runEditorCommand(_accessor: ServicesAccessor, editor: ICodeEditor, ...args: any[]) { + ChatEditorController.get(editor)?.acceptNearestChange(args[0]); + } +} + class OpenDiffFromHunkAction extends EditorAction2 { constructor() { super({ @@ -243,5 +270,6 @@ export function registerChatEditorActions() { registerAction2(AcceptAction); registerAction2(RejectAction); registerAction2(UndoHunkAction); + registerAction2(AcceptHunkAction); registerAction2(OpenDiffFromHunkAction); } diff --git a/src/vs/workbench/contrib/chat/browser/chatEditorController.ts b/src/vs/workbench/contrib/chat/browser/chatEditorController.ts index e2a186b5886..bf0c199267f 100644 --- a/src/vs/workbench/contrib/chat/browser/chatEditorController.ts +++ b/src/vs/workbench/contrib/chat/browser/chatEditorController.ts @@ -12,7 +12,6 @@ import { ICodeEditor, IOverlayWidget, IOverlayWidgetPosition, IOverlayWidgetPosi import { LineSource, renderLines, RenderOptions } from '../../../../editor/browser/widget/diffEditor/components/diffEditorViewZones/renderLines.js'; import { diffAddDecoration, diffDeleteDecoration, diffWholeLineAddDecoration } from '../../../../editor/browser/widget/diffEditor/registrations.contribution.js'; import { EditorOption, IEditorStickyScrollOptions } from '../../../../editor/common/config/editorOptions.js'; -import { EditOperation, ISingleEditOperation } from '../../../../editor/common/core/editOperation.js'; import { Range } from '../../../../editor/common/core/range.js'; import { IDocumentDiff } from '../../../../editor/common/diff/documentDiffProvider.js'; import { IEditorContribution, ScrollType } from '../../../../editor/common/editorCommon.js'; @@ -31,6 +30,7 @@ import { Selection } from '../../../../editor/common/core/selection.js'; import { HiddenItemStrategy, MenuWorkbenchToolBar } from '../../../../platform/actions/browser/toolbar.js'; import { observableCodeEditor } from '../../../../editor/browser/observableCodeEditor.js'; import { minimapGutterAddedBackground, minimapGutterDeletedBackground, minimapGutterModifiedBackground, overviewRulerAddedForeground, overviewRulerDeletedForeground, overviewRulerModifiedForeground } from '../../scm/common/quickDiff.js'; +import { DetailedLineRangeMapping } from '../../../../editor/common/diff/rangeMapping.js'; export const ctxHasEditorModification = new RawContextKey('chat.hasEditorModifications', undefined, localize('chat.hasEditorModifications', "The current editor contains chat modifications")); export const ctxHasRequestInProgress = new RawContextKey('chat.ctxHasRequestInProgress', false, localize('chat.ctxHasRequestInProgress', "The current editor shows a file from an edit session which is still in progress")); @@ -328,13 +328,7 @@ export class ChatEditorController extends Disposable implements IEditorContribut } // Add content widget for each diff change - const undoEdits: ISingleEditOperation[] = []; - for (const c of diffEntry.innerChanges ?? []) { - const oldText = originalModel.getValueInRange(c.originalRange); - undoEdits.push(EditOperation.replace(c.modifiedRange, oldText)); - } - - const widget = this._instantiationService.createInstance(DiffHunkWidget, entry, undoEdits, this._editor.getModel()!.getVersionId(), this._editor, isCreatedContent ? 0 : result.heightInLines); + const widget = this._instantiationService.createInstance(DiffHunkWidget, entry, diffEntry, this._editor.getModel()!.getVersionId(), this._editor, isCreatedContent ? 0 : result.heightInLines); widget.layout(diffEntry.modified.startLineNumber); this._diffHunkWidgets.push(widget); @@ -492,28 +486,41 @@ export class ChatEditorController extends Disposable implements IEditorContribut return true; } - undoNearestChange(closestWidget: DiffHunkWidget | undefined): void { + private _findClosestWidget(): DiffHunkWidget | undefined { if (!this._editor.hasModel()) { - return; + return undefined; } const lineRelativeTop = this._editor.getTopForLineNumber(this._editor.getPosition().lineNumber) - this._editor.getScrollTop(); + let closestWidget: DiffHunkWidget | undefined; let closestDistance = Number.MAX_VALUE; - if (!(closestWidget instanceof DiffHunkWidget)) { - for (const widget of this._diffHunkWidgets) { - const widgetTop = (widget.getPosition()?.preference)?.top; - if (widgetTop !== undefined) { - const distance = Math.abs(widgetTop - lineRelativeTop); - if (distance < closestDistance) { - closestDistance = distance; - closestWidget = widget; - } + for (const widget of this._diffHunkWidgets) { + const widgetTop = (widget.getPosition()?.preference)?.top; + if (widgetTop !== undefined) { + const distance = Math.abs(widgetTop - lineRelativeTop); + if (distance < closestDistance) { + closestDistance = distance; + closestWidget = widget; } } } + return closestWidget; + } + + undoNearestChange(closestWidget: DiffHunkWidget | undefined): void { + closestWidget = closestWidget ?? this._findClosestWidget(); if (closestWidget instanceof DiffHunkWidget) { - closestWidget.undo(); + closestWidget.reject(); + this.revealNext(); + } + } + + acceptNearestChange(closestWidget: DiffHunkWidget | undefined): void { + closestWidget = closestWidget ?? this._findClosestWidget(); + if (closestWidget instanceof DiffHunkWidget) { + closestWidget.accept(); + this.revealNext(); } } @@ -570,7 +577,7 @@ class DiffHunkWidget implements IOverlayWidget { constructor( readonly entry: IModifiedFileEntry, - private readonly _undoEdits: ISingleEditOperation[], + private readonly _change: DetailedLineRangeMapping, private readonly _versionId: number, private readonly _editor: ICodeEditor, private readonly _lineDelta: number, @@ -641,9 +648,15 @@ class DiffHunkWidget implements IOverlayWidget { // --- - undo() { + reject(): void { if (this._versionId === this._editor.getModel()?.getVersionId()) { - this._editor.executeEdits('chatEdits.undo', this._undoEdits); + this.entry.rejectHunk(this._change); + } + } + + accept(): void { + if (this._versionId === this._editor.getModel()?.getVersionId()) { + this.entry.acceptHunk(this._change); } } } diff --git a/src/vs/workbench/contrib/chat/browser/chatEditorOverlay.ts b/src/vs/workbench/contrib/chat/browser/chatEditorOverlay.ts index bb94d4e0fc9..4b0dd3c8111 100644 --- a/src/vs/workbench/contrib/chat/browser/chatEditorOverlay.ts +++ b/src/vs/workbench/contrib/chat/browser/chatEditorOverlay.ts @@ -310,6 +310,10 @@ export class ChatEditorOverlayController implements IEditorContribution { } const entry = entries[idx]; + if (entry.state.read(r) === WorkingSetEntryState.Accepted || entry.state.read(r) === WorkingSetEntryState.Rejected) { + widget.hide(); + return; + } widget.show(session, entry, entries[(idx + 1) % entries.length]); })); diff --git a/src/vs/workbench/contrib/chat/browser/chatParticipant.contribution.ts b/src/vs/workbench/contrib/chat/browser/chatParticipant.contribution.ts index 3d16ee993b7..6949c731803 100644 --- a/src/vs/workbench/contrib/chat/browser/chatParticipant.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chatParticipant.contribution.ts @@ -199,7 +199,7 @@ export class ChatExtensionPointHandler implements IWorkbenchContribution { continue; } - if (providerDescriptor.isDefault && !isProposedApiEnabled(extension.description, 'defaultChatParticipant')) { + if ((providerDescriptor.isDefault || providerDescriptor.isAgent) && !isProposedApiEnabled(extension.description, 'defaultChatParticipant')) { this.logService.error(`Extension '${extension.description.identifier.value}' CANNOT use API proposal: defaultChatParticipant.`); continue; } @@ -245,6 +245,7 @@ export class ChatExtensionPointHandler implements IWorkbenchContribution { name: providerDescriptor.name, fullName: providerDescriptor.fullName, isDefault: providerDescriptor.isDefault, + isToolsAgent: providerDescriptor.isAgent, locations: isNonEmptyArray(providerDescriptor.locations) ? providerDescriptor.locations.map(ChatAgentLocation.fromRaw) : [ChatAgentLocation.Panel], diff --git a/src/vs/workbench/contrib/chat/browser/chatQuotasService.ts b/src/vs/workbench/contrib/chat/browser/chatQuotasService.ts index 62bf59aca5e..6e2ab0a5b34 100644 --- a/src/vs/workbench/contrib/chat/browser/chatQuotasService.ts +++ b/src/vs/workbench/contrib/chat/browser/chatQuotasService.ts @@ -123,7 +123,7 @@ export class ChatQuotasService extends Disposable implements IChatQuotasService message = localize('chatAndCompletionsQuotaExceeded', "You've reached the limit of the Copilot Free plan. These limits will reset on {0}.", dateFormatter.format(that.quotas.quotaResetDate)); } - const upgradeToPro = localize('upgradeToPro', "Here's what you can expect when upgrading to Copilot Pro:\n- Unlimited code completions\n- Unlimited chat messages\n- 30-day free trial"); + const upgradeToPro = localize('upgradeToPro', "Upgrade to Copilot Pro (your first 30 days are free) for:\n- Unlimited code completions\n- Unlimited chat messages\n- Access to additional models"); await dialogService.prompt({ type: 'none', diff --git a/src/vs/workbench/contrib/chat/browser/chatSetup.ts b/src/vs/workbench/contrib/chat/browser/chatSetup.ts index 53a86ec5245..52bb5bea1b0 100644 --- a/src/vs/workbench/contrib/chat/browser/chatSetup.ts +++ b/src/vs/workbench/contrib/chat/browser/chatSetup.ts @@ -60,6 +60,7 @@ import { IHostService } from '../../../services/host/browser/host.js'; import Severity from '../../../../base/common/severity.js'; import { IWorkbenchEnvironmentService } from '../../../services/environment/common/environmentService.js'; import { isWeb } from '../../../../base/common/platform.js'; +import { ExtensionUrlHandlerOverrideRegistry } from '../../../services/extensions/browser/extensionUrlHandler.js'; const defaultChat = { extensionId: product.defaultChatAgent?.extensionId ?? '', @@ -109,7 +110,9 @@ export class ChatSetupContribution extends Disposable implements IWorkbenchContr constructor( @IProductService private readonly productService: IProductService, @IInstantiationService private readonly instantiationService: IInstantiationService, - @IWorkbenchEnvironmentService private readonly environmentService: IWorkbenchEnvironmentService + @IWorkbenchEnvironmentService private readonly environmentService: IWorkbenchEnvironmentService, + @ICommandService private readonly commandService: ICommandService, + @ITelemetryService private readonly telemetryService: ITelemetryService ) { super(); @@ -122,6 +125,7 @@ export class ChatSetupContribution extends Disposable implements IWorkbenchContr this.registerChatWelcome(); this.registerActions(); + this.registerUrlLinkHandler(); } private registerChatWelcome(): void { @@ -292,6 +296,18 @@ export class ChatSetupContribution extends Disposable implements IWorkbenchContr registerAction2(ChatSetupHideAction); registerAction2(UpgradePlanAction); } + + private registerUrlLinkHandler(): void { + this._register(ExtensionUrlHandlerOverrideRegistry.registerHandler(URI.parse(`${this.productService.urlProtocol}://${defaultChat.chatExtensionId}`), { + handleURL: async () => { + this.telemetryService.publicLog2('workbenchActionExecuted', { id: TRIGGER_SETUP_COMMAND_ID, from: 'url' }); + + await this.commandService.executeCommand(TRIGGER_SETUP_COMMAND_ID); + + return true; + } + })); + } } //#endregion @@ -302,6 +318,7 @@ type EntitlementClassification = { entitlement: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Flag indicating the chat entitlement state' }; quotaChat: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The number of chat completions available to the user' }; quotaCompletions: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The number of chat completions available to the user' }; + quotaResetDate: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The date the quota will reset' }; owner: 'bpasero'; comment: 'Reporting chat setup entitlements'; }; @@ -310,6 +327,7 @@ type EntitlementEvent = { entitlement: ChatEntitlement; quotaChat: number | undefined; quotaCompletions: number | undefined; + quotaResetDate: string | undefined; }; interface IEntitlementsResponse { @@ -520,7 +538,8 @@ class ChatSetupRequests extends Disposable { this.telemetryService.publicLog2('chatInstallEntitlement', { entitlement: entitlements.entitlement, quotaChat: entitlementsResponse.limited_user_quotas?.chat, - quotaCompletions: entitlementsResponse.limited_user_quotas?.completions + quotaCompletions: entitlementsResponse.limited_user_quotas?.completions, + quotaResetDate: entitlementsResponse.limited_user_reset_date }); return entitlements; diff --git a/src/vs/workbench/contrib/chat/browser/codeBlockPart.ts b/src/vs/workbench/contrib/chat/browser/codeBlockPart.ts index 6f34a50203b..29ed76b29c4 100644 --- a/src/vs/workbench/contrib/chat/browser/codeBlockPart.ts +++ b/src/vs/workbench/contrib/chat/browser/codeBlockPart.ts @@ -74,6 +74,7 @@ const $ = dom.$; export interface ICodeBlockData { readonly codeBlockIndex: number; + readonly codeBlockPartIndex: number; readonly element: unknown; readonly textModel: Promise; diff --git a/src/vs/workbench/contrib/chat/browser/contrib/chatInputCompletions.ts b/src/vs/workbench/contrib/chat/browser/contrib/chatInputCompletions.ts index bf5dfa67cef..68e436eeeef 100644 --- a/src/vs/workbench/contrib/chat/browser/contrib/chatInputCompletions.ts +++ b/src/vs/workbench/contrib/chat/browser/contrib/chatInputCompletions.ts @@ -3,6 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { coalesce } from '../../../../../base/common/arrays.js'; import { raceTimeout } from '../../../../../base/common/async.js'; import { CancellationToken } from '../../../../../base/common/cancellation.js'; import { isPatternInWord } from '../../../../../base/common/filters.js'; @@ -258,7 +259,11 @@ class AgentCompletions extends Disposable { return { suggestions: justAgents.concat( - agents.flatMap(agent => agent.slashCommands.map((c, i) => { + coalesce(agents.flatMap(agent => agent.slashCommands.map((c, i) => { + if (agent.isDefault && this.chatAgentService.getDefaultAgent(widget.location)?.id !== agent.id) { + return; + } + const { label: agentLabel, isDupe } = this.getAgentCompletionDetails(agent); const label = `${agentLabel} ${chatSubcommandLeader}${c.name}`; const item: CompletionItem = { @@ -284,7 +289,7 @@ class AgentCompletions extends Disposable { } return item; - }))) + })))) }; } })); @@ -313,7 +318,11 @@ class AgentCompletions extends Disposable { .filter(a => a.locations.includes(widget.location)); return { - suggestions: agents.flatMap(agent => agent.slashCommands.map((c, i) => { + suggestions: coalesce(agents.flatMap(agent => agent.slashCommands.map((c, i) => { + if (agent.isDefault && this.chatAgentService.getDefaultAgent(widget.location)?.id !== agent.id) { + return; + } + const { label: agentLabel, isDupe } = this.getAgentCompletionDetails(agent); const withSlash = `${chatSubcommandLeader}${c.name}`; const extraSortText = agent.id === 'github.copilot.terminalPanel' ? `z` : ``; @@ -338,7 +347,7 @@ class AgentCompletions extends Disposable { } return item; - })) + }))) }; } })); diff --git a/src/vs/workbench/contrib/chat/browser/languageModelToolsService.ts b/src/vs/workbench/contrib/chat/browser/languageModelToolsService.ts index d0bce841451..d53c0db6859 100644 --- a/src/vs/workbench/contrib/chat/browser/languageModelToolsService.ts +++ b/src/vs/workbench/contrib/chat/browser/languageModelToolsService.ts @@ -13,6 +13,7 @@ import { Disposable, DisposableStore, dispose, IDisposable, toDisposable } from import { localize } from '../../../../nls.js'; import { IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; import { IDialogService } from '../../../../platform/dialogs/common/dialogs.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; import { IExtensionService } from '../../../services/extensions/common/extensions.js'; import { ChatModel } from '../common/chatModel.js'; @@ -46,6 +47,7 @@ export class LanguageModelToolsService extends Disposable implements ILanguageMo @IChatService private readonly _chatService: IChatService, @IDialogService private readonly _dialogService: IDialogService, @ITelemetryService private readonly _telemetryService: ITelemetryService, + @ILogService private readonly _logService: ILogService, ) { super(); @@ -125,6 +127,8 @@ export class LanguageModelToolsService extends Disposable implements ILanguageMo } async invokeTool(dto: IToolInvocation, countTokens: CountTokensCallback, token: CancellationToken): Promise { + this._logService.trace(`[LanguageModelToolsService#invokeTool] Invoking tool ${dto.toolId} with parameters ${JSON.stringify(dto.parameters)}`); + // When invoking a tool, don't validate the "when" clause. An extension may have invoked a tool just as it was becoming disabled, and just let it go through rather than throw and break the chat. let tool = this._tools.get(dto.toolId); if (!tool) { @@ -165,12 +169,15 @@ export class LanguageModelToolsService extends Disposable implements ILanguageMo const source = new CancellationTokenSource(); store.add(toDisposable(() => { - toolInvocation!.confirmed.complete(false); source.dispose(true); })); store.add(token.onCancellationRequested(() => { + toolInvocation?.confirmed.complete(false); source.cancel(); })); + store.add(source.token.onCancellationRequested(() => { + toolInvocation?.confirmed.complete(false); + })); token = source.token; const prepared = tool.impl.prepareToolInvocation ? @@ -179,13 +186,14 @@ export class LanguageModelToolsService extends Disposable implements ILanguageMo const defaultMessage = localize('toolInvocationMessage', "Using {0}", `"${tool.data.displayName}"`); const invocationMessage = prepared?.invocationMessage ?? defaultMessage; - toolInvocation = new ChatToolInvocation(invocationMessage, prepared?.confirmationMessages); - - model.acceptResponseProgress(request, toolInvocation); - if (prepared?.confirmationMessages) { - const userConfirmed = await toolInvocation.confirmed.p; - if (!userConfirmed) { - throw new CancellationError(); + if (tool.data.id !== 'vscode_editFile') { + toolInvocation = new ChatToolInvocation(invocationMessage, prepared?.confirmationMessages); + model.acceptResponseProgress(request, toolInvocation); + if (prepared?.confirmationMessages) { + const userConfirmed = await toolInvocation.confirmed.p; + if (!userConfirmed) { + throw new CancellationError(); + } } } } else { diff --git a/src/vs/workbench/contrib/chat/browser/tools/tools.ts b/src/vs/workbench/contrib/chat/browser/tools/tools.ts new file mode 100644 index 00000000000..359b47eb454 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/tools/tools.ts @@ -0,0 +1,157 @@ +/*--------------------------------------------------------------------------------------------- + * 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 '../../../../../base/common/cancellation.js'; +import { MarkdownString } from '../../../../../base/common/htmlContent.js'; +import { IJSONSchema } from '../../../../../base/common/jsonSchema.js'; +import { Disposable } from '../../../../../base/common/lifecycle.js'; +import { autorun } from '../../../../../base/common/observable.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { localize } from '../../../../../nls.js'; +import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; +import { IWorkbenchContribution } from '../../../../common/contributions.js'; +import { ICodeMapperService } from '../../common/chatCodeMapperService.js'; +import { IChatEditingService } from '../../common/chatEditingService.js'; +import { ChatModel } from '../../common/chatModel.js'; +import { IChatService } from '../../common/chatService.js'; +import { CountTokensCallback, ILanguageModelToolsService, IToolData, IToolImpl, IToolInvocation, IToolResult } from '../../common/languageModelToolsService.js'; + +export class BuiltinToolsContribution extends Disposable implements IWorkbenchContribution { + + static readonly ID = 'chat.builtinTools'; + + constructor( + @ILanguageModelToolsService toolsService: ILanguageModelToolsService, + @IInstantiationService instantiationService: IInstantiationService, + ) { + super(); + + const editTool = instantiationService.createInstance(EditTool); + this._register(toolsService.registerToolData(editTool)); + this._register(toolsService.registerToolImplementation(editTool.id, editTool)); + } +} + +interface EditToolParams { + filePath: string; + explanation: string; + code: string; +} + +const codeInstructions = ` +The user is very smart and can understand how to apply your edits to their files, you just need to provide minimal hints. +Avoid repeating existing code, instead use comments to represent regions of unchanged code. The user prefers that you are as concise as possible. For example: +// ...existing code... +{ changed code } +// ...existing code... +{ changed code } +// ...existing code... + +Here is an example of how you should format an edit to an existing Person class: +class Person { + // ...existing code... + age: number; + // ...existing code... + getAge() { + return this.age; + } +} +`; + +class EditTool implements IToolData, IToolImpl { + readonly id = 'vscode_editFile'; + readonly tags = ['vscode_editing']; + readonly displayName = localize('chat.tools.editFile', "Edit File"); + readonly modelDescription = `Edit a file in the workspace. Use this tool once per file that needs to be modified, even if there are multiple changes for a file. ${codeInstructions}`; + readonly inputSchema: IJSONSchema; + + constructor( + @IChatService private readonly chatService: IChatService, + @IChatEditingService private readonly chatEditingService: IChatEditingService, + @ICodeMapperService private readonly codeMapperService: ICodeMapperService + ) { + this.inputSchema = { + type: 'object', + properties: { + filePath: { + type: 'string', + description: 'An absolute path to the file to edit', + }, + explanation: { + type: 'string', + description: 'A short explanation of the edit being made. Can be the same as the explanation you showed to the user.', + }, + code: { + type: 'string', + description: 'The code change to apply to the file. ' + codeInstructions + } + }, + required: ['filePath', 'explanation', 'code'] + }; + } + + async invoke(invocation: IToolInvocation, countTokens: CountTokensCallback, token: CancellationToken): Promise { + if (!invocation.context) { + throw new Error('toolInvocationToken is required for this tool'); + } + + + const parameters = invocation.parameters as EditToolParams; + if (!parameters.filePath || !parameters.explanation || !parameters.code) { + throw new Error(`Invalid tool input: ${JSON.stringify(parameters)}`); + } + + const model = this.chatService.getSession(invocation.context?.sessionId) as ChatModel; + const request = model.getRequests().at(-1)!; + + const uri = URI.file(parameters.filePath); + model.acceptResponseProgress(request, { + kind: 'markdownContent', + content: new MarkdownString('\n````\n') + }); + model.acceptResponseProgress(request, { + kind: 'codeblockUri', + uri + }); + model.acceptResponseProgress(request, { + kind: 'markdownContent', + content: new MarkdownString(parameters.code + '\n````\n') + }); + + if (this.chatEditingService.currentEditingSession?.chatSessionId !== model.sessionId) { + throw new Error('This tool must be called from within an editing session'); + } + + const result = await this.codeMapperService.mapCode({ + codeBlocks: [{ code: parameters.code, resource: uri, markdownBeforeBlock: parameters.explanation }], + conversation: [] + }, { + textEdit: (target, edits) => { + model.acceptResponseProgress(request, { kind: 'textEdit', uri: target, edits }); + } + }, token); + + model.acceptResponseProgress(request, { kind: 'textEdit', uri, edits: [], done: true }); + + if (result?.errorMessage) { + throw new Error(result.errorMessage); + } + + await new Promise((resolve) => { + autorun((r) => { + const currentEditingSession = this.chatEditingService.currentEditingSessionObs.read(r); + const entries = currentEditingSession?.entries.read(r); + const currentFile = entries?.find((e) => e.modifiedURI.toString() === uri.toString()); + if (currentFile && !currentFile.isCurrentlyBeingModified.read(r)) { + resolve(true); + } + }); + }); + + return { + content: [{ kind: 'text', value: 'Success' }] + }; + } +} diff --git a/src/vs/workbench/contrib/chat/common/chatAgents.ts b/src/vs/workbench/contrib/chat/common/chatAgents.ts index e8d88c92598..8045fc3d091 100644 --- a/src/vs/workbench/contrib/chat/common/chatAgents.ts +++ b/src/vs/workbench/contrib/chat/common/chatAgents.ts @@ -70,6 +70,8 @@ export interface IChatAgentData { extensionDisplayName: string; /** The agent invoked when no agent is specified */ isDefault?: boolean; + /** The default agent when "agent-mode" is enabled */ + isToolsAgent?: boolean; /** This agent is not contributed in package.json, but is registered dynamically */ isDynamic?: boolean; metadata: IChatAgentMetadata; @@ -201,9 +203,11 @@ export interface IChatAgentCompletionItem { export interface IChatAgentService { _serviceBrand: undefined; /** - * undefined when an agent was removed IChatAgent + * undefined when an agent was removed */ readonly onDidChangeAgents: Event; + readonly toolsAgentModeEnabled: boolean; + toggleToolsAgentMode(): void; registerAgent(id: string, data: IChatAgentData): IDisposable; registerAgentImplementation(id: string, agent: IChatAgentImplementation): IDisposable; registerDynamicAgent(data: IChatAgentData, agentImpl: IChatAgentImplementation): IDisposable; @@ -235,6 +239,8 @@ export interface IChatAgentService { updateAgent(id: string, updateMetadata: IChatAgentMetadata): void; } +const ChatToolsAgentModeStorageKey = 'chat.toolsAgentMode'; + export class ChatAgentService extends Disposable implements IChatAgentService { public static readonly AGENT_LEADER = '@'; @@ -250,11 +256,14 @@ export class ChatAgentService extends Disposable implements IChatAgentService { private readonly _hasDefaultAgent: IContextKey; private readonly _defaultAgentRegistered: IContextKey; private readonly _editingAgentRegistered: IContextKey; + private readonly _agentModeContextKey: IContextKey; + private readonly _hasToolsAgentContextKey: IContextKey; private _chatParticipantDetectionProviders = new Map(); constructor( @IContextKeyService private readonly contextKeyService: IContextKeyService, + @IStorageService private readonly storageService: IStorageService, ) { super(); this._hasDefaultAgent = ChatContextKeys.enabled.bindTo(this.contextKeyService); @@ -265,6 +274,13 @@ export class ChatAgentService extends Disposable implements IChatAgentService { this._updateContextKeys(); } })); + + this._agentModeContextKey = ChatContextKeys.Editing.agentMode.bindTo(contextKeyService); + this._hasToolsAgentContextKey = ChatContextKeys.Editing.hasToolsAgent.bindTo(contextKeyService); + this._agentModeContextKey.set( + this.storageService.getBoolean(ChatToolsAgentModeStorageKey, StorageScope.WORKSPACE, false)); + this._register( + this.storageService.onWillSaveState(() => this.storageService.store(ChatToolsAgentModeStorageKey, this._agentModeContextKey.get(), StorageScope.WORKSPACE, StorageTarget.USER))); } registerAgent(id: string, data: IChatAgentData): IDisposable { @@ -311,15 +327,23 @@ export class ChatAgentService extends Disposable implements IChatAgentService { private _updateContextKeys(): void { let editingAgentRegistered = false; let defaultAgentRegistered = false; + let toolsAgentRegistered = false; for (const agent of this.getAgents()) { if (agent.isDefault && agent.locations.includes(ChatAgentLocation.EditingSession)) { editingAgentRegistered = true; + if (agent.isToolsAgent) { + toolsAgentRegistered = true; + } } else if (agent.isDefault) { defaultAgentRegistered = true; } } this._editingAgentRegistered.set(editingAgentRegistered); this._defaultAgentRegistered.set(defaultAgentRegistered); + if (toolsAgentRegistered !== this._hasToolsAgentContextKey.get()) { + this._hasToolsAgentContextKey.set(toolsAgentRegistered); + this._onDidChangeAgents.fire(this.getDefaultAgent(ChatAgentLocation.EditingSession)); + } } registerAgentImplementation(id: string, agentImpl: IChatAgentImplementation): IDisposable { @@ -384,7 +408,22 @@ export class ChatAgentService extends Disposable implements IChatAgentService { } getDefaultAgent(location: ChatAgentLocation): IChatAgent | undefined { - return findLast(this.getActivatedAgents(), a => !!a.isDefault && a.locations.includes(location)); + return findLast(this.getActivatedAgents(), a => { + if (location === ChatAgentLocation.EditingSession && this.toolsAgentModeEnabled !== !!a.isToolsAgent) { + return false; + } + + return !!a.isDefault && a.locations.includes(location); + }); + } + + public get toolsAgentModeEnabled(): boolean { + return !!this._hasToolsAgentContextKey.get() && !!this._agentModeContextKey.get(); + } + + toggleToolsAgentMode(): void { + this._agentModeContextKey.set(!this._agentModeContextKey.get()); + this._onDidChangeAgents.fire(this.getDefaultAgent(ChatAgentLocation.EditingSession)); } getContributedDefaultAgent(location: ChatAgentLocation): IChatAgentData | undefined { @@ -404,8 +443,8 @@ export class ChatAgentService extends Disposable implements IChatAgentService { return this._agents.get(id)?.data; } - private _agentIsEnabled(id: string): boolean { - const entry = this._agents.get(id); + private _agentIsEnabled(idOrAgent: string | IChatAgentEntry): boolean { + const entry = typeof idOrAgent === 'string' ? this._agents.get(idOrAgent) : idOrAgent; return !entry?.data.when || this.contextKeyService.contextMatchesRules(ContextKeyExpr.deserialize(entry.data.when)); } @@ -554,6 +593,7 @@ export class MergedChatAgent implements IChatAgent { get extensionPublisherDisplayName() { return this.data.publisherDisplayName; } get extensionDisplayName(): string { return this.data.extensionDisplayName; } get isDefault(): boolean | undefined { return this.data.isDefault; } + get isToolsAgent(): boolean | undefined { return this.data.isToolsAgent; } get metadata(): IChatAgentMetadata { return this.data.metadata; } get slashCommands(): IChatAgentCommand[] { return this.data.slashCommands; } get locations(): ChatAgentLocation[] { return this.data.locations; } diff --git a/src/vs/workbench/contrib/chat/common/chatContextKeys.ts b/src/vs/workbench/contrib/chat/common/chatContextKeys.ts index be03126dd2d..68c50c90600 100644 --- a/src/vs/workbench/contrib/chat/common/chatContextKeys.ts +++ b/src/vs/workbench/contrib/chat/common/chatContextKeys.ts @@ -76,4 +76,9 @@ export namespace ChatContextKeys { export const chatQuotaExceeded = new RawContextKey('chatQuotaExceeded', false, true); export const completionsQuotaExceeded = new RawContextKey('completionsQuotaExceeded', false, true); + + export const Editing = { + hasToolsAgent: new RawContextKey('chatHasToolsAgent', false, { type: 'boolean', description: localize('chatEditingHasToolsAgent', "True when a tools agent is registered.") }), + agentMode: new RawContextKey('chatAgentMode', false, { type: 'boolean', description: localize('chatEditingAgentMode', "True when edits is in agent mode.") }), + }; } diff --git a/src/vs/workbench/contrib/chat/common/chatEditingService.ts b/src/vs/workbench/contrib/chat/common/chatEditingService.ts index ed970c30625..57e0924ce48 100644 --- a/src/vs/workbench/contrib/chat/common/chatEditingService.ts +++ b/src/vs/workbench/contrib/chat/common/chatEditingService.ts @@ -10,6 +10,7 @@ import { ResourceMap } from '../../../../base/common/map.js'; import { IObservable, IReader, ITransaction } from '../../../../base/common/observable.js'; import { URI } from '../../../../base/common/uri.js'; import { IDocumentDiff } from '../../../../editor/common/diff/documentDiffProvider.js'; +import { DetailedLineRangeMapping } from '../../../../editor/common/diff/rangeMapping.js'; import { TextEdit } from '../../../../editor/common/languages.js'; import { ITextModel } from '../../../../editor/common/model.js'; import { localize } from '../../../../nls.js'; @@ -120,6 +121,8 @@ export interface IModifiedFileEntry { readonly rewriteRatio: IObservable; readonly maxLineNumber: IObservable; readonly diffInfo: IObservable; + acceptHunk(change: DetailedLineRangeMapping): Promise; + rejectHunk(change: DetailedLineRangeMapping): Promise; readonly lastModifyingRequestId: string; accept(transaction: ITransaction | undefined): Promise; reject(transaction: ITransaction | undefined): Promise; diff --git a/src/vs/workbench/contrib/chat/common/chatParticipantContribTypes.ts b/src/vs/workbench/contrib/chat/common/chatParticipantContribTypes.ts index 211d7e7e9c9..401211b3c4f 100644 --- a/src/vs/workbench/contrib/chat/common/chatParticipantContribTypes.ts +++ b/src/vs/workbench/contrib/chat/common/chatParticipantContribTypes.ts @@ -22,6 +22,7 @@ export interface IRawChatParticipantContribution { when?: string; description?: string; isDefault?: boolean; + isAgent?: boolean; isSticky?: boolean; sampleRequest?: string; commands?: IRawChatCommandContribution[]; diff --git a/src/vs/workbench/contrib/chat/common/chatProgressTypes/chatToolInvocation.ts b/src/vs/workbench/contrib/chat/common/chatProgressTypes/chatToolInvocation.ts index 7632ee2f0b6..76e572c91df 100644 --- a/src/vs/workbench/contrib/chat/common/chatProgressTypes/chatToolInvocation.ts +++ b/src/vs/workbench/contrib/chat/common/chatProgressTypes/chatToolInvocation.ts @@ -48,10 +48,6 @@ export class ChatToolInvocation implements IChatToolInvocation { this._confirmDeferred.p.then(confirmed => { this._isConfirmed = confirmed; this._confirmationMessages = undefined; - if (!confirmed) { - // Spinner -> check - this._isCompleteDeferred.complete(); - } }); this._isCompleteDeferred.p.then(() => { diff --git a/src/vs/workbench/contrib/chat/common/chatViewModel.ts b/src/vs/workbench/contrib/chat/common/chatViewModel.ts index b7cd3f36141..b078e519666 100644 --- a/src/vs/workbench/contrib/chat/common/chatViewModel.ts +++ b/src/vs/workbench/contrib/chat/common/chatViewModel.ts @@ -147,7 +147,7 @@ export interface IChatCodeCitations { export type IChatRendererContent = IChatProgressRenderableResponseContent | IChatReferences | IChatCodeCitations; export interface IChatLiveUpdateData { - firstWordTime: number; + totalTime: number; lastUpdateTime: number; impliedWordLoadRate: number; lastWordCount: number; @@ -566,10 +566,10 @@ export class ChatResponseViewModel extends Disposable implements IChatResponseVi if (!_model.isComplete) { this._contentUpdateTimings = { - firstWordTime: 0, + totalTime: 0, lastUpdateTime: Date.now(), impliedWordLoadRate: 0, - lastWordCount: 0 + lastWordCount: 0, }; } @@ -579,12 +579,14 @@ export class ChatResponseViewModel extends Disposable implements IChatResponseVi const now = Date.now(); const wordCount = countWords(_model.response.getMarkdown()); - // Apply a min time difference, or the rate is typically too high for first few words - const timeDiff = Math.max(now - this._contentUpdateTimings.firstWordTime, 250); - const impliedWordLoadRate = this._contentUpdateTimings.lastWordCount / (timeDiff / 1000); - this.trace('onDidChange', `Update- got ${this._contentUpdateTimings.lastWordCount} words over last ${timeDiff}ms = ${impliedWordLoadRate} words/s. ${wordCount} words are now available.`); + const timeDiff = Math.min(now - this._contentUpdateTimings.lastUpdateTime, 1000); + const newTotalTime = Math.max(this._contentUpdateTimings.totalTime + timeDiff, 250); + const impliedWordLoadRate = this._contentUpdateTimings.lastWordCount / (newTotalTime / 1000); + this.trace('onDidChange', `Update- got ${this._contentUpdateTimings.lastWordCount} words over last ${newTotalTime}ms = ${impliedWordLoadRate} words/s. ${wordCount} words are now available.`); this._contentUpdateTimings = { - firstWordTime: this._contentUpdateTimings.firstWordTime === 0 && this.response.value.some(v => v.kind === 'markdownContent') ? now : this._contentUpdateTimings.firstWordTime, + totalTime: this._contentUpdateTimings.totalTime !== 0 || this.response.value.some(v => v.kind === 'markdownContent') ? + newTotalTime : + this._contentUpdateTimings.totalTime, lastUpdateTime: now, impliedWordLoadRate, lastWordCount: wordCount diff --git a/src/vs/workbench/contrib/chat/test/common/chatAgents.test.ts b/src/vs/workbench/contrib/chat/test/common/chatAgents.test.ts index cec803118e1..580fa44ce45 100644 --- a/src/vs/workbench/contrib/chat/test/common/chatAgents.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/chatAgents.test.ts @@ -9,6 +9,7 @@ import { ContextKeyExpression } from '../../../../../platform/contextkey/common/ import { ExtensionIdentifier } from '../../../../../platform/extensions/common/extensions.js'; import { MockContextKeyService } from '../../../../../platform/keybinding/test/common/mockKeybindingService.js'; import { ChatAgentService, IChatAgentData, IChatAgentImplementation } from '../../common/chatAgents.js'; +import { TestStorageService } from '../../../../test/common/workbenchTestServices.js'; const testAgentId = 'testAgent'; const testAgentData: IChatAgentData = { @@ -41,7 +42,7 @@ suite('ChatAgents', function () { let contextKeyService: TestingContextKeyService; setup(() => { contextKeyService = new TestingContextKeyService(); - chatAgentService = store.add(new ChatAgentService(contextKeyService)); + chatAgentService = store.add(new ChatAgentService(contextKeyService, store.add(new TestStorageService()))); }); test('registerAgent', async () => { diff --git a/src/vs/workbench/contrib/chat/test/common/voiceChatService.test.ts b/src/vs/workbench/contrib/chat/test/common/voiceChatService.test.ts index 60e55441ce7..f84b3ba640b 100644 --- a/src/vs/workbench/contrib/chat/test/common/voiceChatService.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/voiceChatService.test.ts @@ -64,15 +64,6 @@ suite('VoiceChat', () => { ]; class TestChatAgentService implements IChatAgentService { - hasChatParticipantDetectionProviders(): boolean { - throw new Error('Method not implemented.'); - } - registerChatParticipantDetectionProvider(handle: number, provider: IChatParticipantDetectionProvider): IDisposable { - throw new Error('Method not implemented.'); - } - detectAgentOrCommand(request: IChatAgentRequest, history: IChatAgentHistoryEntry[], options: { location: ChatAgentLocation }, token: CancellationToken): Promise<{ agent: IChatAgentData; command?: IChatAgentCommand } | undefined> { - throw new Error('Method not implemented.'); - } _serviceBrand: undefined; readonly onDidChangeAgents = Event.None; registerAgentImplementation(id: string, agent: IChatAgentImplementation): IDisposable { throw new Error(); } @@ -93,6 +84,19 @@ suite('VoiceChat', () => { getAgentCompletionItems(id: string, query: string, token: CancellationToken): Promise { throw new Error('Method not implemented.'); } agentHasDupeName(id: string): boolean { throw new Error('Method not implemented.'); } getChatTitle(id: string, history: IChatAgentHistoryEntry[], token: CancellationToken): Promise { throw new Error('Method not implemented.'); } + readonly toolsAgentModeEnabled: boolean = false; + toggleToolsAgentMode(): void { + throw new Error('Method not implemented.'); + } + hasChatParticipantDetectionProviders(): boolean { + throw new Error('Method not implemented.'); + } + registerChatParticipantDetectionProvider(handle: number, provider: IChatParticipantDetectionProvider): IDisposable { + throw new Error('Method not implemented.'); + } + detectAgentOrCommand(request: IChatAgentRequest, history: IChatAgentHistoryEntry[], options: { location: ChatAgentLocation }, token: CancellationToken): Promise<{ agent: IChatAgentData; command?: IChatAgentCommand } | undefined> { + throw new Error('Method not implemented.'); + } } class TestSpeechService implements ISpeechService { diff --git a/src/vs/workbench/contrib/comments/browser/commentThreadBody.ts b/src/vs/workbench/contrib/comments/browser/commentThreadBody.ts index fa68fe09303..035b88131fe 100644 --- a/src/vs/workbench/contrib/comments/browser/commentThreadBody.ts +++ b/src/vs/workbench/contrib/comments/browser/commentThreadBody.ts @@ -41,7 +41,6 @@ export class CommentThreadBody extends D return this._commentElements.filter(node => node.isEditing)[0]; } - constructor( private readonly _parentEditor: LayoutableEditor, readonly owner: string, @@ -77,6 +76,10 @@ export class CommentThreadBody extends D this._commentsElement.focus(); } + hasCommentsInEditMode() { + return this._commentElements.some(commentNode => commentNode.isEditing); + } + ensureFocusIntoNewEditingComment() { if (this._commentElements.length === 1 && this._commentElements[0].isEditing) { this._commentElements[0].setFocus(true); diff --git a/src/vs/workbench/contrib/comments/browser/commentThreadHeader.ts b/src/vs/workbench/contrib/comments/browser/commentThreadHeader.ts index 3f42e0cdf32..cb721233760 100644 --- a/src/vs/workbench/contrib/comments/browser/commentThreadHeader.ts +++ b/src/vs/workbench/contrib/comments/browser/commentThreadHeader.ts @@ -44,9 +44,9 @@ export class CommentThreadHeader extends Disposable { private _delegate: { collapse: () => void }, private _commentMenus: CommentMenus, private _commentThread: languages.CommentThread, - private _contextKeyService: IContextKeyService, - private instantiationService: IInstantiationService, - private _contextMenuService: IContextMenuService + @IContextKeyService private readonly _contextKeyService: IContextKeyService, + @IInstantiationService private readonly _instantiationService: IInstantiationService, + @IContextMenuService private readonly _contextMenuService: IContextMenuService ) { super(); this._headElement = dom.$('.head'); @@ -63,7 +63,7 @@ export class CommentThreadHeader extends Disposable { const actionsContainer = dom.append(this._headElement, dom.$('.review-actions')); this._actionbarWidget = new ActionBar(actionsContainer, { - actionViewItemProvider: createActionViewItem.bind(undefined, this.instantiationService) + actionViewItemProvider: createActionViewItem.bind(undefined, this._instantiationService) }); this._register(this._actionbarWidget); diff --git a/src/vs/workbench/contrib/comments/browser/commentThreadWidget.ts b/src/vs/workbench/contrib/comments/browser/commentThreadWidget.ts index 94578614d7b..41fbd153cfd 100644 --- a/src/vs/workbench/contrib/comments/browser/commentThreadWidget.ts +++ b/src/vs/workbench/contrib/comments/browser/commentThreadWidget.ts @@ -5,6 +5,7 @@ import './media/review.css'; import * as dom from '../../../../base/browser/dom.js'; +import * as nls from '../../../../nls.js'; import * as domStylesheets from '../../../../base/browser/domStylesheets.js'; import { Emitter } from '../../../../base/common/event.js'; import { Disposable, dispose, IDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; @@ -28,7 +29,6 @@ import { IRange, Range } from '../../../../editor/common/core/range.js'; import { commentThreadStateBackgroundColorVar, commentThreadStateColorVar } from './commentColors.js'; import { ICellRange } from '../../notebook/common/notebookRange.js'; import { FontInfo } from '../../../../editor/common/config/fontInfo.js'; -import { IContextMenuService } from '../../../../platform/contextview/browser/contextView.js'; import { registerNavigableContainer } from '../../../browser/actions/widgetNavigationCommands.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { COMMENTS_SECTION, ICommentsConfiguration } from '../common/commentsConfiguration.js'; @@ -38,6 +38,8 @@ import { IKeybindingService } from '../../../../platform/keybinding/common/keybi import { AccessibilityCommandId } from '../../accessibility/common/accessibilityCommands.js'; import { LayoutableEditor } from './simpleCommentEditor.js'; import { isCodeEditor } from '../../../../editor/browser/editorBrowser.js'; +import { IDialogService } from '../../../../platform/dialogs/common/dialogs.js'; +import Severity from '../../../../base/common/severity.js'; export const COMMENTEDITOR_DECORATION_KEY = 'commenteditordecoration'; @@ -76,10 +78,11 @@ export class CommentThreadWidget extends actionRunner: (() => void) | null; collapse: () => void; }, - @ICommentService private commentService: ICommentService, - @IContextMenuService contextMenuService: IContextMenuService, - @IConfigurationService private configurationService: IConfigurationService, - @IKeybindingService private _keybindingService: IKeybindingService + @ICommentService private readonly commentService: ICommentService, + @IConfigurationService private readonly configurationService: IConfigurationService, + @IKeybindingService private readonly _keybindingService: IKeybindingService, + @IConfigurationService private readonly _configurationService: IConfigurationService, + @IDialogService private readonly _dialogService: IDialogService ) { super(); @@ -89,16 +92,14 @@ export class CommentThreadWidget extends this._commentMenus = this.commentService.getCommentMenus(this._owner); - this._register(this._header = new CommentThreadHeader( + this._register(this._header = this._scopedInstantiationService.createInstance( + CommentThreadHeader, container, { - collapse: this.collapse.bind(this) + collapse: this.collapseAction.bind(this) }, this._commentMenus, - this._commentThread, - this._contextKeyService, - this._scopedInstantiationService, - contextMenuService + this._commentThread )); this._header.updateCommentThread(this._commentThread); @@ -159,6 +160,21 @@ export class CommentThreadWidget extends this.currentThreadListeners(); } + private async confirmCollapse(): Promise { + const confirmSetting = this._configurationService.getValue<'whenHasUnsubmittedComments' | 'never'>('comments.thread.confirmOnCollapse'); + + const hasUnsubmitted = !!this._commentReply?.commentEditor.getValue() || this._body.hasCommentsInEditMode(); + if (confirmSetting === 'whenHasUnsubmittedComments' && hasUnsubmitted) { + const result = await this._dialogService.confirm({ + message: nls.localize('confirmCollapse', "This comment thread has unsubmitted comments. Do you want to collapse it?"), + primaryButton: nls.localize('collapse', "Collapse"), + type: Severity.Warning + }); + return result.confirmed; + } + return true; + } + private _setAriaLabel(): void { let ariaLabel = localize('commentLabel', "Comment"); let keybinding: string | undefined; @@ -375,6 +391,12 @@ export class CommentThreadWidget extends } } + private async collapseAction() { + if (await this.confirmCollapse()) { + this.collapse(); + } + } + collapse() { if (Range.isIRange(this.commentThread.range) && isCodeEditor(this._parentEditor)) { this._parentEditor.setSelection(this.commentThread.range); diff --git a/src/vs/workbench/contrib/comments/browser/comments.contribution.ts b/src/vs/workbench/contrib/comments/browser/comments.contribution.ts index d2cfe1a5680..3cf98a0cde9 100644 --- a/src/vs/workbench/contrib/comments/browser/comments.contribution.ts +++ b/src/vs/workbench/contrib/comments/browser/comments.contribution.ts @@ -138,6 +138,12 @@ Registry.as(ConfigurationExtensions.Configuration).regis type: 'boolean', default: true, description: nls.localize('collapseOnResolve', "Controls whether the comment thread should collapse when the thread is resolved.") + }, + 'comments.thread.confirmOnCollapse': { + type: 'string', + enum: ['whenHasUnsubmittedComments', 'never'], + default: 'never', + description: nls.localize('confirmOnCollapse', "Controls whether a confirmation dialog is shown when collapsing a comment thread with unsubmitted comments.") } } }); diff --git a/src/vs/workbench/contrib/files/browser/fileActions.contribution.ts b/src/vs/workbench/contrib/files/browser/fileActions.contribution.ts index be7dddf26ff..6cd04336cfb 100644 --- a/src/vs/workbench/contrib/files/browser/fileActions.contribution.ts +++ b/src/vs/workbench/contrib/files/browser/fileActions.contribution.ts @@ -14,7 +14,7 @@ import { COPY_PATH_COMMAND_ID, REVEAL_IN_EXPLORER_COMMAND_ID, OPEN_TO_SIDE_COMMA import { CommandsRegistry, ICommandHandler } from '../../../../platform/commands/common/commands.js'; import { ContextKeyExpr, ContextKeyExpression } from '../../../../platform/contextkey/common/contextkey.js'; import { KeybindingsRegistry, KeybindingWeight } from '../../../../platform/keybinding/common/keybindingsRegistry.js'; -import { FilesExplorerFocusCondition, ExplorerRootContext, ExplorerFolderContext, ExplorerResourceNotReadonlyContext, ExplorerResourceCut, ExplorerResourceMoveableToTrash, ExplorerResourceAvailableEditorIdsContext, FoldersViewVisibleContext } from '../common/files.js'; +import { FilesExplorerFocusCondition, ExplorerRootContext, ExplorerFolderContext, ExplorerResourceWritableContext, ExplorerResourceCut, ExplorerResourceMoveableToTrash, ExplorerResourceAvailableEditorIdsContext, FoldersViewVisibleContext } from '../common/files.js'; import { ADD_ROOT_FOLDER_COMMAND_ID, ADD_ROOT_FOLDER_LABEL } from '../../../browser/actions/workspaceCommands.js'; import { CLOSE_SAVED_EDITORS_COMMAND_ID, CLOSE_EDITORS_IN_GROUP_COMMAND_ID, CLOSE_EDITOR_COMMAND_ID, CLOSE_OTHER_EDITORS_IN_GROUP_COMMAND_ID, REOPEN_WITH_COMMAND_ID } from '../../../browser/parts/editor/editorCommands.js'; import { AutoSaveAfterShortDelayContext } from '../../../services/filesConfiguration/common/filesConfigurationService.js'; @@ -52,7 +52,7 @@ const RENAME_ID = 'renameFile'; KeybindingsRegistry.registerCommandAndKeybindingRule({ id: RENAME_ID, weight: KeybindingWeight.WorkbenchContrib + explorerCommandsWeightBonus, - when: ContextKeyExpr.and(FilesExplorerFocusCondition, ExplorerRootContext.toNegated(), ExplorerResourceNotReadonlyContext), + when: ContextKeyExpr.and(FilesExplorerFocusCondition, ExplorerRootContext.toNegated(), ExplorerResourceWritableContext), primary: KeyCode.F2, mac: { primary: KeyCode.Enter @@ -64,7 +64,7 @@ const MOVE_FILE_TO_TRASH_ID = 'moveFileToTrash'; KeybindingsRegistry.registerCommandAndKeybindingRule({ id: MOVE_FILE_TO_TRASH_ID, weight: KeybindingWeight.WorkbenchContrib + explorerCommandsWeightBonus, - when: ContextKeyExpr.and(FilesExplorerFocusCondition, ExplorerResourceNotReadonlyContext, ExplorerResourceMoveableToTrash), + when: ContextKeyExpr.and(FilesExplorerFocusCondition, ExplorerResourceMoveableToTrash), primary: KeyCode.Delete, mac: { primary: KeyMod.CtrlCmd | KeyCode.Backspace, @@ -77,7 +77,7 @@ const DELETE_FILE_ID = 'deleteFile'; KeybindingsRegistry.registerCommandAndKeybindingRule({ id: DELETE_FILE_ID, weight: KeybindingWeight.WorkbenchContrib + explorerCommandsWeightBonus, - when: ContextKeyExpr.and(FilesExplorerFocusCondition, ExplorerResourceNotReadonlyContext), + when: FilesExplorerFocusCondition, primary: KeyMod.Shift | KeyCode.Delete, mac: { primary: KeyMod.CtrlCmd | KeyMod.Alt | KeyCode.Backspace @@ -88,7 +88,7 @@ KeybindingsRegistry.registerCommandAndKeybindingRule({ KeybindingsRegistry.registerCommandAndKeybindingRule({ id: DELETE_FILE_ID, weight: KeybindingWeight.WorkbenchContrib + explorerCommandsWeightBonus, - when: ContextKeyExpr.and(FilesExplorerFocusCondition, ExplorerResourceNotReadonlyContext, ExplorerResourceMoveableToTrash.toNegated()), + when: ContextKeyExpr.and(FilesExplorerFocusCondition, ExplorerResourceMoveableToTrash.toNegated()), primary: KeyCode.Delete, mac: { primary: KeyMod.CtrlCmd | KeyCode.Backspace @@ -100,7 +100,7 @@ const CUT_FILE_ID = 'filesExplorer.cut'; KeybindingsRegistry.registerCommandAndKeybindingRule({ id: CUT_FILE_ID, weight: KeybindingWeight.WorkbenchContrib + explorerCommandsWeightBonus, - when: ContextKeyExpr.and(FilesExplorerFocusCondition, ExplorerRootContext.toNegated(), ExplorerResourceNotReadonlyContext), + when: ContextKeyExpr.and(FilesExplorerFocusCondition, ExplorerRootContext.toNegated(), ExplorerResourceWritableContext), primary: KeyMod.CtrlCmd | KeyCode.KeyX, handler: cutFileHandler, }); @@ -121,7 +121,7 @@ CommandsRegistry.registerCommand(PASTE_FILE_ID, pasteFileHandler); KeybindingsRegistry.registerKeybindingRule({ id: `^${PASTE_FILE_ID}`, // the `^` enables pasting files into the explorer by preventing default bubble up weight: KeybindingWeight.WorkbenchContrib + explorerCommandsWeightBonus, - when: ContextKeyExpr.and(FilesExplorerFocusCondition, ExplorerResourceNotReadonlyContext), + when: ContextKeyExpr.and(FilesExplorerFocusCondition, ExplorerResourceWritableContext), primary: KeyMod.CtrlCmd | KeyCode.KeyV, }); @@ -479,7 +479,7 @@ MenuRegistry.appendMenuItem(MenuId.ExplorerContext, { command: { id: NEW_FILE_COMMAND_ID, title: NEW_FILE_LABEL, - precondition: ExplorerResourceNotReadonlyContext + precondition: ExplorerResourceWritableContext }, when: ExplorerFolderContext }); @@ -490,7 +490,7 @@ MenuRegistry.appendMenuItem(MenuId.ExplorerContext, { command: { id: NEW_FOLDER_COMMAND_ID, title: NEW_FOLDER_LABEL, - precondition: ExplorerResourceNotReadonlyContext + precondition: ExplorerResourceWritableContext }, when: ExplorerFolderContext }); @@ -540,7 +540,7 @@ MenuRegistry.appendMenuItem(MenuId.ExplorerContext, { id: CUT_FILE_ID, title: nls.localize('cut', "Cut"), }, - when: ContextKeyExpr.and(ExplorerRootContext.toNegated(), ExplorerResourceNotReadonlyContext) + when: ContextKeyExpr.and(ExplorerRootContext.toNegated(), ExplorerResourceWritableContext) }); MenuRegistry.appendMenuItem(MenuId.ExplorerContext, { @@ -559,7 +559,7 @@ MenuRegistry.appendMenuItem(MenuId.ExplorerContext, { command: { id: PASTE_FILE_ID, title: PASTE_FILE_LABEL, - precondition: ContextKeyExpr.and(ExplorerResourceNotReadonlyContext, FileCopiedContext) + precondition: ContextKeyExpr.and(ExplorerResourceWritableContext, FileCopiedContext) }, when: ExplorerFolderContext }); @@ -593,8 +593,8 @@ MenuRegistry.appendMenuItem(MenuId.ExplorerContext, ({ IsWebContext, // only on folders ExplorerFolderContext, - // only on editable folders - ExplorerResourceNotReadonlyContext + // only on writable folders + ExplorerResourceWritableContext ) })); @@ -638,7 +638,7 @@ MenuRegistry.appendMenuItem(MenuId.ExplorerContext, { command: { id: RENAME_ID, title: TRIGGER_RENAME_LABEL, - precondition: ExplorerResourceNotReadonlyContext, + precondition: ExplorerResourceWritableContext, }, when: ExplorerRootContext.toNegated() }); @@ -648,13 +648,11 @@ MenuRegistry.appendMenuItem(MenuId.ExplorerContext, { order: 20, command: { id: MOVE_FILE_TO_TRASH_ID, - title: MOVE_FILE_TO_TRASH_LABEL, - precondition: ContextKeyExpr.and(ExplorerResourceNotReadonlyContext), + title: MOVE_FILE_TO_TRASH_LABEL }, alt: { id: DELETE_FILE_ID, - title: nls.localize('deleteFile', "Delete Permanently"), - precondition: ContextKeyExpr.and(ExplorerResourceNotReadonlyContext), + title: nls.localize('deleteFile', "Delete Permanently") }, when: ContextKeyExpr.and(ExplorerRootContext.toNegated(), ExplorerResourceMoveableToTrash) }); @@ -664,8 +662,7 @@ MenuRegistry.appendMenuItem(MenuId.ExplorerContext, { order: 20, command: { id: DELETE_FILE_ID, - title: nls.localize('deleteFile', "Delete Permanently"), - precondition: ExplorerResourceNotReadonlyContext, + title: nls.localize('deleteFile', "Delete Permanently") }, when: ContextKeyExpr.and(ExplorerRootContext.toNegated(), ExplorerResourceMoveableToTrash.toNegated()) }); diff --git a/src/vs/workbench/contrib/files/browser/fileActions.ts b/src/vs/workbench/contrib/files/browser/fileActions.ts index 635765f09b7..50f786ea2f7 100644 --- a/src/vs/workbench/contrib/files/browser/fileActions.ts +++ b/src/vs/workbench/contrib/files/browser/fileActions.ts @@ -93,7 +93,7 @@ async function refreshIfSeparator(value: string, explorerService: IExplorerServi } } -async function deleteFiles(explorerService: IExplorerService, workingCopyFileService: IWorkingCopyFileService, dialogService: IDialogService, configurationService: IConfigurationService, elements: ExplorerItem[], useTrash: boolean, skipConfirm = false, ignoreIfNotExists = false): Promise { +async function deleteFiles(explorerService: IExplorerService, workingCopyFileService: IWorkingCopyFileService, dialogService: IDialogService, configurationService: IConfigurationService, filesConfigurationService: IFilesConfigurationService, elements: ExplorerItem[], useTrash: boolean, skipConfirm = false, ignoreIfNotExists = false): Promise { let primaryButton: string; if (useTrash) { primaryButton = isWindows ? nls.localize('deleteButtonLabelRecycleBin', "&&Move to Recycle Bin") : nls.localize({ key: 'deleteButtonLabelTrash', comment: ['&& denotes a mnemonic'] }, "&&Move to Trash"); @@ -109,7 +109,7 @@ async function deleteFiles(explorerService: IExplorerService, workingCopyFileSer dirtyWorkingCopies.add(dirtyWorkingCopy); } } - let confirmed = true; + if (dirtyWorkingCopies.size) { let message: string; if (distinctElements.length > 1) { @@ -132,18 +132,40 @@ async function deleteFiles(explorerService: IExplorerService, workingCopyFileSer }); if (!response.confirmed) { - confirmed = false; + return; } else { skipConfirm = true; } } - // Check if file is dirty in editor and save it to avoid data loss - if (!confirmed) { - return; + // Handle readonly + if (!skipConfirm) { + const readonlyResources = distinctElements.filter(e => filesConfigurationService.isReadonly(e.resource)); + if (readonlyResources.length) { + let message: string; + if (readonlyResources.length > 1) { + message = nls.localize('readonlyMessageFilesDelete', "You are deleting files that are configured to be read-only. Do you want to continue?"); + } else if (readonlyResources[0].isDirectory) { + message = nls.localize('readonlyMessageFolderOneDelete', "You are deleting a folder {0} that is configured to be read-only. Do you want to continue?", distinctElements[0].name); + } else { + message = nls.localize('readonlyMessageFolderDelete', "You are deleting a file {0} that is configured to be read-only. Do you want to continue?", distinctElements[0].name); + } + + const response = await dialogService.confirm({ + type: 'warning', + message, + detail: nls.localize('continueDetail', "The read-only protection will be overridden if you continue."), + primaryButton: nls.localize('continueButtonLabel', "Continue") + }); + + if (!response.confirmed) { + return; + } + } } let confirmation: IConfirmationResult; + // We do not support undo of folders, so in that case the delete action is irreversible const deleteDetail = distinctElements.some(e => e.isDirectory) ? nls.localize('irreversible', "This action is irreversible!") : distinctElements.length > 1 ? nls.localize('restorePlural', "You can restore these files using the Undo command.") : nls.localize('restore', "You can restore this file using the Undo command."); @@ -234,7 +256,7 @@ async function deleteFiles(explorerService: IExplorerService, workingCopyFileSer skipConfirm = true; ignoreIfNotExists = true; - return deleteFiles(explorerService, workingCopyFileService, dialogService, configurationService, elements, useTrash, skipConfirm, ignoreIfNotExists); + return deleteFiles(explorerService, workingCopyFileService, dialogService, configurationService, filesConfigurationService, elements, useTrash, skipConfirm, ignoreIfNotExists); } } } @@ -1020,7 +1042,7 @@ export const moveFileToTrashHandler = async (accessor: ServicesAccessor) => { const explorerService = accessor.get(IExplorerService); const stats = explorerService.getContext(true).filter(s => !s.isRoot); if (stats.length) { - await deleteFiles(accessor.get(IExplorerService), accessor.get(IWorkingCopyFileService), accessor.get(IDialogService), accessor.get(IConfigurationService), stats, true); + await deleteFiles(accessor.get(IExplorerService), accessor.get(IWorkingCopyFileService), accessor.get(IDialogService), accessor.get(IConfigurationService), accessor.get(IFilesConfigurationService), stats, true); } }; @@ -1029,7 +1051,7 @@ export const deleteFileHandler = async (accessor: ServicesAccessor) => { const stats = explorerService.getContext(true).filter(s => !s.isRoot); if (stats.length) { - await deleteFiles(accessor.get(IExplorerService), accessor.get(IWorkingCopyFileService), accessor.get(IDialogService), accessor.get(IConfigurationService), stats, false); + await deleteFiles(accessor.get(IExplorerService), accessor.get(IWorkingCopyFileService), accessor.get(IDialogService), accessor.get(IConfigurationService), accessor.get(IFilesConfigurationService), stats, false); } }; diff --git a/src/vs/workbench/contrib/files/browser/views/explorerView.ts b/src/vs/workbench/contrib/files/browser/views/explorerView.ts index 1c718837501..f91029d58d5 100644 --- a/src/vs/workbench/contrib/files/browser/views/explorerView.ts +++ b/src/vs/workbench/contrib/files/browser/views/explorerView.ts @@ -8,7 +8,7 @@ import { URI } from '../../../../../base/common/uri.js'; import * as perf from '../../../../../base/common/performance.js'; import { WorkbenchActionExecutedEvent, WorkbenchActionExecutedClassification } from '../../../../../base/common/actions.js'; import { memoize } from '../../../../../base/common/decorators.js'; -import { IFilesConfiguration, ExplorerFolderContext, FilesExplorerFocusedContext, ExplorerFocusedContext, ExplorerRootContext, ExplorerResourceReadonlyContext, ExplorerResourceCut, ExplorerResourceMoveableToTrash, ExplorerCompressedFocusContext, ExplorerCompressedFirstFocusContext, ExplorerCompressedLastFocusContext, ExplorerResourceAvailableEditorIdsContext, VIEW_ID, ExplorerResourceNotReadonlyContext, ViewHasSomeCollapsibleRootItemContext, FoldersViewVisibleContext, ExplorerResourceParentReadOnlyContext, ExplorerFindProviderActive } from '../../common/files.js'; +import { IFilesConfiguration, ExplorerFolderContext, FilesExplorerFocusedContext, ExplorerFocusedContext, ExplorerRootContext, ExplorerResourceReadonlyContext, ExplorerResourceCut, ExplorerResourceMoveableToTrash, ExplorerCompressedFocusContext, ExplorerCompressedFirstFocusContext, ExplorerCompressedLastFocusContext, ExplorerResourceAvailableEditorIdsContext, VIEW_ID, ExplorerResourceWritableContext, ViewHasSomeCollapsibleRootItemContext, FoldersViewVisibleContext, ExplorerResourceParentReadOnlyContext, ExplorerFindProviderActive } from '../../common/files.js'; import { FileCopiedContext, NEW_FILE_COMMAND_ID, NEW_FOLDER_COMMAND_ID } from '../fileActions.js'; import * as DOM from '../../../../../base/browser/dom.js'; import { IWorkbenchLayoutService } from '../../../../services/layout/browser/layoutService.js'; @@ -988,7 +988,7 @@ export function createFileIconThemableTreeContainerScope(container: HTMLElement, const CanCreateContext = ContextKeyExpr.or( // Folder: can create unless readonly - ContextKeyExpr.and(ExplorerFolderContext, ExplorerResourceNotReadonlyContext), + ContextKeyExpr.and(ExplorerFolderContext, ExplorerResourceWritableContext), // File: can create unless parent is readonly ContextKeyExpr.and(ExplorerFolderContext.toNegated(), ExplorerResourceParentReadOnlyContext.toNegated()) ); diff --git a/src/vs/workbench/contrib/files/common/files.ts b/src/vs/workbench/contrib/files/common/files.ts index 56715560f15..0eb091699a5 100644 --- a/src/vs/workbench/contrib/files/common/files.ts +++ b/src/vs/workbench/contrib/files/common/files.ts @@ -40,7 +40,7 @@ export const ExplorerViewletVisibleContext = new RawContextKey('explore export const FoldersViewVisibleContext = new RawContextKey('foldersViewVisible', true, { type: 'boolean', description: localize('foldersViewVisible', "True when the FOLDERS view (the file tree within the explorer view container) is visible.") }); export const ExplorerFolderContext = new RawContextKey('explorerResourceIsFolder', false, { type: 'boolean', description: localize('explorerResourceIsFolder', "True when the focused item in the EXPLORER is a folder.") }); export const ExplorerResourceReadonlyContext = new RawContextKey('explorerResourceReadonly', false, { type: 'boolean', description: localize('explorerResourceReadonly', "True when the focused item in the EXPLORER is read-only.") }); -export const ExplorerResourceNotReadonlyContext = ExplorerResourceReadonlyContext.toNegated(); +export const ExplorerResourceWritableContext = ExplorerResourceReadonlyContext.toNegated(); export const ExplorerResourceParentReadOnlyContext = new RawContextKey('explorerResourceParentReadonly', false, { type: 'boolean', description: localize('explorerResourceParentReadonly', "True when the focused item in the EXPLORER's parent is read-only.") }); /** diff --git a/src/vs/workbench/contrib/notebook/browser/diff/diffComponents.ts b/src/vs/workbench/contrib/notebook/browser/diff/diffComponents.ts index 3ef8a33b088..a0a5557ddab 100644 --- a/src/vs/workbench/contrib/notebook/browser/diff/diffComponents.ts +++ b/src/vs/workbench/contrib/notebook/browser/diff/diffComponents.ts @@ -847,7 +847,7 @@ abstract class AbstractElementRenderer extends Disposable { return; } - const modifiedMetadataSource = getFormattedMetadataJSON(this.notebookEditor.textModel?.transientOptions.transientCellMetadata, this.cell.modified?.metadata || {}, this.cell.modified?.language); + const modifiedMetadataSource = getFormattedMetadataJSON(this.notebookEditor.textModel?.transientOptions.transientCellMetadata, this.cell.modified?.metadata || {}, this.cell.modified?.language, true); modifiedMetadataModel.object.textEditorModel.setValue(modifiedMetadataSource); })); @@ -869,7 +869,7 @@ abstract class AbstractElementRenderer extends Disposable { const originalMetadataSource = getFormattedMetadataJSON(this.notebookEditor.textModel?.transientOptions.transientCellMetadata, this.cell.type === 'insert' ? this.cell.modified!.metadata || {} - : this.cell.original!.metadata || {}); + : this.cell.original!.metadata || {}, undefined, true); const uri = this.cell.type === 'insert' ? this.cell.modified!.uri : this.cell.original!.uri; diff --git a/src/vs/workbench/contrib/notebook/browser/notebook.contribution.ts b/src/vs/workbench/contrib/notebook/browser/notebook.contribution.ts index a09069d24cf..17568757a13 100644 --- a/src/vs/workbench/contrib/notebook/browser/notebook.contribution.ts +++ b/src/vs/workbench/contrib/notebook/browser/notebook.contribution.ts @@ -452,7 +452,7 @@ class CellInfoContentProvider { for (const cell of ref.object.notebook.cells) { if (cell.handle === data.handle) { const cellIndex = ref.object.notebook.cells.indexOf(cell); - const metadataSource = getFormattedMetadataJSON(ref.object.notebook.transientOptions.transientCellMetadata, cell.metadata, cell.language); + const metadataSource = getFormattedMetadataJSON(ref.object.notebook.transientOptions.transientCellMetadata, cell.metadata, cell.language, true); result = this._modelService.createModel( metadataSource, mode, @@ -460,9 +460,9 @@ class CellInfoContentProvider { ); this._disposables.push(disposables.add(ref.object.notebook.onDidChangeContent(e => { if (result && e.rawEvents.some(event => (event.kind === NotebookCellsChangeType.ChangeCellMetadata || event.kind === NotebookCellsChangeType.ChangeCellLanguage) && event.index === cellIndex)) { - const value = getFormattedMetadataJSON(ref.object.notebook.transientOptions.transientCellMetadata, cell.metadata, cell.language); + const value = getFormattedMetadataJSON(ref.object.notebook.transientOptions.transientCellMetadata, cell.metadata, cell.language, true); if (result.getValue() !== value) { - result.setValue(getFormattedMetadataJSON(ref.object.notebook.transientOptions.transientCellMetadata, cell.metadata, cell.language)); + result.setValue(value); } } }))); diff --git a/src/vs/workbench/contrib/notebook/browser/notebookEditorWidget.ts b/src/vs/workbench/contrib/notebook/browser/notebookEditorWidget.ts index b08b816b95a..b528b21b2a6 100644 --- a/src/vs/workbench/contrib/notebook/browser/notebookEditorWidget.ts +++ b/src/vs/workbench/contrib/notebook/browser/notebookEditorWidget.ts @@ -282,7 +282,7 @@ export class NotebookEditorWidget extends Disposable implements INotebookEditorD } get visibleRanges() { - return this._list.visibleRanges || []; + return this._list ? (this._list.visibleRanges || []) : []; } private _baseCellEditorOptions = new Map(); diff --git a/src/vs/workbench/contrib/notebook/common/model/notebookCellTextModel.ts b/src/vs/workbench/contrib/notebook/common/model/notebookCellTextModel.ts index 018aa490c9a..ac481402ced 100644 --- a/src/vs/workbench/contrib/notebook/common/model/notebookCellTextModel.ts +++ b/src/vs/workbench/contrib/notebook/common/model/notebookCellTextModel.ts @@ -516,7 +516,7 @@ function computeRunStartTimeAdjustment(oldMetadata: NotebookCellInternalMetadata } -export function getFormattedMetadataJSON(transientCellMetadata: TransientCellMetadata | undefined, metadata: NotebookCellMetadata, language?: string) { +export function getFormattedMetadataJSON(transientCellMetadata: TransientCellMetadata | undefined, metadata: NotebookCellMetadata, language?: string, sortKeys?: boolean): string { let filteredMetadata: { [key: string]: any } = {}; if (transientCellMetadata) { @@ -541,7 +541,28 @@ export function getFormattedMetadataJSON(transientCellMetadata: TransientCellMet if (language) { obj.language = language; } - const metadataSource = toFormattedString(obj, {}); + const metadataSource = toFormattedString(sortKeys ? sortObjectPropertiesRecursively(obj) : obj, {}); return metadataSource; } + + +/** + * Sort the JSON to ensure when diffing, the JSON keys are sorted & matched correctly in diff view. + */ +export function sortObjectPropertiesRecursively(obj: any): any { + if (Array.isArray(obj)) { + return obj.map(sortObjectPropertiesRecursively); + } + if (obj !== undefined && obj !== null && typeof obj === 'object' && Object.keys(obj).length > 0) { + return ( + Object.keys(obj) + .sort() + .reduce>((sortedObj, prop) => { + sortedObj[prop] = sortObjectPropertiesRecursively(obj[prop]); + return sortedObj; + }, {}) as any + ); + } + return obj; +} diff --git a/src/vs/workbench/contrib/preferences/browser/settingsEditor2.ts b/src/vs/workbench/contrib/preferences/browser/settingsEditor2.ts index 8e3388a7008..43214c91777 100644 --- a/src/vs/workbench/contrib/preferences/browser/settingsEditor2.ts +++ b/src/vs/workbench/contrib/preferences/browser/settingsEditor2.ts @@ -1606,7 +1606,7 @@ export class SettingsEditor2 extends EditorPane { } private async triggerSearch(query: string): Promise { - const progressRunner = this.editorProgressService.show(true); + const progressRunner = this.editorProgressService.show(true, 800); this.viewState.tagFilters = new Set(); this.viewState.extensionFilters = new Set(); this.viewState.featureFilters = new Set(); diff --git a/src/vs/workbench/contrib/scm/browser/scmHistoryViewPane.ts b/src/vs/workbench/contrib/scm/browser/scmHistoryViewPane.ts index af17ed451bd..1c3f71ac1ca 100644 --- a/src/vs/workbench/contrib/scm/browser/scmHistoryViewPane.ts +++ b/src/vs/workbench/contrib/scm/browser/scmHistoryViewPane.ts @@ -459,10 +459,14 @@ class HistoryItemRenderer implements ITreeRenderer { + if (e.affectsConfiguration('window.titleBarStyle')) { + this.updateDefaultTitlebarStyle(); + } + })); + } + + private updateDefaultTitlebarStyle(): void { + const titleBarStyle = this.configurationService.inspect('window.titleBarStyle'); + + let titleBarStyleOverride: 'custom' | undefined; + if (titleBarStyle.applicationValue || titleBarStyle.userValue || titleBarStyle.userLocalValue) { + // configured by user or application: clear override + titleBarStyleOverride = undefined; + } else { + // not configured: set override if experiment is active + titleBarStyleOverride = titleBarStyle.defaultValue === 'native' ? undefined : 'custom'; + } + + this.nativeHostService.overrideDefaultTitlebarStyle(titleBarStyleOverride); } } diff --git a/src/vs/workbench/services/driver/browser/driver.ts b/src/vs/workbench/services/driver/browser/driver.ts index d78e55aa973..ad689b9db6f 100644 --- a/src/vs/workbench/services/driver/browser/driver.ts +++ b/src/vs/workbench/services/driver/browser/driver.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { getClientArea, getTopLeftOffset } from '../../../../base/browser/dom.js'; +import { getClientArea, getTopLeftOffset, isHTMLDivElement, isHTMLTextAreaElement } from '../../../../base/browser/dom.js'; import { mainWindow } from '../../../../base/browser/window.js'; import { coalesce } from '../../../../base/common/arrays.js'; import { language, locale } from '../../../../base/common/platform.js'; @@ -133,18 +133,36 @@ export class BrowserWindowDriver implements IWindowDriver { if (!element) { throw new Error(`Editor not found: ${selector}`); } + if (isHTMLDivElement(element)) { + // Edit context is enabled + const editContext = element.editContext; + if (!editContext) { + throw new Error(`Edit context not found: ${selector}`); + } + const selectionStart = editContext.selectionStart; + const selectionEnd = editContext.selectionEnd; + const event = new TextUpdateEvent('textupdate', { + updateRangeStart: selectionStart, + updateRangeEnd: selectionEnd, + text, + selectionStart: selectionStart + text.length, + selectionEnd: selectionStart + text.length, + compositionStart: 0, + compositionEnd: 0 + }); + editContext.dispatchEvent(event); + } else if (isHTMLTextAreaElement(element)) { + const start = element.selectionStart; + const newStart = start + text.length; + const value = element.value; + const newValue = value.substr(0, start) + text + value.substr(start); - const textarea = element as HTMLTextAreaElement; - const start = textarea.selectionStart; - const newStart = start + text.length; - const value = textarea.value; - const newValue = value.substr(0, start) + text + value.substr(start); + element.value = newValue; + element.setSelectionRange(newStart, newStart); - textarea.value = newValue; - textarea.setSelectionRange(newStart, newStart); - - const event = new Event('input', { 'bubbles': true, 'cancelable': true }); - textarea.dispatchEvent(event); + const event = new Event('input', { 'bubbles': true, 'cancelable': true }); + element.dispatchEvent(event); + } } async getTerminalBuffer(selector: string): Promise { diff --git a/src/vs/workbench/services/extensions/browser/extensionUrlHandler.ts b/src/vs/workbench/services/extensions/browser/extensionUrlHandler.ts index ac3c7ed5006..04aa6fe51f8 100644 --- a/src/vs/workbench/services/extensions/browser/extensionUrlHandler.ts +++ b/src/vs/workbench/services/extensions/browser/extensionUrlHandler.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { localize, localize2 } from '../../../../nls.js'; -import { IDisposable, combinedDisposable } from '../../../../base/common/lifecycle.js'; +import { IDisposable, combinedDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; import { URI } from '../../../../base/common/uri.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { IDialogService } from '../../../../platform/dialogs/common/dialogs.js'; @@ -27,6 +27,7 @@ import { ICommandService } from '../../../../platform/commands/common/commands.j import { isCancellationError } from '../../../../base/common/errors.js'; import { INotificationService } from '../../../../platform/notification/common/notification.js'; import { IWorkbenchEnvironmentService } from '../../environment/common/environmentService.js'; +import { ResourceMap } from '../../../../base/common/map.js'; const FIVE_MINUTES = 5 * 60 * 1000; const THIRTY_SECONDS = 30 * 1000; @@ -99,6 +100,25 @@ type ExtensionUrlReloadHandlerClassification = { comment: 'This is used to understand the drop funnel of extension URI handling by the OS & VS Code.'; }; +export interface IExtensionUrlHandlerOverride { + handleURL(uri: URI): Promise; +} + +export class ExtensionUrlHandlerOverrideRegistry { + + private static readonly handlers = new ResourceMap(); + + static registerHandler(uri: URI, handler: IExtensionUrlHandlerOverride): IDisposable { + this.handlers.set(uri, handler); + + return toDisposable(() => this.handlers.delete(uri)); + } + + static getHandler(uri: URI): IExtensionUrlHandlerOverride | undefined { + return this.handlers.get(uri); + } +} + /** * This class handles URLs which are directed towards extensions. * If a URL is directed towards an inactive extension, it buffers it, @@ -153,6 +173,14 @@ class ExtensionUrlHandler implements IExtensionUrlHandler, IURLHandler { return false; } + const overrideHandler = ExtensionUrlHandlerOverrideRegistry.getHandler(uri); + if (overrideHandler) { + const handled = await overrideHandler.handleURL(uri); + if (handled) { + return handled; + } + } + const extensionId = uri.authority; this.telemetryService.publicLog2('uri_invoked/start', { extensionId }); diff --git a/src/vs/workbench/services/extensions/electron-sandbox/cachedExtensionScanner.ts b/src/vs/workbench/services/extensions/electron-sandbox/cachedExtensionScanner.ts index 04d4ff9703d..9e6d46f7824 100644 --- a/src/vs/workbench/services/extensions/electron-sandbox/cachedExtensionScanner.ts +++ b/src/vs/workbench/services/extensions/electron-sandbox/cachedExtensionScanner.ts @@ -53,7 +53,7 @@ export class CachedExtensionScanner { try { const language = platform.language; const result = await Promise.allSettled([ - this._extensionsScannerService.scanSystemExtensions({ language, useCache: true, checkControlFile: true }), + this._extensionsScannerService.scanSystemExtensions({ language, checkControlFile: true }), this._extensionsScannerService.scanUserExtensions({ language, profileLocation: this._userDataProfileService.currentProfile.extensionsResource, useCache: true }), this._environmentService.remoteAuthority ? [] : this._extensionManagementService.getInstalledWorkspaceExtensions(false) ]); @@ -86,7 +86,7 @@ export class CachedExtensionScanner { } try { - scannedDevelopedExtensions = await this._extensionsScannerService.scanExtensionsUnderDevelopment({ language }, [...scannedSystemExtensions, ...scannedUserExtensions]); + scannedDevelopedExtensions = await this._extensionsScannerService.scanExtensionsUnderDevelopment([...scannedSystemExtensions, ...scannedUserExtensions], { language }); } catch (error) { this._logService.error(error); } diff --git a/src/vs/workbench/services/statusbar/browser/statusbar.ts b/src/vs/workbench/services/statusbar/browser/statusbar.ts index 3b3f1f1fe17..7c81333e416 100644 --- a/src/vs/workbench/services/statusbar/browser/statusbar.ts +++ b/src/vs/workbench/services/statusbar/browser/statusbar.ts @@ -8,6 +8,7 @@ import { DisposableStore, IDisposable } from '../../../../base/common/lifecycle. import { ThemeColor } from '../../../../base/common/themables.js'; import { Command } from '../../../../editor/common/languages.js'; import { IMarkdownString } from '../../../../base/common/htmlContent.js'; +import { IManagedHoverTooltipMarkdownString } from '../../../../base/browser/ui/hover/hover.js'; import { ColorIdentifier } from '../../../../platform/theme/common/colorRegistry.js'; import { IAuxiliaryStatusbarPart, IStatusbarEntryContainer } from '../../../browser/parts/statusbar/statusbarPart.js'; @@ -111,6 +112,19 @@ export interface IStatusbarStyleOverride { export type StatusbarEntryKind = 'standard' | 'warning' | 'error' | 'prominent' | 'remote' | 'offline'; export const StatusbarEntryKinds: StatusbarEntryKind[] = ['standard', 'warning', 'error', 'prominent', 'remote', 'offline']; +export type TooltipContent = string | IMarkdownString | IManagedHoverTooltipMarkdownString | HTMLElement; + +export interface ITooltipWithCommands { + readonly content: TooltipContent; + readonly commands: Command[]; +} + +export function isTooltipWithCommands(thing: unknown): thing is ITooltipWithCommands { + const candidate = thing as ITooltipWithCommands | undefined; + + return !!candidate?.content && Array.isArray(candidate?.commands); +} + /** * A declarative way of describing a status bar entry */ @@ -141,9 +155,11 @@ export interface IStatusbarEntry { readonly role?: string; /** - * An optional tooltip text to show when you hover over the entry + * An optional tooltip text to show when you hover over the entry. + * + * Use `ITooltipWithCommands` to show a tooltip with commands in hover footer area. */ - readonly tooltip?: string | IMarkdownString | HTMLElement; + readonly tooltip?: TooltipContent | ITooltipWithCommands; /** * An optional color to use for the entry. diff --git a/src/vs/workbench/services/textfile/common/textFileEditorModelManager.ts b/src/vs/workbench/services/textfile/common/textFileEditorModelManager.ts index 8be051a5821..e48add78adb 100644 --- a/src/vs/workbench/services/textfile/common/textFileEditorModelManager.ts +++ b/src/vs/workbench/services/textfile/common/textFileEditorModelManager.ts @@ -26,6 +26,17 @@ import { PLAINTEXT_EXTENSION, PLAINTEXT_LANGUAGE_ID } from '../../../../editor/c import { IUriIdentityService } from '../../../../platform/uriIdentity/common/uriIdentity.js'; import { IProgress, IProgressStep } from '../../../../platform/progress/common/progress.js'; +interface ITextFileEditorModelToRestore { + readonly source: URI; + readonly target: URI; + readonly snapshot?: ITextSnapshot; + readonly language?: { + readonly id: string; + readonly explicit: boolean; + }; + readonly encoding?: string; +} + export class TextFileEditorModelManager extends Disposable implements ITextFileEditorModelManager { private readonly _onDidCreate = this._register(new Emitter({ leakWarningThreshold: 500 /* increased for users with hundreds of inputs opened */ })); @@ -171,13 +182,13 @@ export class TextFileEditorModelManager extends Disposable implements ITextFileE } } - private readonly mapCorrelationIdToModelsToRestore = new Map(); + private readonly mapCorrelationIdToModelsToRestore = new Map(); private onWillRunWorkingCopyFileOperation(e: WorkingCopyFileEvent): void { // Move / Copy: remember models to restore after the operation if (e.operation === FileOperation.MOVE || e.operation === FileOperation.COPY) { - const modelsToRestore: { source: URI; target: URI; snapshot?: ITextSnapshot; languageId?: string; encoding?: string }[] = []; + const modelsToRestore: ITextFileEditorModelToRestore[] = []; for (const { source, target } of e.files) { if (source) { @@ -210,10 +221,14 @@ export class TextFileEditorModelManager extends Disposable implements ITextFileE targetModelResource = joinPath(target, sourceModelResource.path.substr(source.path.length + 1)); } + const languageId = sourceModel.getLanguageId(); modelsToRestore.push({ source: sourceModelResource, target: targetModelResource, - languageId: sourceModel.getLanguageId(), + language: languageId ? { + id: languageId, + explicit: sourceModel.languageChangeSource === 'user' + } : undefined, encoding: sourceModel.getEncoding(), snapshot: sourceModel.isDirty() ? sourceModel.createSnapshot() : undefined }); @@ -286,16 +301,22 @@ export class TextFileEditorModelManager extends Disposable implements ITextFileE encoding: modelToRestore.encoding }); - // restore previous language only if the language is now unspecified and it was specified - // but not when the file was explicitly stored with the plain text extension - // (https://github.com/microsoft/vscode/issues/125795) - if ( - modelToRestore.languageId && - modelToRestore.languageId !== PLAINTEXT_LANGUAGE_ID && - restoredModel.getLanguageId() === PLAINTEXT_LANGUAGE_ID && - extname(target) !== PLAINTEXT_EXTENSION - ) { - restoredModel.updateTextEditorModel(undefined, modelToRestore.languageId); + // restore model language only if it is specific + if (modelToRestore.language?.id && modelToRestore.language.id !== PLAINTEXT_LANGUAGE_ID) { + + // an explicitly set language is restored via `setLanguageId` + // to preserve it as explicitly set by the user. + // (https://github.com/microsoft/vscode/issues/203648) + if (modelToRestore.language.explicit) { + restoredModel.setLanguageId(modelToRestore.language.id); + } + + // otherwise, a model language is applied via lower level + // APIs to not confuse it with an explicitly set language. + // (https://github.com/microsoft/vscode/issues/125795) + else if (restoredModel.getLanguageId() === PLAINTEXT_LANGUAGE_ID && extname(target) !== PLAINTEXT_EXTENSION) { + restoredModel.updateTextEditorModel(undefined, modelToRestore.language.id); + } } })); } diff --git a/src/vs/workbench/test/electron-sandbox/workbenchTestServices.ts b/src/vs/workbench/test/electron-sandbox/workbenchTestServices.ts index 9bc958141ff..ecec6ce983d 100644 --- a/src/vs/workbench/test/electron-sandbox/workbenchTestServices.ts +++ b/src/vs/workbench/test/electron-sandbox/workbenchTestServices.ts @@ -162,6 +162,7 @@ export class TestNativeHostService implements INativeHostService { async windowsGetStringRegKey(hive: 'HKEY_CURRENT_USER' | 'HKEY_LOCAL_MACHINE' | 'HKEY_CLASSES_ROOT' | 'HKEY_USERS' | 'HKEY_CURRENT_CONFIG', path: string, name: string): Promise { return undefined; } async profileRenderer(): Promise { throw new Error(); } async getScreenshot(): Promise { return undefined; } + async overrideDefaultTitlebarStyle(style: 'native' | 'custom' | undefined): Promise { } } export class TestExtensionTipsService extends AbstractNativeExtensionTipsService { diff --git a/src/vscode-dts/vscode.proposed.scmHistoryProvider.d.ts b/src/vscode-dts/vscode.proposed.scmHistoryProvider.d.ts index ef2984d9686..8ab6b7cbd66 100644 --- a/src/vscode-dts/vscode.proposed.scmHistoryProvider.d.ts +++ b/src/vscode-dts/vscode.proposed.scmHistoryProvider.d.ts @@ -51,6 +51,7 @@ declare module 'vscode' { readonly message: string; readonly displayId?: string; readonly author?: string; + readonly authorEmail?: string; readonly timestamp?: number; readonly statistics?: SourceControlHistoryItemStatistics; readonly references?: SourceControlHistoryItemRef[]; diff --git a/test/automation/src/code.ts b/test/automation/src/code.ts index a925cdd65bc..fd64b7cdeb1 100644 --- a/test/automation/src/code.ts +++ b/test/automation/src/code.ts @@ -12,6 +12,7 @@ import { launch as launchPlaywrightBrowser } from './playwrightBrowser'; import { PlaywrightDriver } from './playwrightDriver'; import { launch as launchPlaywrightElectron } from './playwrightElectron'; import { teardown } from './processes'; +import { Quality } from './application'; export interface LaunchOptions { codePath?: string; @@ -28,6 +29,7 @@ export interface LaunchOptions { readonly tracing?: boolean; readonly headless?: boolean; readonly browser?: 'chromium' | 'webkit' | 'firefox'; + readonly quality: Quality; } interface ICodeInstance { @@ -77,7 +79,7 @@ export async function launch(options: LaunchOptions): Promise { const { serverProcess, driver } = await measureAndLog(() => launchPlaywrightBrowser(options), 'launch playwright (browser)', options.logger); registerInstance(serverProcess, options.logger, 'server'); - return new Code(driver, options.logger, serverProcess); + return new Code(driver, options.logger, serverProcess, options.quality); } // Electron smoke tests (playwright) @@ -85,7 +87,7 @@ export async function launch(options: LaunchOptions): Promise { const { electronProcess, driver } = await measureAndLog(() => launchPlaywrightElectron(options), 'launch playwright (electron)', options.logger); registerInstance(electronProcess, options.logger, 'electron'); - return new Code(driver, options.logger, electronProcess); + return new Code(driver, options.logger, electronProcess, options.quality); } } @@ -96,7 +98,8 @@ export class Code { constructor( driver: PlaywrightDriver, readonly logger: Logger, - private readonly mainProcess: cp.ChildProcess + private readonly mainProcess: cp.ChildProcess, + readonly quality: Quality ) { this.driver = new Proxy(driver, { get(target, prop) { diff --git a/test/automation/src/debug.ts b/test/automation/src/debug.ts index b7b7d427f4b..e2e227fc35e 100644 --- a/test/automation/src/debug.ts +++ b/test/automation/src/debug.ts @@ -9,6 +9,7 @@ import { Code, findElement } from './code'; import { Editors } from './editors'; import { Editor } from './editor'; import { IElement } from './driver'; +import { Quality } from './application'; const VIEWLET = 'div[id="workbench.view.debug"]'; const DEBUG_VIEW = `${VIEWLET}`; @@ -31,7 +32,8 @@ const CONSOLE_OUTPUT = `.repl .output.expression .value`; const CONSOLE_EVALUATION_RESULT = `.repl .evaluation-result.expression .value`; const CONSOLE_LINK = `.repl .value a.link`; -const REPL_FOCUSED = '.repl-input-wrapper .monaco-editor textarea'; +const REPL_FOCUSED_NATIVE_EDIT_CONTEXT = '.repl-input-wrapper .monaco-editor .native-edit-context'; +const REPL_FOCUSED_TEXTAREA = '.repl-input-wrapper .monaco-editor textarea'; export interface IStackFrame { name: string; @@ -127,8 +129,9 @@ export class Debug extends Viewlet { async waitForReplCommand(text: string, accept: (result: string) => boolean): Promise { await this.commands.runCommand('Debug: Focus on Debug Console View'); - await this.code.waitForActiveElement(REPL_FOCUSED); - await this.code.waitForSetValue(REPL_FOCUSED, text); + const selector = this.code.quality === Quality.Stable ? REPL_FOCUSED_TEXTAREA : REPL_FOCUSED_NATIVE_EDIT_CONTEXT; + await this.code.waitForActiveElement(selector); + await this.code.waitForSetValue(selector, text); // Wait for the keys to be picked up by the editor model such that repl evaluates what just got typed await this.editor.waitForEditorContents('debug:replinput', s => s.indexOf(text) >= 0); diff --git a/test/automation/src/editor.ts b/test/automation/src/editor.ts index 538866bfc06..dd616079565 100644 --- a/test/automation/src/editor.ts +++ b/test/automation/src/editor.ts @@ -6,6 +6,7 @@ import { References } from './peek'; import { Commands } from './workbench'; import { Code } from './code'; +import { Quality } from './application'; const RENAME_BOX = '.monaco-editor .monaco-editor.rename-box'; const RENAME_INPUT = `${RENAME_BOX} .rename-input`; @@ -78,10 +79,10 @@ export class Editor { async waitForEditorFocus(filename: string, lineNumber: number, selectorPrefix = ''): Promise { const editor = [selectorPrefix || '', EDITOR(filename)].join(' '); const line = `${editor} .view-lines > .view-line:nth-child(${lineNumber})`; - const textarea = `${editor} textarea`; + const editContext = `${editor} ${this._editContextSelector()}`; await this.code.waitAndClick(line, 1, 1); - await this.code.waitForActiveElement(textarea); + await this.code.waitForActiveElement(editContext); } async waitForTypeInEditor(filename: string, text: string, selectorPrefix = ''): Promise { @@ -92,14 +93,18 @@ export class Editor { await this.code.waitForElement(editor); - const textarea = `${editor} textarea`; - await this.code.waitForActiveElement(textarea); + const editContext = `${editor} ${this._editContextSelector()}`; + await this.code.waitForActiveElement(editContext); - await this.code.waitForTypeInEditor(textarea, text); + await this.code.waitForTypeInEditor(editContext, text); await this.waitForEditorContents(filename, c => c.indexOf(text) > -1, selectorPrefix); } + private _editContextSelector() { + return this.code.quality === Quality.Stable ? 'textarea' : '.native-edit-context'; + } + async waitForEditorContents(filename: string, accept: (contents: string) => boolean, selectorPrefix = ''): Promise { const selector = [selectorPrefix || '', `${EDITOR(filename)} .view-lines`].join(' '); return this.code.waitForTextContent(selector, undefined, c => accept(c.replace(/\u00a0/g, ' '))); diff --git a/test/automation/src/editors.ts b/test/automation/src/editors.ts index b3a914ffff0..472385c8534 100644 --- a/test/automation/src/editors.ts +++ b/test/automation/src/editors.ts @@ -3,6 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { Quality } from './application'; import { Code } from './code'; export class Editors { @@ -53,7 +54,7 @@ export class Editors { } async waitForActiveEditor(fileName: string, retryCount?: number): Promise { - const selector = `.editor-instance .monaco-editor[data-uri$="${fileName}"] textarea`; + const selector = `.editor-instance .monaco-editor[data-uri$="${fileName}"] ${this.code.quality === Quality.Stable ? 'textarea' : '.native-edit-context'}`; return this.code.waitForActiveElement(selector, retryCount); } diff --git a/test/automation/src/extensions.ts b/test/automation/src/extensions.ts index 2a481f9fe76..c881e4fd8dc 100644 --- a/test/automation/src/extensions.ts +++ b/test/automation/src/extensions.ts @@ -8,6 +8,7 @@ import { Code } from './code'; import { ncp } from 'ncp'; import { promisify } from 'util'; import { Commands } from './workbench'; +import { Quality } from './application'; import path = require('path'); import fs = require('fs'); @@ -20,7 +21,7 @@ export class Extensions extends Viewlet { async searchForExtension(id: string): Promise { await this.commands.runCommand('Extensions: Focus on Extensions View', { exactLabelMatch: true }); - await this.code.waitForTypeInEditor('div.extensions-viewlet[id="workbench.view.extensions"] .monaco-editor textarea', `@id:${id}`); + await this.code.waitForTypeInEditor(`div.extensions-viewlet[id="workbench.view.extensions"] .monaco-editor ${this.code.quality === Quality.Stable ? 'textarea' : '.native-edit-context'}`, `@id:${id}`); await this.code.waitForTextContent(`div.part.sidebar div.composite.title h2`, 'Extensions: Marketplace'); let retrials = 1; diff --git a/test/automation/src/notebook.ts b/test/automation/src/notebook.ts index dff250027db..cd46cbdb0dd 100644 --- a/test/automation/src/notebook.ts +++ b/test/automation/src/notebook.ts @@ -3,6 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { Quality } from './application'; import { Code } from './code'; import { QuickAccess } from './quickaccess'; import { QuickInput } from './quickinput'; @@ -46,10 +47,10 @@ export class Notebook { await this.code.waitForElement(editor); - const textarea = `${editor} textarea`; - await this.code.waitForActiveElement(textarea); + const editContext = `${editor} ${this.code.quality === Quality.Stable ? 'textarea' : '.native-edit-context'}`; + await this.code.waitForActiveElement(editContext); - await this.code.waitForTypeInEditor(textarea, text); + await this.code.waitForTypeInEditor(editContext, text); await this._waitForActiveCellEditorContents(c => c.indexOf(text) > -1); } diff --git a/test/automation/src/scm.ts b/test/automation/src/scm.ts index 9f950f2b16a..6489badbe8a 100644 --- a/test/automation/src/scm.ts +++ b/test/automation/src/scm.ts @@ -6,9 +6,11 @@ import { Viewlet } from './viewlet'; import { IElement } from './driver'; import { findElement, findElements, Code } from './code'; +import { Quality } from './application'; const VIEWLET = 'div[id="workbench.view.scm"]'; -const SCM_INPUT = `${VIEWLET} .scm-editor textarea`; +const SCM_INPUT_NATIVE_EDIT_CONTEXT = `${VIEWLET} .scm-editor .native-edit-context`; +const SCM_INPUT_TEXTAREA = `${VIEWLET} .scm-editor textarea`; const SCM_RESOURCE = `${VIEWLET} .monaco-list-row .resource`; const REFRESH_COMMAND = `div[id="workbench.parts.sidebar"] .actions-container a.action-label[aria-label="Refresh"]`; const COMMIT_COMMAND = `div[id="workbench.parts.sidebar"] .actions-container a.action-label[aria-label="Commit"]`; @@ -44,7 +46,7 @@ export class SCM extends Viewlet { async openSCMViewlet(): Promise { await this.code.dispatchKeybinding('ctrl+shift+g'); - await this.code.waitForElement(SCM_INPUT); + await this.code.waitForElement(this._editContextSelector()); } async waitForChange(name: string, type?: string): Promise { @@ -71,9 +73,13 @@ export class SCM extends Viewlet { } async commit(message: string): Promise { - await this.code.waitAndClick(SCM_INPUT); - await this.code.waitForActiveElement(SCM_INPUT); - await this.code.waitForSetValue(SCM_INPUT, message); + await this.code.waitAndClick(this._editContextSelector()); + await this.code.waitForActiveElement(this._editContextSelector()); + await this.code.waitForSetValue(this._editContextSelector(), message); await this.code.waitAndClick(COMMIT_COMMAND); } + + private _editContextSelector(): string { + return this.code.quality === Quality.Stable ? SCM_INPUT_TEXTAREA : SCM_INPUT_NATIVE_EDIT_CONTEXT; + } } diff --git a/test/automation/src/settings.ts b/test/automation/src/settings.ts index 68401eb0eda..8cf221b1487 100644 --- a/test/automation/src/settings.ts +++ b/test/automation/src/settings.ts @@ -7,8 +7,10 @@ import { Editor } from './editor'; import { Editors } from './editors'; import { Code } from './code'; import { QuickAccess } from './quickaccess'; +import { Quality } from './application'; -const SEARCH_BOX = '.settings-editor .suggest-input-container .monaco-editor textarea'; +const SEARCH_BOX_NATIVE_EDIT_CONTEXT = '.settings-editor .suggest-input-container .monaco-editor .native-edit-context'; +const SEARCH_BOX_TEXTAREA = '.settings-editor .suggest-input-container .monaco-editor textarea'; export class SettingsEditor { constructor(private code: Code, private editors: Editors, private editor: Editor, private quickaccess: QuickAccess) { } @@ -57,13 +59,13 @@ export class SettingsEditor { async openUserSettingsUI(): Promise { await this.quickaccess.runCommand('workbench.action.openSettings2'); - await this.code.waitForActiveElement(SEARCH_BOX); + await this.code.waitForActiveElement(this._editContextSelector()); } async searchSettingsUI(query: string): Promise { await this.openUserSettingsUI(); - await this.code.waitAndClick(SEARCH_BOX); + await this.code.waitAndClick(this._editContextSelector()); if (process.platform === 'darwin') { await this.code.dispatchKeybinding('cmd+a'); } else { @@ -71,7 +73,11 @@ export class SettingsEditor { } await this.code.dispatchKeybinding('Delete'); await this.code.waitForElements('.settings-editor .settings-count-widget', false, results => !results || (results?.length === 1 && !results[0].textContent)); - await this.code.waitForTypeInEditor('.settings-editor .suggest-input-container .monaco-editor textarea', query); + await this.code.waitForTypeInEditor(this._editContextSelector(), query); await this.code.waitForElements('.settings-editor .settings-count-widget', false, results => results?.length === 1 && results[0].textContent.includes('Found')); } + + private _editContextSelector() { + return this.code.quality === Quality.Stable ? SEARCH_BOX_TEXTAREA : SEARCH_BOX_NATIVE_EDIT_CONTEXT; + } }