Merge branch 'main' into close-wombat

This commit is contained in:
Eleanor Boyd
2026-01-29 11:07:34 -08:00
committed by GitHub
22 changed files with 457 additions and 125 deletions
+18 -5
View File
@@ -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);
@@ -13,3 +13,5 @@ export default withDefaults({
['git-editor-main']: './src/git-editor-main.ts'
}
});
export const StripOutSourceMaps = ['dist/askpass-main.js'];
+4 -4
View File
@@ -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": {
+2 -2
View File
@@ -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"
}
}
}
+4 -4
View File
@@ -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": {
+1 -1
View File
@@ -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",
+1
View File
@@ -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;
+23 -8
View File
@@ -97,6 +97,7 @@ export abstract class BaseStringEdit<T extends BaseStringReplacement<T> = 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<T extends BaseStringReplacement<T> = 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<T extends BaseStringReplacement<T> = 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++;
@@ -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', () => {
@@ -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<IBrowserViewNavigationEvent>;
onDynamicDidChangeLoadingState(id: string): Event<IBrowserViewLoadingEvent>;
onDynamicDidChangeFocus(id: string): Event<IBrowserViewFocusEvent>;
onDynamicDidChangeVisibility(id: string): Event<IBrowserViewVisibilityEvent>;
onDynamicDidChangeDevToolsState(id: string): Event<IBrowserViewDevToolsStateEvent>;
onDynamicDidKeyCommand(id: string): Event<IBrowserViewKeyDownEvent>;
onDynamicDidChangeTitle(id: string): Event<IBrowserViewTitleChangeEvent>;
@@ -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<IBrowserViewFocusEvent>());
readonly onDidChangeFocus: Event<IBrowserViewFocusEvent> = this._onDidChangeFocus.event;
private readonly _onDidChangeVisibility = this._register(new Emitter<IBrowserViewVisibilityEvent>());
readonly onDidChangeVisibility: Event<IBrowserViewVisibilityEvent> = this._onDidChangeVisibility.event;
private readonly _onDidChangeDevToolsState = this._register(new Emitter<IBrowserViewDevToolsStateEvent>());
readonly onDidChangeDevToolsState: Event<IBrowserViewDevToolsStateEvent> = 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 });
}
/**
@@ -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;
}
@@ -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<IBrowserViewFaviconChangeEvent>;
readonly onDidRequestNewPage: Event<IBrowserViewNewPageRequest>;
readonly onDidFindInPage: Event<IBrowserViewFindInPageResult>;
readonly onDidChangeVisibility: Event<IBrowserViewVisibilityEvent>;
readonly onDidClose: Event<void>;
readonly onWillDispose: Event<void>;
@@ -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<IBrowserViewVisibilityEvent> {
return this.browserViewService.onDynamicDidChangeVisibility(this.id);
}
get onDidClose(): Event<void> {
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<void> {
@@ -269,6 +283,7 @@ export class BrowserViewModel extends Disposable implements IBrowserViewModel {
}
async setVisible(visible: boolean): Promise<void> {
this._visible = visible; // Set optimistically so model is in sync immediately
return this.browserViewService.setVisible(this.id, visible);
}
@@ -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<typeof setTimeout> | 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<void> {
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<void> {
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();
@@ -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 {
@@ -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;
@@ -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<void>());
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 = `<details>\n<summary>GitHub Copilot Chat Output (${channelName})</summary>\n\n\`\`\`\n${rawOutput}\n\`\`\`\n</details>`;
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
@@ -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));
@@ -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<string>(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;
@@ -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<void>(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<void>(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);
}
}
/**
@@ -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);
});
});
});
@@ -543,7 +543,7 @@ export const terminalChatAgentToolsConfiguration: IStringDictionary<IConfigurati
restricted: true,
},
[TerminalChatAgentToolsSettingId.TerminalSandboxLinuxFileSystem]: {
markdownDescription: localize('terminalSandbox.linuxFileSystemSetting', "Note: this setting is applicable only when {0} is enabled. Controls file system access in the terminal sandbox on Linux.Paths does not support glob patterns, only literal paths (ex: ./src/, ~/.ssh, .env).", `\`#${TerminalChatAgentToolsSettingId.TerminalSandboxEnabled}#\``),
markdownDescription: localize('terminalSandbox.linuxFileSystemSetting', "Note: this setting is applicable only when {0} is enabled. Controls file system access in the terminal sandbox on Linux. Paths do not support glob patterns, only literal paths (ex: ./src/, ~/.ssh, .env). **bubblewrap**, **socat** and **ripgrep** should be installed for this setting to work.", `\`#${TerminalChatAgentToolsSettingId.TerminalSandboxEnabled}#\``),
type: 'object',
properties: {
denyRead: {
@@ -574,7 +574,7 @@ export const terminalChatAgentToolsConfiguration: IStringDictionary<IConfigurati
restricted: true,
},
[TerminalChatAgentToolsSettingId.TerminalSandboxMacFileSystem]: {
markdownDescription: localize('terminalSandbox.macFileSystemSetting', "Note: this setting is applicable only when {0} is enabled. Controls file system access in the terminal sandbox on macOS.Paths also support git-style glob patterns(ex: *.ts, ./src, ./src/**/*.ts, file?.txt).", `\`#${TerminalChatAgentToolsSettingId.TerminalSandboxEnabled}#\``),
markdownDescription: localize('terminalSandbox.macFileSystemSetting', "Note: this setting is applicable only when {0} is enabled. Controls file system access in the terminal sandbox on macOS.Paths also support git-style glob patterns(ex: *.ts, ./src, ./src/**/*.ts, file?.txt). **ripgrep** should be installed for this setting to work.", `\`#${TerminalChatAgentToolsSettingId.TerminalSandboxEnabled}#\``),
type: 'object',
properties: {
denyRead: {