Merge branch 'main' into roblou/specified-narwhal

This commit is contained in:
Rob Lourens
2026-03-01 21:14:28 -08:00
committed by GitHub
15 changed files with 603 additions and 33 deletions
+95 -11
View File
@@ -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<string, string> = 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;
+47 -5
View File
@@ -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<string, string>;
}
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<string, string> = {};
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<string, string> {
const fileContents: Record<string, string> = {};
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<string, string> | 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],
+2 -1
View File
@@ -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 => {
@@ -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<URI | undefined>;
readonly hasOverrideProjectRoot: IObservable<boolean>;
/**
* Transient override for the project root. When set, `activeProjectRoot`
* returns this value instead of the session-derived root.
*/
private readonly _overrideRoot: ISettableObservable<URI | undefined>;
/**
* 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,
@@ -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<void> {
return Event.fromObservableLight(this.activeSessionService.activeSession);
return Event.fromObservableLight(this.customizationWorkspaceService.activeProjectRoot);
}
public override async getHookSourceFolders(): Promise<readonly URI[]> {
@@ -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;
}
@@ -201,6 +201,11 @@ class GroupHeaderRenderer implements IListRenderer<IGroupHeaderEntry, IGroupHead
class AICustomizationItemRenderer implements IListRenderer<IFileItemEntry, IAICustomizationItemTemplateData> {
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<IFileItemEntry, IAICu
templateData.elementDisposables.clear();
const element = entry.item;
// Hover tooltip: name + full path
templateData.elementDisposables.add(this.hoverService.setupDelayedHover(templateData.container, () => {
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);
@@ -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<boolean>;
private readonly sectionContextKey: IContextKey<string>;
@@ -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<void> {
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<void> {
// 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();
}
@@ -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<void> {
// No-op in core VS Code.
}
@@ -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 {
@@ -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<boolean>, LanguageFeatureRegistry<unknown>]> | 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<void>): IDisposable {
@@ -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));
};
@@ -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<Set<() => 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';
}
}
}
}
@@ -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;
@@ -103,4 +103,22 @@ export interface IAICustomizationWorkspaceService {
* Launches the AI-guided creation flow for the given customization type.
*/
generateCustomization(type: PromptsType): Promise<void>;
/**
* Whether a transient project root override is currently active.
*/
readonly hasOverrideProjectRoot: IObservable<boolean>;
/**
* 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;
}
@@ -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 <table>
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 = '<p>hello</p>';
const { layoutParticipants } = wrap(container);
assert.strictEqual(layoutParticipants.size, 0);
assert.strictEqual(container.querySelector('table'), null);
});
});