Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -96,5 +96,6 @@
"typescript-eslint": "^8.30.1",
"vite": "^6.3.0",
"vitest": "^3.1.1"
}
},
"packageManager": "pnpm@10.17.1+sha512.17c560fca4867ae9473a3899ad84a88334914f379be46d455cbf92e5cf4b39d34985d452d2583baf19967fa76cb5c17bc9e245529d0b98745721aa7200ecaf7a"
}
18 changes: 16 additions & 2 deletions src/view/BorderButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ export interface IBorderButtonProps {
}

/** @internal */
export const BorderButton = (props: IBorderButtonProps) => {
export const BorderButton = React.memo((props: IBorderButtonProps) => {
const { layout, node, selected, border, icons, path } = props;
const selfRef = React.useRef<HTMLDivElement | null>(null);
const contentRef = React.useRef<HTMLInputElement | null>(null);
Expand All @@ -39,6 +39,14 @@ export const BorderButton = (props: IBorderButtonProps) => {

const onAuxMouseClick = (event: React.MouseEvent<HTMLElement, MouseEvent>) => {
if (isAuxMouseEvent(event)) {
// middle click to close when enabled
if (layout.isCloseTabOnMiddleClick() && event.nativeEvent instanceof MouseEvent && event.nativeEvent.button === 1) {
if (isClosable()) {
layout.doAction(Actions.deleteTab(node.getId()));
event.stopPropagation();
return;
}
}
layout.auxMouseClick(node, event);
}
};
Expand Down Expand Up @@ -198,4 +206,10 @@ export const BorderButton = (props: IBorderButtonProps) => {
{renderState.buttons}
</div>
);
};
}, (prev, next) => (
prev.node === next.node &&
prev.selected === next.selected &&
prev.path === next.path &&
prev.layout === next.layout &&
prev.border === next.border
));
27 changes: 13 additions & 14 deletions src/view/BorderTabSet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -120,19 +120,16 @@ export const BorderTabSet = (props: IBorderTabSetProps) => {
borderClasses += " " + border.getClassName();
}

// allow customization of tabset
let leading : React.ReactNode = undefined;
let buttons: any[] = [];
let stickyButtons: any[] = [];
const renderState: ITabSetRenderValues = { leading, buttons, stickyButtons: stickyButtons, overflowPosition: undefined };
layout.customizeTabSet(border, renderState);
leading = renderState.leading;
stickyButtons = renderState.stickyButtons;
buttons = renderState.buttons;

if (renderState.overflowPosition === undefined) {
renderState.overflowPosition = stickyButtons.length;
}
const baseHeader = React.useMemo(() => {
const rs: ITabSetRenderValues = { leading: undefined, buttons: [], stickyButtons: [], overflowPosition: undefined };
layout.customizeTabSet(border, rs);
return rs;
}, [border, (layout as any).props?.onRenderTabSet]);

Copilot AI Sep 23, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The type assertion (layout as any) should be avoided. Consider properly typing the layout props or accessing the callback through a typed interface.

Copilot uses AI. Check for mistakes.

let leading: React.ReactNode = baseHeader.leading;
let stickyButtons: any[] = [...baseHeader.stickyButtons];
let buttons: any[] = [...baseHeader.buttons];


if (stickyButtons.length > 0) {
if (isDockStickyButtons) {
Expand Down Expand Up @@ -163,7 +160,8 @@ export const BorderTabSet = (props: IBorderTabSetProps) => {
<div className={cm(CLASSES.FLEXLAYOUT__TAB_BUTTON_OVERFLOW_COUNT)}>{hiddenTabs.length>0?hiddenTabs.length: ""}</div>
</>);
}
buttons.splice(Math.min(renderState.overflowPosition, buttons.length), 0,
const overflowPosition = baseHeader.overflowPosition === undefined ? stickyButtons.length : baseHeader.overflowPosition;
buttons.splice(Math.min(overflowPosition, buttons.length), 0,
<button
key="overflowbutton"
ref={overflowbuttonRef}
Expand Down Expand Up @@ -194,6 +192,7 @@ export const BorderTabSet = (props: IBorderTabSetProps) => {
document.addEventListener("pointerdown", onBodyPointerDown);
return () => document.removeEventListener("pointerdown", onBodyPointerDown);
}
return undefined;
}, [selectedTabNode, isPinned, border, layout]);

if (selectedTabNode !== undefined) {
Expand Down
6 changes: 6 additions & 0 deletions src/view/Layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ export interface ILayoutProps {
model: Model;
/** factory function for creating the tab components */
factory: (node: TabNode) => React.ReactNode;
/** if true, middle-click on a tab or border tab will close it (when closable). Defaults to false */
closeTabOnMiddleClick?: boolean;
/** sets a top level class name on popout windows */
popoutClassName?: string;
/** object mapping keys among close, maximize, restore, more, popout to React nodes to use in place of the default icons, can alternatively return functions for creating the React nodes */
Expand Down Expand Up @@ -844,6 +846,10 @@ export class LayoutInternal extends React.Component<ILayoutInternalProps, ILayou
return this.props.realtimeResize ?? false;
}

isCloseTabOnMiddleClick() {
return this.props.closeTabOnMiddleClick ?? false;
}

getPopoutURL() {
return this.popoutURL;
}
Expand Down
17 changes: 15 additions & 2 deletions src/view/TabButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ export interface ITabButtonProps {
}

/** @internal */
export const TabButton = (props: ITabButtonProps) => {
export const TabButton = React.memo((props: ITabButtonProps) => {
const { layout, node, selected, path } = props;
const selfRef = React.useRef<HTMLDivElement | null>(null);
const contentRef = React.useRef<HTMLInputElement | null>(null);
Expand Down Expand Up @@ -45,6 +45,14 @@ export const TabButton = (props: ITabButtonProps) => {

const onAuxMouseClick = (event: React.MouseEvent<HTMLElement, MouseEvent>) => {
if (isAuxMouseEvent(event)) {
// middle click to close when enabled
if (layout.isCloseTabOnMiddleClick() && event.nativeEvent instanceof MouseEvent && event.nativeEvent.button === 1) {
if (isClosable()) {
layout.doAction(Actions.deleteTab(node.getId()));
event.stopPropagation();
return;
}
}
layout.auxMouseClick(node, event);
}
};
Expand Down Expand Up @@ -197,4 +205,9 @@ export const TabButton = (props: ITabButtonProps) => {
{renderState.buttons}
</div>
);
};
}, (prev, next) => (
prev.node === next.node &&
prev.selected === next.selected &&
prev.path === next.path &&
prev.layout === next.layout
));
24 changes: 11 additions & 13 deletions src/view/TabSet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -177,23 +177,21 @@ export const TabSet = (props: ITabSetProps) => {
}
}

let leading : React.ReactNode = undefined;
let stickyButtons: React.ReactNode[] = [];
let buttons: React.ReactNode[] = [];
const baseHeader = React.useMemo(() => {
const rs: ITabSetRenderValues = { leading: undefined, stickyButtons: [], buttons: [], overflowPosition: undefined };
layout.customizeTabSet(node, rs);
return rs;
// depend on node identity and callback identity
}, [node, (layout as any).props?.onRenderTabSet]);

Copilot AI Sep 23, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The type assertion (layout as any) should be avoided. Consider properly typing the layout props or accessing the callback through a typed interface.

Suggested change
}, [node, (layout as any).props?.onRenderTabSet]);
}, [node, layout.onRenderTabSet]);

Copilot uses AI. Check for mistakes.

// allow customization of header contents and buttons
const renderState: ITabSetRenderValues = { leading, stickyButtons, buttons, overflowPosition: undefined };
layout.customizeTabSet(node, renderState);
leading = renderState.leading;
stickyButtons = renderState.stickyButtons;
buttons = renderState.buttons;
let leading: React.ReactNode = baseHeader.leading;
let stickyButtons: React.ReactNode[] = [...baseHeader.stickyButtons];
let buttons: React.ReactNode[] = [...baseHeader.buttons];

const isTabStretch = node.isEnableSingleTabStretch() && node.getChildren().length === 1;
const showClose = (isTabStretch && ((node.getChildren()[0] as TabNode).isEnableClose())) || node.isEnableClose();

if (renderState.overflowPosition === undefined) {
renderState.overflowPosition = stickyButtons.length;
}
const overflowPosition = baseHeader.overflowPosition === undefined ? stickyButtons.length : baseHeader.overflowPosition;

if (stickyButtons.length > 0) {
if (!node.isEnableTabWrap() && (isDockStickyButtons || isTabStretch)) {
Expand Down Expand Up @@ -224,7 +222,7 @@ export const TabSet = (props: ITabSetProps) => {
<div className={cm(CLASSES.FLEXLAYOUT__TAB_BUTTON_OVERFLOW_COUNT)}>{hiddenTabs.length > 0 ? hiddenTabs.length : ""}</div>
</>);
}
buttons.splice(Math.min(renderState.overflowPosition, buttons.length), 0,
buttons.splice(Math.min(overflowPosition, buttons.length), 0,
<button
key="overflowbutton"
data-layout-path={path + "/button/overflow"}
Expand Down
49 changes: 29 additions & 20 deletions src/view/Utils.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,35 +10,44 @@ export function isDesktop() {
return desktop;
}
/** @internal */
const tabRenderCache: WeakMap<TabNode, { key: string; base: { leading: React.ReactNode; content: React.ReactNode; name: string; buttons: any[] } }> = new WeakMap();

Copilot AI Sep 23, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The cache value type is complex and duplicated. Consider extracting it to an interface to improve maintainability and type safety.

Copilot uses AI. Check for mistakes.

export function getRenderStateEx(
layout: LayoutInternal,
node: TabNode,
iconAngle?: number
) {
let leadingContent = undefined;
const titleContent: React.ReactNode = node.getName();
const name = node.getName();
if (iconAngle === undefined) {
iconAngle = 0;
}

if (leadingContent === undefined && node.getIcon() !== undefined) {
if (iconAngle !== 0) {
leadingContent = <img style={{ width: "1em", height: "1em", transform: "rotate(" + iconAngle + "deg)" }} src={node.getIcon()} alt="leadingContent" />;
} else {
leadingContent = <img style={{ width: "1em", height: "1em" }} src={node.getIcon()} alt="leadingContent" />;
const icon = node.getIcon();
const angle = iconAngle ?? 0;
const onRenderRef = (layout as any).props?.onRenderTab; // function identity
const cacheKey = `${name}|${icon ?? ''}|${angle}|${onRenderRef ? onRenderRef : 'no'}`;

Copilot AI Sep 23, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using a function reference in a string cache key is unreliable since different function instances with identical behavior will create different cache keys. Consider using a more stable identifier or removing this from the cache key.

Suggested change
const cacheKey = `${name}|${icon ?? ''}|${angle}|${onRenderRef ? onRenderRef : 'no'}`;
const cacheKey = `${name}|${icon ?? ''}|${angle}|${onRenderRef ? (onRenderRef.name || 'anon') : 'no'}`;

Copilot uses AI. Check for mistakes.

let cached = tabRenderCache.get(node);
if (!cached || cached.key !== cacheKey) {
// base leading/content
let leadingContent: React.ReactNode | undefined;
const titleContent: React.ReactNode = name;
if (icon !== undefined) {
if (angle !== 0) {
leadingContent = <img style={{ width: "1em", height: "1em", transform: "rotate(" + angle + "deg)" }} src={icon} alt="leadingContent" />;
} else {
leadingContent = <img style={{ width: "1em", height: "1em" }} src={icon} alt="leadingContent" />;
}
}
}

const buttons: any[] = [];

// allow customization of leading contents (icon) and contents
const renderState = { leading: leadingContent, content: titleContent, name, buttons };
layout.customizeTab(node, renderState);

node.setRenderedName(renderState.name);
const buttons: any[] = [];
const baseState = { leading: leadingContent, content: titleContent, name, buttons };
// allow customization of leading contents (icon) and contents
layout.customizeTab(node, baseState);
// cache the base, but never reuse the buttons array instance directly
cached = { key: cacheKey, base: { ...baseState, buttons: [...baseState.buttons] } };
tabRenderCache.set(node, cached);
node.setRenderedName(baseState.name);
}

return renderState;
// Return a fresh object each render so callers can safely push more buttons
return { leading: cached.base.leading, content: cached.base.content, name: cached.base.name, buttons: [...cached.base.buttons] };
}

/** @internal */
Expand Down