waveterm/frontend/app/tab/tabbar.tsx
Evan Simkowitz aa77b2c259
Pinned tabs (#1375)
![image](https://github.com/user-attachments/assets/a4072368-b204-4eed-bb65-8e3884687f9a)

This functions very similarly to VSCode's pinned tab feature. To pin a
tab, you can right-click on it and select "Pin tab" from the context
menu. Once pinned, a tab will be fixed to the left-most edge of the tab
bar, in order of pinning. Pinned tabs can be dragged around like any
others. If you drag an unpinned tab into the pinned tabs section (any
index less than the highest-index pinned tab), it will be pinned. If you
drag a pinned tab out of the pinned tab section, it will be unpinned.
Pinned tabs' close button is replaced with a persistent pin button,
which can be clicked to unpin them. This adds an extra barrier to
accidentally closing a pinned tab. They can still be closed from the
context menu.
2024-12-04 13:34:22 -08:00

657 lines
25 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, 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 { 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 DRAGGER_RIGHT_MIN_WIDTH = 74;
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>
);
};
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<HTMLDivElement>(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 draggerRightRef = useRef<HTMLDivElement>(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 updateStatusButtonRef = 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) {
// Compare current tabIds with new workspace.tabids
console.log("tabbar workspace", workspace);
const newTabIds = new Set([...(workspace.pinnedtabids ?? []), ...(workspace.tabids ?? [])]);
const newPinnedTabIds = workspace.pinnedtabids ?? [];
const areEqual =
tabIds.length === newTabIds.size &&
tabIds.every((id) => newTabIds.has(id)) &&
newPinnedTabIds.length === pinnedTabIds.size;
if (!areEqual) {
const newPinnedTabIdSet = new Set(newPinnedTabIds);
console.log("newPinnedTabIds", newPinnedTabIds);
const newTabIdList = newPinnedTabIds.concat([...newTabIds].filter((id) => !newPinnedTabIdSet.has(id))); // Corrects for any duplicates between the two lists
console.log("newTabIdList", newTabIdList);
setTabIds(newTabIdList);
setPinnedTabIds(newPinnedTabIdSet);
}
}
}, [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 = updateStatusButtonRef.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 +
DRAGGER_RIGHT_MIN_WIDTH +
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);
if (pinnedTabIds.has(tabIds[tabIndex + 1]) && !isPinned) {
pinnedTabIds.add(draggedTabId);
} else if (!pinnedTabIds.has(tabIds[tabIndex - 1]) && isPinned) {
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(
async () =>
await 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();
getApi().closeTab(tabId);
tabsWrapperRef.current.style.setProperty("--tabs-wrapper-transition", "width 0.3s ease");
deleteLayoutModelForTab(tabId);
};
const handlePinChange = (tabId: string, pinned: boolean) => {
console.log("handlePinChange", tabId, pinned);
fireAndForget(async () => {
await WorkspaceService.ChangeTabPinning(workspace.oid, tabId, pinned);
});
};
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();
}
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;
return (
<div ref={tabbarWrapperRef} className="tab-bar-wrapper">
<WindowDrag ref={draggerLeftRef} className="left" />
{appMenuButton}
{devLabel}
<WorkspaceSwitcher />
<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>
<div ref={addBtnRef} className="add-tab-btn" onClick={handleAddTab}>
<i className="fa fa-solid fa-plus fa-fw" />
</div>
<WindowDrag ref={draggerRightRef} className="right" style={{ minWidth: DRAGGER_RIGHT_MIN_WIDTH }} />
<UpdateStatusBanner buttonRef={updateStatusButtonRef} />
<ConfigErrorIcon buttonRef={configErrorButtonRef} />
</div>
);
});
export { TabBar };