mirror of
https://github.com/wavetermdev/waveterm.git
synced 2025-01-31 23:11:28 +01:00
aa77b2c259
![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.
238 lines
9.5 KiB
TypeScript
238 lines
9.5 KiB
TypeScript
// Copyright 2024, Command Line Inc.
|
|
// SPDX-License-Identifier: Apache-2.0
|
|
|
|
import { atoms, globalStore, refocusNode } from "@/app/store/global";
|
|
import { RpcApi } from "@/app/store/wshclientapi";
|
|
import { TabRpcClient } from "@/app/store/wshrpcutil";
|
|
import { Button } from "@/element/button";
|
|
import { ContextMenuModel } from "@/store/contextmenu";
|
|
import { clsx } from "clsx";
|
|
import { forwardRef, memo, useEffect, useImperativeHandle, useRef, useState } from "react";
|
|
import { ObjectService } from "../store/services";
|
|
import { makeORef, useWaveObjectValue } from "../store/wos";
|
|
import "./tab.scss";
|
|
|
|
interface TabProps {
|
|
id: string;
|
|
active: boolean;
|
|
isFirst: boolean;
|
|
isBeforeActive: boolean;
|
|
isDragging: boolean;
|
|
tabWidth: number;
|
|
isNew: boolean;
|
|
isPinned: boolean;
|
|
onSelect: () => void;
|
|
onClose: (event: React.MouseEvent<HTMLButtonElement, MouseEvent> | null) => void;
|
|
onDragStart: (event: React.MouseEvent<HTMLDivElement, MouseEvent>) => void;
|
|
onLoaded: () => void;
|
|
onPinChange: () => void;
|
|
}
|
|
|
|
const Tab = memo(
|
|
forwardRef<HTMLDivElement, TabProps>(
|
|
(
|
|
{
|
|
id,
|
|
active,
|
|
isPinned,
|
|
isBeforeActive,
|
|
isDragging,
|
|
tabWidth,
|
|
isNew,
|
|
onLoaded,
|
|
onSelect,
|
|
onClose,
|
|
onDragStart,
|
|
onPinChange,
|
|
},
|
|
ref
|
|
) => {
|
|
const [tabData, _] = useWaveObjectValue<Tab>(makeORef("tab", id));
|
|
const [originalName, setOriginalName] = useState("");
|
|
const [isEditable, setIsEditable] = useState(false);
|
|
|
|
const editableRef = useRef<HTMLDivElement>(null);
|
|
const editableTimeoutRef = useRef<NodeJS.Timeout>();
|
|
const loadedRef = useRef(false);
|
|
const tabRef = useRef<HTMLDivElement>(null);
|
|
|
|
useImperativeHandle(ref, () => tabRef.current as HTMLDivElement);
|
|
|
|
useEffect(() => {
|
|
if (tabData?.name) {
|
|
setOriginalName(tabData.name);
|
|
}
|
|
}, [tabData]);
|
|
|
|
useEffect(() => {
|
|
return () => {
|
|
if (editableTimeoutRef.current) {
|
|
clearTimeout(editableTimeoutRef.current);
|
|
}
|
|
};
|
|
}, []);
|
|
|
|
const handleRenameTab = (event) => {
|
|
event?.stopPropagation();
|
|
setIsEditable(true);
|
|
editableTimeoutRef.current = setTimeout(() => {
|
|
if (editableRef.current) {
|
|
editableRef.current.focus();
|
|
document.execCommand("selectAll", false);
|
|
}
|
|
}, 0);
|
|
};
|
|
|
|
const handleBlur = () => {
|
|
let newText = editableRef.current.innerText.trim();
|
|
newText = newText || originalName;
|
|
editableRef.current.innerText = newText;
|
|
setIsEditable(false);
|
|
ObjectService.UpdateTabName(id, newText);
|
|
setTimeout(() => refocusNode(null), 10);
|
|
};
|
|
|
|
const handleKeyDown = (event) => {
|
|
if ((event.metaKey || event.ctrlKey) && event.key === "a") {
|
|
event.preventDefault();
|
|
if (editableRef.current) {
|
|
const range = document.createRange();
|
|
const selection = window.getSelection();
|
|
range.selectNodeContents(editableRef.current);
|
|
selection.removeAllRanges();
|
|
selection.addRange(range);
|
|
}
|
|
return;
|
|
}
|
|
// this counts glyphs, not characters
|
|
const curLen = Array.from(editableRef.current.innerText).length;
|
|
if (event.key === "Enter") {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
if (editableRef.current.innerText.trim() === "") {
|
|
editableRef.current.innerText = originalName;
|
|
}
|
|
editableRef.current.blur();
|
|
} else if (event.key === "Escape") {
|
|
editableRef.current.innerText = originalName;
|
|
editableRef.current.blur();
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
} else if (curLen >= 14 && !["Backspace", "Delete", "ArrowLeft", "ArrowRight"].includes(event.key)) {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (!loadedRef.current) {
|
|
onLoaded();
|
|
loadedRef.current = true;
|
|
}
|
|
}, [onLoaded]);
|
|
|
|
useEffect(() => {
|
|
if (tabRef.current && isNew) {
|
|
const initialWidth = `${(tabWidth / 3) * 2}px`;
|
|
tabRef.current.style.setProperty("--initial-tab-width", initialWidth);
|
|
tabRef.current.style.setProperty("--final-tab-width", `${tabWidth}px`);
|
|
}
|
|
}, [isNew, tabWidth]);
|
|
|
|
// Prevent drag from being triggered on mousedown
|
|
const handleMouseDownOnClose = (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
|
|
event.stopPropagation();
|
|
};
|
|
|
|
function handleContextMenu(e: React.MouseEvent<HTMLDivElement, MouseEvent>) {
|
|
e.preventDefault();
|
|
let menu: ContextMenuItem[] = [
|
|
{ label: isPinned ? "Unpin Tab" : "Pin Tab", click: onPinChange },
|
|
{ label: "Rename Tab", click: () => handleRenameTab(null) },
|
|
{ label: "Copy TabId", click: () => navigator.clipboard.writeText(id) },
|
|
{ type: "separator" },
|
|
];
|
|
const fullConfig = globalStore.get(atoms.fullConfigAtom);
|
|
const bgPresets: string[] = [];
|
|
for (const key in fullConfig?.presets ?? {}) {
|
|
if (key.startsWith("bg@")) {
|
|
bgPresets.push(key);
|
|
}
|
|
}
|
|
bgPresets.sort((a, b) => {
|
|
const aOrder = fullConfig.presets[a]["display:order"] ?? 0;
|
|
const bOrder = fullConfig.presets[b]["display:order"] ?? 0;
|
|
return aOrder - bOrder;
|
|
});
|
|
if (bgPresets.length > 0) {
|
|
const submenu: ContextMenuItem[] = [];
|
|
const oref = makeORef("tab", id);
|
|
for (const presetName of bgPresets) {
|
|
const preset = fullConfig.presets[presetName];
|
|
if (preset == null) {
|
|
continue;
|
|
}
|
|
submenu.push({
|
|
label: preset["display:name"] ?? presetName,
|
|
click: () => {
|
|
ObjectService.UpdateObjectMeta(oref, preset);
|
|
RpcApi.ActivityCommand(TabRpcClient, { settabtheme: 1 });
|
|
},
|
|
});
|
|
}
|
|
menu.push({ label: "Backgrounds", type: "submenu", submenu }, { type: "separator" });
|
|
}
|
|
menu.push({ label: "Close Tab", click: () => onClose(null) });
|
|
ContextMenuModel.showContextMenu(menu, e);
|
|
}
|
|
|
|
return (
|
|
<div
|
|
ref={tabRef}
|
|
className={clsx("tab", {
|
|
active,
|
|
isDragging,
|
|
"before-active": isBeforeActive,
|
|
"new-tab": isNew,
|
|
})}
|
|
onMouseDown={onDragStart}
|
|
onClick={onSelect}
|
|
onContextMenu={handleContextMenu}
|
|
data-tab-id={id}
|
|
>
|
|
<div className="tab-inner">
|
|
<div
|
|
ref={editableRef}
|
|
className={clsx("name", { focused: isEditable })}
|
|
contentEditable={isEditable}
|
|
onDoubleClick={handleRenameTab}
|
|
onBlur={handleBlur}
|
|
onKeyDown={handleKeyDown}
|
|
suppressContentEditableWarning={true}
|
|
>
|
|
{tabData?.name}
|
|
</div>
|
|
{isPinned ? (
|
|
<Button
|
|
className="ghost grey pin"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
onPinChange();
|
|
}}
|
|
>
|
|
<i className="fa fa-solid fa-thumbtack" />
|
|
</Button>
|
|
) : (
|
|
<Button className="ghost grey close" onClick={onClose} onMouseDown={handleMouseDownOnClose}>
|
|
<i className="fa fa-solid fa-xmark" />
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
)
|
|
);
|
|
|
|
export { Tab };
|