waveterm/frontend/app/tab/tabbar.tsx
Evan Simkowitz a9eeb55021
Fix inconsistent tab sizing between views (#1502)
The windowdrag right spacer was acting erratic. It's also not necessary,
since we can just use margins to let the banner buttons fill empty space
so they float to the right side of the tab bar. Without it, though, I
had to add more padding for the add tab button so it has more room.
2024-12-11 18:25:36 -08:00

698 lines
26 KiB
TypeScript

// Copyright 2024, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
import { Button } from "@/app/element/button";
import { modalsModel } from "@/app/store/modalmodel";
import { WindowDrag } from "@/element/windowdrag";
import { deleteLayoutModelForTab } from "@/layout/index";
import { atoms, createTab, getApi, globalStore, isDev, PLATFORM, setActiveTab } from "@/store/global";
import { fireAndForget } from "@/util/util";
import { useAtomValue } from "jotai";
import { OverlayScrollbars } from "overlayscrollbars";
import { createRef, memo, useCallback, useEffect, useRef, useState } from "react";
import { debounce } from "throttle-debounce";
import { IconButton } from "../element/iconbutton";
import { WorkspaceService } from "../store/services";
import { Tab } from "./tab";
import "./tabbar.scss";
import { UpdateStatusBanner } from "./updatebanner";
import { WorkspaceSwitcher } from "./workspaceswitcher";
const TAB_DEFAULT_WIDTH = 130;
const TAB_MIN_WIDTH = 100;
const OS_OPTIONS = {
overflow: {
x: "scroll",
y: "hidden",
},
scrollbars: {
theme: "os-theme-dark",
visibility: "auto",
autoHide: "leave",
autoHideDelay: 1300,
autoHideSuspend: false,
dragScroll: true,
clickScroll: false,
pointers: ["mouse", "touch", "pen"],
},
};
interface TabBarProps {
workspace: Workspace;
}
const ConfigErrorMessage = () => {
const fullConfig = useAtomValue(atoms.fullConfigAtom);
if (fullConfig?.configerrors == null || fullConfig?.configerrors.length == 0) {
return (
<div className="config-error-message">
<h3>Configuration Clean</h3>
<p>There are no longer any errors detected in your config.</p>
</div>
);
}
if (fullConfig?.configerrors.length == 1) {
const singleError = fullConfig.configerrors[0];
return (
<div className="config-error-message">
<h3>Configuration Error</h3>
<div>
{singleError.file}: {singleError.err}
</div>
</div>
);
}
return (
<div className="config-error-message">
<h3>Configuration Error</h3>
<ul>
{fullConfig.configerrors.map((error, index) => (
<li key={index}>
{error.file}: {error.err}
</li>
))}
</ul>
</div>
);
};
const ConfigErrorIcon = ({ buttonRef }: { buttonRef: React.RefObject<HTMLElement> }) => {
const fullConfig = useAtomValue(atoms.fullConfigAtom);
function handleClick() {
modalsModel.pushModal("MessageModal", { children: <ConfigErrorMessage /> });
}
if (fullConfig?.configerrors == null || fullConfig?.configerrors.length == 0) {
return null;
}
return (
<Button
ref={buttonRef as React.RefObject<HTMLButtonElement>}
className="config-error-button red"
onClick={handleClick}
>
<i className="fa fa-solid fa-exclamation-triangle" />
Config Error
</Button>
);
};
function strArrayIsEqual(a: string[], b: string[]) {
// null check
if (a == null && b == null) {
return true;
}
if (a == null || b == null) {
return false;
}
if (a.length !== b.length) {
return false;
}
for (let i = 0; i < a.length; i++) {
if (a[i] !== b[i]) {
return false;
}
}
return true;
}
function setIsEqual(a: Set<string> | null, b: Set<string> | null): boolean {
if (a == null && b == null) {
return true;
}
if (a == null || b == null) {
return false;
}
if (a.size !== b.size) {
return false;
}
for (const item of a) {
if (!b.has(item)) {
return false;
}
}
return true;
}
const TabBar = memo(({ workspace }: TabBarProps) => {
const [tabIds, setTabIds] = useState<string[]>([]);
const [pinnedTabIds, setPinnedTabIds] = useState<Set<string>>(new Set());
const [dragStartPositions, setDragStartPositions] = useState<number[]>([]);
const [draggingTab, setDraggingTab] = useState<string>();
const [tabsLoaded, setTabsLoaded] = useState({});
const [newTabId, setNewTabId] = useState<string | null>(null);
const tabbarWrapperRef = useRef<HTMLDivElement>(null);
const tabBarRef = useRef<HTMLDivElement>(null);
const tabsWrapperRef = useRef<HTMLDivElement>(null);
const tabRefs = useRef<React.RefObject<HTMLDivElement>[]>([]);
const addBtnRef = useRef<HTMLButtonElement>(null);
const draggingRemovedRef = useRef(false);
const draggingTabDataRef = useRef({
tabId: "",
ref: { current: null },
tabStartX: 0,
tabStartIndex: 0,
tabIndex: 0,
initialOffsetX: null,
totalScrollOffset: null,
dragged: false,
});
const osInstanceRef = useRef<OverlayScrollbars>(null);
const draggerLeftRef = useRef<HTMLDivElement>(null);
const workspaceSwitcherRef = useRef<HTMLDivElement>(null);
const devLabelRef = useRef<HTMLDivElement>(null);
const appMenuButtonRef = useRef<HTMLDivElement>(null);
const tabWidthRef = useRef<number>(TAB_DEFAULT_WIDTH);
const scrollableRef = useRef<boolean>(false);
const updateStatusBannerRef = useRef<HTMLButtonElement>(null);
const configErrorButtonRef = useRef<HTMLElement>(null);
const prevAllLoadedRef = useRef<boolean>(false);
const activeTabId = useAtomValue(atoms.staticTabId);
const isFullScreen = useAtomValue(atoms.isFullScreen);
const settings = useAtomValue(atoms.settingsAtom);
let prevDelta: number;
let prevDragDirection: string;
// Update refs when tabIds change
useEffect(() => {
tabRefs.current = tabIds.map((_, index) => tabRefs.current[index] || createRef());
}, [tabIds]);
useEffect(() => {
if (!workspace) {
return;
}
// Compare current tabIds with new workspace.tabids
console.log("tabbar workspace", workspace);
const newTabIdsArr = [...(workspace.pinnedtabids ?? []), ...(workspace.tabids ?? [])];
const newPinnedTabSet = new Set(workspace.pinnedtabids ?? []);
const areEqual = strArrayIsEqual(tabIds, newTabIdsArr) && setIsEqual(pinnedTabIds, newPinnedTabSet);
if (!areEqual) {
console.log("newPinnedTabIds", newPinnedTabSet);
console.log("newTabIdList", newTabIdsArr);
setTabIds(newTabIdsArr);
setPinnedTabIds(newPinnedTabSet);
}
}, [workspace, tabIds, pinnedTabIds]);
const saveTabsPosition = useCallback(() => {
const tabs = tabRefs.current;
if (tabs === null) return;
const newStartPositions: number[] = [];
let cumulativeLeft = 0; // Start from the left edge
tabRefs.current.forEach((ref) => {
if (ref.current) {
newStartPositions.push(cumulativeLeft);
cumulativeLeft += ref.current.getBoundingClientRect().width; // Add each tab's actual width to the cumulative position
}
});
setDragStartPositions(newStartPositions);
}, []);
const setSizeAndPosition = (animate?: boolean) => {
const tabBar = tabBarRef.current;
if (tabBar === null) return;
const tabbarWrapperWidth = tabbarWrapperRef.current.getBoundingClientRect().width;
const windowDragLeftWidth = draggerLeftRef.current.getBoundingClientRect().width;
const addBtnWidth = addBtnRef.current.getBoundingClientRect().width;
const updateStatusLabelWidth = updateStatusBannerRef.current?.getBoundingClientRect().width ?? 0;
const configErrorWidth = configErrorButtonRef.current?.getBoundingClientRect().width ?? 0;
const appMenuButtonWidth = appMenuButtonRef.current?.getBoundingClientRect().width ?? 0;
const workspaceSwitcherWidth = workspaceSwitcherRef.current?.getBoundingClientRect().width ?? 0;
const devLabelWidth = devLabelRef.current?.getBoundingClientRect().width ?? 0;
const nonTabElementsWidth =
windowDragLeftWidth +
addBtnWidth +
updateStatusLabelWidth +
configErrorWidth +
appMenuButtonWidth +
workspaceSwitcherWidth +
devLabelWidth;
const spaceForTabs = tabbarWrapperWidth - nonTabElementsWidth;
const numberOfTabs = tabIds.length;
// Compute the ideal width per tab by dividing the available space by the number of tabs
let idealTabWidth = spaceForTabs / numberOfTabs;
// Apply min/max constraints
idealTabWidth = Math.max(TAB_MIN_WIDTH, Math.min(idealTabWidth, TAB_DEFAULT_WIDTH));
// Determine if the tab bar needs to be scrollable
const newScrollable = idealTabWidth * numberOfTabs > spaceForTabs;
// Apply the calculated width and position to all tabs
tabRefs.current.forEach((ref, index) => {
if (ref.current) {
if (animate) {
ref.current.classList.add("animate");
} else {
ref.current.classList.remove("animate");
}
ref.current.style.width = `${idealTabWidth}px`;
ref.current.style.transform = `translate3d(${index * idealTabWidth}px,0,0)`;
ref.current.style.opacity = "1";
}
});
// Update the state with the new tab width if it has changed
if (idealTabWidth !== tabWidthRef.current) {
tabWidthRef.current = idealTabWidth;
}
// Update the state with the new scrollable state if it has changed
if (newScrollable !== scrollableRef.current) {
scrollableRef.current = newScrollable;
}
// Initialize/destroy overlay scrollbars
if (newScrollable) {
osInstanceRef.current = OverlayScrollbars(tabBarRef.current, { ...(OS_OPTIONS as any) });
} else {
if (osInstanceRef.current) {
osInstanceRef.current.destroy();
}
}
};
const saveTabsPositionDebounced = useCallback(
debounce(100, () => saveTabsPosition()),
[saveTabsPosition]
);
const handleResizeTabs = useCallback(() => {
setSizeAndPosition();
saveTabsPositionDebounced();
}, [tabIds, newTabId, isFullScreen]);
const reinitVersion = useAtomValue(atoms.reinitVersion);
useEffect(() => {
if (reinitVersion > 0) {
setSizeAndPosition();
}
}, [reinitVersion]);
useEffect(() => {
window.addEventListener("resize", () => handleResizeTabs());
return () => {
window.removeEventListener("resize", () => handleResizeTabs());
};
}, [handleResizeTabs]);
useEffect(() => {
// Check if all tabs are loaded
const allLoaded = tabIds.length > 0 && tabIds.every((id) => tabsLoaded[id]);
if (allLoaded) {
setSizeAndPosition(newTabId === null && prevAllLoadedRef.current);
saveTabsPosition();
if (!prevAllLoadedRef.current) {
prevAllLoadedRef.current = true;
}
}
}, [tabIds, tabsLoaded, newTabId, saveTabsPosition]);
const getDragDirection = (currentX: number) => {
let dragDirection: string;
if (currentX - prevDelta > 0) {
dragDirection = "+";
} else if (currentX - prevDelta === 0) {
dragDirection = prevDragDirection;
} else {
dragDirection = "-";
}
prevDelta = currentX;
prevDragDirection = dragDirection;
return dragDirection;
};
const getNewTabIndex = (currentX: number, tabIndex: number, dragDirection: string) => {
let newTabIndex = tabIndex;
const tabWidth = tabWidthRef.current;
if (dragDirection === "+") {
// Dragging to the right
for (let i = tabIndex + 1; i < tabIds.length; i++) {
const otherTabStart = dragStartPositions[i];
if (currentX + tabWidth > otherTabStart + tabWidth / 2) {
newTabIndex = i;
}
}
} else {
// Dragging to the left
for (let i = tabIndex - 1; i >= 0; i--) {
const otherTabEnd = dragStartPositions[i] + tabWidth;
if (currentX < otherTabEnd - tabWidth / 2) {
newTabIndex = i;
}
}
}
return newTabIndex;
};
const handleMouseMove = (event: MouseEvent) => {
const { tabId, ref, tabStartX } = draggingTabDataRef.current;
let initialOffsetX = draggingTabDataRef.current.initialOffsetX;
let totalScrollOffset = draggingTabDataRef.current.totalScrollOffset;
if (initialOffsetX === null) {
initialOffsetX = event.clientX - tabStartX;
draggingTabDataRef.current.initialOffsetX = initialOffsetX;
}
let currentX = event.clientX - initialOffsetX - totalScrollOffset;
let tabBarRectWidth = tabBarRef.current.getBoundingClientRect().width;
// for macos, it's offset to make space for the window buttons
const tabBarRectLeftOffset = tabBarRef.current.getBoundingClientRect().left;
const incrementDecrement = tabBarRectLeftOffset * 0.05;
const dragDirection = getDragDirection(currentX);
const scrollable = scrollableRef.current;
const tabWidth = tabWidthRef.current;
// Scroll the tab bar if the dragged tab overflows the container bounds
if (scrollable) {
const { viewport } = osInstanceRef.current.elements();
const currentScrollLeft = viewport.scrollLeft;
if (event.clientX <= tabBarRectLeftOffset) {
viewport.scrollLeft = Math.max(0, currentScrollLeft - incrementDecrement); // Scroll left
if (viewport.scrollLeft !== currentScrollLeft) {
// Only adjust if the scroll actually changed
draggingTabDataRef.current.totalScrollOffset += currentScrollLeft - viewport.scrollLeft;
}
} else if (event.clientX >= tabBarRectWidth + tabBarRectLeftOffset) {
viewport.scrollLeft = Math.min(viewport.scrollWidth, currentScrollLeft + incrementDecrement); // Scroll right
if (viewport.scrollLeft !== currentScrollLeft) {
// Only adjust if the scroll actually changed
draggingTabDataRef.current.totalScrollOffset -= viewport.scrollLeft - currentScrollLeft;
}
}
}
// Re-calculate currentX after potential scroll adjustment
initialOffsetX = draggingTabDataRef.current.initialOffsetX;
totalScrollOffset = draggingTabDataRef.current.totalScrollOffset;
currentX = event.clientX - initialOffsetX - totalScrollOffset;
setDraggingTab((prev) => (prev !== tabId ? tabId : prev));
// Check if the tab has moved 5 pixels
if (Math.abs(currentX - tabStartX) >= 50) {
draggingTabDataRef.current.dragged = true;
}
// Constrain movement within the container bounds
if (tabBarRef.current) {
const numberOfTabs = tabIds.length;
const totalDefaultTabWidth = numberOfTabs * TAB_DEFAULT_WIDTH;
if (totalDefaultTabWidth < tabBarRectWidth) {
// Set to the total default tab width if there's vacant space
tabBarRectWidth = totalDefaultTabWidth;
} else if (scrollable) {
// Set to the scrollable width if the tab bar is scrollable
tabBarRectWidth = tabsWrapperRef.current.scrollWidth;
}
const minLeft = 0;
const maxRight = tabBarRectWidth - tabWidth;
// Adjust currentX to stay within bounds
currentX = Math.min(Math.max(currentX, minLeft), maxRight);
}
ref.current!.style.transform = `translate3d(${currentX}px,0,0)`;
ref.current!.style.zIndex = "100";
const tabIndex = draggingTabDataRef.current.tabIndex;
const newTabIndex = getNewTabIndex(currentX, tabIndex, dragDirection);
if (newTabIndex !== tabIndex) {
// Remove the dragged tab if not already done
if (!draggingRemovedRef.current) {
tabIds.splice(tabIndex, 1);
draggingRemovedRef.current = true;
}
// Find current index of the dragged tab in tempTabs
const currentIndexOfDraggingTab = tabIds.indexOf(tabId);
// Move the dragged tab to its new position
if (currentIndexOfDraggingTab !== -1) {
tabIds.splice(currentIndexOfDraggingTab, 1);
}
tabIds.splice(newTabIndex, 0, tabId);
// Update visual positions of the tabs
tabIds.forEach((localTabId, index) => {
const ref = tabRefs.current.find((ref) => ref.current.dataset.tabId === localTabId);
if (ref.current && localTabId !== tabId) {
ref.current.style.transform = `translate3d(${index * tabWidth}px,0,0)`;
ref.current.classList.add("animate");
}
});
draggingTabDataRef.current.tabIndex = newTabIndex;
}
};
// } else if ((tabIndex > pinnedTabCount || (tabIndex === 1 && pinnedTabCount === 1)) && isPinned) {
const setUpdatedTabsDebounced = useCallback(
debounce(300, (tabIndex: number, tabIds: string[], pinnedTabIds: Set<string>) => {
console.log(
"setting updated tabs",
tabIds,
pinnedTabIds,
tabIndex,
draggingTabDataRef.current.tabStartIndex
);
// Reset styles
tabRefs.current.forEach((ref) => {
ref.current.style.zIndex = "0";
ref.current.classList.remove("animate");
});
let pinnedTabCount = pinnedTabIds.size;
const draggedTabId = draggingTabDataRef.current.tabId;
const isPinned = pinnedTabIds.has(draggedTabId);
const nextTabId = tabIds[tabIndex + 1];
const prevTabId = tabIds[tabIndex - 1];
if (!isPinned && nextTabId && pinnedTabIds.has(nextTabId)) {
pinnedTabIds.add(draggedTabId);
} else if (isPinned && prevTabId && !pinnedTabIds.has(prevTabId)) {
pinnedTabIds.delete(draggedTabId);
}
if (pinnedTabCount != pinnedTabIds.size) {
console.log("updated pinnedTabIds", pinnedTabIds, tabIds);
setPinnedTabIds(pinnedTabIds);
pinnedTabCount = pinnedTabIds.size;
}
// Reset dragging state
setDraggingTab(null);
// Update workspace tab ids
fireAndForget(() =>
WorkspaceService.UpdateTabIds(
workspace.oid,
tabIds.slice(pinnedTabCount),
tabIds.slice(0, pinnedTabCount)
)
);
}),
[]
);
const handleMouseUp = (event: MouseEvent) => {
const { tabIndex, dragged } = draggingTabDataRef.current;
// Update the final position of the dragged tab
const draggingTab = tabIds[tabIndex];
const tabWidth = tabWidthRef.current;
const finalLeftPosition = tabIndex * tabWidth;
const ref = tabRefs.current.find((ref) => ref.current.dataset.tabId === draggingTab);
if (ref.current) {
ref.current.classList.add("animate");
ref.current.style.transform = `translate3d(${finalLeftPosition}px,0,0)`;
}
if (dragged) {
setUpdatedTabsDebounced(tabIndex, tabIds, pinnedTabIds);
} else {
// Reset styles
tabRefs.current.forEach((ref) => {
ref.current.style.zIndex = "0";
ref.current.classList.remove("animate");
});
// Reset dragging state
setDraggingTab(null);
}
document.removeEventListener("mouseup", handleMouseUp);
document.removeEventListener("mousemove", handleMouseMove);
draggingRemovedRef.current = false;
};
const handleDragStart = useCallback(
(event: React.MouseEvent<HTMLDivElement, MouseEvent>, tabId: string, ref: React.RefObject<HTMLDivElement>) => {
if (event.button !== 0) return;
const tabIndex = tabIds.indexOf(tabId);
const tabStartX = dragStartPositions[tabIndex]; // Starting X position of the tab
console.log("handleDragStart", tabId, tabIndex, tabStartX);
if (ref.current) {
draggingTabDataRef.current = {
tabId: ref.current.dataset.tabId,
ref,
tabStartX,
tabIndex,
tabStartIndex: tabIndex,
initialOffsetX: null,
totalScrollOffset: 0,
dragged: false,
};
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
}
},
[tabIds, dragStartPositions]
);
const handleSelectTab = (tabId: string) => {
if (!draggingTabDataRef.current.dragged) {
setActiveTab(tabId);
}
};
const updateScrollDebounced = useCallback(
debounce(30, () => {
if (scrollableRef.current) {
const { viewport } = osInstanceRef.current.elements();
viewport.scrollLeft = tabIds.length * tabWidthRef.current;
}
}),
[tabIds]
);
const setNewTabIdDebounced = useCallback(
debounce(100, (tabId: string) => {
setNewTabId(tabId);
}),
[]
);
const handleAddTab = () => {
createTab();
tabsWrapperRef.current.style.transition;
tabsWrapperRef.current.style.setProperty("--tabs-wrapper-transition", "width 0.1s ease");
updateScrollDebounced();
setNewTabIdDebounced(null);
};
const handleCloseTab = (event: React.MouseEvent<HTMLButtonElement, MouseEvent> | null, tabId: string) => {
event?.stopPropagation();
const ws = globalStore.get(atoms.workspace);
getApi().closeTab(ws.oid, tabId);
tabsWrapperRef.current.style.setProperty("--tabs-wrapper-transition", "width 0.3s ease");
deleteLayoutModelForTab(tabId);
};
const handlePinChange = useCallback(
(tabId: string, pinned: boolean) => {
console.log("handlePinChange", tabId, pinned);
fireAndForget(() => WorkspaceService.ChangeTabPinning(workspace.oid, tabId, pinned));
},
[workspace]
);
const handleTabLoaded = useCallback((tabId: string) => {
setTabsLoaded((prev) => {
if (!prev[tabId]) {
// Only update if the tab isn't already marked as loaded
return { ...prev, [tabId]: true };
}
return prev;
});
}, []);
const isBeforeActive = (tabId: string) => {
return tabIds.indexOf(tabId) === tabIds.indexOf(activeTabId) - 1;
};
function onEllipsisClick() {
getApi().showContextMenu(workspace.oid);
}
const tabsWrapperWidth = tabIds.length * tabWidthRef.current;
const devLabel = isDev() ? (
<div ref={devLabelRef} className="dev-label">
<i className="fa fa-brands fa-dev fa-fw" />
</div>
) : undefined;
const appMenuButton =
PLATFORM !== "darwin" && !settings["window:showmenubar"] ? (
<div ref={appMenuButtonRef} className="app-menu-button" onClick={onEllipsisClick}>
<i className="fa fa-ellipsis" />
</div>
) : undefined;
const addtabButtonDecl: IconButtonDecl = {
elemtype: "iconbutton",
icon: "plus",
click: handleAddTab,
title: "Add Tab",
};
return (
<div ref={tabbarWrapperRef} className="tab-bar-wrapper">
<WindowDrag ref={draggerLeftRef} className="left" />
{appMenuButton}
{devLabel}
<WorkspaceSwitcher ref={workspaceSwitcherRef} />
<div className="tab-bar" ref={tabBarRef} data-overlayscrollbars-initialize>
<div className="tabs-wrapper" ref={tabsWrapperRef} style={{ width: `${tabsWrapperWidth}px` }}>
{tabIds.map((tabId, index) => {
const isPinned = pinnedTabIds.has(tabId);
return (
<Tab
key={tabId}
ref={tabRefs.current[index]}
id={tabId}
isFirst={index === 0}
isPinned={isPinned}
onSelect={() => handleSelectTab(tabId)}
active={activeTabId === tabId}
onDragStart={(event) => handleDragStart(event, tabId, tabRefs.current[index])}
onClose={(event) => handleCloseTab(event, tabId)}
onLoaded={() => handleTabLoaded(tabId)}
onPinChange={() => handlePinChange(tabId, !isPinned)}
isBeforeActive={isBeforeActive(tabId)}
isDragging={draggingTab === tabId}
tabWidth={tabWidthRef.current}
isNew={tabId === newTabId}
/>
);
})}
</div>
</div>
<IconButton className="add-tab" ref={addBtnRef} decl={addtabButtonDecl} />
<div className="tab-bar-right">
<UpdateStatusBanner ref={updateStatusBannerRef} />
<ConfigErrorIcon buttonRef={configErrorButtonRef} />
</div>
</div>
);
});
export { TabBar };