feat: implement SplitView for changes panel and enhance CI status widget layout

Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
mrleemurray
2026-03-26 14:41:44 +00:00
parent cfbbd6b3dd
commit 872217c276
4 changed files with 278 additions and 212 deletions

View File

@@ -12,6 +12,7 @@ import { IObjectTreeElement, ITreeNode } from '../../../../base/browser/ui/tree/
import { Codicon } from '../../../../base/common/codicons.js';
import { Iterable } from '../../../../base/common/iterator.js';
import { Disposable, DisposableStore, MutableDisposable } from '../../../../base/common/lifecycle.js';
import { Event } from '../../../../base/common/event.js';
import { autorun, constObservable, derived, derivedOpts, IObservable, IObservableWithChange, ISettableObservable, ObservablePromise, observableSignalFromEvent, observableValue, runOnChange } from '../../../../base/common/observable.js';
import { basename, dirname } from '../../../../base/common/path.js';
import { extUriBiasedIgnorePathCase, isEqual } from '../../../../base/common/resources.js';
@@ -62,6 +63,10 @@ import { IGitHubService } from '../../github/browser/githubService.js';
import { CIStatusWidget } from './ciStatusWidget.js';
import { arrayEqualsC } from '../../../../base/common/equals.js';
import { GITHUB_REMOTE_FILE_SCHEME } from '../../sessions/common/sessionData.js';
import { Orientation } from '../../../../base/browser/ui/sash/sash.js';
import { IView, Sizing, SplitView } from '../../../../base/browser/ui/splitview/splitview.js';
import { Color } from '../../../../base/common/color.js';
import { PANEL_SECTION_BORDER } from '../../../../workbench/common/theme.js';
const $ = dom.$;
@@ -302,6 +307,8 @@ export class ChangesViewPane extends ViewPane {
private bodyContainer: HTMLElement | undefined;
private welcomeContainer: HTMLElement | undefined;
private filesHeaderNode: HTMLElement | undefined;
private filesCountBadge: HTMLElement | undefined;
private contentContainer: HTMLElement | undefined;
private overviewContainer: HTMLElement | undefined;
private summaryContainer: HTMLElement | undefined;
@@ -311,6 +318,8 @@ export class ChangesViewPane extends ViewPane {
private tree: WorkbenchCompressibleObjectTree<ChangesTreeElement> | undefined;
private ciStatusWidget: CIStatusWidget | undefined;
private splitView: SplitView | undefined;
private splitViewContainer: HTMLElement | undefined;
private readonly renderDisposables = this._register(new DisposableStore());
@@ -339,7 +348,7 @@ export class ChangesViewPane extends ViewPane {
@ICodeReviewService private readonly codeReviewService: ICodeReviewService,
@IGitHubService private readonly gitHubService: IGitHubService,
) {
super(options, keybindingService, contextMenuService, configurationService, contextKeyService, viewDescriptorService, instantiationService, openerService, themeService, hoverService);
super({ ...options, titleMenuId: MenuId.ChatEditingSessionTitleToolbar }, keybindingService, contextMenuService, configurationService, contextKeyService, viewDescriptorService, instantiationService, openerService, themeService, hoverService);
this.viewModel = this.instantiationService.createInstance(ChangesViewModel);
this._register(this.viewModel);
@@ -394,8 +403,11 @@ export class ChangesViewPane extends ViewPane {
// Actions container - positioned outside and above the card
this.actionsContainer = dom.append(this.bodyContainer, $('.chat-editing-session-actions.outside-card'));
// Main container with file icons support (the "card")
this.contentContainer = dom.append(this.bodyContainer, $('.chat-editing-session-container.show-file-icons'));
// SplitView container for resizable file tree / CI checks split
this.splitViewContainer = dom.append(this.bodyContainer, $('.changes-splitview-container'));
// Main container with file icons support (the "card") — top pane
this.contentContainer = dom.append(this.splitViewContainer, $('.chat-editing-session-container.show-file-icons'));
this._register(createFileIconThemableTreeContainerScope(this.contentContainer, this.themeService));
// Toggle class based on whether the file icon theme has file icons
@@ -405,6 +417,12 @@ export class ChangesViewPane extends ViewPane {
updateHasFileIcons();
this._register(this.themeService.onDidFileIconThemeChange(updateHasFileIcons));
// Files header
this.filesHeaderNode = dom.append(this.contentContainer, $('.changes-files-header'));
const filesTitle = dom.append(this.filesHeaderNode, $('.changes-files-title'));
filesTitle.textContent = localize('changesView.filesTitle', "Changed Files");
this.filesCountBadge = dom.append(this.filesHeaderNode, $('.changes-files-count'));
// Overview section (header with summary only - actions moved outside card)
this.overviewContainer = dom.append(this.contentContainer, $('.chat-editing-session-overview'));
this.summaryContainer = dom.append(this.overviewContainer, $('.changes-summary'));
@@ -412,9 +430,73 @@ export class ChangesViewPane extends ViewPane {
// List container
this.listContainer = dom.append(this.contentContainer, $('.chat-editing-session-list'));
// CI Status widget beneath the card
this.ciStatusWidget = this._register(this.instantiationService.createInstance(CIStatusWidget, this.bodyContainer));
this._register(this.ciStatusWidget.onDidChangeHeight(() => this.layoutTree()));
// CI Status widget — bottom pane
this.ciStatusWidget = this._register(this.instantiationService.createInstance(CIStatusWidget, this.splitViewContainer));
// Create SplitView
this.splitView = this._register(new SplitView(this.splitViewContainer, {
orientation: Orientation.VERTICAL,
proportionalLayout: false,
}));
// Shared constants for pane sizing
const ciMarginTop = 8; // matches margin-top on .ci-status-widget
const ciMinHeight = CIStatusWidget.HEADER_HEIGHT + CIStatusWidget.MIN_BODY_HEIGHT + ciMarginTop;
// Top pane: file tree
const self = this;
const treePane: IView = {
element: this.contentContainer,
minimumSize: ciMinHeight,
maximumSize: Number.POSITIVE_INFINITY,
onDidChange: Event.None,
layout: (height) => {
self.contentContainer!.style.height = `${height}px`;
self._layoutTreeInPane(height);
},
};
// Bottom pane: CI checks
const ciElement = this.ciStatusWidget.element;
const ciWidget = this.ciStatusWidget;
const ciPane: IView = {
element: ciElement,
minimumSize: ciMinHeight,
maximumSize: Number.POSITIVE_INFINITY,
onDidChange: Event.map(this.ciStatusWidget.onDidChangeHeight, () => undefined),
layout: (height) => {
const innerHeight = Math.max(0, height - ciMarginTop);
ciElement.style.height = `${innerHeight}px`;
const bodyHeight = Math.max(0, innerHeight - CIStatusWidget.HEADER_HEIGHT);
ciWidget.layout(bodyHeight);
},
};
this.splitView.addView(treePane, Sizing.Distribute, 0, true);
this.splitView.addView(ciPane, CIStatusWidget.HEADER_HEIGHT + CIStatusWidget.MIN_BODY_HEIGHT + ciMarginTop, 1, true);
// Style the sash as a visible separator between sections
const updateSplitViewStyles = () => {
const borderColor = this.themeService.getColorTheme().getColor(PANEL_SECTION_BORDER);
this.splitView!.style({ separatorBorder: borderColor ?? Color.transparent });
};
updateSplitViewStyles();
this._register(this.themeService.onDidColorThemeChange(updateSplitViewStyles));
// Initially hide CI pane until checks arrive
this.splitView.setViewVisible(1, false);
this._register(this.ciStatusWidget.onDidChangeHeight(() => {
if (!this.splitView || !this.ciStatusWidget) {
return;
}
const visible = this.ciStatusWidget.visible;
const isCurrentlyVisible = this.splitView.isViewVisible(1);
if (visible !== isCurrentlyVisible) {
this.splitView.setViewVisible(1, visible);
}
this.layoutSplitView();
}));
this._register(this.onDidChangeBodyVisibility(visible => {
if (visible) {
@@ -731,14 +813,19 @@ export class ChangesViewPane extends ViewPane {
}));
}
// Update visibility based on entries
// Update visibility and file count badge based on entries
this.renderDisposables.add(autorun(reader => {
const { files } = topLevelStats.read(reader);
const hasEntries = files > 0;
dom.setVisibility(hasEntries, this.contentContainer!);
dom.setVisibility(hasEntries, this.actionsContainer!);
dom.setVisibility(hasEntries, this.splitViewContainer!);
dom.setVisibility(!hasEntries, this.welcomeContainer!);
if (this.filesCountBadge) {
this.filesCountBadge.textContent = `${files}`;
}
}));
// Update summary text (line counts only, file count is shown in badge)
@@ -814,7 +901,7 @@ export class ChangesViewPane extends ViewPane {
const tree = this.tree;
// Re-layout when collapse state changes so the card height adjusts
this.renderDisposables.add(tree.onDidChangeContentHeight(() => this.layoutTree()));
this.renderDisposables.add(tree.onDidChangeContentHeight(() => this.layoutSplitView()));
const openFileItem = (item: IChangesFileItem, items: IChangesFileItem[], sideBySide: boolean) => {
const { uri: modifiedFileUri, originalUri, isDeletion } = item;
@@ -921,85 +1008,45 @@ export class ChangesViewPane extends ViewPane {
this.tree.setChildren(null, listChildren);
}
this.layoutTree();
this.layoutSplitView();
}));
}
private layoutTree(): void {
if (!this.tree || !this.listContainer) {
/** Layout the tree within its SplitView pane. */
private _layoutTreeInPane(paneHeight: number): void {
if (!this.tree) {
return;
}
// Subtract overview/padding within the content container
const overviewHeight = this.overviewContainer?.offsetHeight ?? 0;
const filesHeaderHeight = this.filesHeaderNode?.offsetHeight ?? 0;
const treeHeight = Math.max(0, paneHeight - filesHeaderHeight - overviewHeight);
this.tree.layout(treeHeight, this.currentBodyWidth);
this.tree.getHTMLElement().style.height = `${treeHeight}px`;
}
// Calculate remaining height for the tree by subtracting other elements
/** Layout the SplitView to fill available body space. */
private layoutSplitView(): void {
if (!this.splitView) {
return;
}
const bodyHeight = this.currentBodyHeight;
if (bodyHeight <= 0) {
return;
}
// Measure non-list elements height (padding, actions, overview)
const bodyPadding = 16; // 8px top + 8px bottom from .changes-view-body
const actionsHeight = this.actionsContainer?.offsetHeight ?? 0;
const actionsMargin = actionsHeight > 0 ? 8 : 0; // margin-bottom on actions container
const overviewHeight = this.overviewContainer?.offsetHeight ?? 0;
const containerPadding = 8; // 4px top + 4px bottom from .chat-editing-session-container
const containerBorder = 2; // 1px top + 1px bottom border
const fixedUsed = bodyPadding + actionsHeight + actionsMargin + overviewHeight + containerPadding + containerBorder;
// Determine CI widget space needs
const ciWidget = this.ciStatusWidget;
const ciVisible = ciWidget?.visible ?? false;
const ciHeaderHeight = ciVisible ? CIStatusWidget.HEADER_HEIGHT : 0;
const ciMargin = ciVisible ? 8 : 0; // margin-top on CI widget
const ciDesiredHeight = ciWidget?.desiredHeight ?? 0;
const spaceForTreeAndCI = Math.max(0, bodyHeight - fixedUsed - ciMargin);
// Give the tree priority, then CI gets the rest (with min/max on CI body)
const treeContentHeight = this.tree.contentHeight;
let treeHeight: number;
let ciBodyHeight = 0;
if (!ciVisible) {
treeHeight = Math.min(spaceForTreeAndCI, treeContentHeight);
} else {
// Reserve space for the CI header
const spaceAfterCIHeader = Math.max(0, spaceForTreeAndCI - ciHeaderHeight);
// Give the tree what it needs first, up to available space
treeHeight = Math.min(spaceAfterCIHeader, treeContentHeight);
// Remaining goes to CI body
const remainingForCIBody = Math.max(0, spaceAfterCIHeader - treeHeight);
const ciDesiredBodyHeight = Math.max(0, ciDesiredHeight - ciHeaderHeight);
ciBodyHeight = Math.min(ciDesiredBodyHeight, remainingForCIBody);
// Ensure CI body gets at least MIN_BODY_HEIGHT if there's content
if (ciDesiredBodyHeight > 0 && ciBodyHeight < CIStatusWidget.MIN_BODY_HEIGHT) {
const minCIBody = Math.min(CIStatusWidget.MIN_BODY_HEIGHT, ciDesiredBodyHeight);
const needed = minCIBody - ciBodyHeight;
const canTake = Math.max(0, treeHeight - 0); // tree can shrink to 0
const taken = Math.min(needed, canTake);
treeHeight -= taken;
ciBodyHeight += taken;
}
// Cap CI body at MAX_BODY_HEIGHT
ciBodyHeight = Math.min(ciBodyHeight, CIStatusWidget.MAX_BODY_HEIGHT);
ciWidget!.layout(ciBodyHeight);
}
this.tree.layout(treeHeight, this.currentBodyWidth);
this.tree.getHTMLElement().style.height = `${treeHeight}px`;
const actionsMargin = actionsHeight > 0 ? 8 : 0;
const availableHeight = Math.max(0, bodyHeight - bodyPadding - actionsHeight - actionsMargin);
this.splitViewContainer!.style.height = `${availableHeight}px`;
this.splitView.layout(availableHeight);
}
protected override layoutBody(height: number, width: number): void {
super.layoutBody(height, width);
this.currentBodyHeight = height;
this.currentBodyWidth = width;
this.layoutTree();
this.layoutSplitView();
}
override focus(): void {