diff --git a/build/lib/extensions.ts b/build/lib/extensions.ts index 24462a3b26e..e06f1510a66 100644 --- a/build/lib/extensions.ts +++ b/build/lib/extensions.ts @@ -98,14 +98,22 @@ function fromLocalWebpack(extensionPath: string, webpackConfigFileName: string, const result = es.through(); const packagedDependencies: string[] = []; + const stripOutSourceMaps: string[] = []; const packageJsonConfig = require(path.join(extensionPath, 'package.json')); if (packageJsonConfig.dependencies) { - const webpackRootConfig = require(path.join(extensionPath, webpackConfigFileName)).default; + const webpackConfig = require(path.join(extensionPath, webpackConfigFileName)); + const webpackRootConfig = webpackConfig.default; for (const key in webpackRootConfig.externals) { if (key in packageJsonConfig.dependencies) { packagedDependencies.push(key); } } + + if (webpackConfig.StripOutSourceMaps) { + for (const filePath of webpackConfig.StripOutSourceMaps) { + stripOutSourceMaps.push(filePath); + } + } } // TODO: add prune support based on packagedDependencies to vsce.PackageManager.Npm similar @@ -177,10 +185,15 @@ function fromLocalWebpack(extensionPath: string, webpackConfigFileName: string, // * rewrite sourceMappingURL // * save to disk so that upload-task picks this up if (path.extname(data.basename) === '.js') { - const contents = (data.contents as Buffer).toString('utf8'); - data.contents = Buffer.from(contents.replace(/\n\/\/# sourceMappingURL=(.*)$/gm, function (_m, g1) { - return `\n//# sourceMappingURL=${sourceMappingURLBase}/extensions/${path.basename(extensionPath)}/${relativeOutputPath}/${g1}`; - }), 'utf8'); + if (stripOutSourceMaps.indexOf(data.relative) >= 0) { // remove source map + const contents = (data.contents as Buffer).toString('utf8'); + data.contents = Buffer.from(contents.replace(/\n\/\/# sourceMappingURL=(.*)$/gm, ''), 'utf8'); + } else { + const contents = (data.contents as Buffer).toString('utf8'); + data.contents = Buffer.from(contents.replace(/\n\/\/# sourceMappingURL=(.*)$/gm, function (_m, g1) { + return `\n//# sourceMappingURL=${sourceMappingURLBase}/extensions/${path.basename(extensionPath)}/${relativeOutputPath}/${g1}`; + }), 'utf8'); + } } this.emit('data', data); diff --git a/extensions/git/extension.webpack.config.js b/extensions/git/extension.webpack.config.js index 15cf273015b..34f801e2eca 100644 --- a/extensions/git/extension.webpack.config.js +++ b/extensions/git/extension.webpack.config.js @@ -13,3 +13,5 @@ export default withDefaults({ ['git-editor-main']: './src/git-editor-main.ts' } }); + +export const StripOutSourceMaps = ['dist/askpass-main.js']; diff --git a/package-lock.json b/package-lock.json index 7d4af81b621..b198a0d53d6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,7 +15,7 @@ "@microsoft/1ds-post-js": "^3.2.13", "@parcel/watcher": "^2.5.6", "@types/semver": "^7.5.8", - "@vscode/codicons": "^0.0.45-2", + "@vscode/codicons": "^0.0.45-4", "@vscode/deviceid": "^0.1.1", "@vscode/iconv-lite-umd": "0.7.1", "@vscode/native-watchdog": "^1.4.6", @@ -2947,9 +2947,9 @@ ] }, "node_modules/@vscode/codicons": { - "version": "0.0.45-2", - "resolved": "https://registry.npmjs.org/@vscode/codicons/-/codicons-0.0.45-2.tgz", - "integrity": "sha512-Z5uZd8E2f84Jf4jv6ozSjIU/cnHn7F1REBGUtzdqJufWoLYauH/nwpVn8fWtvXNtR1QwEyh6x3WAeR2l5rnnyg==", + "version": "0.0.45-4", + "resolved": "https://registry.npmjs.org/@vscode/codicons/-/codicons-0.0.45-4.tgz", + "integrity": "sha512-uuWqpry+FcHAw1JDkXwEW0YIuTtX3n6KqSshNlvLUjuP92PSrfq99jW52AWJ7qeunmPvgKCaZOeSSLUqHRHjmw==", "license": "CC-BY-4.0" }, "node_modules/@vscode/deviceid": { diff --git a/package.json b/package.json index 7678b49e9b6..6c0299c23ec 100644 --- a/package.json +++ b/package.json @@ -77,7 +77,7 @@ "@microsoft/1ds-post-js": "^3.2.13", "@parcel/watcher": "^2.5.6", "@types/semver": "^7.5.8", - "@vscode/codicons": "^0.0.45-2", + "@vscode/codicons": "^0.0.45-4", "@vscode/deviceid": "^0.1.1", "@vscode/iconv-lite-umd": "0.7.1", "@vscode/native-watchdog": "^1.4.6", @@ -240,4 +240,4 @@ "optionalDependencies": { "windows-foreground-love": "0.6.1" } -} \ No newline at end of file +} diff --git a/remote/web/package-lock.json b/remote/web/package-lock.json index b2d86a2b17e..9ebe85bde28 100644 --- a/remote/web/package-lock.json +++ b/remote/web/package-lock.json @@ -10,7 +10,7 @@ "dependencies": { "@microsoft/1ds-core-js": "^3.2.13", "@microsoft/1ds-post-js": "^3.2.13", - "@vscode/codicons": "^0.0.45-2", + "@vscode/codicons": "^0.0.45-4", "@vscode/iconv-lite-umd": "0.7.1", "@vscode/tree-sitter-wasm": "^0.3.0", "@vscode/vscode-languagedetection": "1.0.21", @@ -73,9 +73,9 @@ "integrity": "sha512-n1VPsljTSkthsAFYdiWfC+DKzK2WwcRp83Y1YAqdX552BstvsDjft9YXppjUzp11BPsapDoO1LDgrDB0XVsfNQ==" }, "node_modules/@vscode/codicons": { - "version": "0.0.45-2", - "resolved": "https://registry.npmjs.org/@vscode/codicons/-/codicons-0.0.45-2.tgz", - "integrity": "sha512-Z5uZd8E2f84Jf4jv6ozSjIU/cnHn7F1REBGUtzdqJufWoLYauH/nwpVn8fWtvXNtR1QwEyh6x3WAeR2l5rnnyg==", + "version": "0.0.45-4", + "resolved": "https://registry.npmjs.org/@vscode/codicons/-/codicons-0.0.45-4.tgz", + "integrity": "sha512-uuWqpry+FcHAw1JDkXwEW0YIuTtX3n6KqSshNlvLUjuP92PSrfq99jW52AWJ7qeunmPvgKCaZOeSSLUqHRHjmw==", "license": "CC-BY-4.0" }, "node_modules/@vscode/iconv-lite-umd": { diff --git a/remote/web/package.json b/remote/web/package.json index 3c5f2e46943..f940ea309ad 100644 --- a/remote/web/package.json +++ b/remote/web/package.json @@ -5,7 +5,7 @@ "dependencies": { "@microsoft/1ds-core-js": "^3.2.13", "@microsoft/1ds-post-js": "^3.2.13", - "@vscode/codicons": "^0.0.45-2", + "@vscode/codicons": "^0.0.45-4", "@vscode/iconv-lite-umd": "0.7.1", "@vscode/tree-sitter-wasm": "^0.3.0", "@vscode/vscode-languagedetection": "1.0.21", diff --git a/src/vs/base/common/codiconsLibrary.ts b/src/vs/base/common/codiconsLibrary.ts index dde42f3be9e..0ff9490a058 100644 --- a/src/vs/base/common/codiconsLibrary.ts +++ b/src/vs/base/common/codiconsLibrary.ts @@ -653,4 +653,5 @@ export const codiconsLibrary = { screenCut: register('screen-cut', 0xec7f), ask: register('ask', 0xec80), openai: register('openai', 0xec81), + claude: register('claude', 0xec82), } as const; diff --git a/src/vs/editor/common/core/edits/stringEdit.ts b/src/vs/editor/common/core/edits/stringEdit.ts index 9f6c9800f41..83d40b5e8c2 100644 --- a/src/vs/editor/common/core/edits/stringEdit.ts +++ b/src/vs/editor/common/core/edits/stringEdit.ts @@ -97,6 +97,7 @@ export abstract class BaseStringEdit = BaseSt let baseIdx = 0; let ourIdx = 0; let offset = 0; + let lastEndEx = -1; // Track end of last added edit to ensure sorted/disjoint invariant while (ourIdx < this.replacements.length || baseIdx < base.replacements.length) { // take the edit that starts first @@ -108,10 +109,17 @@ export abstract class BaseStringEdit = BaseSt break; } else if (!baseEdit) { // no more edits from base - newEdits.push(new StringReplacement( - ourEdit.replaceRange.delta(offset), - ourEdit.newText - )); + const transformedRange = ourEdit.replaceRange.delta(offset); + // Check if the transformed edit would violate the sorted/disjoint invariant + if (transformedRange.start < lastEndEx) { + if (noOverlap) { + return undefined; + } + ourIdx++; // Skip this edit as it conflicts with a previously added edit + continue; + } + newEdits.push(new StringReplacement(transformedRange, ourEdit.newText)); + lastEndEx = transformedRange.endExclusive; ourIdx++; } else if (ourEdit.replaceRange.intersects(baseEdit.replaceRange) || areConcurrentInserts(ourEdit.replaceRange, baseEdit.replaceRange)) { ourIdx++; // Don't take our edit, as it is conflicting -> skip @@ -120,10 +128,17 @@ export abstract class BaseStringEdit = BaseSt } } else if (ourEdit.replaceRange.start < baseEdit.replaceRange.start) { // Our edit starts first - newEdits.push(new StringReplacement( - ourEdit.replaceRange.delta(offset), - ourEdit.newText - )); + const transformedRange = ourEdit.replaceRange.delta(offset); + // Check if the transformed edit would violate the sorted/disjoint invariant + if (transformedRange.start < lastEndEx) { + if (noOverlap) { + return undefined; + } + ourIdx++; // Skip this edit as it conflicts with a previously added edit + continue; + } + newEdits.push(new StringReplacement(transformedRange, ourEdit.newText)); + lastEndEx = transformedRange.endExclusive; ourIdx++; } else { baseIdx++; diff --git a/src/vs/editor/test/common/core/stringEdit.test.ts b/src/vs/editor/test/common/core/stringEdit.test.ts index c54e132580e..9189dd62be3 100644 --- a/src/vs/editor/test/common/core/stringEdit.test.ts +++ b/src/vs/editor/test/common/core/stringEdit.test.ts @@ -154,6 +154,62 @@ suite('Edit', () => { // This should return undefined because both are inserts at the same position assert.strictEqual(rebasedEdit, undefined); }); + + test('tryRebase should return undefined when rebasing would produce non-disjoint edits (negative offset case)', () => { + // ourEdit1: [100, 110) -> "A" + // ourEdit2: [120, 120) -> "B" + // baseEdit: [110, 125) -> "" (delete 15 chars, offset = -15) + // After transformation, ourEdit2 at [105, 105) < ourEdit1 end (110) + + const ourEdit = StringEdit.create([ + new StringReplacement(new OffsetRange(100, 110), 'A'), + new StringReplacement(OffsetRange.emptyAt(120), 'B'), + ]); + + const baseEdit = StringEdit.create([ + new StringReplacement(new OffsetRange(110, 125), ''), + ]); + + const result = ourEdit.tryRebase(baseEdit); + assert.strictEqual(result, undefined); + }); + + test('tryRebase should succeed when edits remain disjoint after rebasing', () => { + // ourEdit1: [100, 110) -> "A" + // ourEdit2: [200, 210) -> "B" + // baseEdit: [50, 60) -> "" (delete 10 chars, offset = -10) + // After: ourEdit1 at [90, 100), ourEdit2 at [190, 200) - still disjoint + + const ourEdit = StringEdit.create([ + new StringReplacement(new OffsetRange(100, 110), 'A'), + new StringReplacement(new OffsetRange(200, 210), 'B'), + ]); + + const baseEdit = StringEdit.create([ + new StringReplacement(new OffsetRange(50, 60), ''), + ]); + + const result = ourEdit.tryRebase(baseEdit); + assert.ok(result); + assert.strictEqual(result?.replacements[0].replaceRange.start, 90); + assert.strictEqual(result?.replacements[1].replaceRange.start, 190); + }); + + test('rebaseSkipConflicting should skip edits that would produce non-disjoint results', () => { + const ourEdit = StringEdit.create([ + new StringReplacement(new OffsetRange(100, 110), 'A'), + new StringReplacement(OffsetRange.emptyAt(120), 'B'), + ]); + + const baseEdit = StringEdit.create([ + new StringReplacement(new OffsetRange(110, 125), ''), + ]); + + // Should not throw, and should skip the conflicting edit + const result = ourEdit.rebaseSkipConflicting(baseEdit); + assert.strictEqual(result.replacements.length, 1); + assert.strictEqual(result.replacements[0].replaceRange.start, 100); + }); }); suite('ArrayEdit', () => { diff --git a/src/vs/platform/browserView/common/browserView.ts b/src/vs/platform/browserView/common/browserView.ts index 3d86bf537a1..5e76962350b 100644 --- a/src/vs/platform/browserView/common/browserView.ts +++ b/src/vs/platform/browserView/common/browserView.ts @@ -27,6 +27,7 @@ export interface IBrowserViewState { canGoForward: boolean; loading: boolean; focused: boolean; + visible: boolean; isDevToolsOpen: boolean; lastScreenshot: VSBuffer | undefined; lastFavicon: string | undefined; @@ -55,6 +56,10 @@ export interface IBrowserViewFocusEvent { focused: boolean; } +export interface IBrowserViewVisibilityEvent { + visible: boolean; +} + export interface IBrowserViewDevToolsStateEvent { isDevToolsOpen: boolean; } @@ -112,6 +117,7 @@ export interface IBrowserViewService { onDynamicDidNavigate(id: string): Event; onDynamicDidChangeLoadingState(id: string): Event; onDynamicDidChangeFocus(id: string): Event; + onDynamicDidChangeVisibility(id: string): Event; onDynamicDidChangeDevToolsState(id: string): Event; onDynamicDidKeyCommand(id: string): Event; onDynamicDidChangeTitle(id: string): Event; diff --git a/src/vs/platform/browserView/electron-main/browserView.ts b/src/vs/platform/browserView/electron-main/browserView.ts index ed86bb2762a..33717cb54c2 100644 --- a/src/vs/platform/browserView/electron-main/browserView.ts +++ b/src/vs/platform/browserView/electron-main/browserView.ts @@ -7,7 +7,7 @@ import { WebContentsView, webContents } from 'electron'; import { Disposable } from '../../../base/common/lifecycle.js'; import { Emitter, Event } from '../../../base/common/event.js'; import { VSBuffer } from '../../../base/common/buffer.js'; -import { IBrowserViewBounds, IBrowserViewDevToolsStateEvent, IBrowserViewFocusEvent, IBrowserViewKeyDownEvent, IBrowserViewState, IBrowserViewNavigationEvent, IBrowserViewLoadingEvent, IBrowserViewLoadError, IBrowserViewTitleChangeEvent, IBrowserViewFaviconChangeEvent, IBrowserViewNewPageRequest, BrowserViewStorageScope, IBrowserViewCaptureScreenshotOptions, IBrowserViewFindInPageOptions, IBrowserViewFindInPageResult } from '../common/browserView.js'; +import { IBrowserViewBounds, IBrowserViewDevToolsStateEvent, IBrowserViewFocusEvent, IBrowserViewKeyDownEvent, IBrowserViewState, IBrowserViewNavigationEvent, IBrowserViewLoadingEvent, IBrowserViewLoadError, IBrowserViewTitleChangeEvent, IBrowserViewFaviconChangeEvent, IBrowserViewNewPageRequest, BrowserViewStorageScope, IBrowserViewCaptureScreenshotOptions, IBrowserViewFindInPageOptions, IBrowserViewFindInPageResult, IBrowserViewVisibilityEvent } from '../common/browserView.js'; import { EVENT_KEY_CODE_MAP, KeyCode, KeyMod, SCAN_CODE_STR_TO_EVENT_KEY_CODE } from '../../../base/common/keyCodes.js'; import { IWindowsMainService } from '../../windows/electron-main/windows.js'; import { IBaseWindow, ICodeWindow } from '../../window/electron-main/window.js'; @@ -51,6 +51,9 @@ export class BrowserView extends Disposable { private readonly _onDidChangeFocus = this._register(new Emitter()); readonly onDidChangeFocus: Event = this._onDidChangeFocus.event; + private readonly _onDidChangeVisibility = this._register(new Emitter()); + readonly onDidChangeVisibility: Event = this._onDidChangeVisibility.event; + private readonly _onDidChangeDevToolsState = this._register(new Emitter()); readonly onDidChangeDevToolsState: Event = this._onDidChangeDevToolsState.event; @@ -281,6 +284,7 @@ export class BrowserView extends Disposable { canGoForward: webContents.navigationHistory.canGoForward(), loading: webContents.isLoading(), focused: webContents.isFocused(), + visible: this._view.getVisible(), isDevToolsOpen: webContents.isDevToolsOpened(), lastScreenshot: this._lastScreenshot, lastFavicon: this._lastFavicon, @@ -322,12 +326,17 @@ export class BrowserView extends Disposable { * Set the visibility of this view */ setVisible(visible: boolean): void { + if (this._view.getVisible() === visible) { + return; + } + // If the view is focused, pass focus back to the window when hiding if (!visible && this._view.webContents.isFocused()) { this._window?.win?.webContents.focus(); } this._view.setVisible(visible); + this._onDidChangeVisibility.fire({ visible }); } /** diff --git a/src/vs/platform/browserView/electron-main/browserViewMainService.ts b/src/vs/platform/browserView/electron-main/browserViewMainService.ts index 2a13abf70e0..a462d108ca0 100644 --- a/src/vs/platform/browserView/electron-main/browserViewMainService.ts +++ b/src/vs/platform/browserView/electron-main/browserViewMainService.ts @@ -123,6 +123,10 @@ export class BrowserViewMainService extends Disposable implements IBrowserViewMa return this._getBrowserView(id).onDidChangeFocus; } + onDynamicDidChangeVisibility(id: string) { + return this._getBrowserView(id).onDidChangeVisibility; + } + onDynamicDidChangeDevToolsState(id: string) { return this._getBrowserView(id).onDidChangeDevToolsState; } diff --git a/src/vs/workbench/contrib/browserView/common/browserView.ts b/src/vs/workbench/contrib/browserView/common/browserView.ts index e532690676e..a292a3a1ba2 100644 --- a/src/vs/workbench/contrib/browserView/common/browserView.ts +++ b/src/vs/workbench/contrib/browserView/common/browserView.ts @@ -22,7 +22,8 @@ import { BrowserViewStorageScope, IBrowserViewCaptureScreenshotOptions, IBrowserViewFindInPageOptions, - IBrowserViewFindInPageResult + IBrowserViewFindInPageResult, + IBrowserViewVisibilityEvent } from '../../../../platform/browserView/common/browserView.js'; import { IWorkspaceContextService, WorkbenchState } from '../../../../platform/workspace/common/workspace.js'; import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; @@ -82,6 +83,7 @@ export interface IBrowserViewModel extends IDisposable { readonly screenshot: VSBuffer | undefined; readonly loading: boolean; readonly focused: boolean; + readonly visible: boolean; readonly canGoBack: boolean; readonly isDevToolsOpen: boolean; readonly canGoForward: boolean; @@ -98,6 +100,7 @@ export interface IBrowserViewModel extends IDisposable { readonly onDidChangeFavicon: Event; readonly onDidRequestNewPage: Event; readonly onDidFindInPage: Event; + readonly onDidChangeVisibility: Event; readonly onDidClose: Event; readonly onWillDispose: Event; @@ -125,6 +128,7 @@ export class BrowserViewModel extends Disposable implements IBrowserViewModel { private _screenshot: VSBuffer | undefined = undefined; private _loading: boolean = false; private _focused: boolean = false; + private _visible: boolean = false; private _isDevToolsOpen: boolean = false; private _canGoBack: boolean = false; private _canGoForward: boolean = false; @@ -150,6 +154,7 @@ export class BrowserViewModel extends Disposable implements IBrowserViewModel { get favicon(): string | undefined { return this._favicon; } get loading(): boolean { return this._loading; } get focused(): boolean { return this._focused; } + get visible(): boolean { return this._visible; } get isDevToolsOpen(): boolean { return this._isDevToolsOpen; } get canGoBack(): boolean { return this._canGoBack; } get canGoForward(): boolean { return this._canGoForward; } @@ -193,6 +198,10 @@ export class BrowserViewModel extends Disposable implements IBrowserViewModel { return this.browserViewService.onDynamicDidFindInPage(this.id); } + get onDidChangeVisibility(): Event { + return this.browserViewService.onDynamicDidChangeVisibility(this.id); + } + get onDidClose(): Event { return this.browserViewService.onDynamicDidClose(this.id); } @@ -221,6 +230,7 @@ export class BrowserViewModel extends Disposable implements IBrowserViewModel { this._title = state.title; this._loading = state.loading; this._focused = state.focused; + this._visible = state.visible; this._isDevToolsOpen = state.isDevToolsOpen; this._canGoBack = state.canGoBack; this._canGoForward = state.canGoForward; @@ -262,6 +272,10 @@ export class BrowserViewModel extends Disposable implements IBrowserViewModel { this._register(this.onDidChangeFocus(({ focused }) => { this._focused = focused; })); + + this._register(this.onDidChangeVisibility(({ visible }) => { + this._visible = visible; + })); } async layout(bounds: IBrowserViewBounds): Promise { @@ -269,6 +283,7 @@ export class BrowserViewModel extends Disposable implements IBrowserViewModel { } async setVisible(visible: boolean): Promise { + this._visible = visible; // Set optimistically so model is in sync immediately return this.browserViewService.setVisible(this.id, visible); } diff --git a/src/vs/workbench/contrib/browserView/electron-browser/browserEditor.ts b/src/vs/workbench/contrib/browserView/electron-browser/browserEditor.ts index ed80694577d..2d79adc5c8d 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/browserEditor.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/browserEditor.ts @@ -5,7 +5,7 @@ import './media/browser.css'; import { localize } from '../../../../nls.js'; -import { $, addDisposableListener, Dimension, disposableWindowInterval, EventType, IDomPosition, registerExternalFocusChecker } from '../../../../base/browser/dom.js'; +import { $, addDisposableListener, Dimension, EventType, IDomPosition, registerExternalFocusChecker } from '../../../../base/browser/dom.js'; import { renderIcon } from '../../../../base/browser/ui/iconLabel/iconLabels.js'; import { CancellationToken, CancellationTokenSource } from '../../../../base/common/cancellation.js'; import { RawContextKey, IContextKey, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; @@ -192,6 +192,7 @@ export class BrowserEditor extends EditorPane { private readonly _inputDisposables = this._register(new DisposableStore()); private overlayManager: BrowserOverlayManager | undefined; private _elementSelectionCts: CancellationTokenSource | undefined; + private _screenshotTimeout: ReturnType | undefined; constructor( group: IEditorGroup, @@ -344,6 +345,9 @@ export class BrowserEditor extends EditorPane { this.focusUrlInput(); } + // Start / stop screenshots when the model visibility changes + this._inputDisposables.add(this._model.onDidChangeVisibility(() => this.doScreenshot())); + // Listen to model events for UI updates this._inputDisposables.add(this._model.onDidKeyCommand(keyEvent => { // Handle like webview does - convert to webview KeyEvent format @@ -398,16 +402,11 @@ export class BrowserEditor extends EditorPane { this.layoutBrowserContainer(); } })); - // Capture screenshot periodically (once per second) to keep background updated - this._inputDisposables.add(disposableWindowInterval( - this.window, - () => this.capturePlaceholderSnapshot(), - 1000 - )); this.updateErrorDisplay(); this.layoutBrowserContainer(); - await this._model.setVisible(this.shouldShowView); + this.updateVisibility(); + this.doScreenshot(); } protected override setEditorVisible(visible: boolean): void { @@ -442,14 +441,26 @@ export class BrowserEditor extends EditorPane { if (this._model) { const show = this.shouldShowView; - void this._model.setVisible(show); - if ( - show && - this._browserContainer.ownerDocument.hasFocus() && - this._browserContainer.ownerDocument.activeElement === this._browserContainer - ) { - // If the editor is focused, ensure the browser view also gets focus - void this._model.focus(); + if (show === this._model.visible) { + return; + } + + if (show) { + this._model.setVisible(true); + if ( + this._browserContainer.ownerDocument.hasFocus() && + this._browserContainer.ownerDocument.activeElement === this._browserContainer + ) { + // If the editor is focused, ensure the browser view also gets focus + void this._model.focus(); + } + } else { + this.doScreenshot(); + + // Hide the browser view just before the next render. + // This attempts to give the screenshot some time to be captured and displayed. + // If we hide immediately it is more likely to flicker while the old screenshot is still visible. + this.window.requestAnimationFrame(() => this._model?.setVisible(false)); } } } @@ -780,17 +791,35 @@ export class BrowserEditor extends EditorPane { } } - /** - * Capture a screenshot of the current browser view to use as placeholder background - */ - private async capturePlaceholderSnapshot(): Promise { - if (this._model && !this._overlayVisible) { - try { - const buffer = await this._model.captureScreenshot({ quality: 80 }); - this.setBackgroundImage(buffer); - } catch (error) { - this.logService.error('BrowserEditor.capturePlaceholderSnapshot: Failed to capture screenshot', error); - } + private async doScreenshot(): Promise { + if (!this._model) { + return; + } + + // Cancel any existing timeout + this.cancelScheduledScreenshot(); + + // Only take screenshots if the model is visible + if (!this._model.visible) { + return; + } + + try { + // Capture screenshot and set as background image + const screenshot = await this._model.captureScreenshot({ quality: 80 }); + this.setBackgroundImage(screenshot); + } catch (error) { + this.logService.error('Failed to capture browser view screenshot', error); + } + + // Schedule next screenshot in 1 second + this._screenshotTimeout = setTimeout(() => this.doScreenshot(), 1000); + } + + private cancelScheduledScreenshot(): void { + if (this._screenshotTimeout) { + clearTimeout(this._screenshotTimeout); + this._screenshotTimeout = undefined; } } @@ -859,6 +888,9 @@ export class BrowserEditor extends EditorPane { this._elementSelectionCts = undefined; } + // Cancel any scheduled screenshots + this.cancelScheduledScreenshot(); + // Clear find widget model this._findWidget.rawValue?.setModel(undefined); this._findWidget.rawValue?.hide(); diff --git a/src/vs/workbench/contrib/browserView/electron-browser/media/browser.css b/src/vs/workbench/contrib/browserView/electron-browser/media/browser.css index 89087be7beb..f4fafcbf3af 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/media/browser.css +++ b/src/vs/workbench/contrib/browserView/electron-browser/media/browser.css @@ -72,15 +72,19 @@ justify-content: center; pointer-events: none; color: var(--vscode-foreground); - background-color: color-mix(in srgb, var(--vscode-editor-background) 60%, transparent); + background-color: color-mix(in srgb, var(--vscode-editor-background) 15%, transparent); opacity: 0; visibility: hidden; - transition: opacity 200ms ease-out; + transition: opacity 200ms ease-in; &.visible { opacity: 1; visibility: visible; } + + &.show-message { + background-color: color-mix(in srgb, var(--vscode-editor-background) 60%, transparent); + } } .browser-overlay-paused-message { diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsControl.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsControl.ts index 6c4eb042e8a..bc13d51d74f 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsControl.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsControl.ts @@ -325,13 +325,13 @@ export class AgentSessionsControl extends Disposable implements IAgentSessionsCo break; } case AgentSessionSection.More: { - const shouldCollapseMore = - !this.sessionsListFindIsOpen && // always expand when find is open - !this.options.filter.getExcludes().read; // always expand when only showing unread - - if (shouldCollapseMore && !child.collapsed) { - this.sessionsList.collapse(child.element); - } else if (!shouldCollapseMore && child.collapsed) { + if ( + child.collapsed && + ( + this.sessionsListFindIsOpen || // always expand when find is open + this.options.filter.getExcludes().read // always expand when only showing unread + ) + ) { this.sessionsList.expand(child.element); } break; diff --git a/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupProviders.ts b/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupProviders.ts index 308b543253d..8e2fa8220f5 100644 --- a/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupProviders.ts +++ b/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupProviders.ts @@ -48,9 +48,6 @@ import { ChatSetupController } from './chatSetupController.js'; import { ChatSetupAnonymous, ChatSetupStep, IChatSetupResult } from './chatSetup.js'; import { ChatSetup } from './chatSetupRunner.js'; import { CommandsRegistry } from '../../../../../platform/commands/common/commands.js'; -import { IOutputService } from '../../../../services/output/common/output.js'; -import { ITextModelService } from '../../../../../editor/common/services/resolverService.js'; -import { IWorkbenchIssueService } from '../../../issue/common/issue.js'; import { IDefaultAccountService } from '../../../../../platform/defaultAccount/common/defaultAccount.js'; import { IHostService } from '../../../../services/host/browser/host.js'; @@ -175,7 +172,6 @@ export class SetupAgent extends Disposable implements IChatAgentImplementation { private static readonly TRUST_NEEDED_MESSAGE = new MarkdownString(localize('trustNeeded', "You need to trust this workspace to use Chat.")); private static readonly CHAT_RETRY_COMMAND_ID = 'workbench.action.chat.retrySetup'; - private static readonly CHAT_REPORT_ISSUE_WITH_OUTPUT_COMMAND_ID = 'workbench.action.chat.reportIssueWithOutput'; private readonly _onUnresolvableError = this._register(new Emitter()); readonly onUnresolvableError = this._onUnresolvableError.event; @@ -200,50 +196,6 @@ export class SetupAgent extends Disposable implements IChatAgentImplementation { private registerCommands(): void { - // Report issue with output command - this._register(CommandsRegistry.registerCommand(SetupAgent.CHAT_REPORT_ISSUE_WITH_OUTPUT_COMMAND_ID, async accessor => { - const outputService = accessor.get(IOutputService); - const textModelService = accessor.get(ITextModelService); - const issueService = accessor.get(IWorkbenchIssueService); - const logService = accessor.get(ILogService); - - let outputData = ''; - let channelName = ''; - - let channel = outputService.getChannel(defaultChat.outputChannelId); - if (channel) { - channelName = defaultChat.outputChannelId; - } else { - logService.warn(`[chat setup] Output channel '${defaultChat.outputChannelId}' not found, falling back to Window output channel`); - channel = outputService.getChannel('rendererLog'); - channelName = 'Window'; - } - - if (channel) { - try { - const model = await textModelService.createModelReference(channel.uri); - try { - const rawOutput = model.object.textEditorModel.getValue(); - outputData = `
\nGitHub Copilot Chat Output (${channelName})\n\n\`\`\`\n${rawOutput}\n\`\`\`\n
`; - logService.info(`[chat setup] Retrieved ${rawOutput.length} characters from ${channelName} output channel`); - } finally { - model.dispose(); - } - } catch (error) { - logService.error(`[chat setup] Failed to retrieve output channel content: ${error}`); - } - } else { - logService.warn(`[chat setup] No output channel available`); - } - - await issueService.openReporter({ - extensionId: defaultChat.chatExtensionId, - issueTitle: 'Chat took too long to get ready', - issueBody: 'Chat took too long to get ready', - data: outputData || localize('chatOutputChannelUnavailable', "GitHub Copilot Chat output channel not available. Please ensure the GitHub Copilot Chat extension is active and try again. If the issue persists, you can manually include relevant information from the Output panel (View > Output > GitHub Copilot Chat).") - }); - })); - // Retry chat command this._register(CommandsRegistry.registerCommand(SetupAgent.CHAT_RETRY_COMMAND_ID, async (accessor, sessionResource: URI) => { const hostService = accessor.get(IHostService); @@ -430,11 +382,7 @@ export class SetupAgent extends Disposable implements IChatAgentImplementation { id: SetupAgent.CHAT_RETRY_COMMAND_ID, title: localize('retryChat', "Restart"), arguments: [requestModel.session.sessionResource] - }, - additionalCommands: [{ - id: SetupAgent.CHAT_REPORT_ISSUE_WITH_OUTPUT_COMMAND_ID, - title: localize('reportChatIssue', "Report Issue"), - }] + } }); // This means Chat is unhealthy and we cannot retry the diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatTerminalToolProgressPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatTerminalToolProgressPart.ts index 2ed4afbf599..c5eae902688 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatTerminalToolProgressPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatTerminalToolProgressPart.ts @@ -371,7 +371,9 @@ export class ChatTerminalToolProgressPart extends BaseChatToolInvocationSubPart this.domNode = progressPart.domNode; } - if (expandedStateByInvocation.get(toolInvocation) || (this._isInThinkingContainer && IChatToolInvocation.isComplete(toolInvocation))) { + // Only auto-expand in thinking containers if there's actual output to show + const hasStoredOutput = !!terminalData.terminalCommandOutput; + if (expandedStateByInvocation.get(toolInvocation) || (this._isInThinkingContainer && IChatToolInvocation.isComplete(toolInvocation) && hasStoredOutput)) { void this._toggleOutput(true); } this._register(this._terminalChatService.registerProgressPart(this)); diff --git a/src/vs/workbench/contrib/tasks/browser/runAutomaticTasks.ts b/src/vs/workbench/contrib/tasks/browser/runAutomaticTasks.ts index 7063ae8be2b..080d0432909 100644 --- a/src/vs/workbench/contrib/tasks/browser/runAutomaticTasks.ts +++ b/src/vs/workbench/contrib/tasks/browser/runAutomaticTasks.ts @@ -47,9 +47,9 @@ export class RunAutomaticTasks extends Disposable implements IWorkbenchContribut if (!this._workspaceTrustManagementService.isWorkspaceTrusted()) { return; } - const hasShownPromptForAutomaticTasks = this._storageService.getBoolean(HAS_PROMPTED_FOR_AUTOMATIC_TASKS, StorageScope.WORKSPACE, false); - if (this._hasRunTasks || - (this._configurationService.getValue(ALLOW_AUTOMATIC_TASKS) === 'off' && hasShownPromptForAutomaticTasks)) { + const { value, userValue } = this._configurationService.inspect(ALLOW_AUTOMATIC_TASKS); + // If user explicitly set it to 'off', don't run or prompt + if (this._hasRunTasks || (value === 'off' && userValue !== undefined)) { return; } this._hasRunTasks = true; diff --git a/src/vs/workbench/contrib/terminal/browser/chatTerminalCommandMirror.ts b/src/vs/workbench/contrib/terminal/browser/chatTerminalCommandMirror.ts index 60308557753..2f3dc25de5e 100644 --- a/src/vs/workbench/contrib/terminal/browser/chatTerminalCommandMirror.ts +++ b/src/vs/workbench/contrib/terminal/browser/chatTerminalCommandMirror.ts @@ -68,6 +68,28 @@ export function computeMaxBufferColumnWidth(buffer: { readonly length: number; g return maxWidth; } +/** + * Checks if two VT strings match around a boundary where we would slice. + * This is an efficient O(1) check that verifies a small window of characters + * before the slice point to detect if the VT sequences have diverged (common on Windows). + * + * @param newVT The new VT text to compare. + * @param oldVT The old VT text to compare against. + * @param slicePoint The point where we would slice. Must be <= both string lengths. + * @param windowSize The number of characters before slicePoint to check (default 50). + * @returns True if the boundary matches, false if VT sequences have diverged. + */ +export function vtBoundaryMatches(newVT: string, oldVT: string, slicePoint: number, windowSize: number = 50): boolean { + const start = Math.max(0, slicePoint - windowSize); + const end = slicePoint; + for (let i = start; i < end; i++) { + if (newVT.charCodeAt(i) !== oldVT.charCodeAt(i)) { + return false; + } + } + return true; +} + export interface IDetachedTerminalCommandMirrorRenderResult { lineCount?: number; maxColumnWidth?: number; @@ -280,7 +302,16 @@ export class DetachedTerminalCommandMirror extends Disposable implements IDetach } await new Promise(resolve => { - if (!this._lastVT) { + // Only append if the boundary around the slice point matches; otherwise rewrite. + // This is an efficient constant-time check (checking up to 50 characters) instead of comparing the entire prefix. + // On Windows, VT sequences can differ even for equivalent content, causing corruption + // if we blindly append. + const canAppend = !!this._lastVT && vt.text.length >= this._lastVT.length && this._vtBoundaryMatches(vt.text, this._lastVT.length); + if (!canAppend) { + // Reset the terminal if we had previous content (can't append, need full rewrite) + if (this._lastVT) { + detached.xterm.clearBuffer(); + } if (vt.text) { detached.xterm.write(vt.text, resolve); } else { @@ -505,9 +536,16 @@ export class DetachedTerminalCommandMirror extends Disposable implements IDetach return; } - const canAppend = !!this._lastVT && startLine >= previousCursor; + // Only append if: (1) cursor hasn't moved backwards, and (2) boundary around slice point matches. + // This is an efficient O(1) check instead of comparing the entire prefix. + // On Windows, VT sequences can differ even for equivalent content, so we must verify. + const canAppend = !!this._lastVT && startLine >= previousCursor && vt.text.length >= this._lastVT.length && this._vtBoundaryMatches(vt.text, this._lastVT.length); await new Promise(resolve => { - if (!this._lastVT || !canAppend) { + if (!canAppend) { + // Reset the terminal if we had previous content (can't append, need full rewrite) + if (this._lastVT) { + detachedRaw.clearBuffer(); + } if (vt.text) { detachedRaw.write(vt.text, resolve); } else { @@ -542,6 +580,13 @@ export class DetachedTerminalCommandMirror extends Disposable implements IDetach private _getAbsoluteCursorY(raw: RawXtermTerminal): number { return raw.buffer.active.baseY + raw.buffer.active.cursorY; } + + /** + * Checks if the new VT text matches the old VT around the boundary where we would slice. + */ + private _vtBoundaryMatches(newVT: string, slicePoint: number): boolean { + return vtBoundaryMatches(newVT, this._lastVT, slicePoint); + } } /** diff --git a/src/vs/workbench/contrib/terminal/test/browser/chatTerminalCommandMirror.test.ts b/src/vs/workbench/contrib/terminal/test/browser/chatTerminalCommandMirror.test.ts index e5fd2c6a08b..851c3d5f702 100644 --- a/src/vs/workbench/contrib/terminal/test/browser/chatTerminalCommandMirror.test.ts +++ b/src/vs/workbench/contrib/terminal/test/browser/chatTerminalCommandMirror.test.ts @@ -14,7 +14,7 @@ import { TerminalCapabilityStore } from '../../../../../platform/terminal/common import { XtermTerminal } from '../../browser/xterm/xtermTerminal.js'; import { workbenchInstantiationService } from '../../../../test/browser/workbenchTestServices.js'; import { TestXtermAddonImporter } from './xterm/xtermTestUtils.js'; -import { computeMaxBufferColumnWidth } from '../../browser/chatTerminalCommandMirror.js'; +import { computeMaxBufferColumnWidth, vtBoundaryMatches } from '../../browser/chatTerminalCommandMirror.js'; const defaultTerminalConfig = { fontFamily: 'monospace', @@ -231,6 +231,123 @@ suite('Workbench - ChatTerminalCommandMirror', () => { // Incremental mirror should match fresh mirror strictEqual(getBufferText(mirror), getBufferText(freshMirror)); }); + + test('VT divergence detection prevents corruption (Windows scenario)', async () => { + // This test simulates the Windows issue where VT sequences can differ + // between calls even for equivalent visual content. On Windows, the + // serializer can produce different escape sequences (e.g., different + // line endings or cursor positioning) causing the prefix to diverge. + // + // Without boundary checking, blindly slicing would corrupt output: + // - vt1: "Line1\r\nLine2" (length 13) + // - vt2: "Line1\nLine2\nLine3" (different format, but starts similarly) + // - slice(13) on vt2 would give "ine3" instead of the full new content + + const mirror = await createXterm(); + + // Simulate first VT snapshot + const vt1 = 'Line1\r\nLine2'; + await write(mirror, vt1); + strictEqual(getBufferText(mirror), 'Line1\nLine2'); + + // Simulate divergent VT snapshot (different escape sequences for same content) + // This mimics what can happen on Windows where the VT serializer + // produces different output between calls + const vt2 = 'DifferentPrefix' + 'Line3'; + + // Use the actual utility function to test boundary checking + const boundaryMatches = vtBoundaryMatches(vt2, vt1, vt1.length); + + // Boundary should NOT match because the prefix diverged + strictEqual(boundaryMatches, false, 'Boundary check should detect divergence'); + + // When boundary doesn't match, the fix does a full reset + rewrite + // instead of corrupting the output by blind slicing + mirror.raw.reset(); + await write(mirror, vt2); + + // Final content should be the complete new VT, not corrupted + strictEqual(getBufferText(mirror), 'DifferentPrefixLine3'); + }); + + test('boundary check allows append when VT prefix matches', async () => { + const mirror = await createXterm(); + + // First VT snapshot + const vt1 = 'Line1\r\nLine2\r\n'; + await write(mirror, vt1); + + // Second VT snapshot that properly extends the first + const vt2 = vt1 + 'Line3\r\n'; + + // Use the actual utility function to test boundary checking + const boundaryMatches = vtBoundaryMatches(vt2, vt1, vt1.length); + + strictEqual(boundaryMatches, true, 'Boundary check should pass when prefix matches'); + + // Append should work correctly + const appended = vt2.slice(vt1.length); + await write(mirror, appended); + + strictEqual(getBufferText(mirror), 'Line1\nLine2\nLine3'); + }); + + test('incremental updates use append path (not full rewrite) in normal operation', async () => { + // This test verifies that in normal operation (VT prefix matches), + // we use the efficient append path rather than full rewrite. + + const source = await createXterm(); + const marker = source.raw.registerMarker(0)!; + + // Build up content incrementally, simulating streaming output + const writes: string[] = []; + + // Step 1: Initial content + await write(source, 'output line 1\r\n'); + const vt1 = await source.getRangeAsVT(marker, undefined, true) ?? ''; + + const mirror = await createXterm(); + await write(mirror, vt1); + writes.push(vt1); + + // Step 2: Add more content - should use append path + await write(source, 'output line 2\r\n'); + const vt2 = await source.getRangeAsVT(marker, undefined, true) ?? ''; + + // Verify VT extends properly (prefix matches) + strictEqual(vt2.startsWith(vt1), true, 'VT2 should start with VT1'); + + // Append only the new part (this is what the append path does) + const appended2 = vt2.slice(vt1.length); + strictEqual(appended2.length > 0, true, 'Should have new content to append'); + strictEqual(appended2.length < vt2.length, true, 'Append should be smaller than full rewrite'); + await write(mirror, appended2); + writes.push(appended2); + + // Step 3: Add more content - should continue using append path + await write(source, 'output line 3\r\n'); + const vt3 = await source.getRangeAsVT(marker, undefined, true) ?? ''; + + strictEqual(vt3.startsWith(vt2), true, 'VT3 should start with VT2'); + + const appended3 = vt3.slice(vt2.length); + strictEqual(appended3.length > 0, true, 'Should have new content to append'); + strictEqual(appended3.length < vt3.length, true, 'Append should be smaller than full rewrite'); + await write(mirror, appended3); + writes.push(appended3); + + marker.dispose(); + + // Verify final content is correct + strictEqual(getBufferText(mirror), 'output line 1\noutput line 2\noutput line 3'); + + // Verify we used the append path (total bytes written should be roughly + // equal to total VT, not 3x the total due to full rewrites) + const totalWritten = writes.reduce((sum, w) => sum + w.length, 0); + const fullRewriteWouldBe = vt1.length + vt2.length + vt3.length; + strictEqual(totalWritten < fullRewriteWouldBe, true, + `Append path should write less (${totalWritten}) than full rewrites would (${fullRewriteWouldBe})`); + }); }); suite('computeMaxBufferColumnWidth', () => { @@ -375,4 +492,67 @@ suite('Workbench - ChatTerminalCommandMirror', () => { strictEqual(computeMaxBufferColumnWidth(buffer, 150), 120); }); }); + + suite('vtBoundaryMatches', () => { + + test('returns true when strings match at boundary', () => { + const oldVT = 'Line1\r\nLine2\r\n'; + const newVT = oldVT + 'Line3\r\n'; + strictEqual(vtBoundaryMatches(newVT, oldVT, oldVT.length), true); + }); + + test('returns false when strings diverge at boundary', () => { + const oldVT = 'Line1\r\nLine2'; + const newVT = 'DifferentPrefixLine3'; + strictEqual(vtBoundaryMatches(newVT, oldVT, oldVT.length), false); + }); + + test('returns false when single character differs in window', () => { + const oldVT = 'AAAAAAAAAA'; + const newVT = 'AAAAABAAAA' + 'NewContent'; + strictEqual(vtBoundaryMatches(newVT, oldVT, oldVT.length), false); + }); + + test('returns true for empty strings', () => { + strictEqual(vtBoundaryMatches('', '', 0), true); + }); + + test('returns true when slicePoint is 0', () => { + const oldVT = ''; + const newVT = 'SomeContent'; + strictEqual(vtBoundaryMatches(newVT, oldVT, 0), true); + }); + + test('handles strings shorter than window size', () => { + const oldVT = 'Short'; + const newVT = 'Short' + 'Added'; + strictEqual(vtBoundaryMatches(newVT, oldVT, oldVT.length), true); + }); + + test('respects custom window size parameter', () => { + // With default window (50), this would match since the diff is at position 70 + const prefix = 'A'.repeat(80); + const oldVT = prefix; + const newVT = 'X' + 'A'.repeat(79) + 'NewContent'; // differs at position 0 + + // With window of 50, only checks chars 30-80, which would match + strictEqual(vtBoundaryMatches(newVT, oldVT, oldVT.length, 50), true); + + // With window of 100, would check chars 0-80, which would NOT match + strictEqual(vtBoundaryMatches(newVT, oldVT, oldVT.length, 100), false); + }); + + test('detects divergence in escape sequences (Windows scenario)', () => { + // Simulates Windows issue where VT escape sequences differ + const oldVT = '\x1b[0m\x1b[1mBold\x1b[0m\r\n'; + const newVT = '\x1b[0m\x1b[22mBold\x1b[0m\r\nMore'; // Different escape code for bold + strictEqual(vtBoundaryMatches(newVT, oldVT, oldVT.length), false); + }); + + test('handles matching escape sequences', () => { + const oldVT = '\x1b[31mRed\x1b[0m\r\n'; + const newVT = '\x1b[31mRed\x1b[0m\r\nGreen'; + strictEqual(vtBoundaryMatches(newVT, oldVT, oldVT.length), true); + }); + }); }); diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalChatAgentToolsConfiguration.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalChatAgentToolsConfiguration.ts index 3db27d7e85b..e2ad2ad76bc 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalChatAgentToolsConfiguration.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalChatAgentToolsConfiguration.ts @@ -543,7 +543,7 @@ export const terminalChatAgentToolsConfiguration: IStringDictionary