mirror of
https://github.com/microsoft/vscode.git
synced 2026-09-25 09:06:21 +01:00
Merge branch 'main' into roblou/specified-narwhal
This commit is contained in:
+95
-11
@@ -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;
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
+83
-1
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
+5
-1
@@ -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.
|
||||
}
|
||||
|
||||
+58
@@ -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 {
|
||||
|
||||
+3
@@ -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));
|
||||
};
|
||||
|
||||
|
||||
+82
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user