From c666bd55e1f19c939373ad708e01f0d08994075e Mon Sep 17 00:00:00 2001 From: Rob Lourens Date: Sun, 1 Mar 2026 13:48:14 -0800 Subject: [PATCH 1/4] Avoid more context key creation from chat inline anchor --- .../chat/browser/attachments/chatAttachmentWidgets.ts | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/attachments/chatAttachmentWidgets.ts b/src/vs/workbench/contrib/chat/browser/attachments/chatAttachmentWidgets.ts index eb154e0b96f..c1763a79436 100644 --- a/src/vs/workbench/contrib/chat/browser/attachments/chatAttachmentWidgets.ts +++ b/src/vs/workbench/contrib/chat/browser/attachments/chatAttachmentWidgets.ts @@ -51,7 +51,7 @@ import { IOpenerService, OpenInternalOptions } from '../../../../../platform/ope import { FolderThemeIcon, IThemeService } from '../../../../../platform/theme/common/themeService.js'; import { fillEditorsDragData } from '../../../../browser/dnd.js'; import { IFileLabelOptions, IResourceLabel, ResourceLabels } from '../../../../browser/labels.js'; -import { ResourceContextKey } from '../../../../common/contextkeys.js'; +import { StaticResourceContextKey } from '../../../../common/contextkeys.js'; import { IEditorService, SIDE_GROUP } from '../../../../services/editor/common/editorService.js'; import { IPreferencesService } from '../../../../services/preferences/common/preferences.js'; import { revealInSideBarCommand } from '../../../files/browser/fileActions.contribution.js'; @@ -1379,7 +1379,7 @@ export function hookUpResourceAttachmentDragAndContextMenu(accessor: ServicesAcc // Context const scopedContextKeyService = store.add(contextKeyService.createScoped(widget)); - store.add(setResourceContext(accessor, scopedContextKeyService, resource)); + setResourceContext(accessor, scopedContextKeyService, resource); // Drag and drop widget.draggable = true; @@ -1422,7 +1422,7 @@ export function hookUpSymbolAttachmentDragAndContextMenu(accessor: ServicesAcces // but resource context and provider contexts are initialized lazily on first use) const scopedContextKeyService = store.add(parentContextKeyService.createScoped(widget)); chatAttachmentResourceContextKey.bindTo(scopedContextKeyService).set(attachment.value.uri.toString()); - store.add(setResourceContext(accessor, scopedContextKeyService, attachment.value.uri)); + setResourceContext(accessor, scopedContextKeyService, attachment.value.uri); let providerContexts: ReadonlyArray<[IContextKey, LanguageFeatureRegistry]> | undefined; @@ -1473,14 +1473,13 @@ export function hookUpSymbolAttachmentDragAndContextMenu(accessor: ServicesAcces return store; } -function setResourceContext(accessor: ServicesAccessor, scopedContextKeyService: IScopedContextKeyService, resource: URI) { +function setResourceContext(accessor: ServicesAccessor, scopedContextKeyService: IScopedContextKeyService, resource: URI): void { const fileService = accessor.get(IFileService); const languageService = accessor.get(ILanguageService); const modelService = accessor.get(IModelService); - const resourceContextKey = new ResourceContextKey(scopedContextKeyService, fileService, languageService, modelService); + const resourceContextKey = new StaticResourceContextKey(scopedContextKeyService, fileService, languageService, modelService); resourceContextKey.set(resource); - return resourceContextKey; } function addBasicContextMenu(accessor: ServicesAccessor, widget: HTMLElement, scopedContextKeyService: IScopedContextKeyService, menuId: MenuId, arg: unknown, updateContextKeys?: () => Promise): IDisposable { From d63b8bde8067b948f935aea8da34c752573a9012 Mon Sep 17 00:00:00 2001 From: Rob Lourens Date: Sun, 1 Mar 2026 14:53:57 -0800 Subject: [PATCH 2/4] chat: wrap markdown tables with horizontal scrollbar (#298604) - Wrap each rendered markdown table in a DomScrollableElement so it scrolls horizontally with the VS Code custom scrollbar instead of the native one. - Apply per-column min-width (in ch units, capped at 3ch) based on max text-content length, so short values like "001" aren't squeezed to a single character wide. - Allow table headers to wrap at word boundaries (white-space: normal) rather than staying on a single line. - Extract the wrapping logic into chatMarkdownTableScrolling.ts with unit tests in chatMarkdownTableScrolling.test.ts. Fixes #265062 (Written by Copilot) --- .../chatMarkdownContentPart.ts | 3 + .../chatMarkdownTableScrolling.ts | 82 ++++++++++ .../chat/browser/widget/media/chat.css | 12 +- .../widget/chatMarkdownTableScrolling.test.ts | 144 ++++++++++++++++++ 4 files changed, 239 insertions(+), 2 deletions(-) create mode 100644 src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatMarkdownTableScrolling.ts create mode 100644 src/vs/workbench/contrib/chat/test/browser/widget/chatMarkdownTableScrolling.test.ts diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatMarkdownContentPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatMarkdownContentPart.ts index 4255c82e1d1..8f388e7ebb5 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatMarkdownContentPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatMarkdownContentPart.ts @@ -10,6 +10,7 @@ import { status } from '../../../../../../base/browser/ui/aria/aria.js'; import { HoverStyle } from '../../../../../../base/browser/ui/hover/hover.js'; import { HoverPosition } from '../../../../../../base/browser/ui/hover/hoverWidget.js'; import { DomScrollableElement } from '../../../../../../base/browser/ui/scrollbar/scrollableElement.js'; +import { wrapTablesWithScrollable } from './chatMarkdownTableScrolling.js'; import { coalesce } from '../../../../../../base/common/arrays.js'; import { findLast } from '../../../../../../base/common/arraysFind.js'; import { Codicon } from '../../../../../../base/common/codicons.js'; @@ -374,6 +375,8 @@ export class ChatMarkdownContentPart extends Disposable implements IChatContentP scrollable.scanDomNode(); } + orderedDisposablesList.push(wrapTablesWithScrollable(this.domNode, layoutParticipants)); + orderedDisposablesList.reverse().forEach(d => store.add(d)); }; diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatMarkdownTableScrolling.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatMarkdownTableScrolling.ts new file mode 100644 index 00000000000..517fe538dc6 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatMarkdownTableScrolling.ts @@ -0,0 +1,82 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as dom from '../../../../../../base/browser/dom.js'; +import { DomScrollableElement } from '../../../../../../base/browser/ui/scrollbar/scrollableElement.js'; +import { Lazy } from '../../../../../../base/common/lazy.js'; +import { DisposableStore } from '../../../../../../base/common/lifecycle.js'; + +import { ScrollbarVisibility } from '../../../../../../base/common/scrollable.js'; + +/** + * Finds all tables in `domNode` and wraps each in a {@link DomScrollableElement} + * so they scroll horizontally with the custom VS Code scrollbar instead of the + * native one. Each wrapped table is pushed onto `orderedDisposablesList` and a + * `scanDomNode` callback is registered on `layoutParticipants` so the scrollbar + * re-measures whenever the container is resized. + * + * Each column's `min-width` is also set to the maximum character count across + * all cells in that column (in `ch` units), preventing short-content columns + * like "001" from being squeezed to one character wide. Single-character columns + * are left unchanged. This is layout-free: only `textContent` lengths are read. + */ +export function wrapTablesWithScrollable(domNode: HTMLElement, layoutParticipants: Lazy void>>): DisposableStore { + const store = new DisposableStore(); + // eslint-disable-next-line no-restricted-syntax + for (const table of domNode.querySelectorAll('table')) { + if (!dom.isHTMLElement(table)) { + continue; + } + + applyTableColumnMinWidths(table); + + // Wrap the table in a div so DomScrollableElement can compare the div's + // constrained clientWidth against the table's natural scrollWidth. + // Passing the table directly doesn't work because a table always expands + // to its content width, so clientWidth == scrollWidth and no scrollbar appears. + const parent = table.parentElement; + const nextSibling = table.nextSibling; + const tableContainer = document.createElement('div'); + tableContainer.appendChild(table); // moves table out of DOM + const scrollable = store.add(new DomScrollableElement(tableContainer, { // moves tableContainer into scrollNode + vertical: ScrollbarVisibility.Hidden, + horizontal: ScrollbarVisibility.Auto, + })); + const scrollNode = scrollable.getDomNode(); + scrollNode.classList.add('rendered-markdown-table-scroll-wrapper'); + parent?.insertBefore(scrollNode, nextSibling); + + layoutParticipants.value.add(() => { scrollable.scanDomNode(); }); + scrollable.scanDomNode(); + } + return store; +} + +/** Maximum `min-width` (in `ch`) applied to any table column, regardless of its content length. */ +const TABLE_COLUMN_MIN_WIDTH_CAP_CH = 3; + +function applyTableColumnMinWidths(table: HTMLTableElement): void { + const rows = table.rows; + const colMaxChars: number[] = []; + for (const row of rows) { + for (let c = 0; c < row.cells.length; c++) { + const len = row.cells[c].textContent?.length ?? 0; + if (len > (colMaxChars[c] ?? 0)) { + colMaxChars[c] = len; + } + } + } + // Apply min-width only to the first row's cells so each column width + // constraint is set once rather than touching every cell in the table. + const firstRow = rows[0]; + if (firstRow) { + for (let c = 0; c < firstRow.cells.length; c++) { + const minCh = colMaxChars[c]; + if (minCh !== undefined && minCh > 1) { + firstRow.cells[c].style.minWidth = Math.min(minCh, TABLE_COLUMN_MIN_WIDTH_CAP_CH) + 'ch'; + } + } + } +} diff --git a/src/vs/workbench/contrib/chat/browser/widget/media/chat.css b/src/vs/workbench/contrib/chat/browser/widget/media/chat.css index c3ec962151a..98a0e4aad6a 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/media/chat.css +++ b/src/vs/workbench/contrib/chat/browser/widget/media/chat.css @@ -313,10 +313,13 @@ font-weight: 600; } -.interactive-item-container .value .rendered-markdown table { +.interactive-item-container .value .rendered-markdown .rendered-markdown-table-scroll-wrapper { width: 100%; - text-align: left; margin-bottom: 16px; +} + +.interactive-item-container .value .rendered-markdown table { + text-align: left; border-radius: var(--vscode-cornerRadius-medium); overflow: hidden; border-collapse: separate; @@ -332,6 +335,11 @@ padding: 4px 6px; } +.interactive-item-container .value .rendered-markdown table th { + white-space: normal; + overflow-wrap: break-word; +} + .interactive-item-container .value .rendered-markdown table td:last-child, .interactive-item-container .value .rendered-markdown table th:last-child { border-right: none; diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/chatMarkdownTableScrolling.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/chatMarkdownTableScrolling.test.ts new file mode 100644 index 00000000000..4a25f74f0b3 --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/browser/widget/chatMarkdownTableScrolling.test.ts @@ -0,0 +1,144 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as assert from 'assert'; +import { Lazy } from '../../../../../../base/common/lazy.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; +import { wrapTablesWithScrollable } from '../../../browser/widget/chatContentParts/chatMarkdownTableScrolling.js'; + +/** Builds an HTMLElement containing one or more tables from markdown-style 2-D arrays. */ +function buildContainer(tables: string[][][]): HTMLDivElement { + const container = document.createElement('div'); + for (const rows of tables) { + const table = document.createElement('table'); + rows.forEach((rowData, rowIndex) => { + const section = rowIndex === 0 + ? table.createTHead() + : (table.tBodies[0] ?? table.createTBody()); + const tr = section.insertRow(); + for (const text of rowData) { + const cell = rowIndex === 0 ? document.createElement('th') : tr.insertCell(); + cell.textContent = text; + if (rowIndex === 0) { + tr.appendChild(cell); + } + } + }); + container.appendChild(table); + } + return container; +} + +suite('wrapTablesWithScrollable', () => { + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + function wrap(container: HTMLDivElement): { layoutParticipants: Set<() => void> } { + const layoutParticipants = new Set<() => void>(); + store.add(wrapTablesWithScrollable(container, new Lazy(() => layoutParticipants))); + return { layoutParticipants }; + } + + test('replaces each table with a scroll wrapper in the DOM', () => { + const container = buildContainer([[ + ['ID', 'Name'], + ['001', 'Alice'], + ]]); + // Before: direct child is + assert.strictEqual(container.children[0].tagName, 'TABLE'); + + wrap(container); + + // After: direct child is the monaco-scrollable-element wrapper + const wrapper = container.children[0]; + assert.ok(wrapper.classList.contains('rendered-markdown-table-scroll-wrapper'), + 'outer node should have the scroll wrapper class'); + }); + + test('table is preserved inside the scroll wrapper', () => { + const container = buildContainer([[['A', 'BB'], ['C', 'DD']]]); + wrap(container); + + // The table must still be in the document, nested inside the wrapper + const table = container.querySelector('table'); + assert.ok(table, 'table should still exist in DOM'); + assert.ok(container.contains(table), 'table should be inside container'); + assert.ok(!container.children[0].isSameNode(table), 'table should not be a direct child anymore'); + }); + + test('registers a layout participant for each table', () => { + const container = buildContainer([ + [['H1', 'H2'], ['a', 'bb']], + [['X', 'YY'], ['c', 'dd']], + ]); + const { layoutParticipants } = wrap(container); + assert.strictEqual(layoutParticipants.size, 2, 'one layout participant registered per table'); + }); + + test('sets column min-width capped at 3ch', () => { + const container = buildContainer([[ + ['ID', 'Name'], + ['001', 'Alice'], + ['002', 'Longer Name'], + ]]); + wrap(container); + + const table = container.querySelector('table')!; + // min-width is set only on the first row; other rows are untouched + // col 0 max = 3 chars -> 3ch; col 1 max = 11 chars -> capped at 3ch + assert.deepStrictEqual( + Array.from(table.rows[0].cells).map(cell => cell.style.minWidth), + ['3ch', '3ch'] + ); + assert.deepStrictEqual( + Array.from(table.rows[1].cells).map(cell => cell.style.minWidth), + ['', ''] + ); + }); + + test('uses actual char count when below the 3ch cap', () => { + const container = buildContainer([[['AB', 'C'], ['DE', 'F']]]); + wrap(container); + + const table = container.querySelector('table')!; + // col 0 max=2 -> 2ch; col 1 max=1 -> no min-width + assert.strictEqual(table.rows[0].cells[0].style.minWidth, '2ch'); + assert.strictEqual(table.rows[0].cells[1].style.minWidth, ''); + }); + + test('does not set min-width on single-character columns', () => { + const container = buildContainer([[['X', 'hello'], ['Y', 'world']]]); + wrap(container); + + const table = container.querySelector('table')!; + assert.strictEqual(table.rows[0].cells[0].style.minWidth, '', 'single-char column should have no min-width'); + }); + + test('handles multiple tables independently', () => { + const container = buildContainer([ + [['AB', 'C'], ['DE', 'F']], + [['X', 'YYY'], ['Z', 'WWW']], + ]); + wrap(container); + + const tables = container.querySelectorAll('table'); + assert.strictEqual(tables.length, 2); + + // Table 1: col 0 max=2, col 1 max=1 -> only col 0 gets min-width + assert.strictEqual(tables[0].rows[0].cells[0].style.minWidth, '2ch'); + assert.strictEqual(tables[0].rows[0].cells[1].style.minWidth, ''); + + // Table 2: col 0 max=1, col 1 max=3 -> only col 1 gets min-width + assert.strictEqual(tables[1].rows[0].cells[0].style.minWidth, ''); + assert.strictEqual(tables[1].rows[0].cells[1].style.minWidth, '3ch'); + }); + + test('no-ops on a container with no tables', () => { + const container = document.createElement('div'); + container.innerHTML = '

hello

'; + const { layoutParticipants } = wrap(container); + assert.strictEqual(layoutParticipants.size, 0); + assert.strictEqual(container.querySelector('table'), null); + }); +}); From f85c0b3c833f8b2ab3a7c91feab33bc7a2993ebf Mon Sep 17 00:00:00 2001 From: Henning Dieterichs Date: Mon, 2 Mar 2026 03:35:08 +0100 Subject: [PATCH 3/4] improves npm caching (#298608) --- .../vscode-extras/src/npmUpToDateFeature.ts | 106 ++++++++++++++++-- build/npm/installStateHash.ts | 52 ++++++++- build/npm/postinstall.ts | 3 +- 3 files changed, 144 insertions(+), 17 deletions(-) diff --git a/.vscode/extensions/vscode-extras/src/npmUpToDateFeature.ts b/.vscode/extensions/vscode-extras/src/npmUpToDateFeature.ts index b3f172eca1d..df9abf863ae 100644 --- a/.vscode/extensions/vscode-extras/src/npmUpToDateFeature.ts +++ b/.vscode/extensions/vscode-extras/src/npmUpToDateFeature.ts @@ -19,6 +19,7 @@ interface PostinstallState { interface InstallState { readonly root: string; + readonly stateContentsFile: string; readonly current: PostinstallState; readonly saved: PostinstallState | undefined; readonly files: readonly string[]; @@ -29,6 +30,10 @@ export class NpmUpToDateFeature extends vscode.Disposable { private readonly _disposables: vscode.Disposable[] = []; private _watchers: fs.FSWatcher[] = []; private _terminal: vscode.Terminal | undefined; + private _stateContentsFile: string | undefined; + private _root: string | undefined; + + private static readonly _scheme = 'npm-dep-state'; constructor(private readonly _output: vscode.LogOutputChannel) { const disposables: vscode.Disposable[] = []; @@ -48,10 +53,28 @@ export class NpmUpToDateFeature extends vscode.Disposable { this._statusBarItem.backgroundColor = new vscode.ThemeColor('statusBarItem.warningBackground'); this._disposables.push(this._statusBarItem); + this._disposables.push( + vscode.workspace.registerTextDocumentContentProvider(NpmUpToDateFeature._scheme, { + provideTextDocumentContent: (uri) => { + const params = new URLSearchParams(uri.query); + const source = params.get('source'); + const file = uri.path.slice(1); // strip leading / + if (source === 'saved') { + return this._readSavedContent(file); + } + return this._readCurrentContent(file); + } + }) + ); + this._disposables.push( vscode.commands.registerCommand('vscode-extras.runNpmInstall', () => this._runNpmInstall()) ); + this._disposables.push( + vscode.commands.registerCommand('vscode-extras.showDependencyDiff', (file: string) => this._showDiff(file)) + ); + this._disposables.push( vscode.window.onDidCloseTerminal(t => { if (t === this._terminal) { @@ -66,8 +89,7 @@ export class NpmUpToDateFeature extends vscode.Disposable { private _runNpmInstall(): void { if (this._terminal) { - this._terminal.show(); - return; + this._terminal.dispose(); } const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri; if (!workspaceRoot) { @@ -113,6 +135,8 @@ export class NpmUpToDateFeature extends vscode.Disposable { return; } + this._stateContentsFile = state.stateContentsFile; + this._root = state.root; this._setupWatcher(state); const changedFiles = this._getChangedFiles(state); @@ -123,9 +147,16 @@ export class NpmUpToDateFeature extends vscode.Disposable { } else { this._statusBarItem.text = '$(warning) node_modules is stale - run npm i'; const tooltip = new vscode.MarkdownString(); - tooltip.appendText('Dependencies are out of date. Click to run npm install.\n\nChanged files:\n'); - for (const file of changedFiles) { - tooltip.appendText(` • ${file}\n`); + tooltip.isTrusted = true; + tooltip.supportHtml = true; + tooltip.appendMarkdown('**Dependencies are out of date.** Click to run npm install.\n\nChanged files:\n\n'); + for (const entry of changedFiles) { + if (entry.isFile) { + const args = encodeURIComponent(JSON.stringify(entry.label)); + tooltip.appendMarkdown(`- [${entry.label}](command:vscode-extras.showDependencyDiff?${args})\n`); + } else { + tooltip.appendMarkdown(`- ${entry.label}\n`); + } } this._statusBarItem.tooltip = tooltip; this._statusBarItem.backgroundColor = new vscode.ThemeColor('statusBarItem.warningBackground'); @@ -133,18 +164,71 @@ export class NpmUpToDateFeature extends vscode.Disposable { } } - private _getChangedFiles(state: InstallState): string[] { - if (!state.saved) { - return ['(no postinstall state found)']; + private _showDiff(file: string): void { + const cacheBuster = Date.now().toString(); + const savedUri = vscode.Uri.from({ + scheme: NpmUpToDateFeature._scheme, + path: `/${file}`, + query: new URLSearchParams({ source: 'saved', t: cacheBuster }).toString(), + }); + const currentUri = vscode.Uri.from({ + scheme: NpmUpToDateFeature._scheme, + path: `/${file}`, + query: new URLSearchParams({ source: 'current', t: cacheBuster }).toString(), + }); + + vscode.commands.executeCommand('vscode.diff', savedUri, currentUri, `${file} (last install ↔ current)`); + } + + private _readSavedContent(file: string): string { + if (!this._stateContentsFile) { + return ''; } - const changed: string[] = []; + try { + const contents: Record = JSON.parse(fs.readFileSync(this._stateContentsFile, 'utf8')); + return contents[file] ?? ''; + } catch { + return ''; + } + } + + private _readCurrentContent(file: string): string { + if (!this._root) { + return ''; + } + try { + return this._normalizeFileContent(path.join(this._root, file)); + } catch { + return ''; + } + } + + private _normalizeFileContent(filePath: string): string { + const raw = fs.readFileSync(filePath, 'utf8'); + if (path.basename(filePath) === 'package.json') { + const json = JSON.parse(raw); + for (const key of NpmUpToDateFeature._packageJsonIgnoredKeys) { + delete json[key]; + } + return JSON.stringify(json, null, '\t') + '\n'; + } + return raw; + } + + private static readonly _packageJsonIgnoredKeys = ['distro']; + + private _getChangedFiles(state: InstallState): { readonly label: string; readonly isFile: boolean }[] { + if (!state.saved) { + return [{ label: '(no postinstall state found)', isFile: false }]; + } + const changed: { readonly label: string; readonly isFile: boolean }[] = []; if (state.saved.nodeVersion !== state.current.nodeVersion) { - changed.push(`Node.js version (${state.saved.nodeVersion} → ${state.current.nodeVersion})`); + changed.push({ label: `Node.js version (${state.saved.nodeVersion} → ${state.current.nodeVersion})`, isFile: false }); } const allKeys = new Set([...Object.keys(state.current.fileHashes), ...Object.keys(state.saved.fileHashes)]); for (const key of allKeys) { if (state.current.fileHashes[key] !== state.saved.fileHashes[key]) { - changed.push(key); + changed.push({ label: key, isFile: true }); } } return changed; diff --git a/build/npm/installStateHash.ts b/build/npm/installStateHash.ts index 79c0c130f5e..5674a1eaee3 100644 --- a/build/npm/installStateHash.ts +++ b/build/npm/installStateHash.ts @@ -10,6 +10,7 @@ import { dirs } from './dirs.ts'; export const root = fs.realpathSync.native(path.dirname(path.dirname(import.meta.dirname))); export const stateFile = path.join(root, 'node_modules', '.postinstall-state'); +export const stateContentsFile = path.join(root, 'node_modules', '.postinstall-state-contents'); export const forceInstallMessage = 'Run \x1b[36mnode build/npm/fast-install.ts --force\x1b[0m to force a full install.'; export function collectInputFiles(): string[] { @@ -17,7 +18,7 @@ export function collectInputFiles(): string[] { for (const dir of dirs) { const base = dir === '' ? root : path.join(root, dir); - for (const file of ['package.json', '.npmrc']) { + for (const file of ['package.json', 'package-lock.json', '.npmrc']) { const filePath = path.join(base, file); if (fs.existsSync(filePath)) { files.push(filePath); @@ -35,23 +36,55 @@ export interface PostinstallState { readonly fileHashes: Record; } -function hashFileContent(filePath: string): string { +const packageJsonIgnoredKeys = new Set(['distro']); + +function normalizeFileContent(filePath: string): string { + const raw = fs.readFileSync(filePath, 'utf8'); + if (path.basename(filePath) === 'package.json') { + const json = JSON.parse(raw); + for (const key of packageJsonIgnoredKeys) { + delete json[key]; + } + return JSON.stringify(json, null, '\t') + '\n'; + } + return raw; +} + +function hashContent(content: string): string { const hash = crypto.createHash('sha256'); - hash.update(fs.readFileSync(filePath)); + hash.update(content); return hash.digest('hex'); } export function computeState(): PostinstallState { const fileHashes: Record = {}; for (const filePath of collectInputFiles()) { - fileHashes[path.relative(root, filePath)] = hashFileContent(filePath); + const key = path.relative(root, filePath); + try { + fileHashes[key] = hashContent(normalizeFileContent(filePath)); + } catch { + // file may not be readable + } } return { nodeVersion: process.versions.node, fileHashes }; } +export function computeContents(): Record { + const fileContents: Record = {}; + for (const filePath of collectInputFiles()) { + try { + fileContents[path.relative(root, filePath)] = normalizeFileContent(filePath); + } catch { + // file may not be readable + } + } + return fileContents; +} + export function readSavedState(): PostinstallState | undefined { try { - return JSON.parse(fs.readFileSync(stateFile, 'utf8')); + const { nodeVersion, fileHashes } = JSON.parse(fs.readFileSync(stateFile, 'utf8')); + return { nodeVersion, fileHashes }; } catch { return undefined; } @@ -67,10 +100,19 @@ export function isUpToDate(): boolean { && JSON.stringify(saved.fileHashes) === JSON.stringify(current.fileHashes); } +export function readSavedContents(): Record | undefined { + try { + return JSON.parse(fs.readFileSync(stateContentsFile, 'utf8')); + } catch { + return undefined; + } +} + // When run directly, output state as JSON for tooling (e.g. the vscode-extras extension). if (import.meta.filename === process.argv[1]) { console.log(JSON.stringify({ root, + stateContentsFile, current: computeState(), saved: readSavedState(), files: [...collectInputFiles(), stateFile], diff --git a/build/npm/postinstall.ts b/build/npm/postinstall.ts index 6b61bbded5c..ae2651cd188 100644 --- a/build/npm/postinstall.ts +++ b/build/npm/postinstall.ts @@ -8,7 +8,7 @@ import path from 'path'; import * as os from 'os'; import * as child_process from 'child_process'; import { dirs } from './dirs.ts'; -import { root, stateFile, computeState, isUpToDate } from './installStateHash.ts'; +import { root, stateFile, stateContentsFile, computeState, computeContents, isUpToDate } from './installStateHash.ts'; const npm = process.platform === 'win32' ? 'npm.cmd' : 'npm'; const rootNpmrcConfigKeys = getNpmrcConfigKeys(path.join(root, '.npmrc')); @@ -287,6 +287,7 @@ async function main() { child_process.execSync('git config blame.ignoreRevsFile .git-blame-ignore-revs'); fs.writeFileSync(stateFile, JSON.stringify(_state)); + fs.writeFileSync(stateContentsFile, JSON.stringify(computeContents())); } main().catch(err => { From b753be80eb72dd88f5104f89226666e52520803f Mon Sep 17 00:00:00 2001 From: Josh Spicer <23246594+joshspicer@users.noreply.github.com> Date: Sun, 1 Mar 2026 20:24:40 -0800 Subject: [PATCH 4/4] customizations: allow transient selection of the folder to explore customizations against (#298403) * customizations: allow transient selection of the folder to explore customizations against * address PR review comments - Fix no-op setOverrideProjectRoot to accept URI parameter - Fix JSDoc/comments to be environment-agnostic (session or workspace) - Account for folder picker footer height in sidebar layout - Use ILabelService for URI tooltip instead of fsPath --- .../aiCustomizationWorkspaceService.ts | 31 ++++++- .../contrib/chat/browser/promptsService.ts | 9 +- .../aiCustomizationListWidget.ts | 17 ++++ .../aiCustomizationManagementEditor.ts | 84 ++++++++++++++++++- .../aiCustomizationWorkspaceService.ts | 6 +- .../media/aiCustomizationManagement.css | 58 +++++++++++++ .../common/aiCustomizationWorkspaceService.ts | 18 ++++ 7 files changed, 215 insertions(+), 8 deletions(-) diff --git a/src/vs/sessions/contrib/chat/browser/aiCustomizationWorkspaceService.ts b/src/vs/sessions/contrib/chat/browser/aiCustomizationWorkspaceService.ts index 79dc93837c9..2f1b25038ee 100644 --- a/src/vs/sessions/contrib/chat/browser/aiCustomizationWorkspaceService.ts +++ b/src/vs/sessions/contrib/chat/browser/aiCustomizationWorkspaceService.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { derived, IObservable } from '../../../../base/common/observable.js'; +import { derived, IObservable, observableValue, ISettableObservable } from '../../../../base/common/observable.js'; import { joinPath } from '../../../../base/common/resources.js'; import { URI } from '../../../../base/common/uri.js'; import { IAICustomizationWorkspaceService, AICustomizationManagementSection, IStorageSourceFilter } from '../../../../workbench/contrib/chat/common/aiCustomizationWorkspaceService.js'; @@ -23,6 +23,13 @@ export class SessionsAICustomizationWorkspaceService implements IAICustomization declare readonly _serviceBrand: undefined; readonly activeProjectRoot: IObservable; + readonly hasOverrideProjectRoot: IObservable; + + /** + * Transient override for the project root. When set, `activeProjectRoot` + * returns this value instead of the session-derived root. + */ + private readonly _overrideRoot: ISettableObservable; /** * CLI-accessible user directories for customization file filtering and creation. @@ -50,17 +57,39 @@ export class SessionsAICustomizationWorkspaceService implements IAICustomization includedUserFileRoots: this._cliUserRoots, }; + this._overrideRoot = observableValue(this, undefined); + this.activeProjectRoot = derived(reader => { + const override = this._overrideRoot.read(reader); + if (override) { + return override; + } const session = this.sessionsService.activeSession.read(reader); return session?.worktree ?? session?.repository; }); + + this.hasOverrideProjectRoot = derived(reader => { + return this._overrideRoot.read(reader) !== undefined; + }); } getActiveProjectRoot(): URI | undefined { + const override = this._overrideRoot.get(); + if (override) { + return override; + } const session = this.sessionsService.getActiveSession(); return session?.worktree ?? session?.repository; } + setOverrideProjectRoot(root: URI): void { + this._overrideRoot.set(root, undefined); + } + + clearOverrideProjectRoot(): void { + this._overrideRoot.set(undefined, undefined); + } + readonly managementSections: readonly AICustomizationManagementSection[] = [ AICustomizationManagementSection.Agents, AICustomizationManagementSection.Skills, diff --git a/src/vs/sessions/contrib/chat/browser/promptsService.ts b/src/vs/sessions/contrib/chat/browser/promptsService.ts index 96596f2de0c..3a7af9380b2 100644 --- a/src/vs/sessions/contrib/chat/browser/promptsService.ts +++ b/src/vs/sessions/contrib/chat/browser/promptsService.ts @@ -19,7 +19,7 @@ import { IWorkbenchEnvironmentService } from '../../../../workbench/services/env import { IPathService } from '../../../../workbench/services/path/common/pathService.js'; import { ISearchService } from '../../../../workbench/services/search/common/search.js'; import { IUserDataProfileService } from '../../../../workbench/services/userDataProfile/common/userDataProfile.js'; -import { ISessionsManagementService } from '../../sessions/browser/sessionsManagementService.js'; +import { IAICustomizationWorkspaceService } from '../../../../workbench/contrib/chat/common/aiCustomizationWorkspaceService.js'; export class AgenticPromptsService extends PromptsService { private _copilotRoot: URI | undefined; @@ -67,7 +67,7 @@ class AgenticPromptFilesLocator extends PromptFilesLocator { @IUserDataProfileService userDataService: IUserDataProfileService, @ILogService logService: ILogService, @IPathService pathService: IPathService, - @ISessionsManagementService private readonly activeSessionService: ISessionsManagementService, + @IAICustomizationWorkspaceService private readonly customizationWorkspaceService: IAICustomizationWorkspaceService, ) { super( fileService, @@ -95,7 +95,7 @@ class AgenticPromptFilesLocator extends PromptFilesLocator { } protected override onDidChangeWorkspaceFolders(): Event { - return Event.fromObservableLight(this.activeSessionService.activeSession); + return Event.fromObservableLight(this.customizationWorkspaceService.activeProjectRoot); } public override async getHookSourceFolders(): Promise { @@ -108,8 +108,7 @@ class AgenticPromptFilesLocator extends PromptFilesLocator { } private getActiveWorkspaceFolder(): IWorkspaceFolder | undefined { - const session = this.activeSessionService.getActiveSession(); - const root = session?.worktree ?? session?.repository; + const root = this.customizationWorkspaceService.getActiveProjectRoot(); if (!root) { return undefined; } diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts index f3f644dffa8..9f3f4492db7 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts @@ -201,6 +201,11 @@ class GroupHeaderRenderer implements IListRenderer { readonly templateId = 'aiCustomizationItem'; + constructor( + @IHoverService private readonly hoverService: IHoverService, + @ILabelService private readonly labelService: ILabelService, + ) { } + renderTemplate(container: HTMLElement): IAICustomizationItemTemplateData { const disposables = new DisposableStore(); const elementDisposables = new DisposableStore(); @@ -236,6 +241,18 @@ class AICustomizationItemRenderer implements IListRenderer { + const uriLabel = this.labelService.getUriLabel(element.uri, { relative: false }); + return { + content: `${element.name}\n${uriLabel}`, + appearance: { + compact: true, + skipFadeInAnimation: true, + } + }; + })); + // Name with highlights templateData.nameLabel.set(element.name, element.nameMatches); diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts index a18e242ba69..7b3d437b0ef 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts @@ -61,11 +61,14 @@ import { getSimpleEditorOptions } from '../../../codeEditor/browser/simpleEditor import { IWorkingCopyService } from '../../../../services/workingCopy/common/workingCopyService.js'; import { ITextFileService } from '../../../../services/textfile/common/textfiles.js'; import { IFileService } from '../../../../../platform/files/common/files.js'; +import { IFileDialogService } from '../../../../../platform/dialogs/common/dialogs.js'; +import { IHoverService } from '../../../../../platform/hover/browser/hover.js'; import { VSBuffer } from '../../../../../base/common/buffer.js'; import { HOOKS_SOURCE_FOLDER } from '../../common/promptSyntax/config/promptFileLocations.js'; import { COPILOT_CLI_HOOK_TYPE_MAP } from '../../common/promptSyntax/hookSchema.js'; import { McpServerEditorInput } from '../../../mcp/browser/mcpServerEditorInput.js'; import { McpServerEditor } from '../../../mcp/browser/mcpServerEditor.js'; +import { getDefaultHoverDelegate } from '../../../../../base/browser/ui/hover/hoverDelegateFactory.js'; import { IWorkbenchMcpServer } from '../../../mcp/common/mcpTypes.js'; const $ = DOM.$; @@ -168,6 +171,11 @@ export class AICustomizationManagementEditor extends EditorPane { private readonly editorDisposables = this._register(new DisposableStore()); private _editorContentChanged = false; + // Folder picker (sessions window only) + private folderPickerContainer: HTMLElement | undefined; + private folderPickerLabel: HTMLElement | undefined; + private folderPickerClearButton: HTMLElement | undefined; + private readonly inEditorContextKey: IContextKey; private readonly sectionContextKey: IContextKey; @@ -187,6 +195,8 @@ export class AICustomizationManagementEditor extends EditorPane { @IWorkingCopyService private readonly workingCopyService: IWorkingCopyService, @ITextFileService private readonly textFileService: ITextFileService, @IFileService private readonly fileService: IFileService, + @IFileDialogService private readonly fileDialogService: IFileDialogService, + @IHoverService private readonly hoverService: IHoverService, ) { super(AICustomizationManagementEditor.ID, group, telemetryService, themeService, storageService); @@ -264,7 +274,8 @@ export class AICustomizationManagementEditor extends EditorPane { layout: (width, _, height) => { this.sidebarContainer.style.width = `${width}px`; if (height !== undefined) { - const listHeight = height - 8; + const footerHeight = this.folderPickerContainer?.offsetHeight ?? 0; + const listHeight = height - 8 - footerHeight; this.sectionsList.layout(listHeight, width); } }, @@ -350,6 +361,72 @@ export class AICustomizationManagementEditor extends EditorPane { this.selectSection(e.elements[0].id); } })); + + // Folder picker (sessions window only) + if (this.workspaceService.isSessionsWindow) { + this.createFolderPicker(sidebarContent); + } + } + + private createFolderPicker(sidebarContent: HTMLElement): void { + const footer = this.folderPickerContainer = DOM.append(sidebarContent, $('.sidebar-folder-picker')); + + const button = DOM.append(footer, $('button.folder-picker-button')); + button.setAttribute('aria-label', localize('browseFolder', "Browse folder")); + + const folderIcon = DOM.append(button, $(`.codicon.codicon-${Codicon.folder.id}`)); + folderIcon.classList.add('folder-picker-icon'); + + this.folderPickerLabel = DOM.append(button, $('span.folder-picker-label')); + + this.folderPickerClearButton = DOM.append(footer, $('button.folder-picker-clear')); + this.folderPickerClearButton.setAttribute('aria-label', localize('clearFolderOverride', "Reset to session folder")); + DOM.append(this.folderPickerClearButton, $(`.codicon.codicon-${Codicon.close.id}`)); + + // Clicking the main button opens the folder dialog + this.editorDisposables.add(DOM.addDisposableListener(button, 'click', () => { + this.browseForFolder(); + })); + + // Clear button resets to session default + this.editorDisposables.add(DOM.addDisposableListener(this.folderPickerClearButton, 'click', () => { + this.workspaceService.clearOverrideProjectRoot(); + })); + + // Hover showing full path + this.editorDisposables.add(this.hoverService.setupManagedHover(getDefaultHoverDelegate('element'), button, () => { + const root = this.workspaceService.getActiveProjectRoot(); + return root?.fsPath ?? ''; + })); + + // Keep label and clear button in sync with the active root + this.editorDisposables.add(autorun(reader => { + const root = this.workspaceService.activeProjectRoot.read(reader); + const hasOverride = this.workspaceService.hasOverrideProjectRoot.read(reader); + this.updateFolderPickerLabel(root, hasOverride); + })); + } + + private updateFolderPickerLabel(root: URI | undefined, hasOverride: boolean): void { + if (this.folderPickerLabel) { + this.folderPickerLabel.textContent = root ? basename(root) : localize('noFolder', "No folder"); + } + if (this.folderPickerClearButton) { + this.folderPickerClearButton.style.display = hasOverride ? '' : 'none'; + } + } + + private async browseForFolder(): Promise { + const result = await this.fileDialogService.showOpenDialog({ + canSelectFolders: true, + canSelectFiles: false, + canSelectMany: false, + title: localize('selectFolder', "Select Folder to Explore"), + defaultUri: this.workspaceService.getActiveProjectRoot(), + }); + if (result?.[0]) { + this.workspaceService.setOverrideProjectRoot(result[0]); + } } private createContent(): void { @@ -585,6 +662,9 @@ export class AICustomizationManagementEditor extends EditorPane { } override async setInput(input: AICustomizationManagementEditorInput, options: IEditorOptions | undefined, context: IEditorOpenContext, token: CancellationToken): Promise { + // On (re)open, clear any override so the root comes from the default source + this.workspaceService.clearOverrideProjectRoot(); + this.inEditorContextKey.set(true); this.sectionContextKey.set(this.selectedSection); @@ -603,6 +683,8 @@ export class AICustomizationManagementEditor extends EditorPane { if (this.viewMode === 'mcpDetail') { this.goBackFromMcpDetail(); } + // Clear transient folder override on close + this.workspaceService.clearOverrideProjectRoot(); super.clearInput(); } diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationWorkspaceService.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationWorkspaceService.ts index 0f22c638e18..6d867728d37 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationWorkspaceService.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationWorkspaceService.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { derived, IObservable, observableFromEventOpts } from '../../../../../base/common/observable.js'; +import { constObservable, derived, IObservable, observableFromEventOpts } from '../../../../../base/common/observable.js'; import { URI } from '../../../../../base/common/uri.js'; import { IWorkspaceContextService } from '../../../../../platform/workspace/common/workspace.js'; import { IAICustomizationWorkspaceService, AICustomizationManagementSection, IStorageSourceFilter } from '../../common/aiCustomizationWorkspaceService.js'; @@ -63,6 +63,10 @@ class AICustomizationWorkspaceService implements IAICustomizationWorkspaceServic readonly isSessionsWindow = false; + readonly hasOverrideProjectRoot = constObservable(false); + setOverrideProjectRoot(_root: URI): void { } + clearOverrideProjectRoot(): void { } + async commitFiles(_projectRoot: URI, _fileUris: URI[]): Promise { // No-op in core VS Code. } diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/media/aiCustomizationManagement.css b/src/vs/workbench/contrib/chat/browser/aiCustomization/media/aiCustomizationManagement.css index eb634aa4562..af43b382e18 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/media/aiCustomizationManagement.css +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/media/aiCustomizationManagement.css @@ -31,7 +31,65 @@ overflow: hidden; } +/* Folder picker footer (sessions window only) */ +.ai-customization-management-editor .sidebar-folder-picker { + flex-shrink: 0; + display: flex; + align-items: center; + gap: 2px; + padding: 6px 4px; + border-top: 1px solid var(--vscode-sideBarSectionHeader-border, transparent); +} +.ai-customization-management-editor .folder-picker-button { + display: flex; + align-items: center; + gap: 6px; + flex: 1; + min-width: 0; + padding: 4px 6px; + border: none; + border-radius: 4px; + background: transparent; + color: var(--vscode-descriptionForeground); + cursor: pointer; + font-size: 11px; +} + +.ai-customization-management-editor .folder-picker-button:hover { + background-color: var(--vscode-list-hoverBackground); +} + +.ai-customization-management-editor .folder-picker-icon { + flex-shrink: 0; + font-size: 14px; +} + +.ai-customization-management-editor .folder-picker-label { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.ai-customization-management-editor .folder-picker-clear { + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + padding: 0; + border: none; + border-radius: 4px; + background: transparent; + color: var(--vscode-descriptionForeground); + cursor: pointer; + font-size: 12px; +} + +.ai-customization-management-editor .folder-picker-clear:hover { + background-color: var(--vscode-list-hoverBackground); +} /* Section list items */ .ai-customization-management-editor .section-list-item { diff --git a/src/vs/workbench/contrib/chat/common/aiCustomizationWorkspaceService.ts b/src/vs/workbench/contrib/chat/common/aiCustomizationWorkspaceService.ts index 28467684c8d..26af14f6561 100644 --- a/src/vs/workbench/contrib/chat/common/aiCustomizationWorkspaceService.ts +++ b/src/vs/workbench/contrib/chat/common/aiCustomizationWorkspaceService.ts @@ -103,4 +103,22 @@ export interface IAICustomizationWorkspaceService { * Launches the AI-guided creation flow for the given customization type. */ generateCustomization(type: PromptsType): Promise; + + /** + * Whether a transient project root override is currently active. + */ + readonly hasOverrideProjectRoot: IObservable; + + /** + * Sets a transient override for the active project root. + * While set, `activeProjectRoot` returns this value instead of the + * session- or workspace-derived root. Call `clearOverrideProjectRoot()` to revert. + */ + setOverrideProjectRoot(root: URI): void; + + /** + * Clears the transient project root override, reverting to the + * session-derived (or workspace-derived) root. + */ + clearOverrideProjectRoot(): void; }