diff --git a/build/linux/debian/dep-lists.ts b/build/linux/debian/dep-lists.ts index d00eb59e3a241..941501b532c52 100644 --- a/build/linux/debian/dep-lists.ts +++ b/build/linux/debian/dep-lists.ts @@ -64,7 +64,6 @@ export const referenceGeneratedDepsByArch = { 'libatk-bridge2.0-0 (>= 2.5.3)', 'libatk1.0-0 (>= 2.11.90)', 'libatspi2.0-0 (>= 2.9.90)', - 'libc6 (>= 2.15)', 'libc6 (>= 2.16)', 'libc6 (>= 2.17)', 'libc6 (>= 2.25)', diff --git a/package-lock.json b/package-lock.json index c5874a4622be4..cb95a5a4cbee5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,7 +20,7 @@ "@vscode/proxy-agent": "^0.36.0", "@vscode/ripgrep": "^1.15.13", "@vscode/spdlog": "^0.15.2", - "@vscode/sqlite3": "5.1.10-vscode", + "@vscode/sqlite3": "5.1.11-vscode", "@vscode/sudo-prompt": "9.3.2", "@vscode/tree-sitter-wasm": "^0.3.0", "@vscode/vscode-languagedetection": "1.0.21", @@ -2875,10 +2875,11 @@ ] }, "node_modules/@vscode/deviceid": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/@vscode/deviceid/-/deviceid-0.1.1.tgz", - "integrity": "sha512-ErpoMeKKNYAkR1IT3zxB5RtiTqEECdh8fxggupWvzuxpTAX77hwOI2NdJ7um+vupnXRBZVx4ugo0+dVHJWUkag==", + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/@vscode/deviceid/-/deviceid-0.1.4.tgz", + "integrity": "sha512-3u705VptsQhKMcHvUMJzaOn9fBrKEQHsl7iibRRVQ8kUNV+cptki7bQXACPNsGtJ5Dh4/7A7W1uKtP3z39GUQg==", "hasInstallScript": true, + "license": "MIT", "dependencies": { "fs-extra": "^11.2.0", "uuid": "^9.0.1" @@ -3258,9 +3259,9 @@ } }, "node_modules/@vscode/policy-watcher": { - "version": "1.3.5", - "resolved": "https://registry.npmjs.org/@vscode/policy-watcher/-/policy-watcher-1.3.5.tgz", - "integrity": "sha512-k1n9gaDBjyVRy5yJLABbZCnyFwgQ8OA4sR3vXmXnmB+mO9JA0nsl/XOXQfVCoLasBu3UHCOfAnDWGn2sRzCR+A==", + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/@vscode/policy-watcher/-/policy-watcher-1.3.7.tgz", + "integrity": "sha512-OvIczTbtGLZs7YU0ResbjM0KEB2ORBnlJ4ICxaB9fKHNVBwNVp4i2qIkDQGp3UBGtu7P8/+eg4/ZKk2oJGFcug==", "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -3320,9 +3321,9 @@ } }, "node_modules/@vscode/spdlog": { - "version": "0.15.4", - "resolved": "https://registry.npmjs.org/@vscode/spdlog/-/spdlog-0.15.4.tgz", - "integrity": "sha512-NmFasVWjn/6BjHMAjqalsbG2srQCt8yfC0EczP5wzNQFawv74rhvuarhWi44x3St9LB8bZBxrpbT7igPaTJwcw==", + "version": "0.15.6", + "resolved": "https://registry.npmjs.org/@vscode/spdlog/-/spdlog-0.15.6.tgz", + "integrity": "sha512-s0ei7I0rLrNlsGTa8EVoAXe4qvbsfXrHebQ5dNbu7dc1Zs/DbnJNSADpHUy8vtNvTJukBWjOXFhAYUfXxGk+Bg==", "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -3332,9 +3333,9 @@ } }, "node_modules/@vscode/sqlite3": { - "version": "5.1.10-vscode", - "resolved": "https://registry.npmjs.org/@vscode/sqlite3/-/sqlite3-5.1.10-vscode.tgz", - "integrity": "sha512-sCJozBr1jItK4eCtbibX3Vi8BXfNyDsPCplojm89OuydoSxwP+Z3gSgzsTXWD5qYyXpTvVaT3LtHLoH2Byv8oA==", + "version": "5.1.11-vscode", + "resolved": "https://registry.npmjs.org/@vscode/sqlite3/-/sqlite3-5.1.11-vscode.tgz", + "integrity": "sha512-x2vBjFRZj/34Ji46lrxotjUtgljistPZU3cbxpckml3bMwF+Z0zbJYiplIeskHLo2g0Kj3kvR8MRRJ+o2nxNug==", "hasInstallScript": true, "license": "BSD-3-Clause", "dependencies": { @@ -3559,9 +3560,9 @@ } }, "node_modules/@vscode/windows-mutex": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/@vscode/windows-mutex/-/windows-mutex-0.5.2.tgz", - "integrity": "sha512-O9CNYVl2GmFVbiHiz7tyFrKIdXVs3qf8HnyWlfxyuMaKzXd1L35jSTNCC1oAVwr8F0O2P4o3C/jOSIXulUCJ7w==", + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/@vscode/windows-mutex/-/windows-mutex-0.5.3.tgz", + "integrity": "sha512-hWNmD+AzINR57jWuc/iW53kA+BghI4iOuicxhAEeeJLPOeMm9X5IUD0ttDwJFEib+D8H/2T9pT/8FeB/xcqbRw==", "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -3580,9 +3581,9 @@ } }, "node_modules/@vscode/windows-registry": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@vscode/windows-registry/-/windows-registry-1.1.2.tgz", - "integrity": "sha512-/eDRmGNe6g11wHckOyiVLvK/mEE5UBZFeoRlBosIL343LDrSKUL5JDAcFeAZqOXnlTtZ3UZtj5yezKiAz99NcA==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@vscode/windows-registry/-/windows-registry-1.1.3.tgz", + "integrity": "sha512-si8+b+2Wh0x2X6W2+kgDyLJD9hyGIrjUo1X/7RWlvsxyI5+Pg+bpdHJrVYtIW4cHOPVB0FYFaN1UZndbUbU5lQ==", "hasInstallScript": true, "license": "MIT" }, @@ -12818,9 +12819,9 @@ "license": "MIT" }, "node_modules/native-keymap": { - "version": "3.3.7", - "resolved": "https://registry.npmjs.org/native-keymap/-/native-keymap-3.3.7.tgz", - "integrity": "sha512-07n5kF0L9ERC9pilqEFucnhs1XG4WttbHAMWhhOSqQYXhB8mMNTSCzP4psTaVgDSp6si2HbIPhTIHuxSia6NPQ==", + "version": "3.3.9", + "resolved": "https://registry.npmjs.org/native-keymap/-/native-keymap-3.3.9.tgz", + "integrity": "sha512-d/ydQ5x+GM5W0dyAjFPwexhtc9CDH1g/xWZESS5CXk16ThyFzSBLvlBJq1+FyzUIFf/F2g1MaHdOpa6G9150YQ==", "hasInstallScript": true, "license": "MIT" }, diff --git a/package.json b/package.json index c985bd3f1cc50..8e94496dee99c 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "code-oss-dev", "version": "1.109.0", - "distro": "f84811280304020eab0bbc930e85b8f2180b1ed6", + "distro": "ce89ce05183635114ccfc46870d71ec520727c8e", "author": { "name": "Microsoft Corporation" }, @@ -83,7 +83,7 @@ "@vscode/proxy-agent": "^0.36.0", "@vscode/ripgrep": "^1.15.13", "@vscode/spdlog": "^0.15.2", - "@vscode/sqlite3": "5.1.10-vscode", + "@vscode/sqlite3": "5.1.11-vscode", "@vscode/sudo-prompt": "9.3.2", "@vscode/tree-sitter-wasm": "^0.3.0", "@vscode/vscode-languagedetection": "1.0.21", @@ -240,4 +240,4 @@ "optionalDependencies": { "windows-foreground-love": "0.5.0" } -} \ No newline at end of file +} diff --git a/remote/package-lock.json b/remote/package-lock.json index 44adad2a8260f..80bae8718596d 100644 --- a/remote/package-lock.json +++ b/remote/package-lock.json @@ -393,10 +393,11 @@ } }, "node_modules/@vscode/deviceid": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/@vscode/deviceid/-/deviceid-0.1.1.tgz", - "integrity": "sha512-ErpoMeKKNYAkR1IT3zxB5RtiTqEECdh8fxggupWvzuxpTAX77hwOI2NdJ7um+vupnXRBZVx4ugo0+dVHJWUkag==", + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/@vscode/deviceid/-/deviceid-0.1.4.tgz", + "integrity": "sha512-3u705VptsQhKMcHvUMJzaOn9fBrKEQHsl7iibRRVQ8kUNV+cptki7bQXACPNsGtJ5Dh4/7A7W1uKtP3z39GUQg==", "hasInstallScript": true, + "license": "MIT", "dependencies": { "fs-extra": "^11.2.0", "uuid": "^9.0.1" @@ -451,9 +452,9 @@ } }, "node_modules/@vscode/spdlog": { - "version": "0.15.4", - "resolved": "https://registry.npmjs.org/@vscode/spdlog/-/spdlog-0.15.4.tgz", - "integrity": "sha512-NmFasVWjn/6BjHMAjqalsbG2srQCt8yfC0EczP5wzNQFawv74rhvuarhWi44x3St9LB8bZBxrpbT7igPaTJwcw==", + "version": "0.15.6", + "resolved": "https://registry.npmjs.org/@vscode/spdlog/-/spdlog-0.15.6.tgz", + "integrity": "sha512-s0ei7I0rLrNlsGTa8EVoAXe4qvbsfXrHebQ5dNbu7dc1Zs/DbnJNSADpHUy8vtNvTJukBWjOXFhAYUfXxGk+Bg==", "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -501,9 +502,9 @@ } }, "node_modules/@vscode/windows-process-tree": { - "version": "0.6.2", - "resolved": "https://registry.npmjs.org/@vscode/windows-process-tree/-/windows-process-tree-0.6.2.tgz", - "integrity": "sha512-uzyUuQ93m7K1jSPrB/72m4IspOyeGpvvghNwFCay/McZ+y4Hk2BnLdZPb6EJ8HLRa3GwCvYjH/MQZzcnLOVnaQ==", + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/@vscode/windows-process-tree/-/windows-process-tree-0.6.3.tgz", + "integrity": "sha512-mjirLbtgjv7P6fwD8gx7iaY961EfGqUExGvfzsKl3spLfScg57ejlMi+7O1jfJqpM2Zly9DTSxyY4cFsDN6c9Q==", "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -511,9 +512,9 @@ } }, "node_modules/@vscode/windows-registry": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@vscode/windows-registry/-/windows-registry-1.1.2.tgz", - "integrity": "sha512-/eDRmGNe6g11wHckOyiVLvK/mEE5UBZFeoRlBosIL343LDrSKUL5JDAcFeAZqOXnlTtZ3UZtj5yezKiAz99NcA==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@vscode/windows-registry/-/windows-registry-1.1.3.tgz", + "integrity": "sha512-si8+b+2Wh0x2X6W2+kgDyLJD9hyGIrjUo1X/7RWlvsxyI5+Pg+bpdHJrVYtIW4cHOPVB0FYFaN1UZndbUbU5lQ==", "hasInstallScript": true, "license": "MIT" }, diff --git a/src/vs/base/common/buffer.ts b/src/vs/base/common/buffer.ts index 244fb8e1c0d47..b105bdfeb8676 100644 --- a/src/vs/base/common/buffer.ts +++ b/src/vs/base/common/buffer.ts @@ -213,7 +213,7 @@ export function binaryIndexOf(haystack: Uint8Array, needle: Uint8Array, offset = } if (needleLen === 1) { - return haystack.indexOf(needle[0]); + return haystack.indexOf(needle[0], offset); } if (needleLen > haystackLen - offset) { diff --git a/src/vs/base/test/common/buffer.test.ts b/src/vs/base/test/common/buffer.test.ts index 9109d4cdb1b5a..998d3dc81191a 100644 --- a/src/vs/base/test/common/buffer.test.ts +++ b/src/vs/base/test/common/buffer.test.ts @@ -437,10 +437,12 @@ suite('Buffer', () => { assert.strictEqual(haystack.indexOf(VSBuffer.fromString('a')), 0); assert.strictEqual(haystack.indexOf(VSBuffer.fromString('c')), 2); + assert.strictEqual(haystack.indexOf(VSBuffer.fromString('c'), 4), 7); assert.strictEqual(haystack.indexOf(VSBuffer.fromString('abcaa')), 0); assert.strictEqual(haystack.indexOf(VSBuffer.fromString('caaab')), 8); assert.strictEqual(haystack.indexOf(VSBuffer.fromString('ccc')), 15); + assert.strictEqual(haystack.indexOf(VSBuffer.fromString('cc'), 9), 15); assert.strictEqual(haystack.indexOf(VSBuffer.fromString('cccb')), -1); }); diff --git a/src/vs/editor/contrib/hover/browser/contentHoverController.ts b/src/vs/editor/contrib/hover/browser/contentHoverController.ts index 8d7421d990693..ba0e7d0b16193 100644 --- a/src/vs/editor/contrib/hover/browser/contentHoverController.ts +++ b/src/vs/editor/contrib/hover/browser/contentHoverController.ts @@ -17,7 +17,7 @@ import { IKeybindingService } from '../../../../platform/keybinding/common/keybi import { ResultKind } from '../../../../platform/keybinding/common/keybindingResolver.js'; import { HoverVerbosityAction } from '../../../common/languages.js'; import { RunOnceScheduler } from '../../../../base/common/async.js'; -import { isMousePositionWithinElement, shouldShowHover } from './hoverUtils.js'; +import { isMousePositionWithinElement, shouldShowHover, isTriggerModifierPressed } from './hoverUtils.js'; import { ContentHoverWidgetWrapper } from './contentHoverWidgetWrapper.js'; import './hover.css'; import { Emitter } from '../../../../base/common/event.js'; @@ -266,12 +266,19 @@ export class ContentHoverController extends Disposable implements IEditorContrib } private _onKeyDown(e: IKeyboardEvent): void { - if (this._ignoreMouseEvents) { + if (this._ignoreMouseEvents || !this._contentWidget) { return; } - if (!this._contentWidget) { + + if (this._hoverSettings.enabled === 'onKeyboardModifier' + && isTriggerModifierPressed(this._editor.getOption(EditorOption.multiCursorModifier), e) + && this._mouseMoveEvent) { + if (!this._contentWidget.isVisible) { + this._contentWidget.showsOrWillShow(this._mouseMoveEvent); + } return; } + const isPotentialKeyboardShortcut = this._isPotentialKeyboardShortcut(e); const isModifierKeyPressed = isModifierKey(e.keyCode); if (isPotentialKeyboardShortcut || isModifierKeyPressed) { diff --git a/src/vs/editor/contrib/hover/browser/glyphHoverController.ts b/src/vs/editor/contrib/hover/browser/glyphHoverController.ts index e26f82ccf57d1..c8dbfa9ec3d2c 100644 --- a/src/vs/editor/contrib/hover/browser/glyphHoverController.ts +++ b/src/vs/editor/contrib/hover/browser/glyphHoverController.ts @@ -12,7 +12,7 @@ import { IEditorContribution, IScrollEvent } from '../../../common/editorCommon. import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { IHoverWidget } from './hoverTypes.js'; import { RunOnceScheduler } from '../../../../base/common/async.js'; -import { isMousePositionWithinElement, shouldShowHover } from './hoverUtils.js'; +import { isMousePositionWithinElement, isTriggerModifierPressed, shouldShowHover } from './hoverUtils.js'; import './hover.css'; import { GlyphHoverWidget } from './glyphHoverWidget.js'; @@ -206,6 +206,14 @@ export class GlyphHoverController extends Disposable implements IEditorContribut if (!this._editor.hasModel()) { return; } + + if (this._hoverSettings.enabled === 'onKeyboardModifier' + && isTriggerModifierPressed(this._editor.getOption(EditorOption.multiCursorModifier), e) + && this._mouseMoveEvent) { + this._tryShowHoverWidget(this._mouseMoveEvent); + return; + } + if (isModifierKey(e.keyCode)) { // Do not hide hover when a modifier key is pressed return; diff --git a/src/vs/editor/contrib/hover/browser/hoverUtils.ts b/src/vs/editor/contrib/hover/browser/hoverUtils.ts index 669b36fbbb746..997d4512c1a94 100644 --- a/src/vs/editor/contrib/hover/browser/hoverUtils.ts +++ b/src/vs/editor/contrib/hover/browser/hoverUtils.ts @@ -37,9 +37,19 @@ export function shouldShowHover( if (hoverEnabled === 'off') { return false; } + return isTriggerModifierPressed(multiCursorModifier, mouseEvent.event); +} + +/** + * Returns true if the trigger modifier (inverse of multi-cursor modifier) is pressed. + * This works with both mouse and keyboard events by relying only on the modifier flags. + */ +export function isTriggerModifierPressed( + multiCursorModifier: 'altKey' | 'ctrlKey' | 'metaKey', + event: { ctrlKey: boolean; metaKey: boolean; altKey: boolean } +): boolean { if (multiCursorModifier === 'altKey') { - return mouseEvent.event.ctrlKey || mouseEvent.event.metaKey; - } else { - return mouseEvent.event.altKey; + return event.ctrlKey || event.metaKey; } + return event.altKey; // multiCursorModifier is ctrlKey or metaKey } diff --git a/src/vs/editor/contrib/hover/test/browser/hoverUtils.test.ts b/src/vs/editor/contrib/hover/test/browser/hoverUtils.test.ts index e491793d5d669..e40987aeefeb7 100644 --- a/src/vs/editor/contrib/hover/test/browser/hoverUtils.test.ts +++ b/src/vs/editor/contrib/hover/test/browser/hoverUtils.test.ts @@ -5,7 +5,7 @@ import assert from 'assert'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; -import { shouldShowHover } from '../../browser/hoverUtils.js'; +import { isMousePositionWithinElement, isTriggerModifierPressed, shouldShowHover } from '../../browser/hoverUtils.js'; import { IEditorMouseEvent } from '../../../../browser/editorBrowser.js'; suite('Hover Utils', () => { @@ -85,4 +85,140 @@ suite('Hover Utils', () => { assert.strictEqual(result, false); }); }); + + suite('isMousePositionWithinElement', () => { + + function createMockElement(left: number, top: number, width: number, height: number): HTMLElement { + const element = document.createElement('div'); + // Mock getDomNodePagePosition by setting up the element's bounding rect + element.getBoundingClientRect = () => ({ + left, + top, + width, + height, + right: left + width, + bottom: top + height, + x: left, + y: top, + toJSON: () => { } + }); + return element; + } + + test('returns true when mouse is inside element bounds', () => { + const element = createMockElement(100, 100, 200, 100); + assert.strictEqual(isMousePositionWithinElement(element, 150, 150), true); + assert.strictEqual(isMousePositionWithinElement(element, 200, 150), true); + assert.strictEqual(isMousePositionWithinElement(element, 250, 180), true); + }); + + test('returns true when mouse is on element edges', () => { + const element = createMockElement(100, 100, 200, 100); + assert.strictEqual(isMousePositionWithinElement(element, 100, 100), true); // top-left corner + assert.strictEqual(isMousePositionWithinElement(element, 300, 100), true); // top-right corner + assert.strictEqual(isMousePositionWithinElement(element, 100, 200), true); // bottom-left corner + assert.strictEqual(isMousePositionWithinElement(element, 300, 200), true); // bottom-right corner + }); + + test('returns false when mouse is left of element', () => { + const element = createMockElement(100, 100, 200, 100); + assert.strictEqual(isMousePositionWithinElement(element, 99, 150), false); + assert.strictEqual(isMousePositionWithinElement(element, 50, 150), false); + }); + + test('returns false when mouse is right of element', () => { + const element = createMockElement(100, 100, 200, 100); + assert.strictEqual(isMousePositionWithinElement(element, 301, 150), false); + assert.strictEqual(isMousePositionWithinElement(element, 400, 150), false); + }); + + test('returns false when mouse is above element', () => { + const element = createMockElement(100, 100, 200, 100); + assert.strictEqual(isMousePositionWithinElement(element, 200, 99), false); + assert.strictEqual(isMousePositionWithinElement(element, 200, 50), false); + }); + + test('returns false when mouse is below element', () => { + const element = createMockElement(100, 100, 200, 100); + assert.strictEqual(isMousePositionWithinElement(element, 200, 201), false); + assert.strictEqual(isMousePositionWithinElement(element, 200, 300), false); + }); + + test('handles element at origin (0,0)', () => { + const element = createMockElement(0, 0, 100, 100); + assert.strictEqual(isMousePositionWithinElement(element, 0, 0), true); + assert.strictEqual(isMousePositionWithinElement(element, 50, 50), true); + assert.strictEqual(isMousePositionWithinElement(element, 100, 100), true); + assert.strictEqual(isMousePositionWithinElement(element, 101, 101), false); + }); + + test('handles small elements (1x1)', () => { + const element = createMockElement(100, 100, 1, 1); + assert.strictEqual(isMousePositionWithinElement(element, 100, 100), true); + assert.strictEqual(isMousePositionWithinElement(element, 101, 101), true); + assert.strictEqual(isMousePositionWithinElement(element, 102, 102), false); + }); + }); + + suite('isTriggerModifierPressed', () => { + + function createModifierEvent(ctrlKey: boolean, altKey: boolean, metaKey: boolean) { + return { ctrlKey, altKey, metaKey }; + } + + test('returns true with ctrl pressed when multiCursorModifier is altKey', () => { + const event = createModifierEvent(true, false, false); + assert.strictEqual(isTriggerModifierPressed('altKey', event), true); + }); + + test('returns true with metaKey pressed when multiCursorModifier is altKey', () => { + const event = createModifierEvent(false, false, true); + assert.strictEqual(isTriggerModifierPressed('altKey', event), true); + }); + + test('returns true with both ctrl and metaKey pressed when multiCursorModifier is altKey', () => { + const event = createModifierEvent(true, false, true); + assert.strictEqual(isTriggerModifierPressed('altKey', event), true); + }); + + test('returns false without ctrl or metaKey when multiCursorModifier is altKey', () => { + const event = createModifierEvent(false, false, false); + assert.strictEqual(isTriggerModifierPressed('altKey', event), false); + }); + + test('returns false with alt pressed when multiCursorModifier is altKey', () => { + const event = createModifierEvent(false, true, false); + assert.strictEqual(isTriggerModifierPressed('altKey', event), false); + }); + + test('returns true with alt pressed when multiCursorModifier is ctrlKey', () => { + const event = createModifierEvent(false, true, false); + assert.strictEqual(isTriggerModifierPressed('ctrlKey', event), true); + }); + + test('returns false without alt pressed when multiCursorModifier is ctrlKey', () => { + const event = createModifierEvent(false, false, false); + assert.strictEqual(isTriggerModifierPressed('ctrlKey', event), false); + }); + + test('returns false with ctrl pressed when multiCursorModifier is ctrlKey', () => { + const event = createModifierEvent(true, false, false); + assert.strictEqual(isTriggerModifierPressed('ctrlKey', event), false); + }); + + test('returns true with alt pressed when multiCursorModifier is metaKey', () => { + const event = createModifierEvent(false, true, false); + assert.strictEqual(isTriggerModifierPressed('metaKey', event), true); + }); + + test('returns false without alt pressed when multiCursorModifier is metaKey', () => { + const event = createModifierEvent(false, false, false); + assert.strictEqual(isTriggerModifierPressed('metaKey', event), false); + }); + + test('returns false with metaKey pressed when multiCursorModifier is metaKey', () => { + const event = createModifierEvent(false, false, true); + assert.strictEqual(isTriggerModifierPressed('metaKey', event), false); + }); + }); }); diff --git a/src/vs/platform/browserElements/common/browserElements.ts b/src/vs/platform/browserElements/common/browserElements.ts index abd2873d924b8..218acce24fd4c 100644 --- a/src/vs/platform/browserElements/common/browserElements.ts +++ b/src/vs/platform/browserElements/common/browserElements.ts @@ -15,12 +15,27 @@ export interface IElementData { readonly bounds: IRectangle; } -export enum BrowserType { - SimpleBrowser = 'simpleBrowser', - LiveServer = 'liveServer', +/** + * Locator for identifying a browser target/webview. + * Uses either the parent webview or browser view id to uniquely identify the target. + */ +export interface IBrowserTargetLocator { + /** + * Identifier of the parent webview hosting the target. + * + * Exactly one of {@link webviewId} or {@link browserViewId} should be provided. + * Use this when the target is rendered inside a webview. + */ + readonly webviewId?: string; + /** + * Identifier of the browser view hosting the target. + * + * Exactly one of {@link webviewId} or {@link browserViewId} should be provided. + * Use this when the target is rendered inside a browser view rather than a webview. + */ + readonly browserViewId?: string; } - export interface INativeBrowserElementsService { readonly _serviceBrand: undefined; @@ -28,7 +43,24 @@ export interface INativeBrowserElementsService { // Properties readonly windowId: number; - getElementData(rect: IRectangle, token: CancellationToken, browserType: BrowserType, cancellationId?: number): Promise; + getElementData(rect: IRectangle, token: CancellationToken, locator: IBrowserTargetLocator, cancellationId?: number): Promise; + + startDebugSession(token: CancellationToken, locator: IBrowserTargetLocator, cancelAndDetachId?: number): Promise; +} + +/** + * Extract a display name from outer HTML (e.g., "div#myId.myClass1.myClass2") + */ +export function getDisplayNameFromOuterHTML(outerHTML: string): string { + const firstElementMatch = outerHTML.match(/^<([^ >]+)([^>]*?)>/); + if (!firstElementMatch) { + throw new Error('No outer element found'); + } - startDebugSession(token: CancellationToken, browserType: BrowserType, cancelAndDetachId?: number): Promise; + const tagName = firstElementMatch[1]; + const idMatch = firstElementMatch[2].match(/\s+id\s*=\s*["']([^"']+)["']/i); + const id = idMatch ? `#${idMatch[1]}` : ''; + const classMatch = firstElementMatch[2].match(/\s+class\s*=\s*["']([^"']+)["']/i); + const className = classMatch ? `.${classMatch[1].replace(/\s+/g, '.')}` : ''; + return `${tagName}${id}${className}`; } diff --git a/src/vs/platform/browserElements/electron-main/nativeBrowserElementsMainService.ts b/src/vs/platform/browserElements/electron-main/nativeBrowserElementsMainService.ts index acdcbe060b6d8..fbf52a2a06408 100644 --- a/src/vs/platform/browserElements/electron-main/nativeBrowserElementsMainService.ts +++ b/src/vs/platform/browserElements/electron-main/nativeBrowserElementsMainService.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { BrowserType, IElementData, INativeBrowserElementsService } from '../common/browserElements.js'; +import { IElementData, INativeBrowserElementsService, IBrowserTargetLocator } from '../common/browserElements.js'; import { CancellationToken } from '../../../base/common/cancellation.js'; import { IRectangle } from '../../window/common/window.js'; import { BrowserWindow, webContents } from 'electron'; @@ -14,6 +14,7 @@ import { IWindowsMainService } from '../../windows/electron-main/windows.js'; import { createDecorator } from '../../instantiation/common/instantiation.js'; import { Disposable } from '../../../base/common/lifecycle.js'; import { AddFirstParameterToFunctions } from '../../../base/common/types.js'; +import { IBrowserViewMainService } from '../../browserView/electron-main/browserViewMainService.js'; export const INativeBrowserElementsMainService = createDecorator('browserElementsMainService'); export interface INativeBrowserElementsMainService extends AddFirstParameterToFunctions /* only methods, not events */, number | undefined /* window ID */> { } @@ -27,53 +28,47 @@ interface NodeDataResponse { export class NativeBrowserElementsMainService extends Disposable implements INativeBrowserElementsMainService { _serviceBrand: undefined; - currentLocalAddress: string | undefined; - constructor( @IWindowsMainService private readonly windowsMainService: IWindowsMainService, @IAuxiliaryWindowsMainService private readonly auxiliaryWindowsMainService: IAuxiliaryWindowsMainService, - + @IBrowserViewMainService private readonly browserViewMainService: IBrowserViewMainService ) { super(); } get windowId(): never { throw new Error('Not implemented in electron-main'); } - async findWebviewTarget(debuggers: Electron.Debugger, windowId: number, browserType: BrowserType): Promise { + /** + * Find the webview target that matches the given locator. + * Checks either webviewId or browserViewId depending on what's provided. + */ + async findWebviewTarget(debuggers: Electron.Debugger, locator: IBrowserTargetLocator): Promise { const { targetInfos } = await debuggers.sendCommand('Target.getTargets'); - let target: typeof targetInfos[number] | undefined = undefined; - const matchingTarget = targetInfos.find((targetInfo: { url: string }) => { - try { - const url = new URL(targetInfo.url); - if (browserType === BrowserType.LiveServer) { - return url.searchParams.get('id') && url.searchParams.get('extensionId') === 'ms-vscode.live-server'; - } else if (browserType === BrowserType.SimpleBrowser) { - return url.searchParams.get('parentId') === windowId.toString() && url.searchParams.get('extensionId') === 'vscode.simple-browser'; + + if (locator.webviewId) { + let extensionId = ''; + for (const targetInfo of targetInfos) { + try { + const url = new URL(targetInfo.url); + if (url.searchParams.get('id') === locator.webviewId) { + extensionId = url.searchParams.get('extensionId') || ''; + break; + } + } catch (err) { + // ignore } - return false; - } catch (err) { - return false; } - }); - - // search for webview via search parameters - if (matchingTarget) { - let resultId: string | undefined; - let url: URL | undefined; - try { - url = new URL(matchingTarget.url); - resultId = url.searchParams.get('id')!; - } catch (e) { + if (!extensionId) { return undefined; } - target = targetInfos.find((targetInfo: { url: string }) => { + // search for webview via search parameters + const target = targetInfos.find((targetInfo: { url: string }) => { try { const url = new URL(targetInfo.url); - const isLiveServer = browserType === BrowserType.LiveServer && url.searchParams.get('serverWindowId') === resultId; - const isSimpleBrowser = browserType === BrowserType.SimpleBrowser && url.searchParams.get('id') === resultId && url.searchParams.has('vscodeBrowserReqId'); + const isLiveServer = extensionId === 'ms-vscode.live-server' && url.searchParams.get('serverWindowId') === locator.webviewId; + const isSimpleBrowser = extensionId === 'vscode.simple-browser' && url.searchParams.get('id') === locator.webviewId && url.searchParams.has('vscodeBrowserReqId'); if (isLiveServer || isSimpleBrowser) { - this.currentLocalAddress = url.origin; return true; } return false; @@ -81,35 +76,30 @@ export class NativeBrowserElementsMainService extends Disposable implements INat return false; } }); - - if (target) { - return target.targetId; - } + return target?.targetId; } - // fallback: search for webview without parameters based on current origin - target = targetInfos.find((targetInfo: { url: string }) => { - try { - const url = new URL(targetInfo.url); - return (this.currentLocalAddress === url.origin); - } catch (e) { - return false; - } - }); + if (locator.browserViewId) { + const webContentsInstance = this.browserViewMainService.tryGetBrowserView(locator.browserViewId)?.webContents; + const target = targetInfos.find((targetInfo: { targetId: string; type: string }) => { + if (targetInfo.type !== 'page') { + return false; + } - if (!target) { - return undefined; + return webContents.fromDevToolsTargetId(targetInfo.targetId) === webContentsInstance; + }); + return target?.targetId; } - return target.targetId; + return undefined; } - async waitForWebviewTargets(debuggers: Electron.Debugger, windowId: number, browserType: BrowserType): Promise { + async waitForWebviewTargets(debuggers: Electron.Debugger, locator: IBrowserTargetLocator): Promise { const start = Date.now(); const timeout = 10000; while (Date.now() - start < timeout) { - const targetId = await this.findWebviewTarget(debuggers, windowId, browserType); + const targetId = await this.findWebviewTarget(debuggers, locator); if (targetId) { return targetId; } @@ -122,7 +112,7 @@ export class NativeBrowserElementsMainService extends Disposable implements INat return undefined; } - async startDebugSession(windowId: number | undefined, token: CancellationToken, browserType: BrowserType, cancelAndDetachId?: number): Promise { + async startDebugSession(windowId: number | undefined, token: CancellationToken, locator: IBrowserTargetLocator, cancelAndDetachId?: number): Promise { const window = this.windowById(windowId); if (!window?.win) { return undefined; @@ -142,7 +132,7 @@ export class NativeBrowserElementsMainService extends Disposable implements INat } try { - const matchingTargetId = await this.waitForWebviewTargets(debuggers, windowId!, browserType); + const matchingTargetId = await this.waitForWebviewTargets(debuggers, locator); if (!matchingTargetId) { if (debuggers.isAttached()) { debuggers.detach(); @@ -187,7 +177,7 @@ export class NativeBrowserElementsMainService extends Disposable implements INat } } - async getElementData(windowId: number | undefined, rect: IRectangle, token: CancellationToken, browserType: BrowserType, cancellationId?: number): Promise { + async getElementData(windowId: number | undefined, rect: IRectangle, token: CancellationToken, locator: IBrowserTargetLocator, cancellationId?: number): Promise { const window = this.windowById(windowId); if (!window?.win) { return undefined; @@ -208,7 +198,7 @@ export class NativeBrowserElementsMainService extends Disposable implements INat let targetSessionId: string | undefined = undefined; try { - const targetId = await this.findWebviewTarget(debuggers, windowId!, browserType); + const targetId = await this.findWebviewTarget(debuggers, locator); const { sessionId } = await debuggers.sendCommand('Target.attachToTarget', { targetId: targetId, flatten: true, @@ -373,7 +363,7 @@ export class NativeBrowserElementsMainService extends Disposable implements INat const content = model.content; const margin = model.margin; const x = Math.min(margin[0], content[0]); - const y = Math.min(margin[1], content[1]) + 32.4; // 32.4 is height of the title bar + const y = Math.min(margin[1], content[1]); const width = Math.max(margin[2] - margin[0], content[2] - content[0]); const height = Math.max(margin[5] - margin[1], content[5] - content[1]); @@ -416,7 +406,7 @@ export class NativeBrowserElementsMainService extends Disposable implements INat }); } - formatMatchedStyles(matched: { inlineStyle?: { cssProperties?: Array<{ name: string; value: string }> }; matchedCSSRules?: Array<{ rule: { selectorList: { selectors: Array<{ text: string }> }; origin: string; style: { cssProperties: Array<{ name: string; value: string }> } } }>; inherited?: Array<{ matchedCSSRules?: Array<{ rule: { selectorList: { selectors: Array<{ text: string }> }; origin: string; style: { cssProperties: Array<{ name: string; value: string }> } } }> }> }): string { + formatMatchedStyles(matched: { inlineStyle?: { cssProperties?: Array<{ name: string; value: string }> }; matchedCSSRules?: Array<{ rule: { selectorList: { selectors: Array<{ text: string }> }; origin: string; style: { cssProperties: Array<{ name: string; value: string }> } } }>; inherited?: Array<{ inlineStyle?: { cssText: string }; matchedCSSRules?: Array<{ rule: { selectorList: { selectors: Array<{ text: string }> }; origin: string; style: { cssProperties: Array<{ name: string; value: string }> } } }> }> }): string { const lines: string[] = []; // inline @@ -451,6 +441,14 @@ export class NativeBrowserElementsMainService extends Disposable implements INat if (matched.inherited?.length) { let level = 1; for (const inherited of matched.inherited) { + const inline = inherited.inlineStyle; + if (inline) { + lines.push(`/* Inherited from ancestor level ${level} (inline) */`); + lines.push('element {'); + lines.push(inline.cssText); + lines.push('}\n'); + } + const rules = inherited.matchedCSSRules || []; for (const ruleEntry of rules) { const rule = ruleEntry.rule; diff --git a/src/vs/platform/browserView/electron-main/browserView.ts b/src/vs/platform/browserView/electron-main/browserView.ts index 1619ad1bccbed..41d1c8e9522ba 100644 --- a/src/vs/platform/browserView/electron-main/browserView.ts +++ b/src/vs/platform/browserView/electron-main/browserView.ts @@ -271,6 +271,10 @@ export class BrowserView extends Disposable { }); } + get webContents(): Electron.WebContents { + return this._view.webContents; + } + /** * Get the current state of this browser view */ diff --git a/src/vs/platform/browserView/electron-main/browserViewMainService.ts b/src/vs/platform/browserView/electron-main/browserViewMainService.ts index 403ebc74399b8..fae1b76846f9a 100644 --- a/src/vs/platform/browserView/electron-main/browserViewMainService.ts +++ b/src/vs/platform/browserView/electron-main/browserViewMainService.ts @@ -16,7 +16,7 @@ import { generateUuid } from '../../../base/common/uuid.js'; export const IBrowserViewMainService = createDecorator('browserViewMainService'); export interface IBrowserViewMainService extends IBrowserViewService { - // Additional electron-specific methods can be added here if needed in the future + tryGetBrowserView(id: string): BrowserView | undefined; } // Same as webviews @@ -96,6 +96,10 @@ export class BrowserViewMainService extends Disposable implements IBrowserViewMa return view.getState(); } + tryGetBrowserView(id: string): BrowserView | undefined { + return this.browserViews.get(id); + } + /** * Get a browser view or throw if not found */ diff --git a/src/vs/platform/files/browser/indexedDBFileSystemProvider.ts b/src/vs/platform/files/browser/indexedDBFileSystemProvider.ts index 1dd1bf5785235..f7c6a4ade5d01 100644 --- a/src/vs/platform/files/browser/indexedDBFileSystemProvider.ts +++ b/src/vs/platform/files/browser/indexedDBFileSystemProvider.ts @@ -156,6 +156,7 @@ export class IndexedDBFileSystemProvider extends Disposable implements IFileSyst readonly capabilities: FileSystemProviderCapabilities = FileSystemProviderCapabilities.FileReadWrite + | FileSystemProviderCapabilities.FileAppend | FileSystemProviderCapabilities.PathCaseSensitive; readonly onDidChangeCapabilities: Event = Event.None; @@ -260,7 +261,18 @@ export class IndexedDBFileSystemProvider extends Disposable implements IFileSyst if (existing?.type === FileType.Directory) { throw ERR_FILE_IS_DIR; } - await this.bulkWrite([[resource, content]]); + + let finalContent = content; + if (opts.append && existing) { + // Read existing content and append new content to it + const existingContent = await this.readFile(resource); + const combined = new Uint8Array(existingContent.byteLength + content.byteLength); + combined.set(existingContent, 0); + combined.set(content, existingContent.byteLength); + finalContent = combined; + } + + await this.bulkWrite([[resource, finalContent]]); } async rename(from: URI, to: URI, opts: IFileOverwriteOptions): Promise { diff --git a/src/vs/platform/files/common/diskFileSystemProviderClient.ts b/src/vs/platform/files/common/diskFileSystemProviderClient.ts index da17f1145ff76..d905f6e14fc9e 100644 --- a/src/vs/platform/files/common/diskFileSystemProviderClient.ts +++ b/src/vs/platform/files/common/diskFileSystemProviderClient.ts @@ -13,7 +13,7 @@ import { newWriteableStream, ReadableStreamEventPayload, ReadableStreamEvents } import { URI } from '../../../base/common/uri.js'; import { generateUuid } from '../../../base/common/uuid.js'; import { IChannel } from '../../../base/parts/ipc/common/ipc.js'; -import { createFileSystemProviderError, IFileAtomicReadOptions, IFileDeleteOptions, IFileOpenOptions, IFileOverwriteOptions, IFileReadStreamOptions, FileSystemProviderCapabilities, FileSystemProviderErrorCode, FileType, IFileWriteOptions, IFileChange, IFileSystemProviderWithFileAtomicReadCapability, IFileSystemProviderWithFileCloneCapability, IFileSystemProviderWithFileFolderCopyCapability, IFileSystemProviderWithFileReadStreamCapability, IFileSystemProviderWithFileReadWriteCapability, IFileSystemProviderWithOpenReadWriteCloseCapability, IStat, IWatchOptions, IFileSystemProviderError } from './files.js'; +import { createFileSystemProviderError, FileSystemProviderCapabilities, FileSystemProviderErrorCode, FileType, IFileAtomicReadOptions, IFileChange, IFileDeleteOptions, IFileOpenOptions, IFileOverwriteOptions, IFileReadStreamOptions, IFileSystemProviderError, IFileSystemProviderWithFileAtomicReadCapability, IFileSystemProviderWithFileCloneCapability, IFileSystemProviderWithFileFolderCopyCapability, IFileSystemProviderWithFileReadStreamCapability, IFileSystemProviderWithFileReadWriteCapability, IFileSystemProviderWithOpenReadWriteCloseCapability, IFileWriteOptions, IStat, IWatchOptions } from './files.js'; import { reviveFileChanges } from './watcher.js'; export const LOCAL_FILE_SYSTEM_CHANNEL_NAME = 'localFilesystem'; @@ -56,6 +56,7 @@ export class DiskFileSystemProviderClient extends Disposable implements FileSystemProviderCapabilities.FileAtomicRead | FileSystemProviderCapabilities.FileAtomicWrite | FileSystemProviderCapabilities.FileAtomicDelete | + FileSystemProviderCapabilities.FileAppend | FileSystemProviderCapabilities.FileClone | FileSystemProviderCapabilities.FileRealpath; diff --git a/src/vs/platform/files/common/fileService.ts b/src/vs/platform/files/common/fileService.ts index b775ea6cf1083..493471fe8560b 100644 --- a/src/vs/platform/files/common/fileService.ts +++ b/src/vs/platform/files/common/fileService.ts @@ -18,7 +18,7 @@ import { extUri, extUriIgnorePathCase, IExtUri, isAbsolutePath } from '../../../ import { consumeStream, isReadableBufferedStream, isReadableStream, listenStream, newWriteableStream, peekReadable, peekStream, transform } from '../../../base/common/stream.js'; import { URI } from '../../../base/common/uri.js'; import { localize } from '../../../nls.js'; -import { ensureFileSystemProviderError, etag, ETAG_DISABLED, FileChangesEvent, IFileDeleteOptions, FileOperation, FileOperationError, FileOperationEvent, FileOperationResult, FilePermission, FileSystemProviderCapabilities, FileSystemProviderErrorCode, FileType, hasFileAtomicReadCapability, hasFileFolderCopyCapability, hasFileReadStreamCapability, hasOpenReadWriteCloseCapability, hasReadWriteCapability, ICreateFileOptions, IFileContent, IFileService, IFileStat, IFileStatWithMetadata, IFileStreamContent, IFileSystemProvider, IFileSystemProviderActivationEvent, IFileSystemProviderCapabilitiesChangeEvent, IFileSystemProviderRegistrationEvent, IFileSystemProviderWithFileAtomicReadCapability, IFileSystemProviderWithFileReadStreamCapability, IFileSystemProviderWithFileReadWriteCapability, IFileSystemProviderWithOpenReadWriteCloseCapability, IReadFileOptions, IReadFileStreamOptions, IResolveFileOptions, IFileStatResult, IFileStatResultWithMetadata, IResolveMetadataFileOptions, IStat, IFileStatWithPartialMetadata, IWatchOptions, IWriteFileOptions, NotModifiedSinceFileOperationError, toFileOperationResult, toFileSystemProviderErrorCode, hasFileCloneCapability, TooLargeFileOperationError, hasFileAtomicDeleteCapability, hasFileAtomicWriteCapability, IWatchOptionsWithCorrelation, IFileSystemWatcher, IWatchOptionsWithoutCorrelation, hasFileRealpathCapability } from './files.js'; +import { ensureFileSystemProviderError, etag, ETAG_DISABLED, FileChangesEvent, IFileDeleteOptions, FileOperation, FileOperationError, FileOperationEvent, FileOperationResult, FilePermission, FileSystemProviderCapabilities, FileSystemProviderErrorCode, FileType, hasFileAppendCapability, hasFileAtomicReadCapability, hasFileFolderCopyCapability, hasFileReadStreamCapability, hasOpenReadWriteCloseCapability, hasReadWriteCapability, ICreateFileOptions, IFileContent, IFileService, IFileStat, IFileStatWithMetadata, IFileStreamContent, IFileSystemProvider, IFileSystemProviderActivationEvent, IFileSystemProviderCapabilitiesChangeEvent, IFileSystemProviderRegistrationEvent, IFileSystemProviderWithFileAtomicReadCapability, IFileSystemProviderWithFileReadStreamCapability, IFileSystemProviderWithFileReadWriteCapability, IFileSystemProviderWithOpenReadWriteCloseCapability, IReadFileOptions, IReadFileStreamOptions, IResolveFileOptions, IFileStatResult, IFileStatResultWithMetadata, IResolveMetadataFileOptions, IStat, IFileStatWithPartialMetadata, IWatchOptions, IWriteFileOptions, NotModifiedSinceFileOperationError, toFileOperationResult, toFileSystemProviderErrorCode, hasFileCloneCapability, TooLargeFileOperationError, hasFileAtomicDeleteCapability, hasFileAtomicWriteCapability, IWatchOptionsWithCorrelation, IFileSystemWatcher, IWatchOptionsWithoutCorrelation, hasFileRealpathCapability } from './files.js'; import { readFileIntoStream } from './io.js'; import { ILogService } from '../../log/common/log.js'; import { ErrorNoTelemetry } from '../../../base/common/errors.js'; @@ -460,6 +460,11 @@ export class FileService extends Disposable implements IFileService { throw new Error(localize('writeFailedUnlockUnsupported', "Unable to unlock file '{0}' because provider does not support it.", this.resourceForError(resource))); } + // Validate append support + if (options?.append && !hasFileAppendCapability(provider)) { + throw new FileOperationError(localize('err.noAppend', "Filesystem provider for scheme '{0}' does not does not support append", this.resourceForError(resource)), FileOperationResult.FILE_PERMISSION_DENIED); + } + // Validate atomic support const atomic = !!options?.atomic; if (atomic) { @@ -1263,7 +1268,7 @@ export class FileService extends Disposable implements IFileService { return this.writeQueue.queueFor(resource, async () => { // open handle - const handle = await provider.open(resource, { create: true, unlock: options?.unlock ?? false }); + const handle = await provider.open(resource, { create: true, unlock: options?.unlock ?? false, append: options?.append ?? false }); // write into handle until all bytes from buffer have been written try { @@ -1374,7 +1379,7 @@ export class FileService extends Disposable implements IFileService { } // Write through the provider - await provider.writeFile(resource, buffer.buffer, { create: true, overwrite: true, unlock: options?.unlock ?? false, atomic: options?.atomic ?? false }); + await provider.writeFile(resource, buffer.buffer, { create: true, overwrite: true, unlock: options?.unlock ?? false, atomic: options?.atomic ?? false, append: options?.append ?? false }); } private async doPipeBuffered(sourceProvider: IFileSystemProviderWithOpenReadWriteCloseCapability, source: URI, targetProvider: IFileSystemProviderWithOpenReadWriteCloseCapability, target: URI): Promise { diff --git a/src/vs/platform/files/common/files.ts b/src/vs/platform/files/common/files.ts index cb3b59e0ba9c4..4181a930379ca 100644 --- a/src/vs/platform/files/common/files.ts +++ b/src/vs/platform/files/common/files.ts @@ -158,6 +158,7 @@ export interface IFileService { /** * Updates the content replacing its previous value. + * If `options.append` is true, appends content to the end of the file instead. * * Emits a `FileOperation.WRITE` file operation event when successful. */ @@ -381,6 +382,12 @@ export interface IFileWriteOptions extends IFileOverwriteOptions, IFileUnlockOpt * throw an error otherwise if the file does not exist. */ readonly create: boolean; + + /** + * Set to `true` to append content to the end of the file. Implies `create: true`, + * and set only when the corresponding `FileAppend` capability is defined. + */ + readonly append?: boolean; } export type IFileOpenOptions = IFileOpenForReadOptions | IFileOpenForWriteOptions; @@ -403,6 +410,12 @@ export interface IFileOpenForWriteOptions extends IFileUnlockOptions { * A hint that the file should be opened for reading and writing. */ readonly create: true; + + /** + * Open the file in append mode. This will write data to the + * end of the file. + */ + readonly append?: boolean; } export interface IFileDeleteOptions { @@ -654,7 +667,12 @@ export const enum FileSystemProviderCapabilities { /** * Provider support to resolve real paths. */ - FileRealpath = 1 << 18 + FileRealpath = 1 << 18, + + /** + * Provider support to append to files. + */ + FileAppend = 1 << 19 } export interface IFileSystemProvider { @@ -696,6 +714,10 @@ export function hasReadWriteCapability(provider: IFileSystemProvider): provider return !!(provider.capabilities & FileSystemProviderCapabilities.FileReadWrite); } +export function hasFileAppendCapability(provider: IFileSystemProvider): boolean { + return !!(provider.capabilities & FileSystemProviderCapabilities.FileAppend); +} + export interface IFileSystemProviderWithFileFolderCopyCapability extends IFileSystemProvider { copy(from: URI, to: URI, opts: IFileOverwriteOptions): Promise; } @@ -1387,6 +1409,12 @@ export interface IWriteFileOptions { * and then renaming it over the target. */ readonly atomic?: IFileAtomicOptions | false; + + /** + * If set to true, will append to the end of the file instead of + * replacing its contents. Will create the file if it doesn't exist. + */ + readonly append?: boolean; } export interface IResolveFileOptions { diff --git a/src/vs/platform/files/common/inMemoryFilesystemProvider.ts b/src/vs/platform/files/common/inMemoryFilesystemProvider.ts index 82ff1b532c78b..ac062a8b22ec3 100644 --- a/src/vs/platform/files/common/inMemoryFilesystemProvider.ts +++ b/src/vs/platform/files/common/inMemoryFilesystemProvider.ts @@ -9,7 +9,7 @@ import { Disposable, IDisposable } from '../../../base/common/lifecycle.js'; import * as resources from '../../../base/common/resources.js'; import { ReadableStreamEvents, newWriteableStream } from '../../../base/common/stream.js'; import { URI } from '../../../base/common/uri.js'; -import { FileChangeType, IFileDeleteOptions, IFileOverwriteOptions, FileSystemProviderCapabilities, FileSystemProviderErrorCode, FileType, IFileWriteOptions, IFileChange, IFileSystemProviderWithFileReadWriteCapability, IStat, IWatchOptions, createFileSystemProviderError, IFileSystemProviderWithOpenReadWriteCloseCapability, IFileOpenOptions, IFileSystemProviderWithFileAtomicDeleteCapability, IFileSystemProviderWithFileAtomicReadCapability, IFileSystemProviderWithFileAtomicWriteCapability, IFileSystemProviderWithFileReadStreamCapability } from './files.js'; +import { FileChangeType, IFileDeleteOptions, IFileOverwriteOptions, FileSystemProviderCapabilities, FileSystemProviderErrorCode, FileType, IFileWriteOptions, IFileChange, IFileSystemProviderWithFileReadWriteCapability, IStat, IWatchOptions, createFileSystemProviderError, IFileSystemProviderWithOpenReadWriteCloseCapability, IFileOpenOptions, IFileSystemProviderWithFileAtomicDeleteCapability, IFileSystemProviderWithFileAtomicReadCapability, IFileSystemProviderWithFileAtomicWriteCapability, IFileSystemProviderWithFileReadStreamCapability, isFileOpenForWriteOptions } from './files.js'; class File implements IStat { @@ -61,18 +61,17 @@ export class InMemoryFileSystemProvider extends Disposable implements IFileSystemProviderWithFileAtomicDeleteCapability { private memoryFdCounter = 0; - private readonly fdMemory = new Map(); + private readonly fdMemory = new Map(); private _onDidChangeCapabilities = this._register(new Emitter()); readonly onDidChangeCapabilities = this._onDidChangeCapabilities.event; - private _capabilities = FileSystemProviderCapabilities.FileReadWrite | FileSystemProviderCapabilities.PathCaseSensitive; + private _capabilities = FileSystemProviderCapabilities.FileReadWrite | FileSystemProviderCapabilities.FileOpenReadWriteClose | FileSystemProviderCapabilities.FileAppend | FileSystemProviderCapabilities.PathCaseSensitive; get capabilities(): FileSystemProviderCapabilities { return this._capabilities; } setReadOnly(readonly: boolean) { const isReadonly = !!(this._capabilities & FileSystemProviderCapabilities.Readonly); if (readonly !== isReadonly) { - this._capabilities = readonly ? FileSystemProviderCapabilities.Readonly | FileSystemProviderCapabilities.PathCaseSensitive | FileSystemProviderCapabilities.FileReadWrite - : FileSystemProviderCapabilities.FileReadWrite | FileSystemProviderCapabilities.PathCaseSensitive; + this._capabilities = FileSystemProviderCapabilities.FileReadWrite | FileSystemProviderCapabilities.FileAppend | FileSystemProviderCapabilities.PathCaseSensitive | (readonly ? FileSystemProviderCapabilities.Readonly : 0); this._onDidChangeCapabilities.fire(); } } @@ -130,47 +129,100 @@ export class InMemoryFileSystemProvider extends Disposable implements this._fireSoon({ type: FileChangeType.ADDED, resource }); } entry.mtime = Date.now(); - entry.size = content.byteLength; - entry.data = content; + + if (opts.append) { + entry.size += content.byteLength; + const oldData = entry.data ?? new Uint8Array(0); + const newData = new Uint8Array(oldData.byteLength + content.byteLength); + newData.set(oldData, 0); + newData.set(content, oldData.byteLength); + entry.data = newData; + } else { + entry.size = content.byteLength; + entry.data = content; + } this._fireSoon({ type: FileChangeType.UPDATED, resource }); } // file open/read/write/close open(resource: URI, opts: IFileOpenOptions): Promise { - const data = this._lookupAsFile(resource, false).data; - if (data) { - const fd = this.memoryFdCounter++; - this.fdMemory.set(fd, data); - return Promise.resolve(fd); + let file = this._lookup(resource, true); + const write = isFileOpenForWriteOptions(opts); + const append = write && !!opts.append; + + if (!file) { + if (!write) { + throw createFileSystemProviderError('file not found', FileSystemProviderErrorCode.FileNotFound); + } + // Create the file if opening for write + const basename = resources.basename(resource); + const parent = this._lookupParentDirectory(resource); + file = new File(basename); + file.data = new Uint8Array(0); + parent.entries.set(basename, file); + this._fireSoon({ type: FileChangeType.ADDED, resource }); + } else if (file instanceof Directory) { + throw createFileSystemProviderError('file is directory', FileSystemProviderErrorCode.FileIsADirectory); } - throw createFileSystemProviderError('file not found', FileSystemProviderErrorCode.FileNotFound); + + if (!file.data) { + file.data = new Uint8Array(0); + } + + const fd = this.memoryFdCounter++; + this.fdMemory.set(fd, { file, resource, write, append }); + return Promise.resolve(fd); } close(fd: number): Promise { + const fdData = this.fdMemory.get(fd); + if (fdData?.write) { + // Update file metadata on close + fdData.file.mtime = Date.now(); + fdData.file.size = fdData.file.data?.byteLength ?? 0; + this._fireSoon({ type: FileChangeType.UPDATED, resource: fdData.resource }); + } this.fdMemory.delete(fd); return Promise.resolve(); } read(fd: number, pos: number, data: Uint8Array, offset: number, length: number): Promise { - const memory = this.fdMemory.get(fd); - if (!memory) { + const fdData = this.fdMemory.get(fd); + if (!fdData) { throw createFileSystemProviderError(`No file with that descriptor open`, FileSystemProviderErrorCode.Unavailable); } - const toWrite = VSBuffer.wrap(memory).slice(pos, pos + length); + if (!fdData.file.data) { + return Promise.resolve(0); + } + + const toWrite = VSBuffer.wrap(fdData.file.data).slice(pos, pos + length); data.set(toWrite.buffer, offset); return Promise.resolve(toWrite.byteLength); } write(fd: number, pos: number, data: Uint8Array, offset: number, length: number): Promise { - const memory = this.fdMemory.get(fd); - if (!memory) { + const fdData = this.fdMemory.get(fd); + if (!fdData) { throw createFileSystemProviderError(`No file with that descriptor open`, FileSystemProviderErrorCode.Unavailable); } const toWrite = VSBuffer.wrap(data).slice(offset, offset + length); - memory.set(toWrite.buffer, pos); + fdData.file.data ??= new Uint8Array(0); + + // In append mode, always write at the end + const writePos = fdData.append ? fdData.file.data.byteLength : pos; + + // Grow the buffer if needed + const endPos = writePos + toWrite.byteLength; + if (endPos > fdData.file.data.byteLength) { + const newData = new Uint8Array(endPos); + newData.set(fdData.file.data, 0); + fdData.file.data = newData; + } + + fdData.file.data.set(toWrite.buffer, writePos); return Promise.resolve(toWrite.byteLength); } diff --git a/src/vs/platform/files/node/diskFileSystemProvider.ts b/src/vs/platform/files/node/diskFileSystemProvider.ts index f29c4742883bc..2e2e64bd6220a 100644 --- a/src/vs/platform/files/node/diskFileSystemProvider.ts +++ b/src/vs/platform/files/node/diskFileSystemProvider.ts @@ -51,6 +51,7 @@ export class DiskFileSystemProvider extends AbstractDiskFileSystemProvider imple FileSystemProviderCapabilities.FileReadStream | FileSystemProviderCapabilities.FileFolderCopy | FileSystemProviderCapabilities.FileWriteUnlock | + FileSystemProviderCapabilities.FileAppend | FileSystemProviderCapabilities.FileAtomicRead | FileSystemProviderCapabilities.FileAtomicWrite | FileSystemProviderCapabilities.FileAtomicDelete | @@ -323,7 +324,7 @@ export class DiskFileSystemProvider extends AbstractDiskFileSystemProvider imple } // Open - handle = await this.open(resource, { create: true, unlock: opts.unlock }, disableWriteLock); + handle = await this.open(resource, { create: true, append: opts.append, unlock: opts.unlock }, disableWriteLock); // Write content at once await this.write(handle, 0, content, 0, content.byteLength); @@ -375,8 +376,8 @@ export class DiskFileSystemProvider extends AbstractDiskFileSystemProvider imple } } - // Windows gets special treatment (write only) - if (isWindows && isFileOpenForWriteOptions(opts)) { + // Windows gets special treatment (write only, but not for append) + if (isWindows && isFileOpenForWriteOptions(opts) && !opts.append) { try { // We try to use 'r+' for opening (which will fail if the file does not exist) @@ -413,7 +414,8 @@ export class DiskFileSystemProvider extends AbstractDiskFileSystemProvider imple // We take `opts.create` as a hint that the file is opened for writing // as such we use 'w' to truncate an existing or create the // file otherwise. we do not allow reading. - 'w' : + // If `opts.append` is true, use 'a' to append to the file. + (opts.append ? 'a' : 'w') : // Otherwise we assume the file is opened for reading // as such we use 'r' to neither truncate, nor create // the file. diff --git a/src/vs/platform/files/test/browser/indexedDBFileService.integrationTest.ts b/src/vs/platform/files/test/browser/indexedDBFileService.integrationTest.ts index fe30a71f5df7e..ec3f98d8158d9 100644 --- a/src/vs/platform/files/test/browser/indexedDBFileService.integrationTest.ts +++ b/src/vs/platform/files/test/browser/indexedDBFileService.integrationTest.ts @@ -584,4 +584,95 @@ flakySuite('IndexedDBFileSystemProvider', function () { assert.deepStrictEqual(await service.exists(file2), false); assert.deepStrictEqual(await service.exists(emptyFolder), false); }); + + test('writeFile with append - existing file', async () => { + const parent = await service.resolve(userdataURIFromPaths([])); + const resource = joinPath(parent.resource, 'appendTest.txt'); + + // Create initial file + await service.writeFile(resource, VSBuffer.fromString('Hello ')); + + // Append to existing file + await service.writeFile(resource, VSBuffer.fromString('World!'), { append: true }); + + // Verify content + const content = await service.readFile(resource); + assert.strictEqual(content.value.toString(), 'Hello World!'); + }); + + test('writeFile with append - non-existent file', async () => { + const parent = await service.resolve(userdataURIFromPaths([])); + const resource = joinPath(parent.resource, 'newAppendTest.txt'); + + // Append to non-existent file (should create it) + await service.writeFile(resource, VSBuffer.fromString('First content'), { append: true }); + + // Verify content + const content = await service.readFile(resource); + assert.strictEqual(content.value.toString(), 'First content'); + }); + + test('writeFile with append - multiple appends', async () => { + const parent = await service.resolve(userdataURIFromPaths([])); + const resource = joinPath(parent.resource, 'multiAppend.txt'); + + // Create and append multiple times + await service.writeFile(resource, VSBuffer.fromString('Line 1\n')); + await service.writeFile(resource, VSBuffer.fromString('Line 2\n'), { append: true }); + await service.writeFile(resource, VSBuffer.fromString('Line 3\n'), { append: true }); + + // Verify content + const content = await service.readFile(resource); + assert.strictEqual(content.value.toString(), 'Line 1\nLine 2\nLine 3\n'); + }); + + test('writeFile without append - overwrites content', async () => { + const parent = await service.resolve(userdataURIFromPaths([])); + const resource = joinPath(parent.resource, 'overwriteTest.txt'); + + // Create initial file + await service.writeFile(resource, VSBuffer.fromString('Original content')); + + // Write without append (should overwrite) + await service.writeFile(resource, VSBuffer.fromString('New content')); + + // Verify content is overwritten + const content = await service.readFile(resource); + assert.strictEqual(content.value.toString(), 'New content'); + }); + + test('writeFile with append - binary content', async () => { + const parent = await service.resolve(userdataURIFromPaths([])); + const resource = joinPath(parent.resource, 'binaryAppend.bin'); + + const data1 = new Uint8Array([1, 2, 3, 4, 5]); + const data2 = new Uint8Array([6, 7, 8, 9, 10]); + + // Create initial file with binary data + await service.writeFile(resource, VSBuffer.wrap(data1)); + + // Append binary data + await service.writeFile(resource, VSBuffer.wrap(data2), { append: true }); + + // Verify combined content + const content = await service.readFile(resource); + const expected = new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]); + assert.strictEqual(content.value.byteLength, expected.byteLength); + for (let i = 0; i < expected.byteLength; i++) { + assert.strictEqual(content.value.buffer[i], expected[i]); + } + }); + + test('provider writeFile with append - direct provider API', async () => { + const parent = await service.resolve(userdataURIFromPaths([])); + const resource = joinPath(parent.resource, 'providerAppend.txt'); + + // Use provider directly + await userdataFileProvider.writeFile(resource, VSBuffer.fromString('First ').buffer, { create: true, overwrite: true, unlock: false, atomic: false }); + await userdataFileProvider.writeFile(resource, VSBuffer.fromString('Second').buffer, { create: true, overwrite: true, unlock: false, atomic: false, append: true }); + + // Verify content + const content = await userdataFileProvider.readFile(resource); + assert.strictEqual(new TextDecoder().decode(content), 'First Second'); + }); }); diff --git a/src/vs/platform/files/test/browser/inmemoryFileService.test.ts b/src/vs/platform/files/test/browser/inmemoryFileService.test.ts new file mode 100644 index 0000000000000..c20b3980d9004 --- /dev/null +++ b/src/vs/platform/files/test/browser/inmemoryFileService.test.ts @@ -0,0 +1,318 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { timeout } from '../../../../base/common/async.js'; +import { streamToBuffer, VSBuffer } from '../../../../base/common/buffer.js'; +import { Schemas } from '../../../../base/common/network.js'; +import { joinPath } from '../../../../base/common/resources.js'; +import { URI } from '../../../../base/common/uri.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { FileChangeType, FileOperation, FileOperationEvent, FileSystemProviderCapabilities, IFileStat } from '../../common/files.js'; +import { FileService } from '../../common/fileService.js'; +import { InMemoryFileSystemProvider } from '../../common/inMemoryFilesystemProvider.js'; +import { NullLogService } from '../../../log/common/log.js'; + +function getByName(root: IFileStat, name: string): IFileStat | undefined { + if (root.children === undefined) { + return undefined; + } + + return root.children.find(child => child.name === name); +} + +function createLargeBuffer(size: number, seed: number): VSBuffer { + const data = new Uint8Array(size); + for (let i = 0; i < data.length; i++) { + data[i] = (seed + i) % 256; + } + + return VSBuffer.wrap(data); +} + +type Fixture = { + root: URI; + indexHtml: URI; + siteCss: URI; + smallTxt: URI; + smallUmlautTxt: URI; + deepDir: URI; + otherDeepDir: URI; +}; + +suite('InMemory File Service', () => { + + const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + + let service: FileService; + let provider: InMemoryFileSystemProvider; + let fixture: Fixture; + + setup(async () => { + service = disposables.add(new FileService(new NullLogService())); + provider = disposables.add(new InMemoryFileSystemProvider()); + disposables.add(service.registerProvider(Schemas.inMemory, provider)); + + fixture = await createFixture(service); + }); + + test('createFolder', async () => { + let event: FileOperationEvent | undefined; + disposables.add(service.onDidRunOperation(e => event = e)); + + const newFolderResource = joinPath(fixture.root, 'newFolder'); + const newFolder = await service.createFolder(newFolderResource); + + assert.strictEqual(newFolder.name, 'newFolder'); + assert.strictEqual(await service.exists(newFolderResource), true); + + assert.ok(event); + assert.strictEqual(event.resource.toString(), newFolderResource.toString()); + assert.strictEqual(event.operation, FileOperation.CREATE); + assert.strictEqual(event.target?.resource.toString(), newFolderResource.toString()); + assert.strictEqual(event.target?.isDirectory, true); + }); + + test('createFolder: creating multiple folders at once', async () => { + let event: FileOperationEvent | undefined; + disposables.add(service.onDidRunOperation(e => event = e)); + + const multiFolderPaths = ['a', 'couple', 'of', 'folders']; + const newFolderResource = joinPath(fixture.root, ...multiFolderPaths); + + const newFolder = await service.createFolder(newFolderResource); + assert.strictEqual(newFolder.name, multiFolderPaths[multiFolderPaths.length - 1]); + assert.strictEqual(await service.exists(newFolderResource), true); + + assert.ok(event); + assert.strictEqual(event.resource.toString(), newFolderResource.toString()); + assert.strictEqual(event.operation, FileOperation.CREATE); + assert.strictEqual(event.target?.resource.toString(), newFolderResource.toString()); + assert.strictEqual(event.target?.isDirectory, true); + }); + + test('exists', async () => { + let exists = await service.exists(fixture.root); + assert.strictEqual(exists, true); + + exists = await service.exists(joinPath(fixture.root, 'does-not-exist')); + assert.strictEqual(exists, false); + }); + + test('resolve - file', async () => { + const resolved = await service.resolve(fixture.indexHtml); + + assert.strictEqual(resolved.name, 'index.html'); + assert.strictEqual(resolved.isFile, true); + assert.strictEqual(resolved.isDirectory, false); + }); + + test('resolve - directory', async () => { + const resolved = await service.resolve(fixture.root); + assert.strictEqual(resolved.isDirectory, true); + assert.ok(resolved.children); + + const names = resolved.children.map(c => c.name).sort(); + assert.deepStrictEqual(names, ['examples', 'index.html', 'other', 'site.css', 'deep', 'small.txt', 'small_umlaut.txt'].sort()); + }); + + test('resolve - directory with resolveTo', async () => { + const resolved = await service.resolve(fixture.root, { resolveTo: [fixture.deepDir] }); + + const deep = getByName(resolved, 'deep'); + assert.ok(deep); + assert.ok(deep.children); + assert.strictEqual(deep.children.length, 4); + }); + + test('readFile', async () => { + const content = await service.readFile(fixture.smallTxt); + assert.strictEqual(content.value.toString(), 'Small File'); + }); + + test('readFile - from position (ASCII)', async () => { + const content = await service.readFile(fixture.smallTxt, { position: 6 }); + assert.strictEqual(content.value.toString(), 'File'); + }); + + test('readFile - from position (with umlaut)', async () => { + const pos = VSBuffer.fromString('Small File with Ü').byteLength; + const content = await service.readFile(fixture.smallUmlautTxt, { position: pos }); + assert.strictEqual(content.value.toString(), 'mlaut'); + }); + + test('readFileStream', async () => { + const content = await service.readFileStream(fixture.smallTxt); + assert.strictEqual((await streamToBuffer(content.value)).toString(), 'Small File'); + }); + + test('writeFile', async () => { + await service.writeFile(fixture.smallTxt, VSBuffer.fromString('Updated')); + + const content = await service.readFile(fixture.smallTxt); + assert.strictEqual(content.value.toString(), 'Updated'); + }); + + test('provider open/write - append', async () => { + const resource = joinPath(fixture.root, 'append.txt'); + await service.writeFile(resource, VSBuffer.fromString('Hello')); + + const fd = await provider.open(resource, { create: true, unlock: false, append: true }); + try { + const data = VSBuffer.fromString(' World').buffer; + await provider.write(fd, 0, data, 0, data.byteLength); + } finally { + await provider.close(fd); + } + + const content = await service.readFile(resource); + assert.strictEqual(content.value.toString(), 'Hello World'); + }); + + test('provider open/write - append (large)', async () => { + const resource = joinPath(fixture.root, 'append-large-open.txt'); + const prefix = createLargeBuffer(256 * 1024, 1); + const suffix = createLargeBuffer(256 * 1024, 2); + + await service.writeFile(resource, prefix); + + const fd = await provider.open(resource, { create: true, unlock: false, append: true }); + try { + await provider.write(fd, 123 /* ignored in append mode */, suffix.buffer, 0, suffix.byteLength); + } finally { + await provider.close(fd); + } + + const content = await service.readFile(resource); + assert.strictEqual(content.value.byteLength, prefix.byteLength + suffix.byteLength); + + assert.deepStrictEqual(content.value.slice(0, 64).buffer, prefix.slice(0, 64).buffer); + assert.deepStrictEqual(content.value.slice(prefix.byteLength, prefix.byteLength + 64).buffer, suffix.slice(0, 64).buffer); + assert.deepStrictEqual(content.value.slice(content.value.byteLength - 64, content.value.byteLength).buffer, suffix.slice(suffix.byteLength - 64, suffix.byteLength).buffer); + }); + + test('writeFile - append', async () => { + const resource = joinPath(fixture.root, 'append-via-writeFile.txt'); + await service.writeFile(resource, VSBuffer.fromString('Hello')); + + await service.writeFile(resource, VSBuffer.fromString(' World'), { append: true }); + + const content = await service.readFile(resource); + assert.strictEqual(content.value.toString(), 'Hello World'); + }); + + test('writeFile - append (large)', async () => { + const resource = joinPath(fixture.root, 'append-large-writeFile.txt'); + const prefix = createLargeBuffer(256 * 1024, 3); + const suffix = createLargeBuffer(256 * 1024, 4); + + await service.writeFile(resource, prefix); + await service.writeFile(resource, suffix, { append: true }); + + const content = await service.readFile(resource); + assert.strictEqual(content.value.byteLength, prefix.byteLength + suffix.byteLength); + + assert.deepStrictEqual(content.value.slice(0, 64).buffer, prefix.slice(0, 64).buffer); + assert.deepStrictEqual(content.value.slice(prefix.byteLength, prefix.byteLength + 64).buffer, suffix.slice(0, 64).buffer); + assert.deepStrictEqual(content.value.slice(content.value.byteLength - 64, content.value.byteLength).buffer, suffix.slice(suffix.byteLength - 64, suffix.byteLength).buffer); + }); + + test('rename', async () => { + const source = joinPath(fixture.root, 'site.css'); + const target = joinPath(fixture.root, 'SITE.css'); + + await service.move(source, target, true); + + assert.strictEqual(await service.exists(source), false); + assert.strictEqual(await service.exists(target), true); + }); + + test('copy', async () => { + const source = fixture.indexHtml; + const target = joinPath(fixture.root, 'index-copy.html'); + + await service.copy(source, target, true); + + const copied = await service.readFile(target); + assert.strictEqual(copied.value.toString(), 'Index'); + }); + + test('deleteFile', async () => { + const resource = joinPath(fixture.root, 'to-delete.txt'); + await service.writeFile(resource, VSBuffer.fromString('delete me')); + assert.strictEqual(await service.exists(resource), true); + + await service.del(resource); + assert.strictEqual(await service.exists(resource), false); + }); + + test('provider events bubble through file service', async () => { + let changeCount = 0; + const resource = joinPath(fixture.root, 'events.txt'); + disposables.add(service.onDidFilesChange(e => { + if (e.contains(resource, FileChangeType.UPDATED) || e.contains(resource, FileChangeType.ADDED) || e.contains(resource, FileChangeType.DELETED)) { + changeCount++; + } + })); + + await service.writeFile(resource, VSBuffer.fromString('1')); + await service.writeFile(resource, VSBuffer.fromString('2')); + await service.del(resource); + + await timeout(20); // provider fires changes async + assert.ok(changeCount > 0); + }); + + test('setReadOnly toggles provider capabilities', async () => { + provider.setReadOnly(true); + assert.ok(provider.capabilities & FileSystemProviderCapabilities.Readonly); + + let error: unknown; + try { + await service.writeFile(joinPath(fixture.root, 'readonly.txt'), VSBuffer.fromString('fail')); + } catch (e) { + error = e; + } + + assert.ok(error); + + provider.setReadOnly(false); + await service.writeFile(joinPath(fixture.root, 'readonly.txt'), VSBuffer.fromString('ok')); + }); +}); + +async function createFixture(service: FileService): Promise { + const root = URI.from({ scheme: Schemas.inMemory, path: '/' }); + + await service.createFolder(joinPath(root, 'examples')); + await service.createFolder(joinPath(root, 'other')); + await service.writeFile(joinPath(root, 'index.html'), VSBuffer.fromString('Index')); + await service.writeFile(joinPath(root, 'site.css'), VSBuffer.fromString('body { }')); + + await service.writeFile(joinPath(root, 'small.txt'), VSBuffer.fromString('Small File')); + await service.writeFile(joinPath(root, 'small_umlaut.txt'), VSBuffer.fromString('Small File with Ümlaut')); + + await service.createFolder(joinPath(root, 'deep')); + await service.writeFile(joinPath(root, 'deep', 'conway.js'), VSBuffer.fromString('console.log("conway");')); + await service.writeFile(joinPath(root, 'deep', 'a.txt'), VSBuffer.fromString('A')); + await service.writeFile(joinPath(root, 'deep', 'b.txt'), VSBuffer.fromString('B')); + await service.writeFile(joinPath(root, 'deep', 'c.txt'), VSBuffer.fromString('C')); + + await service.createFolder(joinPath(root, 'other', 'deep')); + await service.writeFile(joinPath(root, 'other', 'deep', '1.txt'), VSBuffer.fromString('1')); + await service.writeFile(joinPath(root, 'other', 'deep', '2.txt'), VSBuffer.fromString('2')); + await service.writeFile(joinPath(root, 'other', 'deep', '3.txt'), VSBuffer.fromString('3')); + await service.writeFile(joinPath(root, 'other', 'deep', '4.txt'), VSBuffer.fromString('4')); + + return { + root, + indexHtml: joinPath(root, 'index.html'), + siteCss: joinPath(root, 'site.css'), + smallTxt: joinPath(root, 'small.txt'), + smallUmlautTxt: joinPath(root, 'small_umlaut.txt'), + deepDir: joinPath(root, 'deep'), + otherDeepDir: joinPath(root, 'other', 'deep') + }; +} diff --git a/src/vs/platform/files/test/node/diskFileService.integrationTest.ts b/src/vs/platform/files/test/node/diskFileService.integrationTest.ts index 6f9622409e1cf..45c32f67359fd 100644 --- a/src/vs/platform/files/test/node/diskFileService.integrationTest.ts +++ b/src/vs/platform/files/test/node/diskFileService.integrationTest.ts @@ -73,6 +73,7 @@ export class TestDiskFileSystemProvider extends DiskFileSystemProvider { FileSystemProviderCapabilities.FileAtomicWrite | FileSystemProviderCapabilities.FileAtomicDelete | FileSystemProviderCapabilities.FileClone | + FileSystemProviderCapabilities.FileAppend | FileSystemProviderCapabilities.FileRealpath; if (isLinux) { @@ -2510,6 +2511,149 @@ flakySuite('Disk File Service', function () { assert.ok(error); }); + test('appendFile', async () => { + setCapabilities(fileProvider, FileSystemProviderCapabilities.FileReadWrite | FileSystemProviderCapabilities.FileAppend); + + return testAppendFile(); + }); + + test('appendFile - buffered', async () => { + setCapabilities(fileProvider, FileSystemProviderCapabilities.FileOpenReadWriteClose | FileSystemProviderCapabilities.FileAppend); + + return testAppendFile(); + }); + + async function testAppendFile() { + let event: FileOperationEvent; + disposables.add(service.onDidRunOperation(e => event = e)); + + const resource = URI.file(join(testDir, 'small.txt')); + + const content = readFileSync(resource.fsPath).toString(); + assert.strictEqual(content, 'Small File'); + + const appendContent = ' - Appended!'; + await service.writeFile(resource, VSBuffer.fromString(appendContent), { append: true }); + + assert.ok(event!); + assert.strictEqual(event!.resource.fsPath, resource.fsPath); + assert.strictEqual(event!.operation, FileOperation.WRITE); + + assert.strictEqual(readFileSync(resource.fsPath).toString(), 'Small File - Appended!'); + } + + test('appendFile (readable)', async () => { + setCapabilities(fileProvider, FileSystemProviderCapabilities.FileReadWrite | FileSystemProviderCapabilities.FileAppend); + + return testAppendFileReadable(); + }); + + test('appendFile (readable) - buffered', async () => { + setCapabilities(fileProvider, FileSystemProviderCapabilities.FileOpenReadWriteClose | FileSystemProviderCapabilities.FileAppend); + + return testAppendFileReadable(); + }); + + async function testAppendFileReadable() { + const resource = URI.file(join(testDir, 'small.txt')); + + const content = readFileSync(resource.fsPath).toString(); + assert.strictEqual(content, 'Small File'); + + const appendContent = ' - Appended via readable!'; + await service.writeFile(resource, bufferToReadable(VSBuffer.fromString(appendContent)), { append: true }); + + assert.strictEqual(readFileSync(resource.fsPath).toString(), 'Small File - Appended via readable!'); + } + + test('appendFile (stream)', async () => { + setCapabilities(fileProvider, FileSystemProviderCapabilities.FileReadWrite | FileSystemProviderCapabilities.FileAppend); + + return testAppendFileStream(); + }); + + test('appendFile (stream) - buffered', async () => { + setCapabilities(fileProvider, FileSystemProviderCapabilities.FileOpenReadWriteClose | FileSystemProviderCapabilities.FileAppend); + + return testAppendFileStream(); + }); + + async function testAppendFileStream() { + const resource = URI.file(join(testDir, 'small.txt')); + + const content = readFileSync(resource.fsPath).toString(); + assert.strictEqual(content, 'Small File'); + + const appendContent = ' - Appended via stream!'; + await service.writeFile(resource, bufferToStream(VSBuffer.fromString(appendContent)), { append: true }); + + assert.strictEqual(readFileSync(resource.fsPath).toString(), 'Small File - Appended via stream!'); + } + + test('appendFile - creates file if not exists', async () => { + setCapabilities(fileProvider, FileSystemProviderCapabilities.FileReadWrite | FileSystemProviderCapabilities.FileAppend); + + return testAppendFileCreatesFile(); + }); + + test('appendFile - creates file if not exists (buffered)', async () => { + setCapabilities(fileProvider, FileSystemProviderCapabilities.FileOpenReadWriteClose | FileSystemProviderCapabilities.FileAppend); + + return testAppendFileCreatesFile(); + }); + + async function testAppendFileCreatesFile() { + const resource = URI.file(join(testDir, 'appendfile-new.txt')); + + assert.strictEqual(existsSync(resource.fsPath), false); + + const content = 'Initial content via append'; + await service.writeFile(resource, VSBuffer.fromString(content), { append: true }); + + assert.strictEqual(existsSync(resource.fsPath), true); + assert.strictEqual(readFileSync(resource.fsPath).toString(), content); + } + + test('appendFile - multiple appends', async () => { + setCapabilities(fileProvider, FileSystemProviderCapabilities.FileReadWrite | FileSystemProviderCapabilities.FileAppend); + + return testAppendFileMultiple(); + }); + + test('appendFile - multiple appends (buffered)', async () => { + setCapabilities(fileProvider, FileSystemProviderCapabilities.FileOpenReadWriteClose | FileSystemProviderCapabilities.FileAppend); + + return testAppendFileMultiple(); + }); + + async function testAppendFileMultiple() { + const resource = URI.file(join(testDir, 'appendfile-multiple.txt')); + + await service.writeFile(resource, VSBuffer.fromString('Line 1\n'), { append: true }); + await service.writeFile(resource, VSBuffer.fromString('Line 2\n'), { append: true }); + await service.writeFile(resource, VSBuffer.fromString('Line 3\n'), { append: true }); + + assert.strictEqual(readFileSync(resource.fsPath).toString(), 'Line 1\nLine 2\nLine 3\n'); + } + + test('appendFile - throws when provider does not support append', async () => { + // Remove FileAppend capability - should throw error + setCapabilities(fileProvider, FileSystemProviderCapabilities.FileOpenReadWriteClose); + + const resource = URI.file(join(testDir, 'small.txt')); + const appendContent = ' - Appended via fallback!'; + + let error: Error | undefined; + try { + await service.writeFile(resource, VSBuffer.fromString(appendContent), { append: true }); + } catch (e) { + error = e as Error; + } + + assert.ok(error); + assert.ok(error.message.includes('does not support append')); + }); + test('read - mixed positions', async () => { const resource = URI.file(join(testDir, 'lorem.txt')); diff --git a/src/vs/workbench/api/browser/mainThreadChatSessions.ts b/src/vs/workbench/api/browser/mainThreadChatSessions.ts index 7173ddc8d7064..6a18a39b05ff6 100644 --- a/src/vs/workbench/api/browser/mainThreadChatSessions.ts +++ b/src/vs/workbench/api/browser/mainThreadChatSessions.ts @@ -652,8 +652,8 @@ export class MainThreadChatSessions extends Disposable implements MainThreadChat if (options?.optionGroups && options.optionGroups.length) { const groupsWithCallbacks = options.optionGroups.map(group => ({ ...group, - onSearch: group.searchable ? async (token: CancellationToken) => { - return await this._proxy.$invokeOptionGroupSearch(handle, group.id, token); + onSearch: group.searchable ? async (query: string, token: CancellationToken) => { + return await this._proxy.$invokeOptionGroupSearch(handle, group.id, query, token); } : undefined, })); this._chatSessionsService.setOptionGroupsForSessionType(chatSessionScheme, handle, groupsWithCallbacks); diff --git a/src/vs/workbench/api/common/extHost.protocol.ts b/src/vs/workbench/api/common/extHost.protocol.ts index ed0f88a69b5e5..5c4dd634a4b2a 100644 --- a/src/vs/workbench/api/common/extHost.protocol.ts +++ b/src/vs/workbench/api/common/extHost.protocol.ts @@ -3339,7 +3339,7 @@ export interface ExtHostChatSessionsShape { $disposeChatSessionContent(providerHandle: number, sessionResource: UriComponents): Promise; $invokeChatSessionRequestHandler(providerHandle: number, sessionResource: UriComponents, request: IChatAgentRequest, history: any[], token: CancellationToken): Promise; $provideChatSessionProviderOptions(providerHandle: number, token: CancellationToken): Promise; - $invokeOptionGroupSearch(providerHandle: number, optionGroupId: string, token: CancellationToken): Promise; + $invokeOptionGroupSearch(providerHandle: number, optionGroupId: string, query: string, token: CancellationToken): Promise; $provideHandleOptionsChange(providerHandle: number, sessionResource: UriComponents, updates: ReadonlyArray, token: CancellationToken): Promise; } diff --git a/src/vs/workbench/api/common/extHostChatSessions.ts b/src/vs/workbench/api/common/extHostChatSessions.ts index b0ad15d2d4991..bc7366256c194 100644 --- a/src/vs/workbench/api/common/extHostChatSessions.ts +++ b/src/vs/workbench/api/common/extHostChatSessions.ts @@ -465,7 +465,7 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio }; } - async $invokeOptionGroupSearch(providerHandle: number, optionGroupId: string, token: CancellationToken): Promise { + async $invokeOptionGroupSearch(providerHandle: number, optionGroupId: string, query: string, token: CancellationToken): Promise { const optionGroups = this._providerOptionGroups.get(providerHandle); if (!optionGroups) { this._logService.warn(`No option groups found for provider handle ${providerHandle}`); @@ -479,7 +479,7 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio } try { - const results = await group.onSearch(token); + const results = await group.onSearch(query, token); return results ?? []; } catch (error) { this._logService.error(`Error calling onSearch for option group ${optionGroupId}:`, error); diff --git a/src/vs/workbench/contrib/browserView/electron-browser/browserEditor.ts b/src/vs/workbench/contrib/browserView/electron-browser/browserEditor.ts index 590290854865a..554cc6279ada3 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/browserEditor.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/browserEditor.ts @@ -6,7 +6,7 @@ import './media/browser.css'; import { localize } from '../../../../nls.js'; import { $, addDisposableListener, disposableWindowInterval, EventType, scheduleAtNextAnimationFrame } from '../../../../base/browser/dom.js'; -import { CancellationToken } from '../../../../base/common/cancellation.js'; +import { CancellationToken, CancellationTokenSource } from '../../../../base/common/cancellation.js'; import { RawContextKey, IContextKey, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; import { MenuId } from '../../../../platform/actions/common/actions.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; @@ -32,13 +32,21 @@ import { Disposable, DisposableStore } from '../../../../base/common/lifecycle.j import { WorkbenchHoverDelegate } from '../../../../platform/hover/browser/hover.js'; import { HoverPosition } from '../../../../base/browser/ui/hover/hoverWidget.js'; import { MenuWorkbenchToolBar } from '../../../../platform/actions/browser/toolbar.js'; +import { IBrowserElementsService } from '../../../services/browserElements/browser/browserElementsService.js'; +import { IChatWidgetService } from '../../chat/browser/chat.js'; +import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; +import { ThemeIcon } from '../../../../base/common/themables.js'; +import { Codicon } from '../../../../base/common/codicons.js'; import { encodeBase64, VSBuffer } from '../../../../base/common/buffer.js'; +import { IChatRequestVariableEntry } from '../../chat/common/attachments/chatVariableEntries.js'; +import { IBrowserTargetLocator, getDisplayNameFromOuterHTML } from '../../../../platform/browserElements/common/browserElements.js'; export const CONTEXT_BROWSER_CAN_GO_BACK = new RawContextKey('browserCanGoBack', false, localize('browser.canGoBack', "Whether the browser can go back")); export const CONTEXT_BROWSER_CAN_GO_FORWARD = new RawContextKey('browserCanGoForward', false, localize('browser.canGoForward', "Whether the browser can go forward")); export const CONTEXT_BROWSER_FOCUSED = new RawContextKey('browserFocused', true, localize('browser.editorFocused', "Whether the browser editor is focused")); export const CONTEXT_BROWSER_STORAGE_SCOPE = new RawContextKey('browserStorageScope', '', localize('browser.storageScope', "The storage scope of the current browser view")); export const CONTEXT_BROWSER_DEVTOOLS_OPEN = new RawContextKey('browserDevToolsOpen', false, localize('browser.devToolsOpen', "Whether developer tools are open for the current browser view")); +export const CONTEXT_BROWSER_ELEMENT_SELECTION_ACTIVE = new RawContextKey('browserElementSelectionActive', false, localize('browser.elementSelectionActive', "Whether element selection is currently active")); class BrowserNavigationBar extends Disposable { private readonly _urlInput: HTMLInputElement; @@ -152,10 +160,12 @@ export class BrowserEditor extends EditorPane { private _canGoForwardContext!: IContextKey; private _storageScopeContext!: IContextKey; private _devToolsOpenContext!: IContextKey; + private _elementSelectionActiveContext!: IContextKey; private _model: IBrowserViewModel | undefined; private readonly _inputDisposables = this._register(new DisposableStore()); private overlayManager: BrowserOverlayManager | undefined; + private _elementSelectionCts: CancellationTokenSource | undefined; constructor( group: IEditorGroup, @@ -166,7 +176,10 @@ export class BrowserEditor extends EditorPane { @ILogService private readonly logService: ILogService, @IInstantiationService private readonly instantiationService: IInstantiationService, @IContextKeyService private readonly contextKeyService: IContextKeyService, - @IEditorService private readonly editorService: IEditorService + @IEditorService private readonly editorService: IEditorService, + @IBrowserElementsService private readonly browserElementsService: IBrowserElementsService, + @IChatWidgetService private readonly chatWidgetService: IChatWidgetService, + @IConfigurationService private readonly configurationService: IConfigurationService ) { super(BrowserEditor.ID, group, telemetryService, themeService, storageService); } @@ -183,6 +196,7 @@ export class BrowserEditor extends EditorPane { this._canGoForwardContext = CONTEXT_BROWSER_CAN_GO_FORWARD.bindTo(contextKeyService); this._storageScopeContext = CONTEXT_BROWSER_STORAGE_SCOPE.bindTo(contextKeyService); this._devToolsOpenContext = CONTEXT_BROWSER_DEVTOOLS_OPEN.bindTo(contextKeyService); + this._elementSelectionActiveContext = CONTEXT_BROWSER_ELEMENT_SELECTION_ACTIVE.bindTo(contextKeyService); // Currently this is always true since it is scoped to the editor container CONTEXT_BROWSER_FOCUSED.bindTo(contextKeyService); @@ -447,6 +461,99 @@ export class BrowserEditor extends EditorPane { return this._model?.toggleDevTools(); } + /** + * Start element selection in the browser view, wait for a user selection, and add it to chat. + */ + async addElementToChat(): Promise { + // If selection is already active, cancel it + if (this._elementSelectionCts) { + this._elementSelectionCts.dispose(true); + this._elementSelectionCts = undefined; + this._elementSelectionActiveContext.set(false); + return; + } + + // Start new selection + const cts = new CancellationTokenSource(); + this._elementSelectionCts = cts; + this._elementSelectionActiveContext.set(true); + + try { + // Get the resource URI for this editor + const resourceUri = this.input?.resource; + if (!resourceUri) { + throw new Error('No resource URI found'); + } + + // Create a locator - for integrated browser, use the URI scheme to identify + // Browser view URIs have a special scheme we can match against + const locator: IBrowserTargetLocator = { browserViewId: BrowserViewUri.getId(this.input.resource) }; + + // Start debug session for integrated browser + await this.browserElementsService.startDebugSession(cts.token, locator); + + // Get the browser container bounds + const { width, height } = this._browserContainer.getBoundingClientRect(); + + // Get element data from user selection + const elementData = await this.browserElementsService.getElementData({ x: 0, y: 0, width, height }, cts.token, locator); + if (!elementData) { + throw new Error('Element data not found'); + } + + const bounds = elementData.bounds; + const toAttach: IChatRequestVariableEntry[] = []; + + // Prepare HTML/CSS context + const displayName = getDisplayNameFromOuterHTML(elementData.outerHTML); + const attachCss = this.configurationService.getValue('chat.sendElementsToChat.attachCSS'); + let value = (attachCss ? 'Attached HTML and CSS Context' : 'Attached HTML Context') + '\n\n' + elementData.outerHTML; + if (attachCss) { + value += '\n\n' + elementData.computedStyle; + } + + toAttach.push({ + id: 'element-' + Date.now(), + name: displayName, + fullName: displayName, + value: value, + kind: 'element', + icon: ThemeIcon.fromId(Codicon.layout.id), + }); + + // Attach screenshot if enabled + if (this.configurationService.getValue('chat.sendElementsToChat.attachImages') && this._model) { + const screenshotBuffer = await this._model.captureScreenshot({ + quality: 90, + rect: bounds + }); + + toAttach.push({ + id: 'element-screenshot-' + Date.now(), + name: 'Element Screenshot', + fullName: 'Element Screenshot', + kind: 'image', + value: screenshotBuffer.buffer + }); + } + + // Attach to chat widget + const widget = await this.chatWidgetService.revealWidget() ?? this.chatWidgetService.lastFocusedWidget; + widget?.attachmentModel?.addContext(...toAttach); + + } catch (error) { + if (!cts.token.isCancellationRequested) { + this.logService.error('BrowserEditor.addElementToChat: Failed to select element', error); + } + } finally { + cts.dispose(); + if (this._elementSelectionCts === cts) { + this._elementSelectionCts = undefined; + this._elementSelectionActiveContext.set(false); + } + } + } + /** * Update navigation state and context keys */ @@ -527,6 +634,12 @@ export class BrowserEditor extends EditorPane { override clearInput(): void { this._inputDisposables.clear(); + // Cancel any active element selection + if (this._elementSelectionCts) { + this._elementSelectionCts.dispose(true); + this._elementSelectionCts = undefined; + } + void this._model?.setVisible(false); this._model = undefined; @@ -534,6 +647,7 @@ export class BrowserEditor extends EditorPane { this._canGoForwardContext.reset(); this._storageScopeContext.reset(); this._devToolsOpenContext.reset(); + this._elementSelectionActiveContext.reset(); this._navigationBar.clear(); this.setBackgroundImage(undefined); diff --git a/src/vs/workbench/contrib/browserView/electron-browser/browserViewActions.ts b/src/vs/workbench/contrib/browserView/electron-browser/browserViewActions.ts index b3e409c365f67..d57048492afb0 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/browserViewActions.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/browserViewActions.ts @@ -11,10 +11,11 @@ import { KeybindingWeight } from '../../../../platform/keybinding/common/keybind import { KeyMod, KeyCode } from '../../../../base/common/keyCodes.js'; import { IEditorService } from '../../../services/editor/common/editorService.js'; import { Codicon } from '../../../../base/common/codicons.js'; -import { BrowserEditor, CONTEXT_BROWSER_CAN_GO_BACK, CONTEXT_BROWSER_CAN_GO_FORWARD, CONTEXT_BROWSER_DEVTOOLS_OPEN, CONTEXT_BROWSER_FOCUSED, CONTEXT_BROWSER_STORAGE_SCOPE } from './browserEditor.js'; +import { BrowserEditor, CONTEXT_BROWSER_CAN_GO_BACK, CONTEXT_BROWSER_CAN_GO_FORWARD, CONTEXT_BROWSER_DEVTOOLS_OPEN, CONTEXT_BROWSER_FOCUSED, CONTEXT_BROWSER_STORAGE_SCOPE, CONTEXT_BROWSER_ELEMENT_SELECTION_ACTIVE } from './browserEditor.js'; import { BrowserViewUri } from '../../../../platform/browserView/common/browserViewUri.js'; import { IBrowserViewWorkbenchService } from '../common/browserView.js'; import { BrowserViewStorageScope } from '../../../../platform/browserView/common/browserView.js'; +import { ChatContextKeys } from '../../chat/common/actions/chatContextKeys.js'; // Context key expression to check if browser editor is active const BROWSER_EDITOR_ACTIVE = ContextKeyExpr.equals('activeEditor', BrowserEditor.ID); @@ -121,7 +122,6 @@ class ReloadAction extends Action2 { group: 'navigation', order: 3, }, - precondition: BROWSER_EDITOR_ACTIVE, keybinding: { when: CONTEXT_BROWSER_FOCUSED, // Keybinding is only active when focus is within the browser editor weight: KeybindingWeight.WorkbenchContrib + 50, // Priority over debug @@ -139,6 +139,33 @@ class ReloadAction extends Action2 { } } +class AddElementToChatAction extends Action2 { + static readonly ID = 'workbench.action.browser.addElementToChat'; + + constructor() { + super({ + id: AddElementToChatAction.ID, + title: localize2('browser.addElementToChatAction', 'Add Element to Chat'), + icon: Codicon.inspect, + f1: true, + precondition: ChatContextKeys.enabled, + toggled: CONTEXT_BROWSER_ELEMENT_SELECTION_ACTIVE, + menu: { + id: MenuId.BrowserActionsToolbar, + group: 'actions', + order: 1, + when: ChatContextKeys.enabled + } + }); + } + + async run(accessor: ServicesAccessor, browserEditor = accessor.get(IEditorService).activeEditorPane): Promise { + if (browserEditor instanceof BrowserEditor) { + await browserEditor.addElementToChat(); + } + } +} + class ToggleDevToolsAction extends Action2 { static readonly ID = 'workbench.action.browser.toggleDevTools'; @@ -153,10 +180,9 @@ class ToggleDevToolsAction extends Action2 { menu: { id: MenuId.BrowserActionsToolbar, group: 'actions', - order: 1, + order: 2, when: BROWSER_EDITOR_ACTIVE - }, - precondition: BROWSER_EDITOR_ACTIVE + } }); } @@ -222,6 +248,7 @@ registerAction2(OpenIntegratedBrowserAction); registerAction2(GoBackAction); registerAction2(GoForwardAction); registerAction2(ReloadAction); +registerAction2(AddElementToChatAction); registerAction2(ToggleDevToolsAction); registerAction2(ClearGlobalBrowserStorageAction); registerAction2(ClearWorkspaceBrowserStorageAction); diff --git a/src/vs/workbench/contrib/chat/browser/attachments/simpleBrowserEditorOverlay.ts b/src/vs/workbench/contrib/chat/browser/attachments/simpleBrowserEditorOverlay.ts index ddd15d9dd8e3d..4ebc648fbfb36 100644 --- a/src/vs/workbench/contrib/chat/browser/attachments/simpleBrowserEditorOverlay.ts +++ b/src/vs/workbench/contrib/chat/browser/attachments/simpleBrowserEditorOverlay.ts @@ -16,8 +16,7 @@ import { EditorGroupView } from '../../../../browser/parts/editor/editorGroupVie import { Event } from '../../../../../base/common/event.js'; import { ServiceCollection } from '../../../../../platform/instantiation/common/serviceCollection.js'; import { IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js'; -import { EditorResourceAccessor, SideBySideEditor } from '../../../../common/editor.js'; -import { isEqual, joinPath } from '../../../../../base/common/resources.js'; +import { joinPath } from '../../../../../base/common/resources.js'; import { CancellationTokenSource } from '../../../../../base/common/cancellation.js'; import { IHostService } from '../../../../services/host/browser/host.js'; import { IChatWidgetService } from '../chat.js'; @@ -35,7 +34,8 @@ import { IPreferencesService } from '../../../../services/preferences/common/pre import { IBrowserElementsService } from '../../../../services/browserElements/browser/browserElementsService.js'; import { IContextMenuService } from '../../../../../platform/contextview/browser/contextView.js'; import { IAction, toAction } from '../../../../../base/common/actions.js'; -import { BrowserType } from '../../../../../platform/browserElements/common/browserElements.js'; +import { WebviewInput } from '../../../webviewPanel/browser/webviewEditorInput.js'; +import { IBrowserTargetLocator, getDisplayNameFromOuterHTML } from '../../../../../platform/browserElements/common/browserElements.js'; class SimpleBrowserOverlayWidget { @@ -47,7 +47,7 @@ class SimpleBrowserOverlayWidget { private _timeout: Timeout | undefined = undefined; - private _activeBrowserType: BrowserType | undefined = undefined; + private _activeLocator: IBrowserTargetLocator | undefined = undefined; constructor( private readonly _editor: IEditorGroup, @@ -226,8 +226,8 @@ class SimpleBrowserOverlayWidget { })); } - setActiveBrowserType(type: BrowserType | undefined) { - this._activeBrowserType = type; + setActiveLocator(locator: IBrowserTargetLocator | undefined) { + this._activeLocator = locator; } hideElement(element: HTMLElement) { @@ -249,7 +249,12 @@ class SimpleBrowserOverlayWidget { const editorContainer = this._container.querySelector('.editor-container') as HTMLDivElement; const editorContainerPosition = editorContainer ? editorContainer.getBoundingClientRect() : this._container.getBoundingClientRect(); - const elementData = await this._browserElementsService.getElementData(editorContainerPosition, cts.token, this._activeBrowserType); + const elementData = await this._browserElementsService.getElementData({ + x: editorContainerPosition.x, + y: editorContainerPosition.y + 32.4, // Height of the title bar + width: editorContainerPosition.width, + height: editorContainerPosition.height - 32.4, + }, cts.token, this._activeLocator); if (!elementData) { throw new Error('Element data not found'); } @@ -257,14 +262,16 @@ class SimpleBrowserOverlayWidget { const toAttach: IChatRequestVariableEntry[] = []; const widget = await this._chatWidgetService.revealWidget() ?? this._chatWidgetService.lastFocusedWidget; - let value = 'Attached HTML and CSS Context\n\n' + elementData.outerHTML; - if (this.configurationService.getValue('chat.sendElementsToChat.attachCSS')) { + const attachCss = this.configurationService.getValue('chat.sendElementsToChat.attachCSS'); + let value = (attachCss ? 'Attached HTML and CSS Context' : 'Attached HTML Context') + '\n\n' + elementData.outerHTML; + if (attachCss) { value += '\n\n' + elementData.computedStyle; } + const displayName = getDisplayNameFromOuterHTML(elementData.outerHTML); toAttach.push({ id: 'element-' + Date.now(), - name: this.getDisplayNameFromOuterHTML(elementData.outerHTML), - fullName: this.getDisplayNameFromOuterHTML(elementData.outerHTML), + name: displayName, + fullName: displayName, value: value, kind: 'element', icon: ThemeIcon.fromId(Codicon.layout.id), @@ -297,21 +304,6 @@ class SimpleBrowserOverlayWidget { widget?.attachmentModel?.addContext(...toAttach); } - - getDisplayNameFromOuterHTML(outerHTML: string): string { - const firstElementMatch = outerHTML.match(/^<(\w+)([^>]*?)>/); - if (!firstElementMatch) { - throw new Error('No outer element found'); - } - - const tagName = firstElementMatch[1]; - const idMatch = firstElementMatch[2].match(/\s+id\s*=\s*["']([^"']+)["']/i); - const id = idMatch ? `#${idMatch[1]}` : ''; - const classMatch = firstElementMatch[2].match(/\s+class\s*=\s*["']([^"']+)["']/i); - const className = classMatch ? `.${classMatch[1].replace(/\s+/g, '.')}` : ''; - return `${tagName}${id}${className}`; - } - dispose() { this._showStore.dispose(); } @@ -354,15 +346,10 @@ class SimpleBrowserOverlayController { connectingWebviewElement.className = 'connecting-webview-element'; - const getActiveBrowserType = () => { - const editor = group.activeEditorPane; - const isSimpleBrowser = editor?.input.editorId === 'mainThreadWebview-simpleBrowser.view'; - const isLiveServer = editor?.input.editorId === 'mainThreadWebview-browserPreview'; - return isSimpleBrowser ? BrowserType.SimpleBrowser : isLiveServer ? BrowserType.LiveServer : undefined; - }; - let cts = new CancellationTokenSource(); - const show = async () => { + const show = async (locator: IBrowserTargetLocator) => { + widget.setActiveLocator(locator); + // Show the connecting indicator while establishing the session connectingWebviewElement.textContent = localize('connectingWebviewElement', 'Connecting to webview...'); if (!container.contains(connectingWebviewElement)) { @@ -370,14 +357,11 @@ class SimpleBrowserOverlayController { } cts = new CancellationTokenSource(); - const activeBrowserType = getActiveBrowserType(); - if (activeBrowserType) { - try { - await this._browserElementsService.startDebugSession(cts.token, activeBrowserType); - } catch (error) { - connectingWebviewElement.textContent = localize('reopenErrorWebviewElement', 'Please reopen the preview.'); - return; - } + try { + await this._browserElementsService.startDebugSession(cts.token, locator); + } catch (error) { + connectingWebviewElement.textContent = localize('reopenErrorWebviewElement', 'Please reopen the preview.'); + return; } if (!container.contains(this._domNode)) { @@ -387,6 +371,7 @@ class SimpleBrowserOverlayController { }; const hide = () => { + widget.setActiveLocator(undefined); if (container.contains(this._domNode)) { cts.cancel(); this._domNode.remove(); @@ -396,32 +381,32 @@ class SimpleBrowserOverlayController { const activeEditorSignal = observableSignalFromEvent(this, Event.any(group.onDidActiveEditorChange, group.onDidModelChange)); - const activeUriObs = derivedOpts({ equalsFn: isEqual }, r => { + const activeIdObs = derivedOpts({}, r => { activeEditorSignal.read(r); // signal const editor = group.activeEditorPane; - const activeBrowser = getActiveBrowserType(); - widget.setActiveBrowserType(activeBrowser); + const isSimpleBrowser = editor?.input.editorId === 'mainThreadWebview-simpleBrowser.view'; + const isLiveServer = editor?.input.editorId === 'mainThreadWebview-browserPreview'; - if (activeBrowser) { - const uri = EditorResourceAccessor.getOriginalUri(editor?.input, { supportSideBySide: SideBySideEditor.PRIMARY }); - return uri; + if (isSimpleBrowser || isLiveServer) { + const webviewInput = editor.input as WebviewInput; + return webviewInput.webview.container.id; } return undefined; }); this._store.add(autorun(r => { - const data = activeUriObs.read(r); + const webviewId = activeIdObs.read(r); - if (!data) { + if (!webviewId) { hide(); return; } - show(); + show({ webviewId }); })); } diff --git a/src/vs/workbench/contrib/chat/browser/chatSessions/searchableOptionPickerActionItemtest.ts b/src/vs/workbench/contrib/chat/browser/chatSessions/searchableOptionPickerActionItemtest.ts index b6272b207162e..8a7d7742419b3 100644 --- a/src/vs/workbench/contrib/chat/browser/chatSessions/searchableOptionPickerActionItemtest.ts +++ b/src/vs/workbench/contrib/chat/browser/chatSessions/searchableOptionPickerActionItemtest.ts @@ -5,7 +5,8 @@ import './media/chatSessionPickerActionItem.css'; import { IAction } from '../../../../../base/common/actions.js'; -import { CancellationToken } from '../../../../../base/common/cancellation.js'; +import { CancellationTokenSource } from '../../../../../base/common/cancellation.js'; +import { Delayer } from '../../../../../base/common/async.js'; import * as dom from '../../../../../base/browser/dom.js'; import { IActionWidgetService } from '../../../../../platform/actionWidget/browser/actionWidget.js'; import { IActionWidgetDropdownAction, IActionWidgetDropdownOptions } from '../../../../../platform/actionWidget/browser/actionWidgetDropdown.js'; @@ -13,7 +14,7 @@ import { IContextKeyService } from '../../../../../platform/contextkey/common/co import { IKeybindingService } from '../../../../../platform/keybinding/common/keybinding.js'; import { ActionWidgetDropdownActionViewItem } from '../../../../../platform/actions/browser/actionWidgetDropdownActionViewItem.js'; import { IChatSessionProviderOptionGroup, IChatSessionProviderOptionItem } from '../../common/chatSessionsService.js'; -import { IDisposable } from '../../../../../base/common/lifecycle.js'; +import { DisposableStore, IDisposable } from '../../../../../base/common/lifecycle.js'; import { renderLabelWithIcons, renderIcon } from '../../../../../base/browser/ui/iconLabel/iconLabels.js'; import { localize } from '../../../../../nls.js'; import { IQuickInputService, IQuickPickItem } from '../../../../../platform/quickinput/common/quickInput.js'; @@ -170,26 +171,56 @@ export class SearchableOptionPickerActionItem extends ActionWidgetDropdownAction */ private async showSearchableQuickPick(optionGroup: IChatSessionProviderOptionGroup): Promise { if (optionGroup.onSearch) { + const disposables = new DisposableStore(); const quickPick = this.quickInputService.createQuickPick(); + disposables.add(quickPick); quickPick.placeholder = optionGroup.description ?? localize('selectOption.placeholder', "Select {0}", optionGroup.name); quickPick.matchOnDescription = true; quickPick.matchOnDetail = true; - quickPick.busy = !!optionGroup.onSearch; + quickPick.ignoreFocusOut = true; + quickPick.busy = true; quickPick.show(); - let items: IChatSessionProviderOptionItem[] = []; - try { - items = await optionGroup.onSearch(CancellationToken.None); - } catch (error) { - this.logService.error('Error fetching searchable option items:', error); - } finally { - quickPick.items = items.map(item => this.createQuickPickItem(item)); - quickPick.busy = false; - } + + // Debounced search state + let currentSearchCts: CancellationTokenSource | undefined; + const searchDelayer = disposables.add(new Delayer(300)); + + const performSearch = async (query: string) => { + // Cancel previous search + currentSearchCts?.cancel(); + currentSearchCts?.dispose(); + currentSearchCts = new CancellationTokenSource(); + const token = currentSearchCts.token; + + quickPick.busy = true; + try { + const items = await optionGroup.onSearch!(query, token); + if (!token.isCancellationRequested) { + quickPick.items = items.map(item => this.createQuickPickItem(item)); + } + } catch (error) { + if (!token.isCancellationRequested) { + this.logService.error('Error fetching searchable option items:', error); + } + } finally { + if (!token.isCancellationRequested) { + quickPick.busy = false; + } + } + }; + + // Initial search with empty query + await performSearch(''); + + // Listen for value changes and perform debounced search + disposables.add(quickPick.onDidChangeValue(value => { + searchDelayer.trigger(() => performSearch(value)); + })); // Handle selection return new Promise((resolve) => { - quickPick.onDidAccept(() => { + disposables.add(quickPick.onDidAccept(() => { const pick = quickPick.selectedItems[0]; if (isSearchableOptionQuickPickItem(pick)) { const selectedItem = pick.optionItem; @@ -198,12 +229,14 @@ export class SearchableOptionPickerActionItem extends ActionWidgetDropdownAction } } quickPick.hide(); - }); + })); - quickPick.onDidHide(() => { - quickPick.dispose(); + disposables.add(quickPick.onDidHide(() => { + currentSearchCts?.cancel(); + currentSearchCts?.dispose(); + disposables.dispose(); resolve(); - }); + })); }); } } diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatMcpServersInteractionContentPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatMcpServersInteractionContentPart.ts index 198b0ced7a413..9ef33d8fa8a86 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatMcpServersInteractionContentPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatMcpServersInteractionContentPart.ts @@ -20,7 +20,7 @@ import { IOpenerService } from '../../../../../../platform/opener/common/opener. import { McpCommandIds } from '../../../../mcp/common/mcpCommandIds.js'; import { IAutostartResult, IMcpService } from '../../../../mcp/common/mcpTypes.js'; import { startServerAndWaitForLiveTools } from '../../../../mcp/common/mcpTypesUtils.js'; -import { IChatMcpServersStarting } from '../../../common/chatService/chatService.js'; +import { IChatMcpServersStarting, IChatMcpServersStartingSerialized } from '../../../common/chatService/chatService.js'; import { IChatRendererContent, IChatResponseViewModel, isResponseVM } from '../../../common/model/chatViewModel.js'; import { IChatContentPart, IChatContentPartRenderContext } from './chatContentParts.js'; import { ChatProgressContentPart } from './chatProgressContentPart.js'; @@ -47,7 +47,7 @@ export class ChatMcpServersInteractionContentPart extends Disposable implements }); constructor( - private readonly data: IChatMcpServersStarting, + private readonly data: IChatMcpServersStarting | IChatMcpServersStartingSerialized, private readonly context: IChatContentPartRenderContext, @IMcpService private readonly mcpService: IMcpService, @IInstantiationService private readonly instantiationService: IInstantiationService, diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatMultiDiffContentPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatMultiDiffContentPart.ts index 5a65f01fea87f..b310d8a20f1c1 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatMultiDiffContentPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatMultiDiffContentPart.ts @@ -29,7 +29,7 @@ import { MultiDiffEditorInput } from '../../../../multiDiffEditor/browser/multiD import { MultiDiffEditorItem } from '../../../../multiDiffEditor/browser/multiDiffSourceResolverService.js'; import { ChatContextKeys } from '../../../common/actions/chatContextKeys.js'; import { IEditSessionEntryDiff } from '../../../common/editing/chatEditingService.js'; -import { IChatMultiDiffData, IChatMultiDiffInnerData } from '../../../common/chatService/chatService.js'; +import { IChatMultiDiffData, IChatMultiDiffDataSerialized, IChatMultiDiffInnerData } from '../../../common/chatService/chatService.js'; import { getChatSessionType } from '../../../common/model/chatUri.js'; import { IChatRendererContent } from '../../../common/model/chatViewModel.js'; import { ChatTreeItem } from '../../chat.js'; @@ -57,7 +57,7 @@ export class ChatMultiDiffContentPart extends Disposable implements IChatContent private readonly diffData: IObservable; constructor( - private readonly content: IChatMultiDiffData, + private readonly content: IChatMultiDiffData | IChatMultiDiffDataSerialized, private readonly _element: ChatTreeItem, @IInstantiationService private readonly instantiationService: IInstantiationService, @IEditorService private readonly editorService: IEditorService, diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatTerminalToolProgressPart.css b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatTerminalToolProgressPart.css index b3a80031f70b6..397f482c9f45a 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatTerminalToolProgressPart.css +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatTerminalToolProgressPart.css @@ -185,6 +185,9 @@ div.chat-terminal-content-part.progress-step > div.chat-terminal-output-containe .chat-terminal-output-terminal.chat-terminal-output-terminal-no-output { display: none; } +.chat-terminal-output-terminal.chat-terminal-output-terminal-clipped { + overflow: hidden; +} .chat-terminal-output { margin: 0; white-space: pre; 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 7cbc201021256..24f8727d48b4c 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 @@ -756,6 +756,7 @@ class ChatTerminalToolOutputSection extends Disposable { private readonly _terminalContainer: HTMLElement; private readonly _emptyElement: HTMLElement; private _lastRenderedLineCount: number | undefined; + private _lastRenderedMaxColumnWidth: number | undefined; private readonly _onDidFocusEmitter = this._register(new Emitter()); public get onDidFocus() { return this._onDidFocusEmitter.event; } @@ -949,8 +950,8 @@ class ChatTerminalToolOutputSection extends Disposable { } const mirror = this._register(this._instantiationService.createInstance(DetachedTerminalCommandMirror, liveTerminalInstance.xterm, command)); this._mirror = mirror; - this._register(mirror.onDidUpdate(lineCount => { - this._layoutOutput(lineCount); + this._register(mirror.onDidUpdate(result => { + this._layoutOutput(result.lineCount, result.maxColumnWidth); if (this._isAtBottom) { this._scrollOutputToBottom(); } @@ -968,13 +969,13 @@ class ChatTerminalToolOutputSection extends Disposable { } else { this._hideEmptyMessage(); } - this._layoutOutput(result?.lineCount ?? 0); + this._layoutOutput(result?.lineCount ?? 0, result?.maxColumnWidth); return true; } private async _renderSnapshotOutput(snapshot: NonNullable): Promise { if (this._snapshotMirror) { - this._layoutOutput(snapshot.lineCount ?? 0); + this._layoutOutput(snapshot.lineCount ?? 0, this._lastRenderedMaxColumnWidth); return; } dom.clearNode(this._terminalContainer); @@ -989,7 +990,7 @@ class ChatTerminalToolOutputSection extends Disposable { this._showEmptyMessage(localize('chat.terminalOutputEmpty', 'No output was produced by the command.')); } const lineCount = result?.lineCount ?? snapshot.lineCount ?? 0; - this._layoutOutput(lineCount); + this._layoutOutput(lineCount, result?.maxColumnWidth); } private _renderUnavailableMessage(liveTerminalInstance: ITerminalInstance | undefined): void { @@ -1045,7 +1046,7 @@ class ChatTerminalToolOutputSection extends Disposable { } } - private _layoutOutput(lineCount?: number): void { + private _layoutOutput(lineCount?: number, maxColumnWidth?: number): void { if (!this._scrollableContainer) { return; } @@ -1056,11 +1057,22 @@ class ChatTerminalToolOutputSection extends Disposable { lineCount = this._lastRenderedLineCount; } + if (maxColumnWidth !== undefined) { + this._lastRenderedMaxColumnWidth = maxColumnWidth; + } else { + maxColumnWidth = this._lastRenderedMaxColumnWidth; + } + this._scrollableContainer.scanDomNode(); if (!this.isExpanded || lineCount === undefined) { return; } + const scrollableDomNode = this._scrollableContainer.getDomNode(); + + // Calculate and apply width based on content + this._applyContentWidth(maxColumnWidth); + const rowHeight = this._computeRowHeightPx(); const padding = this._getOutputPadding(); const minHeight = rowHeight * MIN_OUTPUT_ROWS + padding; @@ -1111,6 +1123,50 @@ class ChatTerminalToolOutputSection extends Disposable { return paddingTop + paddingBottom; } + private _applyContentWidth(maxColumnWidth?: number): void { + if (!this._scrollableContainer) { + return; + } + + const window = dom.getActiveWindow(); + const font = this._terminalConfigurationService.getFont(window); + const charWidth = font.charWidth; + + if (!charWidth || !maxColumnWidth || maxColumnWidth <= 0) { + // No content width info, leave existing width unchanged + return; + } + + // Calculate the pixel width needed for the content + // Add some padding for scrollbar and visual comfort + // Account for container padding + const horizontalPadding = 24; + const contentWidth = Math.ceil(maxColumnWidth * charWidth) + horizontalPadding; + + // Get the max available width (container's parent width) + const parentWidth = this.domNode.parentElement?.clientWidth ?? 0; + + const scrollableDomNode = this._scrollableContainer.getDomNode(); + + if (parentWidth > 0 && contentWidth < parentWidth) { + // Content is smaller than available space - shrink to fit + // Apply width to both the scrollable container and the content body + // The xterm element renders at full column width, so we need to clip it + scrollableDomNode.style.width = `${contentWidth}px`; + this._outputBody.style.width = `${contentWidth}px`; + this._terminalContainer.style.width = `${contentWidth}px`; + this._terminalContainer.classList.add('chat-terminal-output-terminal-clipped'); + } else { + // Content needs full width or more (scrollbar will show) + scrollableDomNode.style.width = ''; + this._outputBody.style.width = ''; + this._terminalContainer.style.width = ''; + this._terminalContainer.classList.remove('chat-terminal-output-terminal-clipped'); + } + + this._scrollableContainer.scanDomNode(); + } + private _computeRowHeightPx(): number { const window = dom.getActiveWindow(); const font = this._terminalConfigurationService.getFont(window); diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts index 540ef09bb584e..a8c47ca54b020 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts @@ -53,7 +53,7 @@ import { IChatAgentMetadata } from '../../common/participants/chatAgents.js'; import { ChatContextKeys } from '../../common/actions/chatContextKeys.js'; import { IChatTextEditGroup } from '../../common/model/chatModel.js'; import { chatSubcommandLeader } from '../../common/requestParser/chatParserTypes.js'; -import { ChatAgentVoteDirection, ChatAgentVoteDownReason, ChatErrorLevel, IChatConfirmation, IChatContentReference, IChatElicitationRequest, IChatElicitationRequestSerialized, IChatExtensionsContent, IChatFollowup, IChatMarkdownContent, IChatMcpServersStarting, IChatMultiDiffData, IChatPullRequestContent, IChatTask, IChatTaskSerialized, IChatThinkingPart, IChatToolInvocation, IChatToolInvocationSerialized, IChatTreeData, IChatUndoStop, isChatFollowup } from '../../common/chatService/chatService.js'; +import { ChatAgentVoteDirection, ChatAgentVoteDownReason, ChatErrorLevel, IChatConfirmation, IChatContentReference, IChatElicitationRequest, IChatElicitationRequestSerialized, IChatExtensionsContent, IChatFollowup, IChatMarkdownContent, IChatMcpServersStarting, IChatMcpServersStartingSerialized, IChatMultiDiffData, IChatMultiDiffDataSerialized, IChatPullRequestContent, IChatTask, IChatTaskSerialized, IChatThinkingPart, IChatToolInvocation, IChatToolInvocationSerialized, IChatTreeData, IChatUndoStop, isChatFollowup } from '../../common/chatService/chatService.js'; import { localChatSessionType } from '../../common/chatSessionsService.js'; import { getChatSessionType } from '../../common/model/chatUri.js'; import { IChatRequestVariableEntry } from '../../common/attachments/chatVariableEntries.js'; @@ -1486,7 +1486,7 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer { this.updateItemHeight(templateData); @@ -1819,7 +1819,7 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer void; task: () => Promise; isSettled: () => boolean; + toJSON(): IChatTaskSerialized; } export interface IChatUndoStop { @@ -341,6 +343,7 @@ export interface IChatElicitationRequest { reject?: () => Promise; isHidden?: IObservable; hide?(): void; + toJSON(): IChatElicitationRequestSerialized; } export interface IChatElicitationRequestSerialized { @@ -460,6 +463,8 @@ export interface IChatToolInvocation { generatedTitle?: string; kind: 'toolInvocation'; + + toJSON(): IChatToolInvocationSerialized; } export namespace IChatToolInvocation { @@ -691,6 +696,13 @@ export interface IChatMcpServersStarting { readonly kind: 'mcpServersStarting'; readonly state?: IObservable; // not hydrated when serialized didStartServerIds?: string[]; + toJSON(): IChatMcpServersStartingSerialized; +} + +export interface IChatMcpServersStartingSerialized { + readonly kind: 'mcpServersStarting'; + readonly state?: undefined; + didStartServerIds?: string[]; } export class ChatMcpServersStarting implements IChatMcpServersStarting { @@ -717,7 +729,7 @@ export class ChatMcpServersStarting implements IChatMcpServersStarting { }); } - toJSON(): IChatMcpServersStarting { + toJSON(): IChatMcpServersStartingSerialized { return { kind: 'mcpServersStarting', didStartServerIds: this.didStartServerIds }; } } @@ -732,6 +744,7 @@ export type IChatProgress = | IChatAgentMarkdownContentWithVulnerability | IChatTreeData | IChatMultiDiffData + | IChatMultiDiffDataSerialized | IChatUsedContext | IChatContentReference | IChatContentInlineReference @@ -757,7 +770,8 @@ export type IChatProgress = | IChatTaskSerialized | IChatElicitationRequest | IChatElicitationRequestSerialized - | IChatMcpServersStarting; + | IChatMcpServersStarting + | IChatMcpServersStartingSerialized; export interface IChatFollowup { kind: 'reply'; diff --git a/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts b/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts index 5722b61e57000..d517d0ce5034d 100644 --- a/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts @@ -33,7 +33,7 @@ import { IMcpService } from '../../../mcp/common/mcpTypes.js'; import { awaitStatsForSession } from '../chat.js'; import { IChatAgentCommand, IChatAgentData, IChatAgentHistoryEntry, IChatAgentRequest, IChatAgentResult, IChatAgentService } from '../participants/chatAgents.js'; import { chatEditingSessionIsReady } from '../editing/chatEditingService.js'; -import { ChatModel, ChatRequestModel, ChatRequestRemovalReason, IChatModel, IChatRequestModel, IChatRequestVariableData, IChatResponseModel, IExportableChatData, ISerializableChatData, ISerializableChatDataIn, ISerializableChatsData, normalizeSerializableChatData, toChatHistoryContent, updateRanges } from '../model/chatModel.js'; +import { ChatModel, ChatRequestModel, ChatRequestRemovalReason, IChatModel, IChatRequestModel, IChatRequestVariableData, IChatResponseModel, IExportableChatData, ISerializableChatData, ISerializableChatDataIn, ISerializableChatsData, ISerializedChatDataReference, normalizeSerializableChatData, toChatHistoryContent, updateRanges } from '../model/chatModel.js'; import { ChatModelStore, IStartSessionProps } from '../model/chatModelStore.js'; import { chatAgentLeader, ChatRequestAgentPart, ChatRequestAgentSubcommandPart, ChatRequestSlashCommandPart, ChatRequestTextPart, chatSubcommandLeader, getPromptText, IParsedChatRequest } from '../requestParser/chatParserTypes.js'; import { ChatRequestParser } from '../requestParser/chatRequestParser.js'; @@ -48,6 +48,7 @@ import { IChatRequestVariableEntry } from '../attachments/chatVariableEntries.js import { ChatAgentLocation, ChatConfiguration, ChatModeKind } from '../constants.js'; import { ChatMessageRole, IChatMessage } from '../languageModels.js'; import { ILanguageModelToolsService } from '../tools/languageModelToolsService.js'; +import { ChatSessionOperationLog } from '../model/chatSessionOperationLog.js'; const serializedChatKey = 'interactive.sessions'; @@ -492,12 +493,12 @@ export class ChatService extends Disposable implements IChatService { throw new Error(`Cannot restore non-local session ${sessionResource}`); } - let sessionData: ISerializableChatData | undefined; + let sessionData: ISerializedChatDataReference | undefined; if (isEqual(this.transferredSessionResource, sessionResource)) { this._transferredSessionResource = undefined; - sessionData = revive(await this._chatSessionStore.readTransferredSession(sessionResource)); + sessionData = await this._chatSessionStore.readTransferredSession(sessionResource); } else { - sessionData = revive(await this._chatSessionStore.readSession(sessionId)); + sessionData = await this._chatSessionStore.readSession(sessionId); } if (!sessionData) { @@ -506,7 +507,7 @@ export class ChatService extends Disposable implements IChatService { const sessionRef = this._sessionModels.acquireOrCreate({ initialData: sessionData, - location: sessionData.initialLocation ?? ChatAgentLocation.Chat, + location: sessionData.value.initialLocation ?? ChatAgentLocation.Chat, sessionResource, sessionId, canUseTools: true, @@ -531,7 +532,7 @@ export class ChatService extends Disposable implements IChatService { const sessionId = (data as ISerializableChatData).sessionId ?? generateUuid(); const sessionResource = LocalChatSessionUri.forSession(sessionId); return this._sessionModels.acquireOrCreate({ - initialData: data, + initialData: { value: data, serializer: new ChatSessionOperationLog() }, location: data.initialLocation ?? ChatAgentLocation.Chat, sessionResource, sessionId, diff --git a/src/vs/workbench/contrib/chat/common/chatSessionsService.ts b/src/vs/workbench/contrib/chat/common/chatSessionsService.ts index 8e9a983a33f11..76a9b34869810 100644 --- a/src/vs/workbench/contrib/chat/common/chatSessionsService.ts +++ b/src/vs/workbench/contrib/chat/common/chatSessionsService.ts @@ -45,7 +45,7 @@ export interface IChatSessionProviderOptionGroup { description?: string; items: IChatSessionProviderOptionItem[]; searchable?: boolean; - onSearch?: (token: CancellationToken) => Thenable; + onSearch?: (query: string, token: CancellationToken) => Thenable; /** * A context key expression that controls visibility of this option group picker. * When specified, the picker is only visible when the expression evaluates to true. diff --git a/src/vs/workbench/contrib/chat/common/editing/chatEditingService.ts b/src/vs/workbench/contrib/chat/common/editing/chatEditingService.ts index a8935339e4d7f..c5db2d082329d 100644 --- a/src/vs/workbench/contrib/chat/common/editing/chatEditingService.ts +++ b/src/vs/workbench/contrib/chat/common/editing/chatEditingService.ts @@ -22,7 +22,7 @@ import { IEditorPane } from '../../../../common/editor.js'; import { ICellEditOperation } from '../../../notebook/common/notebookCommon.js'; import { IChatAgentResult } from '../participants/chatAgents.js'; import { ChatModel, IChatRequestDisablement, IChatResponseModel } from '../model/chatModel.js'; -import { IChatMultiDiffData, IChatProgress } from '../chatService/chatService.js'; +import { IChatMultiDiffData, IChatMultiDiffDataSerialized, IChatProgress } from '../chatService/chatService.js'; export const IChatEditingService = createDecorator('chatEditingService'); @@ -213,19 +213,28 @@ export function chatEditingSessionIsReady(session: IChatEditingSession): Promise } export function editEntriesToMultiDiffData(entriesObs: IObservable): IChatMultiDiffData { + const multiDiffData = entriesObs.map(entries => ({ + title: localize('chatMultidiff.autoGenerated', 'Changes to {0} files', entries.length), + resources: entries.map(entry => ({ + originalUri: entry.originalURI, + modifiedUri: entry.modifiedURI, + goToFileUri: entry.modifiedURI, + added: entry.added, + removed: entry.removed, + })) + })); + return { kind: 'multiDiffData', collapsed: true, - multiDiffData: entriesObs.map(entries => ({ - title: localize('chatMultidiff.autoGenerated', 'Changes to {0} files', entries.length), - resources: entries.map(entry => ({ - originalUri: entry.originalURI, - modifiedUri: entry.modifiedURI, - goToFileUri: entry.modifiedURI, - added: entry.added, - removed: entry.removed, - })) - })), + multiDiffData, + toJSON(): IChatMultiDiffDataSerialized { + return { + kind: 'multiDiffData', + collapsed: this.collapsed, + multiDiffData: multiDiffData.get(), + }; + } }; } diff --git a/src/vs/workbench/contrib/chat/common/model/chatModel.ts b/src/vs/workbench/contrib/chat/common/model/chatModel.ts index 1c917062110fb..7627b5f85fbcd 100644 --- a/src/vs/workbench/contrib/chat/common/model/chatModel.ts +++ b/src/vs/workbench/contrib/chat/common/model/chatModel.ts @@ -16,9 +16,8 @@ import { Schemas } from '../../../../../base/common/network.js'; import { equals } from '../../../../../base/common/objects.js'; import { IObservable, autorun, autorunSelfDisposable, derived, observableFromEvent, observableSignalFromEvent, observableValue, observableValueOpts } from '../../../../../base/common/observable.js'; import { basename, isEqual } from '../../../../../base/common/resources.js'; -import { ThemeIcon } from '../../../../../base/common/themables.js'; -import { WithDefinedProps } from '../../../../../base/common/types.js'; -import { URI, UriComponents, UriDto, isUriComponents } from '../../../../../base/common/uri.js'; +import { hasKey, WithDefinedProps } from '../../../../../base/common/types.js'; +import { URI, UriDto } from '../../../../../base/common/uri.js'; import { generateUuid } from '../../../../../base/common/uuid.js'; import { IRange } from '../../../../../editor/common/core/range.js'; import { OffsetRange } from '../../../../../editor/common/core/ranges/offsetRange.js'; @@ -30,13 +29,14 @@ import { ILogService } from '../../../../../platform/log/common/log.js'; import { CellUri, ICellEditOperation } from '../../../notebook/common/notebookCommon.js'; import { ChatRequestToolReferenceEntry, IChatRequestVariableEntry } from '../attachments/chatVariableEntries.js'; import { migrateLegacyTerminalToolSpecificData } from '../chat.js'; -import { ChatAgentVoteDirection, ChatAgentVoteDownReason, ChatResponseClearToPreviousToolInvocationReason, ElicitationState, IChatAgentMarkdownContentWithVulnerability, IChatClearToPreviousToolInvocation, IChatCodeCitation, IChatCommandButton, IChatConfirmation, IChatContentInlineReference, IChatContentReference, IChatEditingSessionAction, IChatElicitationRequest, IChatElicitationRequestSerialized, IChatExtensionsContent, IChatFollowup, IChatLocationData, IChatMarkdownContent, IChatMcpServersStarting, IChatModelReference, IChatMultiDiffData, IChatMultiDiffDataSerialized, IChatNotebookEdit, IChatPrepareToolInvocationPart, IChatProgress, IChatProgressMessage, IChatPullRequestContent, IChatResponseCodeblockUriPart, IChatResponseProgressFileTreeData, IChatService, IChatSessionContext, IChatSessionTiming, IChatTask, IChatTaskSerialized, IChatTextEdit, IChatThinkingPart, IChatToolInvocation, IChatToolInvocationSerialized, IChatTreeData, IChatUndoStop, IChatUsedContext, IChatWarningMessage, ResponseModelState, isIUsedContext } from '../chatService/chatService.js'; +import { ChatAgentVoteDirection, ChatAgentVoteDownReason, ChatResponseClearToPreviousToolInvocationReason, ElicitationState, IChatAgentMarkdownContentWithVulnerability, IChatClearToPreviousToolInvocation, IChatCodeCitation, IChatCommandButton, IChatConfirmation, IChatContentInlineReference, IChatContentReference, IChatEditingSessionAction, IChatElicitationRequest, IChatElicitationRequestSerialized, IChatExtensionsContent, IChatFollowup, IChatLocationData, IChatMarkdownContent, IChatMcpServersStarting, IChatMcpServersStartingSerialized, IChatModelReference, IChatMultiDiffData, IChatMultiDiffDataSerialized, IChatNotebookEdit, IChatPrepareToolInvocationPart, IChatProgress, IChatProgressMessage, IChatPullRequestContent, IChatResponseCodeblockUriPart, IChatResponseProgressFileTreeData, IChatService, IChatSessionContext, IChatSessionTiming, IChatTask, IChatTaskSerialized, IChatTextEdit, IChatThinkingPart, IChatToolInvocation, IChatToolInvocationSerialized, IChatTreeData, IChatUndoStop, IChatUsedContext, IChatWarningMessage, ResponseModelState, isIUsedContext } from '../chatService/chatService.js'; import { ChatAgentLocation, ChatModeKind } from '../constants.js'; import { IChatEditingService, IChatEditingSession, ModifiedFileEntryState } from '../editing/chatEditingService.js'; import { ILanguageModelChatMetadata, ILanguageModelChatMetadataAndIdentifier } from '../languageModels.js'; import { IChatAgentCommand, IChatAgentData, IChatAgentResult, IChatAgentService, UserSelectedTools, reviveSerializedAgent } from '../participants/chatAgents.js'; import { ChatRequestTextPart, IParsedChatRequest, reviveParsedChatRequest } from '../requestParser/chatParserTypes.js'; import { LocalChatSessionUri } from './chatUri.js'; +import { ObjectMutationLog } from './objectMutationLog.js'; export const CHAT_ATTACHABLE_IMAGE_MIME_TYPES: Record = { @@ -159,7 +159,16 @@ export type IChatProgressResponseContent = | IChatElicitationRequest | IChatElicitationRequestSerialized | IChatClearToPreviousToolInvocation - | IChatMcpServersStarting; + | IChatMcpServersStarting + | IChatMcpServersStartingSerialized; + +export type IChatProgressResponseContentSerialized = Exclude; const nonHistoryKinds = new Set(['toolInvocation', 'toolInvocationSerialized', 'undoStop', 'prepareToolInvocation']); function isChatProgressHistoryResponseContent(content: IChatProgressResponseContent): content is IChatProgressHistoryResponseContent { @@ -184,7 +193,6 @@ export interface IChatResponseModel { readonly requestId: string; readonly request: IChatRequestModel | undefined; readonly username: string; - readonly avatarIcon?: ThemeIcon | URI; readonly session: IChatModel; readonly agent?: IChatAgentData; readonly usedContext: IChatUsedContext | undefined; @@ -203,6 +211,8 @@ export interface IChatResponseModel { readonly completedAt?: number; /** The state of this response */ readonly state: ResponseModelState; + /** @internal */ + readonly stateT: ResponseModelStateT; /** * Adjusted millisecond timestamp that excludes the duration during which * the model was pending user confirmation. `Date.now() - confirmationAdjustedTimestamp` @@ -585,7 +595,7 @@ export class Response extends AbstractResponse implements IDisposable { private _citations: IChatCodeCitation[] = []; - constructor(value: IMarkdownString | ReadonlyArray) { + constructor(value: IMarkdownString | ReadonlyArray) { super(asArray(value).map((v) => ( 'kind' in v ? v : isMarkdownString(v) ? { content: v, kind: 'markdownContent' } satisfies IChatMarkdownContent : @@ -776,7 +786,7 @@ export class Response extends AbstractResponse implements IDisposable { } export interface IChatResponseModelParameters { - responseContent: IMarkdownString | ReadonlyArray; + responseContent: IMarkdownString | ReadonlyArray; session: ChatModel; agent?: IChatAgentData; slashCommand?: IChatAgentCommand; @@ -798,7 +808,7 @@ export interface IChatResponseModelParameters { codeBlockInfos: ICodeBlockInfo[] | undefined; } -type ResponseModelStateT = +export type ResponseModelStateT = | { value: ResponseModelState.Pending } | { value: ResponseModelState.NeedsInput } | { value: ResponseModelState.Complete | ResponseModelState.Cancelled | ResponseModelState.Failed; completedAt: number }; @@ -875,6 +885,10 @@ export class ChatResponseModel extends Disposable implements IChatResponseModel return state; } + public get stateT(): ResponseModelStateT { + return this._modelState.get(); + } + public get vote(): ChatAgentVoteDirection | undefined { return this._vote; } @@ -901,10 +915,6 @@ export class ChatResponseModel extends Disposable implements IChatResponseModel return this.session.responderUsername; } - public get avatarIcon(): ThemeIcon | URI | undefined { - return this.session.responderAvatarIcon; - } - private _followups?: IChatFollowup[]; public get agent(): IChatAgentData | undefined { @@ -1221,6 +1231,7 @@ export interface IChatModel extends IDisposable { readonly initialLocation: ChatAgentLocation; readonly title: string; readonly hasCustomTitle: boolean; + readonly responderUsername: string; /** True whenever a request is currently running */ readonly requestInProgress: IObservable; /** Provides session information when a request needs user interaction to continue */ @@ -1267,18 +1278,19 @@ interface ISerializableChatResponseData { timeSpentWaiting?: number; } +export type SerializedChatResponsePart = IMarkdownString | IChatResponseProgressFileTreeData | IChatContentInlineReference | IChatAgentMarkdownContentWithVulnerability | IChatThinkingPart | IChatProgressResponseContentSerialized; + export interface ISerializableChatRequestData extends ISerializableChatResponseData { requestId: string; message: string | IParsedChatRequest; // string => old format /** Is really like "prompt data". This is the message in the format in which the agent gets it + variable values. */ variableData: IChatRequestVariableData; - response: ReadonlyArray | undefined; + response: ReadonlyArray | undefined; /**Old, persisted name for shouldBeRemovedOnSend */ isHidden?: boolean; shouldBeRemovedOnSend?: IChatRequestDisablement; agent?: ISerializableChatAgentData; - workingSet?: UriComponents[]; // responseErrorDetails: IChatResponseErrorDetails | undefined; /** @deprecated modelState is used instead now */ isCanceled?: boolean; @@ -1296,7 +1308,6 @@ export interface IExportableChatData { initialLocation: ChatAgentLocation | undefined; requests: ISerializableChatRequestData[]; responderUsername: string; - responderAvatarIconUri: ThemeIcon | UriComponents | undefined; // Keeping Uri name for backcompat } /* @@ -1306,25 +1317,16 @@ export interface IExportableChatData { export interface ISerializableChatData1 extends IExportableChatData { sessionId: string; creationDate: number; - - /** Indicates that this session was created in this window. Is cleared after the chat has been written to storage once. Needed to sync chat creations/deletions between empty windows. */ - isNew?: boolean; } export interface ISerializableChatData2 extends ISerializableChatData1 { version: 2; - lastMessageDate: number; computedTitle: string | undefined; } export interface ISerializableChatData3 extends Omit { version: 3; customTitle: string | undefined; - /** - * Whether the session had pending edits when it was stored. - * todo@connor4312 This will be cleaned up with the globalization of edits. - */ - hasPendingEdits?: boolean; /** Current draft input state (added later, fully backwards compatible) */ inputState?: ISerializableChatModelInputState; } @@ -1412,6 +1414,13 @@ export interface ISerializableChatModelInputState { */ export type ISerializableChatData = ISerializableChatData3; +export type IChatDataSerializerLog = ObjectMutationLog; + +export interface ISerializedChatDataReference { + value: ISerializableChatData | IExportableChatData; + serializer: IChatDataSerializerLog; +} + /** * Chat data that has been loaded but not normalized, and could be any format */ @@ -1428,7 +1437,6 @@ export function normalizeSerializableChatData(raw: ISerializableChatDataIn): ISe return { version: 3, ...raw, - lastMessageDate: raw.creationDate, customTitle: undefined, }; } @@ -1454,13 +1462,6 @@ function normalizeOldFields(raw: ISerializableChatDataIn): void { raw.creationDate = getLastYearDate(); } - if ('version' in raw && (raw.version === 2 || raw.version === 3)) { - if (!raw.lastMessageDate) { - // A bug led to not porting creationDate properly, and that was copied to lastMessageDate, so fix that up if missing. - raw.lastMessageDate = getLastYearDate(); - } - } - // eslint-disable-next-line @typescript-eslint/no-explicit-any, local/code-no-any-casts if ((raw.initialLocation as any) === 'editing-session') { raw.initialLocation = ChatAgentLocation.Chat; @@ -1693,9 +1694,8 @@ export class ChatModel extends Disposable implements IChatModel { }; } - private _lastMessageDate: number; get lastMessageDate(): number { - return this._lastMessageDate; + return this._requests.at(-1)?.timestamp ?? this._timestamp; } private get _defaultAgent() { @@ -1708,12 +1708,6 @@ export class ChatModel extends Disposable implements IChatModel { this._initialResponderUsername ?? ''; } - private readonly _initialResponderAvatarIconUri: ThemeIcon | URI | undefined; - get responderAvatarIcon(): ThemeIcon | URI | undefined { - return this._defaultAgent?.metadata.themeIcon ?? - this._initialResponderAvatarIconUri; - } - private _isImported = false; get isImported(): boolean { return this._isImported; @@ -1753,8 +1747,10 @@ export class ChatModel extends Disposable implements IChatModel { return !this._disableBackgroundKeepAlive; } + public dataSerializer?: IChatDataSerializerLog; + constructor( - initialData: ISerializableChatData | IExportableChatData | undefined, + dataRef: ISerializedChatDataReference | undefined, initialModelProps: { initialLocation: ChatAgentLocation; canUseTools: boolean; inputState?: ISerializableChatModelInputState; resource?: URI; sessionId?: string; disableBackgroundKeepAlive?: boolean }, @ILogService private readonly logService: ILogService, @IChatAgentService private readonly chatAgentService: IChatAgentService, @@ -1763,6 +1759,7 @@ export class ChatModel extends Disposable implements IChatModel { ) { super(); + const initialData = dataRef?.value; const isValidExportedData = isExportableSessionData(initialData); const isValidFullData = isValidExportedData && isSerializableSessionData(initialData); if (initialData && !isValidExportedData) { @@ -1776,7 +1773,6 @@ export class ChatModel extends Disposable implements IChatModel { this._requests = initialData ? this._deserialize(initialData) : []; this._timestamp = (isValidFullData && initialData.creationDate) || Date.now(); - this._lastMessageDate = (isValidFullData && initialData.lastMessageDate) || this._timestamp; this._customTitle = isValidFullData ? initialData.customTitle : undefined; // Initialize input model from serialized data (undefined for new chats) @@ -1793,10 +1789,10 @@ export class ChatModel extends Disposable implements IChatModel { selections: serializedInputState.selections }); + this.dataSerializer = dataRef?.serializer; this._initialResponderUsername = initialData?.responderUsername; - this._initialResponderAvatarIconUri = isUriComponents(initialData?.responderAvatarIconUri) ? URI.revive(initialData.responderAvatarIconUri) : initialData?.responderAvatarIconUri; - this._initialLocation = initialData?.initialLocation ?? initialModelProps.initialLocation; + this._canUseTools = initialModelProps.canUseTools; this.lastRequestObs = observableFromEvent(this, this.onDidChange, () => this._requests.at(-1)); @@ -1890,8 +1886,8 @@ export class ChatModel extends Disposable implements IChatModel { } } - private _deserialize(obj: IExportableChatData | ISerializableChatData): ChatRequestModel[] { - const requests = obj.requests; + private _deserialize(obj: IExportableChatData | ISerializedChatDataReference): ChatRequestModel[] { + const requests = hasKey(obj, { serializer: true }) ? obj.value.requests : obj.requests; if (!Array.isArray(requests)) { this.logService.error(`Ignoring malformed session data: ${JSON.stringify(obj)}`); return []; @@ -1926,13 +1922,18 @@ export class ChatModel extends Disposable implements IChatModel { const result = 'responseErrorDetails' in raw ? // eslint-disable-next-line local/code-no-dangerous-type-assertions { errorDetails: raw.responseErrorDetails } as IChatAgentResult : raw.result; + let modelState = raw.modelState || { value: raw.isCanceled ? ResponseModelState.Cancelled : ResponseModelState.Complete, completedAt: Date.now() }; + if (modelState.value === ResponseModelState.Pending || modelState.value === ResponseModelState.NeedsInput) { + modelState = { value: ResponseModelState.Cancelled, completedAt: Date.now() }; + } + request.response = new ChatResponseModel({ responseContent: raw.response ?? [new MarkdownString(raw.response)], session: this, agent, slashCommand: raw.slashCommand, requestId: request.id, - modelState: raw.modelState || { value: raw.isCanceled ? ResponseModelState.Cancelled : ResponseModelState.Complete, completedAt: 'lastMessageDate' in obj ? obj.lastMessageDate : Date.now() }, + modelState: raw.modelState || { value: raw.isCanceled ? ResponseModelState.Cancelled : ResponseModelState.Complete, completedAt: Date.now() }, vote: raw.vote, timestamp: raw.timestamp, voteDownReason: raw.voteDownReason, @@ -2079,7 +2080,6 @@ export class ChatModel extends Disposable implements IChatModel { }); this._requests.push(request); - this._lastMessageDate = Date.now(); this._onDidChange.fire({ kind: 'addRequest', request }); return request; } @@ -2190,7 +2190,6 @@ export class ChatModel extends Disposable implements IChatModel { toExport(): IExportableChatData { return { responderUsername: this.responderUsername, - responderAvatarIconUri: this.responderAvatarIcon, initialLocation: this.initialLocation, requests: this._requests.map((r): ISerializableChatRequestData => { const message = { @@ -2236,9 +2235,7 @@ export class ChatModel extends Disposable implements IChatModel { ...this.toExport(), sessionId: this.sessionId, creationDate: this._timestamp, - lastMessageDate: this._lastMessageDate, customTitle: this._customTitle, - hasPendingEdits: !!(this._editingSession?.entries.get().some(e => e.state.get() === ModifiedFileEntryState.Modified)), inputState: this.inputModel.toJSON(), }; } diff --git a/src/vs/workbench/contrib/chat/common/model/chatModelStore.ts b/src/vs/workbench/contrib/chat/common/model/chatModelStore.ts index dd3e4e19900fd..25b37e97ae82e 100644 --- a/src/vs/workbench/contrib/chat/common/model/chatModelStore.ts +++ b/src/vs/workbench/contrib/chat/common/model/chatModelStore.ts @@ -8,12 +8,12 @@ import { DisposableStore, IDisposable, IReference, ReferenceCollection } from '. import { ObservableMap } from '../../../../../base/common/observable.js'; import { URI } from '../../../../../base/common/uri.js'; import { ILogService } from '../../../../../platform/log/common/log.js'; -import { IChatEditingSession } from '../editing/chatEditingService.js'; -import { ChatModel, IExportableChatData, ISerializableChatData, ISerializableChatModelInputState } from './chatModel.js'; import { ChatAgentLocation } from '../constants.js'; +import { IChatEditingSession } from '../editing/chatEditingService.js'; +import { ChatModel, ISerializableChatModelInputState, ISerializedChatDataReference } from './chatModel.js'; export interface IStartSessionProps { - readonly initialData?: IExportableChatData | ISerializableChatData; + readonly initialData?: ISerializedChatDataReference; readonly location: ChatAgentLocation; readonly sessionResource: URI; readonly sessionId?: string; diff --git a/src/vs/workbench/contrib/chat/common/model/chatSessionOperationLog.ts b/src/vs/workbench/contrib/chat/common/model/chatSessionOperationLog.ts new file mode 100644 index 0000000000000..97dda654be065 --- /dev/null +++ b/src/vs/workbench/contrib/chat/common/model/chatSessionOperationLog.ts @@ -0,0 +1,170 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { assertNever } from '../../../../../base/common/assert.js'; +import { isMarkdownString } from '../../../../../base/common/htmlContent.js'; +import { equals as objectsEqual } from '../../../../../base/common/objects.js'; +import { isEqual as urisEqual } from '../../../../../base/common/resources.js'; +import { hasKey } from '../../../../../base/common/types.js'; +import { IChatMarkdownContent, ResponseModelState } from '../chatService/chatService.js'; +import { IParsedChatRequest } from '../requestParser/chatParserTypes.js'; +import { IChatAgentEditedFileEvent, IChatDataSerializerLog, IChatModel, IChatProgressResponseContent, IChatRequestModel, IChatRequestVariableData, ISerializableChatData, ISerializableChatModelInputState, ISerializableChatRequestData, SerializedChatResponsePart } from './chatModel.js'; +import * as Adapt from './objectMutationLog.js'; + +/** + * ChatModel has lots of properties and lots of ways those properties can mutate. + * The naive way to store the ChatModel is serializing it to JSON and calling it + * a day. However, chats can get very, very long, and thus doing so is slow. + * + * In this file, we define a `storageSchema` that adapters from the `IChatModel` + * into the serializable format. This schema tells us what properties in the chat + * model correspond to the serialized properties, *and how they change*. For + * example, `Adapt.constant(...)` defines a property that will never be checked + * for changes after it's written, and `Adapt.primitive(...)` defines a property + * that will be checked for changes using strict equality each time we store it. + * + * We can then use this to generate a log of mutations that we can append to + * cheaply without rewriting and reserializing the entire request each time. + */ + +const toJson = (obj: T): T extends { toJSON?(): infer R } ? R : T => { + const cast = obj as { toJSON?: () => T }; + // eslint-disable-next-line local/code-no-any-casts, @typescript-eslint/no-explicit-any + return (cast && typeof cast.toJSON === 'function' ? cast.toJSON() : obj) as any; +}; + +const responsePartSchema = Adapt.v( + (obj): SerializedChatResponsePart => obj.kind === 'markdownContent' ? obj.content : toJson(obj), + (a, b) => { + if (isMarkdownString(a) && isMarkdownString(b)) { + return a.value === b.value; + } + + if (hasKey(a, { kind: true }) && hasKey(b, { kind: true })) { + if (a.kind !== b.kind) { + return false; + } + + switch (a.kind) { + case 'markdownContent': + return a.content === (b as IChatMarkdownContent).content; + + // Dynamic types that can change after initial push need deep equality + // Note: these are the *serialized* kind names (e.g. toolInvocationSerialized not toolInvocation) + case 'toolInvocationSerialized': + case 'elicitationSerialized': + case 'progressTaskSerialized': + case 'textEditGroup': + case 'multiDiffData': + case 'mcpServersStarting': + return objectsEqual(a, b); + + // Static types that won't change after being pushed can use strict equality. + case 'clearToPreviousToolInvocation': + case 'codeblockUri': + case 'command': + case 'confirmation': + case 'extensions': + case 'inlineReference': + case 'markdownVuln': + case 'notebookEditGroup': + case 'prepareToolInvocation': + case 'progressMessage': + case 'pullRequest': + case 'thinking': + case 'undoStop': + case 'warning': + case 'treeData': + return a.kind === b.kind; + + default: { + // Hello developer! You are probably here because you added a new chat response type. + // This logic controls when we'll update chat parts stored on disk as part of the session. + // If it's a 'static' type that is not expected to change, add it to the 'return true' + // block above. However it's a type that is going to change, add it to the 'objectsEqual' + // block or make something more tailored. + assertNever(a); + } + } + } + + return false; + } +); + +const messageSchema = Adapt.object({ + text: Adapt.v(m => m.text), + parts: Adapt.v(m => m.parts, (a, b) => a.length === b.length && a.every((part, i) => part.text === b[i].text)), +}); + +const agentEditedFileEventSchema = Adapt.object({ + uri: Adapt.v(e => e.uri, urisEqual), + eventKind: Adapt.v(e => e.eventKind), +}); + +const chatVariableSchema = Adapt.object({ + variables: Adapt.t(v => v.variables, Adapt.array(Adapt.value((a, b) => a.name === b.name))), +}); + +const requestSchema = Adapt.object({ + // request parts + requestId: Adapt.t(m => m.id, Adapt.key()), + timestamp: Adapt.v(m => m.timestamp), + confirmation: Adapt.v(m => m.confirmation), + message: Adapt.t(m => m.message, messageSchema), + shouldBeRemovedOnSend: Adapt.v(m => m.shouldBeRemovedOnSend, objectsEqual), + agent: Adapt.v(m => m.response?.agent, (a, b) => a?.id === b?.id), + modelId: Adapt.v(m => m.modelId), + editedFileEvents: Adapt.t(m => m.editedFileEvents, Adapt.array(agentEditedFileEventSchema)), + variableData: Adapt.t(m => m.variableData, chatVariableSchema), + isHidden: Adapt.v(() => undefined), // deprecated, always undefined for new data + isCanceled: Adapt.v(() => undefined), // deprecated, modelState is used instead + + // response parts (from ISerializableChatResponseData via response.toJSON()) + response: Adapt.t(m => m.response?.entireResponse.value, Adapt.array(responsePartSchema)), + responseId: Adapt.v(m => m.response?.id), + result: Adapt.v(m => m.response?.result, objectsEqual), + responseMarkdownInfo: Adapt.v( + m => m.response?.codeBlockInfos?.map(info => ({ suggestionId: info.suggestionId })), + objectsEqual, + ), + followups: Adapt.v(m => m.response?.followups, objectsEqual), + modelState: Adapt.v(m => m.response?.stateT, objectsEqual), + vote: Adapt.v(m => m.response?.vote), + voteDownReason: Adapt.v(m => m.response?.voteDownReason), + slashCommand: Adapt.t(m => m.response?.slashCommand, Adapt.value((a, b) => a?.name === b?.name)), + usedContext: Adapt.v(m => m.response?.usedContext, objectsEqual), + contentReferences: Adapt.v(m => m.response?.contentReferences, objectsEqual), + codeCitations: Adapt.v(m => m.response?.codeCitations, objectsEqual), + timeSpentWaiting: Adapt.v(m => m.response?.timestamp), // based on response timestamp +}, { + sealed: (o) => o.modelState?.value === ResponseModelState.Cancelled || o.modelState?.value === ResponseModelState.Failed || o.modelState?.value === ResponseModelState.Complete, +}); + +const inputStateSchema = Adapt.object({ + attachments: Adapt.v(i => i.attachments, objectsEqual), + mode: Adapt.v(i => i.mode, (a, b) => a.id === b.id), + selectedModel: Adapt.v(i => i.selectedModel, (a, b) => a?.identifier === b?.identifier), + inputText: Adapt.v(i => i.inputText), + selections: Adapt.v(i => i.selections, objectsEqual), + contrib: Adapt.v(i => i.contrib, objectsEqual), +}); + +export const storageSchema = Adapt.object({ + version: Adapt.v(() => 3), + creationDate: Adapt.v(m => m.timestamp), + customTitle: Adapt.v(m => m.hasCustomTitle ? m.title : undefined), + initialLocation: Adapt.v(m => m.initialLocation), + inputState: Adapt.t(m => m.inputModel.toJSON(), inputStateSchema), + responderUsername: Adapt.v(m => m.responderUsername), + sessionId: Adapt.v(m => m.sessionId), + requests: Adapt.t(m => m.getRequests(), Adapt.array(requestSchema)), +}); + +export class ChatSessionOperationLog extends Adapt.ObjectMutationLog implements IChatDataSerializerLog { + constructor() { + super(storageSchema, 1024); + } +} diff --git a/src/vs/workbench/contrib/chat/common/model/chatSessionStore.ts b/src/vs/workbench/contrib/chat/common/model/chatSessionStore.ts index 03ee51514f793..63ac4c99c214b 100644 --- a/src/vs/workbench/contrib/chat/common/model/chatSessionStore.ts +++ b/src/vs/workbench/contrib/chat/common/model/chatSessionStore.ts @@ -12,6 +12,7 @@ import { revive } from '../../../../../base/common/marshalling.js'; import { joinPath } from '../../../../../base/common/resources.js'; import { URI } from '../../../../../base/common/uri.js'; import { localize } from '../../../../../nls.js'; +import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { IEnvironmentService } from '../../../../../platform/environment/common/environment.js'; import { FileOperationResult, IFileService, toFileOperationResult } from '../../../../../platform/files/common/files.js'; import { ILogService } from '../../../../../platform/log/common/log.js'; @@ -22,11 +23,12 @@ import { IWorkspaceContextService } from '../../../../../platform/workspace/comm import { Dto } from '../../../../services/extensions/common/proxyIdentifier.js'; import { ILifecycleService } from '../../../../services/lifecycle/common/lifecycle.js'; import { awaitStatsForSession } from '../chat.js'; -import { ModifiedFileEntryState } from '../editing/chatEditingService.js'; -import { ChatModel, ISerializableChatData, ISerializableChatDataIn, ISerializableChatsData, normalizeSerializableChatData } from './chatModel.js'; import { IChatSessionStats, IChatSessionTiming, ResponseModelState } from '../chatService/chatService.js'; -import { LocalChatSessionUri } from './chatUri.js'; import { ChatAgentLocation } from '../constants.js'; +import { ModifiedFileEntryState } from '../editing/chatEditingService.js'; +import { ChatModel, ISerializableChatData, ISerializableChatDataIn, ISerializableChatsData, ISerializedChatDataReference, normalizeSerializableChatData } from './chatModel.js'; +import { ChatSessionOperationLog } from './chatSessionOperationLog.js'; +import { LocalChatSessionUri } from './chatUri.js'; const maxPersistedSessions = 25; @@ -52,6 +54,7 @@ export class ChatSessionStore extends Disposable { @IStorageService private readonly storageService: IStorageService, @ILifecycleService private readonly lifecycleService: ILifecycleService, @IUserDataProfilesService private readonly userDataProfilesService: IUserDataProfilesService, + @IConfigurationService private readonly configurationService: IConfigurationService, ) { super(); @@ -202,7 +205,7 @@ export class ChatSessionStore extends Disposable { } } - async readTransferredSession(sessionResource: URI): Promise { + async readTransferredSession(sessionResource: URI): Promise { try { const storageLocation = this.getTransferredSessionStorageLocation(sessionResource); const sessionId = LocalChatSessionUri.parseLocalSessionId(sessionResource); @@ -210,7 +213,7 @@ export class ChatSessionStore extends Disposable { return undefined; } - const sessionData = await this.readSessionFromLocation(storageLocation, sessionId); + const sessionData = await this.readSessionFromLocation(storageLocation, undefined, sessionId); // Clean up the transferred session after reading await this.cleanupTransferredSession(sessionResource); @@ -247,8 +250,23 @@ export class ChatSessionStore extends Disposable { try { const index = this.internalGetIndex(); const storageLocation = this.getStorageLocation(session.sessionId); - const content = JSON.stringify(session, undefined, 2); - await this.fileService.writeFile(storageLocation, VSBuffer.fromString(content)); + if (storageLocation.log) { + if (session instanceof ChatModel) { + if (!session.dataSerializer) { + session.dataSerializer = new ChatSessionOperationLog(); + } + + const { op, data } = session.dataSerializer.write(session); + if (data.byteLength > 0) { + await this.fileService.writeFile(storageLocation.log, data, { append: op === 'append' }); + } + } else { + const content = new ChatSessionOperationLog().createInitialFromSerialized(session); + await this.fileService.writeFile(storageLocation.log, content); + } + } else { + await this.fileService.writeFile(storageLocation.flat, VSBuffer.fromString(JSON.stringify(session))); + } // Write succeeded, update index index.entries[session.sessionId] = await getSessionMetadata(session); @@ -314,13 +332,17 @@ export class ChatSessionStore extends Disposable { } const storageLocation = this.getStorageLocation(sessionId); - try { - await this.fileService.del(storageLocation); - } catch (e) { - if (toFileOperationResult(e) !== FileOperationResult.FILE_NOT_FOUND) { - this.reportError('sessionDelete', 'Error deleting chat session', e); + for (const uri of [storageLocation.flat, storageLocation.log]) { + try { + if (uri) { + await this.fileService.del(uri); + } + } catch (e) { + if (toFileOperationResult(e) !== FileOperationResult.FILE_NOT_FOUND) { + this.reportError('sessionDelete', 'Error deleting chat session', e); + } } - } finally { + delete index.entries[sessionId]; } } @@ -458,32 +480,53 @@ export class ChatSessionStore extends Disposable { await this.flushIndex(); } - public async readSession(sessionId: string): Promise { + public async readSession(sessionId: string): Promise { return await this.storeQueue.queue(async () => { const storageLocation = this.getStorageLocation(sessionId); - return this.readSessionFromLocation(storageLocation, sessionId); + return this.readSessionFromLocation(storageLocation.flat, storageLocation.log, sessionId); }); } - private async readSessionFromLocation(storageLocation: URI, sessionId: string): Promise { - let rawData: string | undefined; - try { - rawData = (await this.fileService.readFile(storageLocation)).value.toString(); - } catch (e) { - this.reportError('sessionReadFile', `Error reading chat session file ${sessionId}`, e); + private async readSessionFromLocation(flatStorageLocation: URI, logStorageLocation: URI | undefined, sessionId: string): Promise { + let fromLocation = flatStorageLocation; + let rawData: VSBuffer | undefined; - if (toFileOperationResult(e) === FileOperationResult.FILE_NOT_FOUND && this.previousEmptyWindowStorageRoot) { - rawData = await this.readSessionFromPreviousLocation(sessionId); + if (logStorageLocation) { + try { + rawData = (await this.fileService.readFile(logStorageLocation)).value; + fromLocation = logStorageLocation; + } catch (e) { + this.reportError('sessionReadFile', `Error reading log chat session file ${sessionId}`, e); } + } - if (!rawData) { - return undefined; + if (!rawData) { + try { + rawData = (await this.fileService.readFile(flatStorageLocation)).value; + fromLocation = flatStorageLocation; + } catch (e) { + this.reportError('sessionReadFile', `Error reading flat chat session file ${sessionId}`, e); + + if (toFileOperationResult(e) === FileOperationResult.FILE_NOT_FOUND && this.previousEmptyWindowStorageRoot) { + rawData = await this.readSessionFromPreviousLocation(sessionId); + } } } + if (!rawData) { + return undefined; + } + try { + let session: ISerializableChatDataIn; + const log = new ChatSessionOperationLog(); + if (fromLocation === logStorageLocation) { + session = revive(log.read(rawData)); + } else { + session = revive(JSON.parse(rawData.toString())); + } + // TODO Copied from ChatService.ts, cleanup - const session: ISerializableChatDataIn = revive(JSON.parse(rawData)); // Revive serialized URIs in session data // Revive serialized markdown strings in response data for (const request of session.requests) { if (Array.isArray(request.response)) { @@ -498,20 +541,20 @@ export class ChatSessionStore extends Disposable { } } - return normalizeSerializableChatData(session); + return { value: normalizeSerializableChatData(session), serializer: log }; } catch (err) { - this.reportError('malformedSession', `Malformed session data in ${storageLocation.fsPath}: [${rawData.substring(0, 20)}${rawData.length > 20 ? '...' : ''}]`, err); + this.reportError('malformedSession', `Malformed session data in ${fromLocation.fsPath}: [${rawData.slice(0, 20).toString()}${rawData.byteLength > 20 ? '...' : ''}]`, err); return undefined; } } - private async readSessionFromPreviousLocation(sessionId: string): Promise { - let rawData: string | undefined; + private async readSessionFromPreviousLocation(sessionId: string): Promise { + let rawData: VSBuffer | undefined; if (this.previousEmptyWindowStorageRoot) { const storageLocation2 = joinPath(this.previousEmptyWindowStorageRoot, `${sessionId}.json`); try { - rawData = (await this.fileService.readFile(storageLocation2)).value.toString(); + rawData = (await this.fileService.readFile(storageLocation2)).value; this.logService.info(`ChatSessionStore: Read chat session ${sessionId} from previous location`); } catch (e) { this.reportError('sessionReadFile', `Error reading chat session file ${sessionId} from previous location`, e); @@ -522,8 +565,17 @@ export class ChatSessionStore extends Disposable { return rawData; } - private getStorageLocation(chatSessionId: string): URI { - return joinPath(this.storageRoot, `${chatSessionId}.json`); + private getStorageLocation(chatSessionId: string): { + /** <1.109 flat JSON file */ + flat: URI; + /** >=1.109 append log */ + log?: URI; + } { + return { + flat: joinPath(this.storageRoot, `${chatSessionId}.json`), + // todo@connor4312: remove after stabilizing + log: this.configurationService.getValue('chat.useLogSessionStorage') !== false ? joinPath(this.storageRoot, `${chatSessionId}.jsonl`) : undefined, + }; } private getTransferredSessionStorageLocation(sessionResource: URI): URI { @@ -609,18 +661,22 @@ async function getSessionMetadata(session: ChatModel | ISerializableChatData): P stats = await awaitStatsForSession(session); } + const lastMessageDate = session instanceof ChatModel ? + session.lastMessageDate : + session.requests.at(-1)?.timestamp ?? session.creationDate; + const timing = session instanceof ChatModel ? session.timing : // session is only ISerializableChatData in the old pre-fs storage data migration scenario { startTime: session.creationDate, - endTime: session.lastMessageDate + endTime: lastMessageDate }; return { sessionId: session.sessionId, title: title || localize('newChat', "New Chat"), - lastMessageDate: session.lastMessageDate, + lastMessageDate, timing, initialLocation: session.initialLocation, hasPendingEdits: session instanceof ChatModel ? (session.editingSession?.entries.get().some(e => e.state.get() === ModifiedFileEntryState.Modified)) : false, diff --git a/src/vs/workbench/contrib/chat/common/model/chatViewModel.ts b/src/vs/workbench/contrib/chat/common/model/chatViewModel.ts index 6ea373b0650bc..4fd1a06dea977 100644 --- a/src/vs/workbench/contrib/chat/common/model/chatViewModel.ts +++ b/src/vs/workbench/contrib/chat/common/model/chatViewModel.ts @@ -198,7 +198,6 @@ export interface IChatResponseViewModel { /** The ID of the associated IChatRequestViewModel */ readonly requestId: string; readonly username: string; - readonly avatarIcon?: URI | ThemeIcon; readonly agent?: IChatAgentData; readonly slashCommand?: IChatAgentCommand; readonly agentOrSlashCommandDetected: boolean; @@ -504,10 +503,6 @@ export class ChatResponseViewModel extends Disposable implements IChatResponseVi return this._model.username; } - get avatarIcon() { - return this._model.avatarIcon; - } - get agent() { return this._model.agent; } diff --git a/src/vs/workbench/contrib/chat/common/model/objectMutationLog.ts b/src/vs/workbench/contrib/chat/common/model/objectMutationLog.ts new file mode 100644 index 0000000000000..657400cd9d3fd --- /dev/null +++ b/src/vs/workbench/contrib/chat/common/model/objectMutationLog.ts @@ -0,0 +1,489 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { assertNever } from '../../../../../base/common/assert.js'; +import { VSBuffer } from '../../../../../base/common/buffer.js'; +import { isUndefinedOrNull } from '../../../../../base/common/types.js'; + + +/** IMPORTANT: `Key` comes first. Then we should sort in order of least->most expensive to diff */ +const enum TransformKind { + Key, + Primitive, + Array, + Object, +} + +/** Schema entries sorted with key properties first */ +export type SchemaEntries = [string, Transform][]; + +interface TransformBase { + readonly kind: TransformKind; + /** Extracts the serializable value from the source object */ + extract(from: TFrom): TTo; +} + +/** Transform for primitive values (keys and values) that can be compared for equality */ +export interface TransformValue extends TransformBase { + readonly kind: TransformKind.Key | TransformKind.Primitive; + /** Compares two serialized values for equality */ + equals(a: TTo, b: TTo): boolean; +} + +/** Transform for arrays with an item schema */ +export interface TransformArray extends TransformBase { + readonly kind: TransformKind.Array; + /** The schema for array items */ + readonly itemSchema: TransformObject | TransformValue; +} + +/** Transform for objects with child properties */ +export interface TransformObject extends TransformBase { + readonly kind: TransformKind.Object; + /** Schema entries sorted with Key properties first */ + readonly children: SchemaEntries; + /** Checks if the object is sealed (won't change). */ + sealed?(obj: TTo, wasSerialized: boolean): boolean; +} + +export type Transform = + | TransformValue + | TransformArray + | TransformObject; + +export type Schema = { + [K in keyof Required]: Transform +}; + +/** + * A primitive that will be tracked and compared first. If this is changed, the entire + * object is thrown out and re-stored. + */ +export function key(comparator?: (a: R, b: R) => boolean): TransformValue { + return { + kind: TransformKind.Key, + extract: (from: T) => from as unknown as R, + equals: comparator ?? ((a, b) => a === b), + }; +} + +/** A value that will be tracked and replaced if the comparator is not equal. */ +export function value(): TransformValue; +export function value(comparator: (a: R, b: R) => boolean): TransformValue; +export function value(comparator?: (a: R, b: R) => boolean): TransformValue { + return { + kind: TransformKind.Primitive, + extract: (from: T) => { + let value = from as unknown as R; + // We map the object to JSON for two reasons (a) reduce issues with references to + // mutable type that could be held internally in the LogAdapter and (b) to make + // object comparison work with the data we re-hydrate from disk (e.g. if using + // objectsEqual, a hydrated URI is not equal to the serialized UriComponents) + if (!!value && typeof value === 'object') { + value = JSON.parse(JSON.stringify(value)); + } + + return value; + }, + equals: comparator ?? ((a, b) => a === b), + }; +} + +/** An array that will use the schema to compare items positionally. */ +export function array(schema: TransformObject | TransformValue): TransformArray { + return { + kind: TransformKind.Array, + itemSchema: schema, + extract: from => from?.map(item => schema.extract(item)), + }; +} + +export interface ObjectOptions { + /** + * Returns true if the object is sealed and will never change again. + * When comparing two sealed objects, only key fields are compared + * (to detect replacement), but other fields are not diffed. + */ + sealed?: (obj: R, wasSerialized: boolean) => boolean; +} + +/** An object schema. */ +export function object(schema: Schema, options?: ObjectOptions): TransformObject { + // Sort entries with key properties first for fast key checking + const entries = (Object.entries(schema) as [string, Transform][]).sort(([, a], [, b]) => a.kind - b.kind); + return { + kind: TransformKind.Object, + children: entries as SchemaEntries, + sealed: options?.sealed, + extract: (from: T) => { + if (isUndefinedOrNull(from)) { + return from as unknown as R; + } + + const result: Record = Object.create(null); + for (const [key, transform] of entries) { + result[key] = transform.extract(from); + } + return result as R; + }, + }; +} + +/** + * Defines a getter on the object to extract a value, compared with the given schema. + * It should return the value that will get serialized in the resulting log file. + */ +export function t(getter: (obj: T) => O, schema: Transform): Transform { + return { + ...schema, + extract: (from: T) => schema.extract(getter(from)), + }; +} + +/** Shortcut for t(fn, value()) */ +export function v(getter: (obj: T) => R): TransformValue; +export function v(getter: (obj: T) => R, comparator: (a: R, b: R) => boolean): TransformValue; +export function v(getter: (obj: T) => R, comparator?: (a: R, b: R) => boolean): TransformValue { + const inner = value(comparator!); + return { + ...inner, + extract: (from: T) => inner.extract(getter(from)), + }; +} + + +const enum EntryKind { + /** Initial complete object state, valid only as the first entry */ + Initial = 0, + /** Property update */ + Set = 1, + /** Array push/splice. */ + Push = 2, + /** Delete a property */ + Delete = 3, +} + +type ObjectPath = (string | number)[]; + +type Entry = + | { kind: EntryKind.Initial; v: unknown } + /** Update a property of an object, replacing it entirely */ + | { kind: EntryKind.Set; k: ObjectPath; v: unknown } + /** Delete a property of an object */ + | { kind: EntryKind.Delete; k: ObjectPath } + /** Pushes 0 or more new entries to an array. If `i` is set, everything after that index is removed */ + | { kind: EntryKind.Push; k: ObjectPath; v?: unknown[]; i?: number }; + +const LF = VSBuffer.fromString('\n'); + +/** + * An implementation of an append-based mutation logger. Given a `Transform` + * definition of an object, it can recreate it from a file on disk. It is + * then stateful, and given a `write` call it can update the log in a minimal + * way. + */ +export class ObjectMutationLog { + private _previous: TTo | undefined; + private _entryCount = 0; + + constructor( + private readonly _transform: Transform, + private readonly _compactAfterEntries = 512, + ) { } + + /** + * Creates an initial log file from the given object. + */ + createInitial(current: TFrom): VSBuffer { + return this.createInitialFromSerialized(this._transform.extract(current)); + } + + + /** + * Creates an initial log file from the serialized object. + */ + createInitialFromSerialized(value: TTo): VSBuffer { + this._previous = value; + this._entryCount = 1; + const entry: Entry = { kind: EntryKind.Initial, v: value }; + return VSBuffer.fromString(JSON.stringify(entry) + '\n'); + } + + /** + * Reads and reconstructs the state from a log file. + */ + read(content: VSBuffer): TTo { + let state: unknown; + let lineCount = 0; + + let start = 0; + const len = content.byteLength; + while (start < len) { + let end = content.indexOf(LF, start); + if (end === -1) { + end = len; + } + + if (end > start) { + const line = content.slice(start, end); + if (line.byteLength > 0) { + lineCount++; + const entry = JSON.parse(line.toString()) as Entry; + switch (entry.kind) { + case EntryKind.Initial: + state = entry.v; + break; + case EntryKind.Set: + this._applySet(state, entry.k, entry.v); + break; + case EntryKind.Push: + this._applyPush(state, entry.k, entry.v, entry.i); + break; + case EntryKind.Delete: + this._applySet(state, entry.k, undefined); + break; + default: + assertNever(entry); + } + } + } + start = end + 1; + } + + if (lineCount === 0) { + throw new Error('Empty log file'); + } + + this._previous = state as TTo; + this._entryCount = lineCount; + return state as TTo; + } + + /** + * Writes updates to the log. Returns the operation type and data to write. + */ + write(current: TFrom): { op: 'append' | 'replace'; data: VSBuffer } { + const currentValue = this._transform.extract(current); + + if (!this._previous || this._entryCount > this._compactAfterEntries) { + // No previous state, create initial + this._previous = currentValue; + this._entryCount = 1; + const entry: Entry = { kind: EntryKind.Initial, v: currentValue }; + return { op: 'replace', data: VSBuffer.fromString(JSON.stringify(entry) + '\n') }; + } + + // Generate diff entries + const entries: Entry[] = []; + const path: ObjectPath = []; + this._diff(this._transform, path, this._previous, currentValue, entries); + + if (entries.length === 0) { + // No changes + return { op: 'append', data: VSBuffer.fromString('') }; + } + + this._entryCount += entries.length; + this._previous = currentValue; + + // Append entries - build string directly + let data = ''; + for (const e of entries) { + data += JSON.stringify(e) + '\n'; + } + return { op: 'append', data: VSBuffer.fromString(data) }; + } + + private _applySet(state: unknown, path: ObjectPath, value: unknown): void { + if (path.length === 0) { + return; // Root replacement handled by caller + } + + let current = state as Record; + for (let i = 0; i < path.length - 1; i++) { + current = current[path[i]] as Record; + } + + current[path[path.length - 1]] = value; + } + + private _applyPush(state: unknown, path: ObjectPath, values: unknown[] | undefined, startIndex: number | undefined): void { + let current = state as Record; + for (let i = 0; i < path.length - 1; i++) { + current = current[path[i]] as Record; + } + + const arrayKey = path[path.length - 1]; + const arr = current[arrayKey] as unknown[] || []; + + if (startIndex !== undefined) { + arr.length = startIndex; + } + + if (values && values.length > 0) { + arr.push(...values); + } + + current[arrayKey] = arr; + } + + private _diff( + transform: Transform, + path: ObjectPath, + prev: R, + curr: R, + entries: Entry[] + ): void { + if (transform.kind === TransformKind.Key || transform.kind === TransformKind.Primitive) { + // Simple value change - copy path since we're storing it + if (!transform.equals(prev, curr)) { + entries.push({ kind: EntryKind.Set, k: path.slice(), v: curr }); + } + } else if (isUndefinedOrNull(prev) || isUndefinedOrNull(curr)) { + if (prev !== curr) { + if (curr === undefined) { + entries.push({ kind: EntryKind.Delete, k: path.slice() }); + } else if (curr === null) { + entries.push({ kind: EntryKind.Set, k: path.slice(), v: null }); + } else { + entries.push({ kind: EntryKind.Set, k: path.slice(), v: curr }); + } + } + } else if (transform.kind === TransformKind.Array) { + this._diffArray(transform, path, prev as unknown[], curr as unknown[], entries); + } else if (transform.kind === TransformKind.Object) { + this._diffObject(transform.children, path, prev, curr, entries, transform.sealed as ((obj: unknown, wasSerialized: boolean) => boolean) | undefined); + } else { + throw new Error(`Unknown transform kind ${JSON.stringify(transform)}`); + } + } + + private _diffObject( + children: SchemaEntries, + path: ObjectPath, + prev: unknown, + curr: unknown, + entries: Entry[], + sealed?: (obj: unknown, wasSerialized: boolean) => boolean, + ): void { + const prevObj = prev as Record | undefined; + const currObj = curr as Record; + + // First check key fields (sorted to front) - if any key changed, replace the entire object + let i = 0; + for (; i < children.length; i++) { + const [key, transform] = children[i]; + if (transform.kind !== TransformKind.Key) { + break; // Keys are sorted to front, so we can stop + } + if (!transform.equals(prevObj?.[key], currObj[key])) { + // Key changed, replace entire object + entries.push({ kind: EntryKind.Set, k: path.slice(), v: curr }); + return; + } + } + + // If both objects are sealed, we've already verified keys match above, + // so we can skip diffing the other properties since sealed objects don't change + if (sealed && sealed(prev, true) && sealed(curr, false)) { + return; + } + + // Diff each property using mutable path + for (; i < children.length; i++) { + const [key, transform] = children[i]; + path.push(key); + this._diff(transform, path, prevObj?.[key], currObj[key], entries); + path.pop(); + } + } + + private _diffArray( + transform: TransformArray, + path: ObjectPath, + prev: unknown[] | undefined, + curr: unknown[] | undefined, + entries: Entry[] + ): void { + const prevArr = prev || []; + const currArr = curr || []; + + const itemSchema = transform.itemSchema; + const minLen = Math.min(prevArr.length, currArr.length); + + // If the item schema is an object, we can recurse into it to diff individual + // properties instead of replacing the entire item. However, we only do this + // if the key fields match. + if (itemSchema.kind === TransformKind.Object) { + const childEntries = itemSchema.children; + + // Diff common elements by recursing into them + for (let i = 0; i < minLen; i++) { + const prevItem = prevArr[i]; + const currItem = currArr[i]; + + // Check if key fields match - if not, we need to replace from this point + if (this._hasKeyMismatch(childEntries, prevItem, currItem)) { + // Key mismatch: replace from this point onward + const newItems = currArr.slice(i); + entries.push({ kind: EntryKind.Push, k: path.slice(), v: newItems.length > 0 ? newItems : undefined, i }); + return; + } + + // Keys match, recurse into the object + path.push(i); + this._diffObject(childEntries, path, prevItem, currItem, entries, itemSchema.sealed); + path.pop(); + } + + // Handle length changes + if (currArr.length > prevArr.length) { + entries.push({ kind: EntryKind.Push, k: path.slice(), v: currArr.slice(prevArr.length) }); + } else if (currArr.length < prevArr.length) { + entries.push({ kind: EntryKind.Push, k: path.slice(), i: currArr.length }); + } + } else { + // No children schema, use the original positional comparison + let firstMismatch = -1; + + for (let i = 0; i < minLen; i++) { + if (!itemSchema.equals(prevArr[i], currArr[i])) { + firstMismatch = i; + break; + } + } + + if (firstMismatch === -1) { + // All common elements match + if (currArr.length > prevArr.length) { + // New items appended + entries.push({ kind: EntryKind.Push, k: path.slice(), v: currArr.slice(prevArr.length) }); + } else if (currArr.length < prevArr.length) { + // Items removed from end + entries.push({ kind: EntryKind.Push, k: path.slice(), i: currArr.length }); + } + // else: same length, all match - no change + } else { + // Mismatch found, rewrite from that point + const newItems = currArr.slice(firstMismatch); + entries.push({ kind: EntryKind.Push, k: path.slice(), v: newItems.length > 0 ? newItems : undefined, i: firstMismatch }); + } + } + } + + private _hasKeyMismatch(children: SchemaEntries, prev: unknown, curr: unknown): boolean { + const prevObj = prev as Record | undefined; + const currObj = curr as Record; + for (const [key, transform] of children) { + if (transform.kind !== TransformKind.Key) { + break; // Keys are sorted to front, so we can stop + } + if (!transform.equals(prevObj?.[key], currObj[key])) { + return true; + } + } + return false; + } +} diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/computeAutomaticInstructions.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/computeAutomaticInstructions.ts index 3c44061648b6a..01a40c2b5c68f 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/computeAutomaticInstructions.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/computeAutomaticInstructions.ts @@ -258,11 +258,11 @@ export class ComputeAutomaticInstructions { const agentsMdPromise = searchNestedAgentMd ? this._promptsService.findAgentMDsInWorkspace(token) : Promise.resolve([]); entries.push(''); - entries.push('Here is a list of instruction files that contain rules for modifying or creating new code.'); - entries.push('These files are important for ensuring that the code is modified or created correctly.'); + entries.push('Here is a list of instruction files that contain rules for working with this codebase.'); + entries.push('These files are important for understanding the codebase structure, conventions, and best practices.'); entries.push('Please make sure to follow the rules specified in these files when working with the codebase.'); entries.push(`If the file is not already available as attachment, use the ${readTool.variable} tool to acquire it.`); - entries.push('Make sure to acquire the instructions before making any changes to the code.'); + entries.push('Make sure to acquire the instructions before working with the codebase.'); let hasContent = false; for (const { uri } of instructionFiles) { const parsedFile = await this._parseInstructionsFile(uri, token); diff --git a/src/vs/workbench/contrib/chat/test/common/chatService/__snapshots__/ChatService_can_deserialize.0.snap b/src/vs/workbench/contrib/chat/test/common/chatService/__snapshots__/ChatService_can_deserialize.0.snap index f247060a455e1..643e3727914e6 100644 --- a/src/vs/workbench/contrib/chat/test/common/chatService/__snapshots__/ChatService_can_deserialize.0.snap +++ b/src/vs/workbench/contrib/chat/test/common/chatService/__snapshots__/ChatService_can_deserialize.0.snap @@ -1,6 +1,5 @@ { responderUsername: "", - responderAvatarIconUri: undefined, initialLocation: "panel", requests: [ { diff --git a/src/vs/workbench/contrib/chat/test/common/chatService/__snapshots__/ChatService_can_deserialize_with_response.0.snap b/src/vs/workbench/contrib/chat/test/common/chatService/__snapshots__/ChatService_can_deserialize_with_response.0.snap index d4da8a17af09f..f0e423320ddae 100644 --- a/src/vs/workbench/contrib/chat/test/common/chatService/__snapshots__/ChatService_can_deserialize_with_response.0.snap +++ b/src/vs/workbench/contrib/chat/test/common/chatService/__snapshots__/ChatService_can_deserialize_with_response.0.snap @@ -1,6 +1,5 @@ { responderUsername: "", - responderAvatarIconUri: undefined, initialLocation: "panel", requests: [ { diff --git a/src/vs/workbench/contrib/chat/test/common/chatService/__snapshots__/ChatService_can_serialize.0.snap b/src/vs/workbench/contrib/chat/test/common/chatService/__snapshots__/ChatService_can_serialize.0.snap index 6745aaeb7c661..8bbe4ed034091 100644 --- a/src/vs/workbench/contrib/chat/test/common/chatService/__snapshots__/ChatService_can_serialize.0.snap +++ b/src/vs/workbench/contrib/chat/test/common/chatService/__snapshots__/ChatService_can_serialize.0.snap @@ -1,6 +1,5 @@ { responderUsername: "", - responderAvatarIconUri: undefined, initialLocation: "panel", requests: [ ] } \ No newline at end of file diff --git a/src/vs/workbench/contrib/chat/test/common/chatService/__snapshots__/ChatService_can_serialize.1.snap b/src/vs/workbench/contrib/chat/test/common/chatService/__snapshots__/ChatService_can_serialize.1.snap index 80ac69bfa9136..3e5c32aa740cc 100644 --- a/src/vs/workbench/contrib/chat/test/common/chatService/__snapshots__/ChatService_can_serialize.1.snap +++ b/src/vs/workbench/contrib/chat/test/common/chatService/__snapshots__/ChatService_can_serialize.1.snap @@ -1,6 +1,5 @@ { responderUsername: "", - responderAvatarIconUri: undefined, initialLocation: "panel", requests: [ { diff --git a/src/vs/workbench/contrib/chat/test/common/chatService/__snapshots__/ChatService_sendRequest_fails.0.snap b/src/vs/workbench/contrib/chat/test/common/chatService/__snapshots__/ChatService_sendRequest_fails.0.snap index 4f779602e96ab..9a7073212a2b7 100644 --- a/src/vs/workbench/contrib/chat/test/common/chatService/__snapshots__/ChatService_sendRequest_fails.0.snap +++ b/src/vs/workbench/contrib/chat/test/common/chatService/__snapshots__/ChatService_sendRequest_fails.0.snap @@ -1,6 +1,5 @@ { responderUsername: "", - responderAvatarIconUri: undefined, initialLocation: "panel", requests: [ { diff --git a/src/vs/workbench/contrib/chat/test/common/model/chatModel.test.ts b/src/vs/workbench/contrib/chat/test/common/model/chatModel.test.ts index e7c0f7cdf1f57..f0029e5096f04 100644 --- a/src/vs/workbench/contrib/chat/test/common/model/chatModel.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/model/chatModel.test.ts @@ -50,12 +50,11 @@ suite('ChatModel', () => { initialLocation: ChatAgentLocation.Chat, requests: [], responderUsername: 'bot', - responderAvatarIconUri: undefined }; const model = testDisposables.add(instantiationService.createInstance( ChatModel, - exportedData, + { value: exportedData, serializer: undefined! }, { initialLocation: ChatAgentLocation.Chat, canUseTools: true } )); @@ -70,24 +69,21 @@ suite('ChatModel', () => { version: 3, sessionId: 'existing-session', creationDate: now - 1000, - lastMessageDate: now, customTitle: 'My Chat', initialLocation: ChatAgentLocation.Chat, requests: [], responderUsername: 'bot', - responderAvatarIconUri: undefined }; const model = testDisposables.add(instantiationService.createInstance( ChatModel, - serializableData, + { value: serializableData, serializer: undefined! }, { initialLocation: ChatAgentLocation.Chat, canUseTools: true } )); assert.strictEqual(model.isImported, false); assert.strictEqual(model.sessionId, 'existing-session'); assert.strictEqual(model.timestamp, now - 1000); - assert.strictEqual(model.lastMessageDate, now); assert.strictEqual(model.customTitle, 'My Chat'); }); @@ -99,7 +95,7 @@ suite('ChatModel', () => { const model = testDisposables.add(instantiationService.createInstance( ChatModel, - invalidData, + { value: invalidData, serializer: undefined! }, { initialLocation: ChatAgentLocation.Chat, canUseTools: true } )); @@ -411,14 +407,12 @@ suite('normalizeSerializableChatData', () => { creationDate: Date.now(), initialLocation: undefined, requests: [], - responderAvatarIconUri: undefined, responderUsername: 'bot', sessionId: 'session1', }; const newData = normalizeSerializableChatData(v1Data); assert.strictEqual(newData.creationDate, v1Data.creationDate); - assert.strictEqual(newData.lastMessageDate, v1Data.creationDate); assert.strictEqual(newData.version, 3); }); @@ -426,10 +420,8 @@ suite('normalizeSerializableChatData', () => { const v2Data: ISerializableChatData2 = { version: 2, creationDate: 100, - lastMessageDate: Date.now(), initialLocation: undefined, requests: [], - responderAvatarIconUri: undefined, responderUsername: 'bot', sessionId: 'session1', computedTitle: 'computed title' @@ -438,7 +430,6 @@ suite('normalizeSerializableChatData', () => { const newData = normalizeSerializableChatData(v2Data); assert.strictEqual(newData.version, 3); assert.strictEqual(newData.creationDate, v2Data.creationDate); - assert.strictEqual(newData.lastMessageDate, v2Data.lastMessageDate); assert.strictEqual(newData.customTitle, v2Data.computedTitle); }); @@ -450,14 +441,12 @@ suite('normalizeSerializableChatData', () => { initialLocation: undefined, requests: [], - responderAvatarIconUri: undefined, responderUsername: 'bot', }; const newData = normalizeSerializableChatData(v1Data); assert.strictEqual(newData.version, 3); assert.ok(newData.creationDate > 0); - assert.ok(newData.lastMessageDate > 0); assert.ok(newData.sessionId); }); @@ -465,12 +454,10 @@ suite('normalizeSerializableChatData', () => { const v3Data: ISerializableChatData3 = { // Test case where old data was wrongly normalized and these fields were missing creationDate: undefined!, - lastMessageDate: undefined!, version: 3, initialLocation: undefined, requests: [], - responderAvatarIconUri: undefined, responderUsername: 'bot', sessionId: 'session1', customTitle: 'computed title' @@ -479,7 +466,6 @@ suite('normalizeSerializableChatData', () => { const newData = normalizeSerializableChatData(v3Data); assert.strictEqual(newData.version, 3); assert.ok(newData.creationDate > 0); - assert.ok(newData.lastMessageDate > 0); assert.ok(newData.sessionId); }); }); @@ -492,7 +478,6 @@ suite('isExportableSessionData', () => { initialLocation: ChatAgentLocation.Chat, requests: [], responderUsername: 'bot', - responderAvatarIconUri: undefined }; assert.strictEqual(isExportableSessionData(validData), true); @@ -502,7 +487,6 @@ suite('isExportableSessionData', () => { const invalidData = { initialLocation: ChatAgentLocation.Chat, responderUsername: 'bot', - responderAvatarIconUri: undefined }; assert.strictEqual(isExportableSessionData(invalidData), false); @@ -513,7 +497,6 @@ suite('isExportableSessionData', () => { initialLocation: ChatAgentLocation.Chat, requests: 'not-an-array', responderUsername: 'bot', - responderAvatarIconUri: undefined }; assert.strictEqual(isExportableSessionData(invalidData), false); @@ -523,7 +506,6 @@ suite('isExportableSessionData', () => { const invalidData = { initialLocation: ChatAgentLocation.Chat, requests: [], - responderAvatarIconUri: undefined }; assert.strictEqual(isExportableSessionData(invalidData), false); @@ -534,7 +516,6 @@ suite('isExportableSessionData', () => { initialLocation: ChatAgentLocation.Chat, requests: [], responderUsername: 123, - responderAvatarIconUri: undefined }; assert.strictEqual(isExportableSessionData(invalidData), false); @@ -557,12 +538,10 @@ suite('isSerializableSessionData', () => { version: 3, sessionId: 'session1', creationDate: Date.now(), - lastMessageDate: Date.now(), customTitle: undefined, initialLocation: ChatAgentLocation.Chat, requests: [], responderUsername: 'bot', - responderAvatarIconUri: undefined }; assert.strictEqual(isSerializableSessionData(validData), true); @@ -573,7 +552,6 @@ suite('isSerializableSessionData', () => { version: 3, sessionId: 'session1', creationDate: Date.now(), - lastMessageDate: Date.now(), customTitle: undefined, initialLocation: ChatAgentLocation.Chat, requests: [{ @@ -584,7 +562,6 @@ suite('isSerializableSessionData', () => { usedContext: { documents: [], kind: 'usedContext' } }], responderUsername: 'bot', - responderAvatarIconUri: undefined }; assert.strictEqual(isSerializableSessionData(validData), true); @@ -594,12 +571,10 @@ suite('isSerializableSessionData', () => { const invalidData = { version: 3, creationDate: Date.now(), - lastMessageDate: Date.now(), customTitle: undefined, initialLocation: ChatAgentLocation.Chat, requests: [], responderUsername: 'bot', - responderAvatarIconUri: undefined }; assert.strictEqual(isSerializableSessionData(invalidData), false); @@ -609,12 +584,10 @@ suite('isSerializableSessionData', () => { const invalidData = { version: 3, sessionId: 'session1', - lastMessageDate: Date.now(), customTitle: undefined, initialLocation: ChatAgentLocation.Chat, requests: [], responderUsername: 'bot', - responderAvatarIconUri: undefined }; assert.strictEqual(isSerializableSessionData(invalidData), false); @@ -625,12 +598,10 @@ suite('isSerializableSessionData', () => { version: 3, sessionId: 'session1', creationDate: Date.now(), - lastMessageDate: Date.now(), customTitle: undefined, initialLocation: ChatAgentLocation.Chat, requests: 'not-an-array', responderUsername: 'bot', - responderAvatarIconUri: undefined }; assert.strictEqual(isSerializableSessionData(invalidData), false); diff --git a/src/vs/workbench/contrib/chat/test/common/model/chatSessionOperationLog.test.ts b/src/vs/workbench/contrib/chat/test/common/model/chatSessionOperationLog.test.ts new file mode 100644 index 0000000000000..28e0e98d1d09d --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/common/model/chatSessionOperationLog.test.ts @@ -0,0 +1,579 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { VSBuffer } from '../../../../../../base/common/buffer.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; +import * as Adapt from '../../../common/model/objectMutationLog.js'; +import { equals } from '../../../../../../base/common/objects.js'; + +suite('ChatSessionOperationLog', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + // Test data types + interface TestItem { + id: string; + value: number; + } + + interface TestObject { + name: string; + count?: number; + items: TestItem[]; + metadata?: { tags: string[] }; + } + + // Helper to create a simple schema for testing + function createTestSchema() { + const itemSchema = Adapt.object({ + id: Adapt.t(i => i.id, Adapt.key()), + value: Adapt.t(i => i.value, Adapt.value()), + }); + + return Adapt.object({ + name: Adapt.t(o => o.name, Adapt.value()), + count: Adapt.t(o => o.count, Adapt.value()), + items: Adapt.t(o => o.items, Adapt.array(itemSchema)), + metadata: Adapt.v(o => o.metadata, equals), + }); + } + + // Helper to simulate file operations + function simulateFileRoundtrip(adapter: Adapt.ObjectMutationLog, initial: TestObject, updates: TestObject[]): TestObject { + let fileContent = adapter.createInitial(initial); + + for (const update of updates) { + const result = adapter.write(update); + if (result.op === 'replace') { + fileContent = result.data; + } else { + fileContent = VSBuffer.concat([fileContent, result.data]); + } + } + + // Create new adapter and read back + const reader = new Adapt.ObjectMutationLog(createTestSchema()); + return reader.read(fileContent); + } + + suite('Transform factories', () => { + test('key uses strict equality by default', () => { + const transform = Adapt.key(); + assert.strictEqual(transform.equals('a', 'a'), true); + assert.strictEqual(transform.equals('a', 'b'), false); + }); + + test('key uses custom comparator', () => { + const transform = Adapt.key<{ id: number }>((a, b) => a.id === b.id); + assert.strictEqual(transform.equals({ id: 1 }, { id: 1 }), true); + assert.strictEqual(transform.equals({ id: 1 }, { id: 2 }), false); + }); + + test('primitive uses strict equality', () => { + const transform = Adapt.value(); + assert.strictEqual(transform.equals(1, 1), true); + assert.strictEqual(transform.equals(1, 2), false); + }); + + test('primitive with custom comparator', () => { + const transform = Adapt.value((a, b) => a.toLowerCase() === b.toLowerCase()); + assert.strictEqual(transform.equals('ABC', 'abc'), true); + assert.strictEqual(transform.equals('ABC', 'def'), false); + }); + + test('object extracts and compares properties', () => { + const schema = Adapt.object<{ x: number; y: string }, { x: number; y: string }>({ + x: Adapt.t(o => o.x, Adapt.value()), + y: Adapt.t(o => o.y, Adapt.value()), + }); + + const extracted = schema.extract({ x: 1, y: 'test' }); + assert.strictEqual(extracted.x, 1); + assert.strictEqual(extracted.y, 'test'); + }); + + test('t composes getter with transform', () => { + const transform = Adapt.t( + (obj: { nested: { value: number } }) => obj.nested.value, + Adapt.value() + ); + + assert.strictEqual(transform.extract({ nested: { value: 42 } }), 42); + }); + + test('differentiated uses separate extract and equals functions', () => { + const transform = Adapt.v<{ type: string; data: number }, string>( + obj => `${obj.type}:${obj.data}`, + (a, b) => a.split(':')[0] === b.split(':')[0], // compare only the type prefix + ); + + const extracted = transform.extract({ type: 'test', data: 123 }); + assert.strictEqual(extracted, 'test:123'); + + // Same type prefix should be equal + assert.strictEqual(transform.equals('test:123', 'test:456'), true); + // Different type prefix should not be equal + assert.strictEqual(transform.equals('test:123', 'other:123'), false); + }); + }); + + suite('LogAdapter', () => { + test('createInitial creates valid log entry', () => { + const schema = createTestSchema(); + const adapter = new Adapt.ObjectMutationLog(schema); + + const initial: TestObject = { name: 'test', count: 0, items: [] }; + const buffer = adapter.createInitial(initial); + + const content = buffer.toString(); + const entry = JSON.parse(content.trim()); + assert.strictEqual(entry.kind, 0); // EntryKind.Initial + assert.deepStrictEqual(entry.v, initial); + }); + + test('read reconstructs initial state', () => { + const schema = createTestSchema(); + const adapter = new Adapt.ObjectMutationLog(schema); + + const initial: TestObject = { name: 'test', count: 5, items: [{ id: 'a', value: 1 }] }; + const buffer = adapter.createInitial(initial); + + const reader = new Adapt.ObjectMutationLog(schema); + const result = reader.read(buffer); + + assert.deepStrictEqual(result, initial); + }); + + test('write returns empty data when no changes', () => { + const schema = createTestSchema(); + const adapter = new Adapt.ObjectMutationLog(schema); + + const obj: TestObject = { name: 'test', count: 0, items: [] }; + adapter.createInitial(obj); + + const result = adapter.write(obj); + assert.strictEqual(result.op, 'append'); + assert.strictEqual(result.data.toString(), ''); + }); + + test('write detects primitive changes', () => { + const schema = createTestSchema(); + const adapter = new Adapt.ObjectMutationLog(schema); + + const obj: TestObject = { name: 'test', count: 0, items: [] }; + adapter.createInitial(obj); + + const updated = { ...obj, count: 10 }; + const result = adapter.write(updated); + + assert.strictEqual(result.op, 'append'); + const entry = JSON.parse(result.data.toString().trim()); + assert.strictEqual(entry.kind, 1); // EntryKind.Set + assert.deepStrictEqual(entry.k, ['count']); + assert.strictEqual(entry.v, 10); + }); + + test('write detects array append', () => { + const schema = createTestSchema(); + const adapter = new Adapt.ObjectMutationLog(schema); + + const obj: TestObject = { name: 'test', count: 0, items: [{ id: 'a', value: 1 }] }; + adapter.createInitial(obj); + + const updated: TestObject = { ...obj, items: [...obj.items, { id: 'b', value: 2 }] }; + const result = adapter.write(updated); + + const entry = JSON.parse(result.data.toString().trim()); + assert.strictEqual(entry.kind, 2); // EntryKind.Push + assert.deepStrictEqual(entry.k, ['items']); + assert.deepStrictEqual(entry.v, [{ id: 'b', value: 2 }]); + assert.strictEqual(entry.i, undefined); + }); + + test('write detects array append nested', () => { + type Item = { id: string; value: number[] }; + const itemSchema = Adapt.object({ + id: Adapt.t(i => i.id, Adapt.key()), + value: Adapt.t(i => i.value, Adapt.array(Adapt.value())), + }); + + type TestObject = { items: Item[] }; + const schema = Adapt.object({ + items: Adapt.t(o => o.items, Adapt.array(itemSchema)), + }); + + const adapter = new Adapt.ObjectMutationLog(schema); + + adapter.createInitial({ items: [{ id: 'a', value: [1, 2] }] }); + + + const result1 = adapter.write({ items: [{ id: 'a', value: [1, 2, 3] }] }); + assert.deepStrictEqual( + JSON.parse(result1.data.toString().trim()), + { kind: 2, k: ['items', 0, 'value'], v: [3] }, + ); + + const result2 = adapter.write({ items: [{ id: 'b', value: [1, 2, 3] }] }); + assert.deepStrictEqual( + JSON.parse(result2.data.toString().trim()), + { kind: 2, k: ['items'], i: 0, v: [{ id: 'b', value: [1, 2, 3] }] }, + ); + }); + + test('write detects array truncation', () => { + const schema = createTestSchema(); + const adapter = new Adapt.ObjectMutationLog(schema); + + const obj: TestObject = { name: 'test', count: 0, items: [{ id: 'a', value: 1 }, { id: 'b', value: 2 }] }; + adapter.createInitial(obj); + + const updated: TestObject = { ...obj, items: [obj.items[0]] }; + const result = adapter.write(updated); + + const entry = JSON.parse(result.data.toString().trim()); + assert.strictEqual(entry.kind, 2); // EntryKind.Push + assert.deepStrictEqual(entry.k, ['items']); + assert.strictEqual(entry.i, 1); + assert.strictEqual(entry.v, undefined); + }); + + test('write detects array item modification and recurses into object', () => { + const schema = createTestSchema(); + const adapter = new Adapt.ObjectMutationLog(schema); + + const obj: TestObject = { + name: 'test', + count: 0, + items: [{ id: 'a', value: 1 }, { id: 'b', value: 2 }, { id: 'c', value: 3 }] + }; + adapter.createInitial(obj); + + // Modify middle item - key 'id' matches, so we recurse to set the 'value' property + const updated: TestObject = { + ...obj, + items: [{ id: 'a', value: 1 }, { id: 'b', value: 999 }, { id: 'c', value: 3 }] + }; + const result = adapter.write(updated); + + const entry = JSON.parse(result.data.toString().trim()); + assert.strictEqual(entry.kind, 1); // EntryKind.Set - setting individual property + assert.deepStrictEqual(entry.k, ['items', 1, 'value']); + assert.strictEqual(entry.v, 999); + }); + + test('read applies multiple entries correctly', () => { + const schema = createTestSchema(); + const initial: TestObject = { name: 'test', count: 0, items: [] }; + + // Build log manually + const entries = [ + { kind: 0, v: initial }, + { kind: 1, k: ['count'], v: 5 }, + { kind: 2, k: ['items'], v: [{ id: 'a', value: 1 }] }, + { kind: 2, k: ['items'], v: [{ id: 'b', value: 2 }] }, + ]; + const logContent = entries.map(e => JSON.stringify(e)).join('\n') + '\n'; + + const adapter = new Adapt.ObjectMutationLog(schema); + const result = adapter.read(VSBuffer.fromString(logContent)); + + assert.strictEqual(result.count, 5); + assert.strictEqual(result.items.length, 2); + assert.deepStrictEqual(result.items[0], { id: 'a', value: 1 }); + assert.deepStrictEqual(result.items[1], { id: 'b', value: 2 }); + }); + + test('roundtrip preserves data through multiple updates', () => { + const schema = createTestSchema(); + const adapter = new Adapt.ObjectMutationLog(schema); + + const initial: TestObject = { name: 'test', count: 0, items: [] }; + const updates: TestObject[] = [ + { name: 'test', count: 1, items: [] }, + { name: 'test', count: 1, items: [{ id: 'a', value: 10 }] }, + { name: 'test', count: 2, items: [{ id: 'a', value: 10 }, { id: 'b', value: 20 }] }, + { name: 'test', count: 2, items: [{ id: 'a', value: 10 }] }, // Remove item + ]; + + const result = simulateFileRoundtrip(adapter, initial, updates); + assert.deepStrictEqual(result, updates[updates.length - 1]); + }); + + test('compacts log when entry count exceeds threshold', () => { + const schema = createTestSchema(); + const adapter = new Adapt.ObjectMutationLog(schema, 3); // Compact after 3 entries + + const obj: TestObject = { name: 'test', count: 0, items: [] }; + adapter.createInitial(obj); // Entry 1 + + adapter.write({ ...obj, count: 1 }); // Entry 2 + adapter.write({ ...obj, count: 2 }); // Entry 3 + + const before = adapter.write({ ...obj, count: 3 }); + assert.strictEqual(before.op, 'append'); + + // This should trigger compaction + const result = adapter.write({ ...obj, count: 4 }); + assert.strictEqual(result.op, 'replace'); + + // Verify the compacted log only has initial entry + const lines = result.data.toString().split('\n').filter(l => l.trim()); + assert.strictEqual(lines.length, 1); + const entry = JSON.parse(lines[0]); + assert.strictEqual(entry.kind, 0); // EntryKind.Initial + }); + + test('handles deepCompare property changes', () => { + const schema = createTestSchema(); + const adapter = new Adapt.ObjectMutationLog(schema); + + const obj: TestObject = { name: 'test', count: 0, items: [], metadata: { tags: ['a'] } }; + adapter.createInitial(obj); + + const updated: TestObject = { ...obj, metadata: { tags: ['a', 'b'] } }; + const result = adapter.write(updated); + + const entry = JSON.parse(result.data.toString().trim()); + assert.strictEqual(entry.kind, 1); // EntryKind.Set + assert.deepStrictEqual(entry.k, ['metadata']); + assert.deepStrictEqual(entry.v, { tags: ['a', 'b'] }); + }); + + test('handles differentiated property changes', () => { + // Schema with a differentiated transform that extracts a string + // but uses a custom equals that only checks the prefix + interface DiffObj { + data: { type: string; version: number }; + } + const schema = Adapt.object({ + data: Adapt.t( + o => o.data, + Adapt.v<{ type: string; version: number }, string>( + obj => `${obj.type}:${obj.version}`, + (a, b) => a.split(':')[0] === b.split(':')[0], // compare only the type prefix + ) + ), + }); + + const adapter = new Adapt.ObjectMutationLog(schema); + + // Initial state: 'foo:1' + adapter.createInitial({ data: { type: 'foo', version: 1 } }); + + // Change type from 'foo' to 'bar' - should detect change (different prefix) + const result1 = adapter.write({ data: { type: 'bar', version: 2 } }); + assert.notStrictEqual(result1.data.toString(), '', 'different type should trigger change'); + const entry1 = JSON.parse(result1.data.toString().trim()); + assert.strictEqual(entry1.kind, 1); // EntryKind.Set + assert.deepStrictEqual(entry1.k, ['data']); + assert.strictEqual(entry1.v, 'bar:2'); + + // Change version but keep type 'bar' - should NOT detect change (same prefix) + const result2 = adapter.write({ data: { type: 'bar', version: 3 } }); + assert.strictEqual(result2.data.toString(), '', 'same type prefix should not trigger change'); + }); + + test('read throws on empty log file', () => { + const schema = createTestSchema(); + const adapter = new Adapt.ObjectMutationLog(schema); + + assert.throws(() => adapter.read(VSBuffer.fromString('')), /Empty log file/); + }); + + test('write without prior read creates initial entry', () => { + const schema = createTestSchema(); + const adapter = new Adapt.ObjectMutationLog(schema); + + const obj: TestObject = { name: 'test', count: 5, items: [] }; + const result = adapter.write(obj); + + assert.strictEqual(result.op, 'replace'); + const entry = JSON.parse(result.data.toString().trim()); + assert.strictEqual(entry.kind, 0); // EntryKind.Initial + }); + + test('sealed objects skip non-key field comparison when both are sealed', () => { + interface SealedItem { + id: string; + value: number; + isSealed: boolean; + } + + interface SealedTestObject { + items: SealedItem[]; + } + + const itemSchema = Adapt.object({ + id: Adapt.t(i => i.id, Adapt.key()), + value: Adapt.t(i => i.value, Adapt.value()), + isSealed: Adapt.t(i => i.isSealed, Adapt.value()), + }, { + sealed: (obj) => obj.isSealed, + }); + + const schema = Adapt.object({ + items: Adapt.t(o => o.items, Adapt.array(itemSchema)), + }); + + const adapter = new Adapt.ObjectMutationLog(schema); + + // Initial state with a sealed item + adapter.createInitial({ items: [{ id: 'a', value: 1, isSealed: true }] }); + + // Change value on sealed item - should NOT be detected because both are sealed + const result1 = adapter.write({ items: [{ id: 'a', value: 999, isSealed: true }] }); + assert.strictEqual(result1.data.toString(), '', 'sealed item value change should be ignored'); + }); + + test('sealed objects still detect key changes', () => { + interface SealedItem { + id: string; + value: number; + isSealed: boolean; + } + + interface SealedTestObject { + items: SealedItem[]; + } + + const itemSchema = Adapt.object({ + id: Adapt.t(i => i.id, Adapt.key()), + value: Adapt.t(i => i.value, Adapt.value()), + isSealed: Adapt.t(i => i.isSealed, Adapt.value()), + }, { + sealed: (obj) => obj.isSealed, + }); + + const schema = Adapt.object({ + items: Adapt.t(o => o.items, Adapt.array(itemSchema)), + }); + + const adapter = new Adapt.ObjectMutationLog(schema); + + // Initial state with a sealed item + adapter.createInitial({ items: [{ id: 'a', value: 1, isSealed: true }] }); + + // Change key on sealed item - SHOULD be detected (replacement) + const result = adapter.write({ items: [{ id: 'b', value: 1, isSealed: true }] }); + assert.notStrictEqual(result.data.toString(), '', 'key change should be detected even when sealed'); + + const entry = JSON.parse(result.data.toString().trim()); + assert.strictEqual(entry.kind, 2); // EntryKind.Push (array replacement) + }); + + test('sealed objects diff normally when one is not sealed', () => { + interface SealedItem { + id: string; + value: number; + isSealed: boolean; + } + + interface SealedTestObject { + items: SealedItem[]; + } + + const itemSchema = Adapt.object({ + id: Adapt.t(i => i.id, Adapt.key()), + value: Adapt.t(i => i.value, Adapt.value()), + isSealed: Adapt.t(i => i.isSealed, Adapt.value()), + }, { + sealed: (obj) => obj.isSealed, + }); + + const schema = Adapt.object({ + items: Adapt.t(o => o.items, Adapt.array(itemSchema)), + }); + + const adapter = new Adapt.ObjectMutationLog(schema); + + // Initial state with a non-sealed item + adapter.createInitial({ items: [{ id: 'a', value: 1, isSealed: false }] }); + + // Change value - should be detected since prev is not sealed + const result1 = adapter.write({ items: [{ id: 'a', value: 999, isSealed: false }] }); + assert.notStrictEqual(result1.data.toString(), '', 'non-sealed item should detect value change'); + + const entry = JSON.parse(result1.data.toString().trim()); + assert.strictEqual(entry.kind, 1); // EntryKind.Set + assert.deepStrictEqual(entry.k, ['items', 0, 'value']); + assert.strictEqual(entry.v, 999); + }); + + test('sealed transition from unsealed to sealed detects final changes', () => { + interface SealedItem { + id: string; + value: number; + isSealed: boolean; + } + + interface SealedTestObject { + items: SealedItem[]; + } + + const itemSchema = Adapt.object({ + id: Adapt.t(i => i.id, Adapt.key()), + value: Adapt.t(i => i.value, Adapt.value()), + isSealed: Adapt.t(i => i.isSealed, Adapt.value()), + }, { + sealed: (obj) => obj.isSealed, + }); + + const schema = Adapt.object({ + items: Adapt.t(o => o.items, Adapt.array(itemSchema)), + }); + + const adapter = new Adapt.ObjectMutationLog(schema); + + // Initial state with a non-sealed item + adapter.createInitial({ items: [{ id: 'a', value: 1, isSealed: false }] }); + + // Transition to sealed with value change - should detect changes since prev was not sealed + const result = adapter.write({ items: [{ id: 'a', value: 999, isSealed: true }] }); + assert.notStrictEqual(result.data.toString(), '', 'transition to sealed should detect value change'); + + // Should have two entries - one for value, one for isSealed + const lines = result.data.toString().trim().split('\n'); + assert.strictEqual(lines.length, 2, 'should have two change entries'); + }); + + test('write detects property set to undefined', () => { + const schema = createTestSchema(); + const adapter = new Adapt.ObjectMutationLog(schema); + + const initial: TestObject = { name: 'test', count: 5, items: [], metadata: { tags: ['foo'] } }; + + const result = simulateFileRoundtrip(adapter, initial, [ + { name: 'test', count: 10, items: [], metadata: { tags: ['foo'] } }, + { name: 'test', count: undefined, items: [], metadata: undefined }, + ]); + assert.deepStrictEqual(result, { name: 'test', count: undefined, items: [], metadata: undefined }); + + const result2 = simulateFileRoundtrip(adapter, initial, [ + { name: 'test', count: 10, items: [], metadata: { tags: ['foo'] } }, + { name: 'test', count: undefined, items: [], metadata: undefined }, + { name: 'test', count: 12, items: [], metadata: { tags: ['bar'] } }, + ]); + assert.deepStrictEqual(result2, { name: 'test', count: 12, items: [], metadata: { tags: ['bar'] } }); + }); + + test('delete followed by set restores property', () => { + const schema = createTestSchema(); + const initial: TestObject = { name: 'test', count: 0, items: [], metadata: { tags: ['a'] } }; + + // Build log with delete then set + const entries = [ + { kind: 0, v: initial }, + { kind: 3, k: ['metadata'] }, // Delete + { kind: 1, k: ['metadata'], v: { tags: ['b', 'c'] } }, // Set to new value + ]; + const logContent = entries.map(e => JSON.stringify(e)).join('\n') + '\n'; + + const adapter = new Adapt.ObjectMutationLog(schema); + const result = adapter.read(VSBuffer.fromString(logContent)); + + assert.deepStrictEqual(result.metadata, { tags: ['b', 'c'] }); + }); + }); +}); diff --git a/src/vs/workbench/contrib/chat/test/common/model/chatSessionStore.test.ts b/src/vs/workbench/contrib/chat/test/common/model/chatSessionStore.test.ts index 0510c2b7afc6e..cce617aabe1a3 100644 --- a/src/vs/workbench/contrib/chat/test/common/model/chatSessionStore.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/model/chatSessionStore.test.ts @@ -19,10 +19,12 @@ import { IWorkspaceContextService, WorkspaceFolder } from '../../../../../../pla import { TestWorkspace, Workspace } from '../../../../../../platform/workspace/test/common/testWorkspace.js'; import { ILifecycleService } from '../../../../../services/lifecycle/common/lifecycle.js'; import { InMemoryTestFileService, TestContextService, TestLifecycleService, TestStorageService } from '../../../../../test/common/workbenchTestServices.js'; -import { ChatModel } from '../../../common/model/chatModel.js'; +import { ChatModel, ISerializableChatData3 } from '../../../common/model/chatModel.js'; import { ChatSessionStore, IChatTransfer } from '../../../common/model/chatSessionStore.js'; import { LocalChatSessionUri } from '../../../common/model/chatUri.js'; import { MockChatModel } from './mockChatModel.js'; +import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; +import { TestConfigurationService } from '../../../../../../platform/configuration/test/common/testConfigurationService.js'; function createMockChatModel(sessionResource: URI, options?: { customTitle?: string }): ChatModel { const sessionId = LocalChatSessionUri.parseLocalSessionId(sessionResource); @@ -58,6 +60,7 @@ suite('ChatSessionStore', () => { instantiationService.stub(IEnvironmentService, { workspaceStorageHome: URI.file('/test/workspaceStorage') }); instantiationService.stub(ILifecycleService, testDisposables.add(new TestLifecycleService())); instantiationService.stub(IUserDataProfilesService, { defaultProfile: toUserDataProfile('default', 'Default', URI.file('/test/userdata'), URI.file('/test/cache')) }); + instantiationService.stub(IConfigurationService, new TestConfigurationService()); }); test('hasSessions returns false when no sessions exist', () => { @@ -140,7 +143,7 @@ suite('ChatSessionStore', () => { const session = await store.readSession('session-1'); assert.ok(session); - assert.strictEqual(session.sessionId, 'session-1'); + assert.strictEqual((session.value as ISerializableChatData3).sessionId, 'session-1'); }); test('deleteSession removes session from index', async () => { @@ -263,7 +266,7 @@ suite('ChatSessionStore', () => { const sessionData = await store.readTransferredSession(sessionResource); assert.ok(sessionData); - assert.strictEqual(sessionData.sessionId, 'transfer-session'); + assert.strictEqual((sessionData.value as ISerializableChatData3).sessionId, 'transfer-session'); }); test('readTransferredSession cleans up after reading', async () => { diff --git a/src/vs/workbench/contrib/chat/test/common/model/mockChatModel.ts b/src/vs/workbench/contrib/chat/test/common/model/mockChatModel.ts index 35ae57d3cc5bd..34f407b43a934 100644 --- a/src/vs/workbench/contrib/chat/test/common/model/mockChatModel.ts +++ b/src/vs/workbench/contrib/chat/test/common/model/mockChatModel.ts @@ -30,6 +30,7 @@ export class MockChatModel extends Disposable implements IChatModel { readonly editingSession = undefined; readonly checkpoint = undefined; readonly willKeepAlive = true; + readonly responderUsername: string = 'agent'; readonly inputModel: IInputModel = { state: observableValue('inputModelState', undefined), setState: () => { }, @@ -62,7 +63,6 @@ export class MockChatModel extends Disposable implements IChatModel { initialLocation: this.initialLocation, requests: [], responderUsername: '', - responderAvatarIconUri: undefined }; } toJSON(): ISerializableChatData { @@ -70,12 +70,10 @@ export class MockChatModel extends Disposable implements IChatModel { version: 3, sessionId: this.sessionId, creationDate: this.timestamp, - lastMessageDate: this.lastMessageDate, customTitle: this.customTitle, initialLocation: this.initialLocation, requests: [], responderUsername: '', - responderAvatarIconUri: undefined }; } } diff --git a/src/vs/workbench/contrib/tags/electron-browser/workspaceTagsService.ts b/src/vs/workbench/contrib/tags/electron-browser/workspaceTagsService.ts index c3eddd4f43148..12f8e73bc273c 100644 --- a/src/vs/workbench/contrib/tags/electron-browser/workspaceTagsService.ts +++ b/src/vs/workbench/contrib/tags/electron-browser/workspaceTagsService.ts @@ -720,6 +720,7 @@ export class WorkspaceTagsService implements IWorkspaceTagsService { "workspace.yeoman.code.ext" : { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true }, "workspace.cordova.high" : { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true }, "workspace.cordova.low" : { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true }, + "workspace.azure.yaml" : { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true }, "workspace.xamarin.android" : { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true }, "workspace.xamarin.ios" : { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true }, "workspace.android.cpp" : { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true }, @@ -1276,6 +1277,8 @@ export class WorkspaceTagsService implements IWorkspaceTagsService { tags['workspace.npm'] = nameSet.has('package.json') || nameSet.has('node_modules'); tags['workspace.bower'] = nameSet.has('bower.json') || nameSet.has('bower_components'); + tags['workspace.azure.yaml'] = nameSet.has('azure.yaml') || nameSet.has('azure.yml'); + tags['workspace.java.pom'] = nameSet.has('pom.xml'); tags['workspace.java.gradle'] = nameSet.has('build.gradle') || nameSet.has('settings.gradle') || nameSet.has('build.gradle.kts') || nameSet.has('settings.gradle.kts') || nameSet.has('gradlew') || nameSet.has('gradlew.bat'); diff --git a/src/vs/workbench/contrib/terminal/browser/chatTerminalCommandMirror.ts b/src/vs/workbench/contrib/terminal/browser/chatTerminalCommandMirror.ts index 4af69123f2a2b..3ea882a7f3492 100644 --- a/src/vs/workbench/contrib/terminal/browser/chatTerminalCommandMirror.ts +++ b/src/vs/workbench/contrib/terminal/browser/chatTerminalCommandMirror.ts @@ -38,16 +38,65 @@ function getChatTerminalBackgroundColor(theme: IColorTheme, contextKeyService: I return theme.getColor(isInEditor ? editorBackground : PANEL_BACKGROUND); } +/** + * Computes the maximum column width of content in a terminal buffer. + * Iterates through each line and finds the rightmost non-empty cell. + * + * @param buffer The buffer to measure + * @param cols The terminal column count (used to clamp line length) + * @returns The maximum column width (number of columns used), or 0 if all lines are empty + */ +export function computeMaxBufferColumnWidth(buffer: { readonly length: number; getLine(y: number): { readonly length: number; getCell(x: number): { getChars(): string } | undefined } | undefined }, cols: number): number { + let maxWidth = 0; + + for (let y = 0; y < buffer.length; y++) { + const line = buffer.getLine(y); + if (!line) { + continue; + } + + // Find the last non-empty cell by iterating backwards + const lineLength = Math.min(line.length, cols); + for (let x = lineLength - 1; x >= 0; x--) { + if (line.getCell(x)?.getChars()) { + maxWidth = Math.max(maxWidth, x + 1); + break; + } + } + } + + return maxWidth; +} + +export interface IDetachedTerminalCommandMirrorRenderResult { + lineCount?: number; + maxColumnWidth?: number; +} + interface IDetachedTerminalCommandMirror { attach(container: HTMLElement): Promise; - renderCommand(): Promise<{ lineCount?: number } | undefined>; - onDidUpdate: Event; + renderCommand(): Promise; + onDidUpdate: Event; onDidInput: Event; } const enum ChatTerminalMirrorMetrics { MirrorRowCount = 10, - MirrorColCountFallback = 80 + MirrorColCountFallback = 80, + /** + * Maximum number of lines for which we compute the max column width. + * Computing max column width iterates the entire buffer, so we skip it + * for large outputs to avoid performance issues. + */ + MaxLinesForColumnWidthComputation = 100 +} + +/** + * Computes the line count for terminal output between start and end lines. + * The end line is exclusive (points to the line after output ends). + */ +function computeOutputLineCount(startLine: number, endLine: number): number { + return Math.max(endLine - startLine, 0); } export async function getCommandOutputSnapshot( @@ -94,13 +143,13 @@ export async function getCommandOutputSnapshot( return { text: '', lineCount: 0 }; } const endLine = endMarker.line; - const lineCount = Math.max(endLine - startLine + 1, 0); + const lineCount = computeOutputLineCount(startLine, endLine); return { text, lineCount }; } const startLine = executedMarker.line; const endLine = endMarker.line; - const lineCount = Math.max(endLine - startLine + 1, 0); + const lineCount = computeOutputLineCount(startLine, endLine); let text: string | undefined; try { @@ -151,13 +200,14 @@ export class DetachedTerminalCommandMirror extends Disposable implements IDetach private _detachedTerminalPromise: Promise | undefined; private _attachedContainer: HTMLElement | undefined; private readonly _streamingDisposables = this._register(new DisposableStore()); - private readonly _onDidUpdateEmitter = this._register(new Emitter()); - public readonly onDidUpdate: Event = this._onDidUpdateEmitter.event; + private readonly _onDidUpdateEmitter = this._register(new Emitter()); + public readonly onDidUpdate: Event = this._onDidUpdateEmitter.event; private readonly _onDidInputEmitter = this._register(new Emitter()); public readonly onDidInput: Event = this._onDidInputEmitter.event; private _lastVT = ''; private _lineCount = 0; + private _maxColumnWidth = 0; private _lastUpToDateCursorY: number | undefined; private _lowestDirtyCursorY: number | undefined; private _flushPromise: Promise | undefined; @@ -200,7 +250,7 @@ export class DetachedTerminalCommandMirror extends Disposable implements IDetach } } - async renderCommand(): Promise<{ lineCount?: number } | undefined> { + async renderCommand(): Promise { if (this._store.isDisposed) { return undefined; } @@ -258,8 +308,13 @@ export class DetachedTerminalCommandMirror extends Disposable implements IDetach } this._lineCount = this._getRenderedLineCount(); + // Only compute max column width after the command finishes and for small outputs + const commandFinished = this._command.endMarker && !this._command.endMarker.isDisposed; + if (commandFinished && this._lineCount <= ChatTerminalMirrorMetrics.MaxLinesForColumnWidthComputation) { + this._maxColumnWidth = this._computeMaxColumnWidth(); + } - return { lineCount: this._lineCount }; + return { lineCount: this._lineCount, maxColumnWidth: this._maxColumnWidth }; } private async _getCommandOutputAsVT(source: XtermTerminal): Promise<{ text: string } | undefined> { @@ -289,7 +344,7 @@ export class DetachedTerminalCommandMirror extends Disposable implements IDetach if (this._command.executedMarker && endMarker && !endMarker.isDisposed) { const startLine = this._command.executedMarker.line; const endLine = endMarker.line; - return Math.max(endLine - startLine, 0); + return computeOutputLineCount(startLine, endLine); } // During streaming (no end marker), calculate from the source terminal buffer @@ -297,12 +352,20 @@ export class DetachedTerminalCommandMirror extends Disposable implements IDetach if (executedMarker && this._sourceRaw) { const buffer = this._sourceRaw.buffer.active; const currentLine = buffer.baseY + buffer.cursorY; - return Math.max(currentLine - executedMarker.line, 0); + return computeOutputLineCount(executedMarker.line, currentLine); } return this._lineCount; } + private _computeMaxColumnWidth(): number { + const detached = this._detachedTerminal; + if (!detached) { + return 0; + } + return computeMaxBufferColumnWidth(detached.xterm.buffer.active, detached.xterm.cols); + } + private async _getOrCreateTerminal(): Promise { if (this._detachedTerminal) { return this._detachedTerminal; @@ -460,11 +523,17 @@ export class DetachedTerminalCommandMirror extends Disposable implements IDetach this._lastVT = vt.text; this._lineCount = this._getRenderedLineCount(); this._lastUpToDateCursorY = currentCursor; - this._onDidUpdateEmitter.fire(this._lineCount); - if (this._command.endMarker && !this._command.endMarker.isDisposed) { + const commandFinished = this._command.endMarker && !this._command.endMarker.isDisposed; + if (commandFinished) { + // Only compute max column width after the command finishes and for small outputs + if (this._lineCount <= ChatTerminalMirrorMetrics.MaxLinesForColumnWidthComputation) { + this._maxColumnWidth = this._computeMaxColumnWidth(); + } this._stopStreaming(); } + + this._onDidUpdateEmitter.fire({ lineCount: this._lineCount, maxColumnWidth: this._maxColumnWidth }); } private _getAbsoluteCursorY(raw: RawXtermTerminal): number { @@ -484,6 +553,7 @@ export class DetachedTerminalSnapshotMirror extends Disposable { private _container: HTMLElement | undefined; private _dirty = true; private _lastRenderedLineCount: number | undefined; + private _lastRenderedMaxColumnWidth: number | undefined; constructor( output: IChatTerminalToolInvocationData['terminalCommandOutput'] | undefined, @@ -534,13 +604,13 @@ export class DetachedTerminalSnapshotMirror extends Disposable { this._applyTheme(container); } - public async render(): Promise<{ lineCount?: number } | undefined> { + public async render(): Promise<{ lineCount?: number; maxColumnWidth?: number } | undefined> { const output = this._output; if (!output) { return undefined; } if (!this._dirty) { - return { lineCount: this._lastRenderedLineCount ?? output.lineCount }; + return { lineCount: this._lastRenderedLineCount ?? output.lineCount, maxColumnWidth: this._lastRenderedMaxColumnWidth }; } const terminal = await this._getTerminal(); if (this._container) { @@ -551,12 +621,21 @@ export class DetachedTerminalSnapshotMirror extends Disposable { if (!text) { this._dirty = false; this._lastRenderedLineCount = lineCount; - return { lineCount: 0 }; + this._lastRenderedMaxColumnWidth = 0; + return { lineCount: 0, maxColumnWidth: 0 }; } await new Promise(resolve => terminal.xterm.write(text, resolve)); this._dirty = false; this._lastRenderedLineCount = lineCount; - return { lineCount }; + // Only compute max column width for small outputs to avoid performance issues + if (this._shouldComputeMaxColumnWidth(lineCount)) { + this._lastRenderedMaxColumnWidth = this._computeMaxColumnWidth(terminal); + } + return { lineCount, maxColumnWidth: this._lastRenderedMaxColumnWidth }; + } + + private _computeMaxColumnWidth(terminal: IDetachedTerminalInstance): number { + return computeMaxBufferColumnWidth(terminal.xterm.buffer.active, terminal.xterm.cols); } private _estimateLineCount(text: string): number { @@ -569,6 +648,10 @@ export class DetachedTerminalSnapshotMirror extends Disposable { return Math.max(count, 1); } + private _shouldComputeMaxColumnWidth(lineCount: number): boolean { + return lineCount <= ChatTerminalMirrorMetrics.MaxLinesForColumnWidthComputation; + } + private _applyTheme(container: HTMLElement): void { const theme = this._getTheme(); if (!theme) { diff --git a/src/vs/workbench/contrib/terminal/browser/terminal.ts b/src/vs/workbench/contrib/terminal/browser/terminal.ts index 4444764f1a7e3..d5ace8862c6a1 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminal.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminal.ts @@ -1493,6 +1493,11 @@ export interface IDetachedXtermTerminal extends IXtermTerminal { * Access to the terminal buffer for reading cursor position and content. */ readonly buffer: IBufferSet; + + /** + * The number of columns in the terminal. + */ + readonly cols: number; } export interface IInternalXtermTerminal { diff --git a/src/vs/workbench/contrib/terminal/browser/terminalTabsChatEntry.ts b/src/vs/workbench/contrib/terminal/browser/terminalTabsChatEntry.ts index 7c0bc25ca410b..7e6e39b2f312a 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminalTabsChatEntry.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminalTabsChatEntry.ts @@ -48,8 +48,8 @@ export class TerminalTabsChatEntry extends Disposable { this._deleteButton.classList.add(...ThemeIcon.asClassNameArray(Codicon.trashcan)); this._deleteButton.tabIndex = 0; this._deleteButton.setAttribute('role', 'button'); - this._deleteButton.setAttribute('aria-label', localize('terminal.tabs.chatEntryDeleteAriaLabel', "Delete all hidden chat terminals")); - this._deleteButton.setAttribute('title', localize('terminal.tabs.chatEntryDeleteTooltip', "Delete all hidden chat terminals")); + this._deleteButton.setAttribute('aria-label', localize('terminal.tabs.chatEntryDeleteAriaLabel', "Kill all hidden chat terminals")); + this._deleteButton.setAttribute('title', localize('terminal.tabs.chatEntryDeleteTooltip', "Kill all hidden chat terminals")); const runChatTerminalsCommand = () => { void this._commandService.executeCommand('workbench.action.terminal.chat.viewHiddenChatTerminals'); diff --git a/src/vs/workbench/contrib/terminal/browser/xterm-private.d.ts b/src/vs/workbench/contrib/terminal/browser/xterm-private.d.ts index 534c69a196658..176f037839473 100644 --- a/src/vs/workbench/contrib/terminal/browser/xterm-private.d.ts +++ b/src/vs/workbench/contrib/terminal/browser/xterm-private.d.ts @@ -32,12 +32,18 @@ export interface IXtermCore { }; } +export interface IBufferLine { + readonly length: number; + getCell(x: number): { getChars(): string } | undefined; + translateToString(trimRight?: boolean): string; +} + export interface IBufferSet { readonly active: { readonly baseY: number; readonly cursorY: number; readonly cursorX: number; readonly length: number; - getLine(y: number): { translateToString(trimRight?: boolean): string } | undefined; + getLine(y: number): IBufferLine | undefined; }; } diff --git a/src/vs/workbench/contrib/terminal/browser/xterm/xtermTerminal.ts b/src/vs/workbench/contrib/terminal/browser/xterm/xtermTerminal.ts index 282a8e3f30365..78849f9d99277 100644 --- a/src/vs/workbench/contrib/terminal/browser/xterm/xtermTerminal.ts +++ b/src/vs/workbench/contrib/terminal/browser/xterm/xtermTerminal.ts @@ -108,6 +108,7 @@ export class XtermTerminal extends Disposable implements IXtermTerminal, IDetach private _progressState: IProgressState = { state: 0, value: 0 }; get progressState(): IProgressState { return this._progressState; } get buffer() { return this.raw.buffer; } + get cols() { return this.raw.cols; } // Always on addons private _markNavigationAddon: MarkNavigationAddon; 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 d66eb7fa7af7a..e5fd2c6a08ba1 100644 --- a/src/vs/workbench/contrib/terminal/test/browser/chatTerminalCommandMirror.test.ts +++ b/src/vs/workbench/contrib/terminal/test/browser/chatTerminalCommandMirror.test.ts @@ -14,6 +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'; const defaultTerminalConfig = { fontFamily: 'monospace', @@ -231,4 +232,147 @@ suite('Workbench - ChatTerminalCommandMirror', () => { strictEqual(getBufferText(mirror), getBufferText(freshMirror)); }); }); + + suite('computeMaxBufferColumnWidth', () => { + + /** + * Creates a mock buffer with the given lines. + * Each string represents a line; characters are cells, spaces are empty cells. + */ + function createMockBuffer(lines: string[], cols: number = 80): { readonly length: number; getLine(y: number): { readonly length: number; getCell(x: number): { getChars(): string } | undefined } | undefined } { + return { + length: lines.length, + getLine(y: number) { + if (y < 0 || y >= lines.length) { + return undefined; + } + const lineContent = lines[y]; + return { + length: Math.max(lineContent.length, cols), + getCell(x: number) { + if (x < 0 || x >= lineContent.length) { + return { getChars: () => '' }; + } + const char = lineContent[x]; + return { getChars: () => char === ' ' ? '' : char }; + } + }; + } + }; + } + + test('returns 0 for empty buffer', () => { + const buffer = createMockBuffer([]); + strictEqual(computeMaxBufferColumnWidth(buffer, 80), 0); + }); + + test('returns 0 for buffer with only empty lines', () => { + const buffer = createMockBuffer(['', '', '']); + strictEqual(computeMaxBufferColumnWidth(buffer, 80), 0); + }); + + test('returns correct width for single character', () => { + const buffer = createMockBuffer(['X']); + strictEqual(computeMaxBufferColumnWidth(buffer, 80), 1); + }); + + test('returns correct width for single line', () => { + const buffer = createMockBuffer(['hello']); + strictEqual(computeMaxBufferColumnWidth(buffer, 80), 5); + }); + + test('returns max width across multiple lines', () => { + const buffer = createMockBuffer([ + 'short', + 'much longer line', + 'mid' + ]); + strictEqual(computeMaxBufferColumnWidth(buffer, 80), 16); + }); + + test('ignores trailing spaces (empty cells)', () => { + // Spaces are treated as empty cells in our mock + const buffer = createMockBuffer(['hello ']); + strictEqual(computeMaxBufferColumnWidth(buffer, 80), 5); + }); + + test('respects cols parameter to clamp line length', () => { + const buffer = createMockBuffer(['abcdefghijklmnop']); // 16 chars, no spaces + strictEqual(computeMaxBufferColumnWidth(buffer, 10), 10); + }); + + test('handles lines with content at different positions', () => { + const buffer = createMockBuffer([ + 'a', // width 1 + ' b', // content at col 2, but width is 3 + ' c', // content at col 4, but width is 5 + ' d' // content at col 6, width is 7 + ]); + strictEqual(computeMaxBufferColumnWidth(buffer, 80), 7); + }); + + test('handles buffer with undefined lines gracefully', () => { + const buffer = { + length: 3, + getLine(y: number) { + if (y === 1) { + return undefined; + } + return { + length: 5, + getCell(x: number) { + return x < 3 ? { getChars: () => 'X' } : { getChars: () => '' }; + } + }; + } + }; + strictEqual(computeMaxBufferColumnWidth(buffer, 80), 3); + }); + + test('handles line with all empty cells', () => { + const buffer = createMockBuffer([' ']); // all spaces = empty cells + strictEqual(computeMaxBufferColumnWidth(buffer, 80), 0); + }); + + test('handles mixed empty and non-empty lines', () => { + const buffer = createMockBuffer([ + '', + 'content', + '', + 'more', + '' + ]); + strictEqual(computeMaxBufferColumnWidth(buffer, 80), 7); + }); + + test('returns correct width for line exactly at 80 cols', () => { + const line80 = 'a'.repeat(80); + const buffer = createMockBuffer([line80]); + strictEqual(computeMaxBufferColumnWidth(buffer, 80), 80); + }); + + test('returns correct width for line exceeding 80 cols with higher cols value', () => { + const line100 = 'a'.repeat(100); + const buffer = createMockBuffer([line100], 120); + strictEqual(computeMaxBufferColumnWidth(buffer, 120), 100); + }); + + test('handles wide terminal with long content', () => { + const buffer = createMockBuffer([ + 'short', + 'a'.repeat(150), + 'medium content here' + ], 200); + strictEqual(computeMaxBufferColumnWidth(buffer, 200), 150); + }); + + test('max of multiple lines where longest exceeds default cols', () => { + const buffer = createMockBuffer([ + 'a'.repeat(50), + 'b'.repeat(120), + 'c'.repeat(90) + ], 150); + strictEqual(computeMaxBufferColumnWidth(buffer, 150), 120); + }); + }); }); diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/task/createAndRunTaskTool.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/task/createAndRunTaskTool.ts index b5ec1c1464499..4918ac03d10a6 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/task/createAndRunTaskTool.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/task/createAndRunTaskTool.ts @@ -157,8 +157,8 @@ export class CreateAndRunTaskTool implements IToolImpl { const allTasks = await this._tasksService.tasks(); if (allTasks?.find(t => t._label === task.label)) { return { - invocationMessage: new MarkdownString(localize('taskExists', 'Task `{0}` already exists.', task.label)), - pastTenseMessage: new MarkdownString(localize('taskExistsPast', 'Task `{0}` already exists.', task.label)), + invocationMessage: new MarkdownString(localize('taskExists', 'Task \`{0}\` already exists.', task.label)), + pastTenseMessage: new MarkdownString(localize('taskExistsPast', 'Task \`{0}\` already exists.', task.label)), confirmationMessages: undefined }; } diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/task/getTaskOutputTool.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/task/getTaskOutputTool.ts index 5d0a0589c25db..dc3f4eaa9168d 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/task/getTaskOutputTool.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/task/getTaskOutputTool.ts @@ -65,17 +65,17 @@ export class GetTaskOutputTool extends Disposable implements IToolImpl { const taskDefinition = getTaskDefinition(args.id); const task = await getTaskForTool(args.id, taskDefinition, args.workspaceFolder, this._configurationService, this._tasksService, true); if (!task) { - return { invocationMessage: new MarkdownString(localize('copilotChat.taskNotFound', 'Task not found: `{0}`', args.id)) }; + return { invocationMessage: new MarkdownString(localize('copilotChat.taskNotFound', 'Task not found: \`{0}\`', args.id)) }; } const taskLabel = task._label; const activeTasks = await this._tasksService.getActiveTasks(); if (activeTasks.includes(task)) { - return { invocationMessage: new MarkdownString(localize('copilotChat.taskAlreadyRunning', 'The task `{0}` is already running.', taskLabel)) }; + return { invocationMessage: new MarkdownString(localize('copilotChat.taskAlreadyRunning', 'The task \`{0}\` is already running.', taskLabel)) }; } return { - invocationMessage: new MarkdownString(localize('copilotChat.checkingTerminalOutput', 'Checking output for task `{0}`', taskLabel)), - pastTenseMessage: new MarkdownString(localize('copilotChat.checkedTerminalOutput', 'Checked output for task `{0}`', taskLabel)), + invocationMessage: new MarkdownString(localize('copilotChat.checkingTerminalOutput', 'Checking output for task \`{0}\`', taskLabel)), + pastTenseMessage: new MarkdownString(localize('copilotChat.checkedTerminalOutput', 'Checked output for task \`{0}\`', taskLabel)), }; } @@ -84,7 +84,7 @@ export class GetTaskOutputTool extends Disposable implements IToolImpl { const taskDefinition = getTaskDefinition(args.id); const task = await getTaskForTool(args.id, taskDefinition, args.workspaceFolder, this._configurationService, this._tasksService, true); if (!task) { - return { content: [{ kind: 'text', value: `Task not found: ${args.id}` }], toolResultMessage: new MarkdownString(localize('copilotChat.taskNotFound', 'Task not found: `{0}`', args.id)) }; + return { content: [{ kind: 'text', value: `Task not found: ${args.id}` }], toolResultMessage: new MarkdownString(localize('copilotChat.taskNotFound', 'Task not found: \`{0}\`', args.id)) }; } const dependencyTasks = await resolveDependencyTasks(task, args.workspaceFolder, this._configurationService, this._tasksService); @@ -92,7 +92,7 @@ export class GetTaskOutputTool extends Disposable implements IToolImpl { const taskLabel = task._label; const terminals = resources?.map(resource => this._terminalService.instances.find(t => t.resource.path === resource?.path && t.resource.scheme === resource.scheme)).filter(t => !!t); if (!terminals || terminals.length === 0) { - return { content: [{ kind: 'text', value: `Terminal not found for task ${taskLabel}` }], toolResultMessage: new MarkdownString(localize('copilotChat.terminalNotFound', 'Terminal not found for task `{0}`', taskLabel)) }; + return { content: [{ kind: 'text', value: `Terminal not found for task ${taskLabel}` }], toolResultMessage: new MarkdownString(localize('copilotChat.terminalNotFound', 'Terminal not found for task \`{0}\`', taskLabel)) }; } const store = new DisposableStore(); const terminalResults = await collectTerminalResults( diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/task/runTaskTool.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/task/runTaskTool.ts index f92fe12ab6f9f..d27210e3022dd 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/task/runTaskTool.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/task/runTaskTool.ts @@ -45,12 +45,12 @@ export class RunTaskTool implements IToolImpl { const taskDefinition = getTaskDefinition(args.id); const task = await getTaskForTool(args.id, taskDefinition, args.workspaceFolder, this._configurationService, this._tasksService, true); if (!task) { - return { content: [{ kind: 'text', value: `Task not found: ${args.id}` }], toolResultMessage: new MarkdownString(localize('chat.taskNotFound', 'Task not found: `{0}`', args.id)) }; + return { content: [{ kind: 'text', value: `Task not found: ${args.id}` }], toolResultMessage: new MarkdownString(localize('chat.taskNotFound', 'Task not found: \`{0}\`', args.id)) }; } const taskLabel = task._label; const activeTasks = await this._tasksService.getActiveTasks(); if (activeTasks.includes(task)) { - return { content: [{ kind: 'text', value: `The task ${taskLabel} is already running.` }], toolResultMessage: new MarkdownString(localize('chat.taskAlreadyRunning', 'The task `{0}` is already running.', taskLabel)) }; + return { content: [{ kind: 'text', value: `The task ${taskLabel} is already running.` }], toolResultMessage: new MarkdownString(localize('chat.taskAlreadyRunning', 'The task \`{0}\` is already running.', taskLabel)) }; } const raceResult = await Promise.race([this._tasksService.run(task, undefined, TaskRunSource.ChatAgent), timeout(3000)]); @@ -59,11 +59,11 @@ export class RunTaskTool implements IToolImpl { const dependencyTasks = await resolveDependencyTasks(task, args.workspaceFolder, this._configurationService, this._tasksService); const resources = this._tasksService.getTerminalsForTasks(dependencyTasks ?? task); if (!resources || resources.length === 0) { - return { content: [{ kind: 'text', value: `Task started but no terminal was found for: ${taskLabel}` }], toolResultMessage: new MarkdownString(localize('chat.noTerminal', 'Task started but no terminal was found for: `{0}`', taskLabel)) }; + return { content: [{ kind: 'text', value: `Task started but no terminal was found for: ${taskLabel}` }], toolResultMessage: new MarkdownString(localize('chat.noTerminal', 'Task started but no terminal was found for: \`{0}\`', taskLabel)) }; } const terminals = this._terminalService.instances.filter(t => resources.some(r => r.path === t.resource.path && r.scheme === t.resource.scheme)); if (terminals.length === 0) { - return { content: [{ kind: 'text', value: `Task started but no terminal was found for: ${taskLabel}` }], toolResultMessage: new MarkdownString(localize('chat.noTerminal', 'Task started but no terminal was found for: `{0}`', taskLabel)) }; + return { content: [{ kind: 'text', value: `Task started but no terminal was found for: ${taskLabel}` }], toolResultMessage: new MarkdownString(localize('chat.noTerminal', 'Task started but no terminal was found for: \`{0}\`', taskLabel)) }; } const store = new DisposableStore(); @@ -117,7 +117,7 @@ export class RunTaskTool implements IToolImpl { const task = await getTaskForTool(args.id, taskDefinition, args.workspaceFolder, this._configurationService, this._tasksService, true); if (!task) { - return { invocationMessage: new MarkdownString(localize('chat.taskNotFound', 'Task not found: `{0}`', args.id)) }; + return { invocationMessage: new MarkdownString(localize('chat.taskNotFound', 'Task not found: \`{0}\`', args.id)) }; } const taskLabel = task._label; const activeTasks = await this._tasksService.getActiveTasks(); @@ -127,19 +127,19 @@ export class RunTaskTool implements IToolImpl { if (await this._isTaskActive(task)) { return { - invocationMessage: new MarkdownString(localize('chat.taskIsAlreadyRunning', '`{0}` is already running.', taskLabel)), - pastTenseMessage: new MarkdownString(localize('chat.taskWasAlreadyRunning', '`{0}` was already running.', taskLabel)), + invocationMessage: new MarkdownString(localize('chat.taskIsAlreadyRunning', '\`{0}\` is already running.', taskLabel)), + pastTenseMessage: new MarkdownString(localize('chat.taskWasAlreadyRunning', '\`{0}\` was already running.', taskLabel)), confirmationMessages: undefined }; } return { - invocationMessage: new MarkdownString(localize('chat.runningTask', 'Running `{0}`', taskLabel)), + invocationMessage: new MarkdownString(localize('chat.runningTask', 'Running \`{0}\`', taskLabel)), pastTenseMessage: new MarkdownString(task?.configurationProperties.isBackground - ? localize('chat.startedTask', 'Started `{0}`', taskLabel) - : localize('chat.ranTask', 'Ran `{0}`', taskLabel)), + ? localize('chat.startedTask', 'Started \`{0}\`', taskLabel) + : localize('chat.ranTask', 'Ran \`{0}\`', taskLabel)), confirmationMessages: task - ? { title: localize('chat.allowTaskRunTitle', 'Allow task run?'), message: localize('chat.allowTaskRunMsg', 'Allow to run the task `{0}`?', taskLabel) } + ? { title: localize('chat.allowTaskRunTitle', 'Allow task run?'), message: localize('chat.allowTaskRunMsg', 'Allow to run the task \`{0}\`?', taskLabel) } : undefined }; } diff --git a/src/vs/workbench/services/accounts/common/defaultAccount.ts b/src/vs/workbench/services/accounts/common/defaultAccount.ts index 86b04cdde2f8a..83d7e3ddb57d3 100644 --- a/src/vs/workbench/services/accounts/common/defaultAccount.ts +++ b/src/vs/workbench/services/accounts/common/defaultAccount.ts @@ -498,12 +498,14 @@ class DefaultAccountSetupContribution extends Disposable implements IWorkbenchCo constructor( @IProductService productService: IProductService, @IInstantiationService instantiationService: IInstantiationService, + @IDefaultAccountService defaultAccountService: IDefaultAccountService, @ILogService logService: ILogService, ) { super(); if (productService.defaultAccount) { this._register(instantiationService.createInstance(DefaultAccountSetup, productService.defaultAccount)).setup(); } else { + defaultAccountService.setDefaultAccount(null); logService.debug('[DefaultAccount] No default account configuration in product service, skipping initialization'); } } diff --git a/src/vs/workbench/services/browserElements/browser/browserElementsService.ts b/src/vs/workbench/services/browserElements/browser/browserElementsService.ts index 4053357623cdb..7e7ae683bff5d 100644 --- a/src/vs/workbench/services/browserElements/browser/browserElementsService.ts +++ b/src/vs/workbench/services/browserElements/browser/browserElementsService.ts @@ -5,7 +5,7 @@ import { CancellationToken } from '../../../../base/common/cancellation.js'; import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; -import { BrowserType, IElementData } from '../../../../platform/browserElements/common/browserElements.js'; +import { IElementData, IBrowserTargetLocator } from '../../../../platform/browserElements/common/browserElements.js'; import { IRectangle } from '../../../../platform/window/common/window.js'; export const IBrowserElementsService = createDecorator('browserElementsService'); @@ -14,7 +14,7 @@ export interface IBrowserElementsService { _serviceBrand: undefined; // no browser implementation yet - getElementData(rect: IRectangle, token: CancellationToken, browserType: BrowserType | undefined): Promise; + getElementData(rect: IRectangle, token: CancellationToken, locator: IBrowserTargetLocator | undefined): Promise; - startDebugSession(token: CancellationToken, browserType: BrowserType): Promise; + startDebugSession(token: CancellationToken, locator: IBrowserTargetLocator): Promise; } diff --git a/src/vs/workbench/services/browserElements/browser/webBrowserElementsService.ts b/src/vs/workbench/services/browserElements/browser/webBrowserElementsService.ts index 7123a7f9b1c0c..337987925cb69 100644 --- a/src/vs/workbench/services/browserElements/browser/webBrowserElementsService.ts +++ b/src/vs/workbench/services/browserElements/browser/webBrowserElementsService.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { BrowserType, IElementData } from '../../../../platform/browserElements/common/browserElements.js'; +import { IElementData, IBrowserTargetLocator } from '../../../../platform/browserElements/common/browserElements.js'; import { IRectangle } from '../../../../platform/window/common/window.js'; import { CancellationToken } from '../../../../base/common/cancellation.js'; import { registerSingleton, InstantiationType } from '../../../../platform/instantiation/common/extensions.js'; @@ -14,11 +14,11 @@ class WebBrowserElementsService implements IBrowserElementsService { constructor() { } - async getElementData(rect: IRectangle, token: CancellationToken): Promise { + async getElementData(rect: IRectangle, token: CancellationToken, locator: IBrowserTargetLocator | undefined): Promise { throw new Error('Not implemented'); } - startDebugSession(token: CancellationToken, browserType: BrowserType): Promise { + async startDebugSession(token: CancellationToken, locator: IBrowserTargetLocator): Promise { throw new Error('Not implemented'); } } diff --git a/src/vs/workbench/services/browserElements/electron-browser/browserElementsService.ts b/src/vs/workbench/services/browserElements/electron-browser/browserElementsService.ts index b2aae31f50049..021dad4e4c979 100644 --- a/src/vs/workbench/services/browserElements/electron-browser/browserElementsService.ts +++ b/src/vs/workbench/services/browserElements/electron-browser/browserElementsService.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { BrowserType, IElementData, INativeBrowserElementsService } from '../../../../platform/browserElements/common/browserElements.js'; +import { IElementData, INativeBrowserElementsService, IBrowserTargetLocator } from '../../../../platform/browserElements/common/browserElements.js'; import { IRectangle } from '../../../../platform/window/common/window.js'; import { ipcRenderer } from '../../../../base/parts/sandbox/electron-browser/globals.js'; import { CancellationToken } from '../../../../base/common/cancellation.js'; @@ -33,7 +33,7 @@ class WorkbenchBrowserElementsService implements IBrowserElementsService { @INativeBrowserElementsService private readonly simpleBrowser: INativeBrowserElementsService ) { } - async startDebugSession(token: CancellationToken, browserType: BrowserType): Promise { + async startDebugSession(token: CancellationToken, locator: IBrowserTargetLocator): Promise { const cancelAndDetachId = cancelAndDetachIdPool++; const onCancelChannel = `vscode:cancelCurrentSession${cancelAndDetachId}`; @@ -42,15 +42,15 @@ class WorkbenchBrowserElementsService implements IBrowserElementsService { disposable.dispose(); }); try { - await this.simpleBrowser.startDebugSession(token, browserType, cancelAndDetachId); + await this.simpleBrowser.startDebugSession(token, locator, cancelAndDetachId); } catch (error) { disposable.dispose(); throw new Error('No debug session target found', error); } } - async getElementData(rect: IRectangle, token: CancellationToken, browserType: BrowserType | undefined): Promise { - if (!browserType) { + async getElementData(rect: IRectangle, token: CancellationToken, locator: IBrowserTargetLocator | undefined): Promise { + if (!locator) { return undefined; } const cancelSelectionId = cancelSelectionIdPool++; @@ -59,7 +59,7 @@ class WorkbenchBrowserElementsService implements IBrowserElementsService { ipcRenderer.send(onCancelChannel, cancelSelectionId); }); try { - const elementData = await this.simpleBrowser.getElementData(rect, token, browserType, cancelSelectionId); + const elementData = await this.simpleBrowser.getElementData(rect, token, locator, cancelSelectionId); return elementData; } catch (error) { disposable.dispose(); diff --git a/src/vscode-dts/vscode.proposed.chatSessionsProvider.d.ts b/src/vscode-dts/vscode.proposed.chatSessionsProvider.d.ts index 5a7f9bd503b28..ac6ade0f4137b 100644 --- a/src/vscode-dts/vscode.proposed.chatSessionsProvider.d.ts +++ b/src/vscode-dts/vscode.proposed.chatSessionsProvider.d.ts @@ -392,12 +392,13 @@ declare module 'vscode' { /** * Handler for dynamic search when `searchable` is true. - * Called when the user clicks "See more..." to load additional items. + * Called when the user types in the searchable QuickPick or clicks "See more..." to load additional items. * + * @param query The search query entered by the user. Empty string for initial load. * @param token A cancellation token. * @returns Additional items to display in the searchable QuickPick. */ - readonly onSearch?: (token: CancellationToken) => Thenable; + readonly onSearch?: (query: string, token: CancellationToken) => Thenable; } export interface ChatSessionProviderOptions {