mirror of
https://github.com/wavetermdev/waveterm.git
synced 2024-12-21 16:38:23 +01:00
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.
This commit is contained in:
parent
0145e8fe99
commit
aa77b2c259
@ -294,8 +294,8 @@ export class WaveBrowserWindow extends BaseWindow {
|
||||
await this.queueTabSwitch(tabView, tabInitialized);
|
||||
}
|
||||
|
||||
async createTab() {
|
||||
const tabId = await WorkspaceService.CreateTab(this.workspaceId, null, true);
|
||||
async createTab(pinned = false) {
|
||||
const tabId = await WorkspaceService.CreateTab(this.workspaceId, null, true, pinned);
|
||||
await this.setActiveTab(tabId, false);
|
||||
}
|
||||
|
||||
|
@ -625,6 +625,7 @@ function createTab() {
|
||||
}
|
||||
|
||||
function setActiveTab(tabId: string) {
|
||||
// We use this hack to prevent a flicker in the tab bar when switching to a new tab. This class is unset in reinitWave in wave.ts. See tab.scss for where this class is used.
|
||||
document.body.classList.add("nohover");
|
||||
getApi().setActiveTab(tabId);
|
||||
}
|
||||
|
@ -99,15 +99,19 @@ function switchBlockInDirection(tabId: string, direction: NavigateDirection) {
|
||||
layoutModel.switchNodeFocusInDirection(direction);
|
||||
}
|
||||
|
||||
function getAllTabs(ws: Workspace): string[] {
|
||||
return [...(ws.pinnedtabids ?? []), ...(ws.tabids ?? [])];
|
||||
}
|
||||
|
||||
function switchTabAbs(index: number) {
|
||||
console.log("switchTabAbs", index);
|
||||
const ws = globalStore.get(atoms.workspace);
|
||||
const waveWindow = globalStore.get(atoms.waveWindow);
|
||||
const newTabIdx = index - 1;
|
||||
if (newTabIdx < 0 || newTabIdx >= ws.tabids.length) {
|
||||
const tabids = getAllTabs(ws);
|
||||
if (newTabIdx < 0 || newTabIdx >= tabids.length) {
|
||||
return;
|
||||
}
|
||||
const newActiveTabId = ws.tabids[newTabIdx];
|
||||
const newActiveTabId = tabids[newTabIdx];
|
||||
getApi().setActiveTab(newActiveTabId);
|
||||
}
|
||||
|
||||
@ -116,8 +120,9 @@ function switchTab(offset: number) {
|
||||
const ws = globalStore.get(atoms.workspace);
|
||||
const curTabId = globalStore.get(atoms.staticTabId);
|
||||
let tabIdx = -1;
|
||||
for (let i = 0; i < ws.tabids.length; i++) {
|
||||
if (ws.tabids[i] == curTabId) {
|
||||
const tabids = getAllTabs(ws);
|
||||
for (let i = 0; i < tabids.length; i++) {
|
||||
if (tabids[i] == curTabId) {
|
||||
tabIdx = i;
|
||||
break;
|
||||
}
|
||||
@ -125,8 +130,8 @@ function switchTab(offset: number) {
|
||||
if (tabIdx == -1) {
|
||||
return;
|
||||
}
|
||||
const newTabIdx = (tabIdx + offset + ws.tabids.length) % ws.tabids.length;
|
||||
const newActiveTabId = ws.tabids[newTabIdx];
|
||||
const newTabIdx = (tabIdx + offset + tabids.length) % tabids.length;
|
||||
const newActiveTabId = tabids[newTabIdx];
|
||||
getApi().setActiveTab(newActiveTabId);
|
||||
}
|
||||
|
||||
@ -241,7 +246,10 @@ function registerGlobalKeys() {
|
||||
});
|
||||
globalKeyMap.set("Cmd:w", () => {
|
||||
const tabId = globalStore.get(atoms.staticTabId);
|
||||
const ws = globalStore.get(atoms.workspace);
|
||||
if (!ws.pinnedtabids?.includes(tabId)) {
|
||||
genericClose(tabId);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
globalKeyMap.set("Cmd:m", () => {
|
||||
|
@ -168,13 +168,18 @@ export const WindowService = new WindowServiceType();
|
||||
|
||||
// workspaceservice.WorkspaceService (workspace)
|
||||
class WorkspaceServiceType {
|
||||
// @returns object updates
|
||||
ChangeTabPinning(workspaceId: string, tabId: string, pinned: boolean): Promise<void> {
|
||||
return WOS.callBackendService("workspace", "ChangeTabPinning", Array.from(arguments))
|
||||
}
|
||||
|
||||
// @returns object updates
|
||||
CloseTab(workspaceId: string, tabId: string, fromElectron: boolean): Promise<CloseTabRtnType> {
|
||||
return WOS.callBackendService("workspace", "CloseTab", Array.from(arguments))
|
||||
}
|
||||
|
||||
// @returns tabId (and object updates)
|
||||
CreateTab(workspaceId: string, tabName: string, activateTab: boolean): Promise<string> {
|
||||
CreateTab(workspaceId: string, tabName: string, activateTab: boolean, pinned: boolean): Promise<string> {
|
||||
return WOS.callBackendService("workspace", "CreateTab", Array.from(arguments))
|
||||
}
|
||||
|
||||
@ -195,7 +200,7 @@ class WorkspaceServiceType {
|
||||
}
|
||||
|
||||
// @returns object updates
|
||||
UpdateTabIds(workspaceId: string, tabIds: string[]): Promise<void> {
|
||||
UpdateTabIds(workspaceId: string, tabIds: string[], pinnedTabIds: string[]): Promise<void> {
|
||||
return WOS.callBackendService("workspace", "UpdateTabIds", Array.from(arguments))
|
||||
}
|
||||
}
|
||||
|
@ -81,8 +81,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
.close {
|
||||
visibility: hidden;
|
||||
.button {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: 4px;
|
||||
@ -97,6 +96,10 @@
|
||||
padding: 1px 2px;
|
||||
transition: none !important;
|
||||
}
|
||||
|
||||
.close {
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
body:not(.nohover) .tab:hover {
|
||||
|
@ -1,17 +1,15 @@
|
||||
// Copyright 2024, Command Line Inc.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import { Button } from "@/element/button";
|
||||
import { ContextMenuModel } from "@/store/contextmenu";
|
||||
import * as services from "@/store/services";
|
||||
import * as WOS from "@/store/wos";
|
||||
import { clsx } from "clsx";
|
||||
import * as React from "react";
|
||||
import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from "react";
|
||||
|
||||
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 {
|
||||
@ -22,19 +20,21 @@ interface TabProps {
|
||||
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 = React.memo(
|
||||
const Tab = memo(
|
||||
forwardRef<HTMLDivElement, TabProps>(
|
||||
(
|
||||
{
|
||||
id,
|
||||
active,
|
||||
isFirst,
|
||||
isPinned,
|
||||
isBeforeActive,
|
||||
isDragging,
|
||||
tabWidth,
|
||||
@ -43,10 +43,11 @@ const Tab = React.memo(
|
||||
onSelect,
|
||||
onClose,
|
||||
onDragStart,
|
||||
onPinChange,
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const [tabData, tabLoading] = WOS.useWaveObjectValue<Tab>(WOS.makeORef("tab", id));
|
||||
const [tabData, _] = useWaveObjectValue<Tab>(makeORef("tab", id));
|
||||
const [originalName, setOriginalName] = useState("");
|
||||
const [isEditable, setIsEditable] = useState(false);
|
||||
|
||||
@ -87,7 +88,7 @@ const Tab = React.memo(
|
||||
newText = newText || originalName;
|
||||
editableRef.current.innerText = newText;
|
||||
setIsEditable(false);
|
||||
services.ObjectService.UpdateTabName(id, newText);
|
||||
ObjectService.UpdateTabName(id, newText);
|
||||
setTimeout(() => refocusNode(null), 10);
|
||||
};
|
||||
|
||||
@ -145,7 +146,12 @@ const Tab = React.memo(
|
||||
|
||||
function handleContextMenu(e: React.MouseEvent<HTMLDivElement, MouseEvent>) {
|
||||
e.preventDefault();
|
||||
let menu: ContextMenuItem[] = [];
|
||||
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 ?? {}) {
|
||||
@ -158,12 +164,9 @@ const Tab = React.memo(
|
||||
const bOrder = fullConfig.presets[b]["display:order"] ?? 0;
|
||||
return aOrder - bOrder;
|
||||
});
|
||||
menu.push({ label: "Rename Tab", click: () => handleRenameTab(null) });
|
||||
menu.push({ label: "Copy TabId", click: () => navigator.clipboard.writeText(id) });
|
||||
menu.push({ type: "separator" });
|
||||
if (bgPresets.length > 0) {
|
||||
const submenu: ContextMenuItem[] = [];
|
||||
const oref = WOS.makeORef("tab", id);
|
||||
const oref = makeORef("tab", id);
|
||||
for (const presetName of bgPresets) {
|
||||
const preset = fullConfig.presets[presetName];
|
||||
if (preset == null) {
|
||||
@ -172,13 +175,12 @@ const Tab = React.memo(
|
||||
submenu.push({
|
||||
label: preset["display:name"] ?? presetName,
|
||||
click: () => {
|
||||
services.ObjectService.UpdateObjectMeta(oref, preset);
|
||||
ObjectService.UpdateObjectMeta(oref, preset);
|
||||
RpcApi.ActivityCommand(TabRpcClient, { settabtheme: 1 });
|
||||
},
|
||||
});
|
||||
}
|
||||
menu.push({ label: "Backgrounds", type: "submenu", submenu });
|
||||
menu.push({ type: "separator" });
|
||||
menu.push({ label: "Backgrounds", type: "submenu", submenu }, { type: "separator" });
|
||||
}
|
||||
menu.push({ label: "Close Tab", click: () => onClose(null) });
|
||||
ContextMenuModel.showContextMenu(menu, e);
|
||||
@ -210,9 +212,21 @@ const Tab = React.memo(
|
||||
>
|
||||
{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>
|
||||
);
|
||||
|
@ -36,9 +36,18 @@
|
||||
|
||||
.tab-bar {
|
||||
position: relative; // Needed for absolute positioning of child tabs
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
height: 33px;
|
||||
}
|
||||
|
||||
.pinned-tab-spacer {
|
||||
display: block;
|
||||
height: 100%;
|
||||
margin: 2px;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.dev-label,
|
||||
.app-menu-button {
|
||||
font-size: 26px;
|
||||
|
@ -101,6 +101,7 @@ const ConfigErrorIcon = ({ buttonRef }: { buttonRef: React.RefObject<HTMLElement
|
||||
|
||||
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({});
|
||||
@ -116,6 +117,7 @@ const TabBar = memo(({ workspace }: TabBarProps) => {
|
||||
tabId: "",
|
||||
ref: { current: null },
|
||||
tabStartX: 0,
|
||||
tabStartIndex: 0,
|
||||
tabIndex: 0,
|
||||
initialOffsetX: null,
|
||||
totalScrollOffset: null,
|
||||
@ -148,17 +150,25 @@ const TabBar = memo(({ workspace }: TabBarProps) => {
|
||||
useEffect(() => {
|
||||
if (workspace) {
|
||||
// Compare current tabIds with new workspace.tabids
|
||||
const currentTabIds = new Set(tabIds);
|
||||
const newTabIds = new Set(workspace.tabids);
|
||||
console.log("tabbar workspace", workspace);
|
||||
const newTabIds = new Set([...(workspace.pinnedtabids ?? []), ...(workspace.tabids ?? [])]);
|
||||
const newPinnedTabIds = workspace.pinnedtabids ?? [];
|
||||
|
||||
const areEqual =
|
||||
currentTabIds.size === newTabIds.size && [...currentTabIds].every((id) => newTabIds.has(id));
|
||||
tabIds.length === newTabIds.size &&
|
||||
tabIds.every((id) => newTabIds.has(id)) &&
|
||||
newPinnedTabIds.length === pinnedTabIds.size;
|
||||
|
||||
if (!areEqual) {
|
||||
setTabIds(workspace.tabids);
|
||||
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]);
|
||||
}, [workspace, tabIds, pinnedTabIds]);
|
||||
|
||||
const saveTabsPosition = useCallback(() => {
|
||||
const tabs = tabRefs.current;
|
||||
@ -246,9 +256,14 @@ const TabBar = memo(({ workspace }: TabBarProps) => {
|
||||
}
|
||||
};
|
||||
|
||||
const saveTabsPositionDebounced = useCallback(
|
||||
debounce(100, () => saveTabsPosition()),
|
||||
[saveTabsPosition]
|
||||
);
|
||||
|
||||
const handleResizeTabs = useCallback(() => {
|
||||
setSizeAndPosition();
|
||||
debounce(100, () => saveTabsPosition())();
|
||||
saveTabsPositionDebounced();
|
||||
}, [tabIds, newTabId, isFullScreen]);
|
||||
|
||||
const reinitVersion = useAtomValue(atoms.reinitVersion);
|
||||
@ -278,7 +293,7 @@ const TabBar = memo(({ workspace }: TabBarProps) => {
|
||||
}, [tabIds, tabsLoaded, newTabId, saveTabsPosition]);
|
||||
|
||||
const getDragDirection = (currentX: number) => {
|
||||
let dragDirection;
|
||||
let dragDirection: string;
|
||||
if (currentX - prevDelta > 0) {
|
||||
dragDirection = "+";
|
||||
} else if (currentX - prevDelta === 0) {
|
||||
@ -418,6 +433,50 @@ const TabBar = memo(({ workspace }: TabBarProps) => {
|
||||
}
|
||||
};
|
||||
|
||||
// } 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;
|
||||
|
||||
@ -432,17 +491,7 @@ const TabBar = memo(({ workspace }: TabBarProps) => {
|
||||
}
|
||||
|
||||
if (dragged) {
|
||||
debounce(300, () => {
|
||||
// Reset styles
|
||||
tabRefs.current.forEach((ref) => {
|
||||
ref.current.style.zIndex = "0";
|
||||
ref.current.classList.remove("animate");
|
||||
});
|
||||
// Reset dragging state
|
||||
setDraggingTab(null);
|
||||
// Update workspace tab ids
|
||||
fireAndForget(async () => await WorkspaceService.UpdateTabIds(workspace.oid, tabIds));
|
||||
})();
|
||||
setUpdatedTabsDebounced(tabIndex, tabIds, pinnedTabIds);
|
||||
} else {
|
||||
// Reset styles
|
||||
tabRefs.current.forEach((ref) => {
|
||||
@ -465,12 +514,14 @@ const TabBar = memo(({ workspace }: TabBarProps) => {
|
||||
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,
|
||||
@ -489,19 +540,31 @@ const TabBar = memo(({ workspace }: TabBarProps) => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddTab = () => {
|
||||
createTab();
|
||||
tabsWrapperRef.current.style.transition;
|
||||
tabsWrapperRef.current.style.setProperty("--tabs-wrapper-transition", "width 0.1s ease");
|
||||
|
||||
const updateScrollDebounced = useCallback(
|
||||
debounce(30, () => {
|
||||
if (scrollableRef.current) {
|
||||
const { viewport } = osInstanceRef.current.elements();
|
||||
viewport.scrollLeft = tabIds.length * tabWidthRef.current;
|
||||
}
|
||||
})();
|
||||
}),
|
||||
[tabIds]
|
||||
);
|
||||
|
||||
debounce(100, () => setNewTabId(null))();
|
||||
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) => {
|
||||
@ -511,7 +574,14 @@ const TabBar = memo(({ workspace }: TabBarProps) => {
|
||||
deleteLayoutModelForTab(tabId);
|
||||
};
|
||||
|
||||
const handleTabLoaded = useCallback((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
|
||||
@ -550,17 +620,20 @@ const TabBar = memo(({ workspace }: TabBarProps) => {
|
||||
<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}
|
||||
|
1
frontend/types/gotypes.d.ts
vendored
1
frontend/types/gotypes.d.ts
vendored
@ -1119,6 +1119,7 @@ declare global {
|
||||
icon: string;
|
||||
color: string;
|
||||
tabids: string[];
|
||||
pinnedtabids: string[];
|
||||
activetabid: string;
|
||||
};
|
||||
|
||||
|
@ -87,11 +87,14 @@ async function initWaveWrap(initOpts: WaveInitOpts) {
|
||||
async function reinitWave() {
|
||||
console.log("Reinit Wave");
|
||||
getApi().sendLog("Reinit Wave");
|
||||
|
||||
// We use this hack to prevent a flicker in the tab bar when switching to a new tab. This class is set in setActiveTab in global.ts. See tab.scss for where this class is used.
|
||||
requestAnimationFrame(() => {
|
||||
setTimeout(() => {
|
||||
document.body.classList.remove("nohover");
|
||||
}, 50);
|
||||
}, 100);
|
||||
});
|
||||
|
||||
const client = await WOS.reloadWaveObject<Client>(WOS.makeORef("client", savedInitOpts.clientId));
|
||||
const waveWindow = await WOS.reloadWaveObject<WaveWindow>(WOS.makeORef("window", savedInitOpts.windowId));
|
||||
const ws = await WOS.reloadWaveObject<Workspace>(WOS.makeORef("workspace", waveWindow.workspaceid));
|
||||
|
@ -55,7 +55,7 @@ func (svc *WindowService) CreateWindow(ctx context.Context, winSize *waveobj.Win
|
||||
return nil, fmt.Errorf("error getting workspace: %w", err)
|
||||
}
|
||||
if len(ws.TabIds) == 0 {
|
||||
_, err = wcore.CreateTab(ctx, ws.OID, "", true)
|
||||
_, err = wcore.CreateTab(ctx, ws.OID, "", true, false)
|
||||
if err != nil {
|
||||
return window, fmt.Errorf("error creating tab: %w", err)
|
||||
}
|
||||
|
@ -3,6 +3,7 @@ package workspaceservice
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"github.com/wavetermdev/waveterm/pkg/blockcontroller"
|
||||
@ -68,16 +69,16 @@ func (svg *WorkspaceService) ListWorkspaces() (waveobj.WorkspaceList, error) {
|
||||
|
||||
func (svc *WorkspaceService) CreateTab_Meta() tsgenmeta.MethodMeta {
|
||||
return tsgenmeta.MethodMeta{
|
||||
ArgNames: []string{"workspaceId", "tabName", "activateTab"},
|
||||
ArgNames: []string{"workspaceId", "tabName", "activateTab", "pinned"},
|
||||
ReturnDesc: "tabId",
|
||||
}
|
||||
}
|
||||
|
||||
func (svc *WorkspaceService) CreateTab(workspaceId string, tabName string, activateTab bool) (string, waveobj.UpdatesRtnType, error) {
|
||||
func (svc *WorkspaceService) CreateTab(workspaceId string, tabName string, activateTab bool, pinned bool) (string, waveobj.UpdatesRtnType, error) {
|
||||
ctx, cancelFn := context.WithTimeout(context.Background(), DefaultTimeout)
|
||||
defer cancelFn()
|
||||
ctx = waveobj.ContextWithUpdates(ctx)
|
||||
tabId, err := wcore.CreateTab(ctx, workspaceId, tabName, activateTab)
|
||||
tabId, err := wcore.CreateTab(ctx, workspaceId, tabName, activateTab, pinned)
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("error creating tab: %w", err)
|
||||
}
|
||||
@ -93,17 +94,39 @@ func (svc *WorkspaceService) CreateTab(workspaceId string, tabName string, activ
|
||||
return tabId, updates, nil
|
||||
}
|
||||
|
||||
func (svc *WorkspaceService) UpdateTabIds_Meta() tsgenmeta.MethodMeta {
|
||||
func (svc *WorkspaceService) ChangeTabPinning_Meta() tsgenmeta.MethodMeta {
|
||||
return tsgenmeta.MethodMeta{
|
||||
ArgNames: []string{"uiContext", "workspaceId", "tabIds"},
|
||||
ArgNames: []string{"ctx", "workspaceId", "tabId", "pinned"},
|
||||
}
|
||||
}
|
||||
|
||||
func (svc *WorkspaceService) UpdateTabIds(uiContext waveobj.UIContext, workspaceId string, tabIds []string) (waveobj.UpdatesRtnType, error) {
|
||||
func (svc *WorkspaceService) ChangeTabPinning(ctx context.Context, workspaceId string, tabId string, pinned bool) (waveobj.UpdatesRtnType, error) {
|
||||
log.Printf("ChangeTabPinning %s %s %v\n", workspaceId, tabId, pinned)
|
||||
ctx = waveobj.ContextWithUpdates(ctx)
|
||||
err := wcore.ChangeTabPinning(ctx, workspaceId, tabId, pinned)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error toggling tab pinning: %w", err)
|
||||
}
|
||||
updates := waveobj.ContextGetUpdatesRtn(ctx)
|
||||
go func() {
|
||||
defer panichandler.PanicHandler("WorkspaceService:ChangeTabPinning:SendUpdateEvents")
|
||||
wps.Broker.SendUpdateEvents(updates)
|
||||
}()
|
||||
return updates, nil
|
||||
}
|
||||
|
||||
func (svc *WorkspaceService) UpdateTabIds_Meta() tsgenmeta.MethodMeta {
|
||||
return tsgenmeta.MethodMeta{
|
||||
ArgNames: []string{"uiContext", "workspaceId", "tabIds", "pinnedTabIds"},
|
||||
}
|
||||
}
|
||||
|
||||
func (svc *WorkspaceService) UpdateTabIds(uiContext waveobj.UIContext, workspaceId string, tabIds []string, pinnedTabIds []string) (waveobj.UpdatesRtnType, error) {
|
||||
log.Printf("UpdateTabIds %s %v %v\n", workspaceId, tabIds, pinnedTabIds)
|
||||
ctx, cancelFn := context.WithTimeout(context.Background(), DefaultTimeout)
|
||||
defer cancelFn()
|
||||
ctx = waveobj.ContextWithUpdates(ctx)
|
||||
err := wcore.UpdateWorkspaceTabIds(ctx, workspaceId, tabIds)
|
||||
err := wcore.UpdateWorkspaceTabIds(ctx, workspaceId, tabIds, pinnedTabIds)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error updating workspace tab ids: %w", err)
|
||||
}
|
||||
|
@ -171,6 +171,7 @@ type Workspace struct {
|
||||
Icon string `json:"icon"`
|
||||
Color string `json:"color"`
|
||||
TabIds []string `json:"tabids"`
|
||||
PinnedTabIds []string `json:"pinnedtabids"`
|
||||
ActiveTabId string `json:"activetabid"`
|
||||
Meta MetaMapType `json:"meta"`
|
||||
}
|
||||
|
@ -62,7 +62,7 @@ func EnsureInitialData() error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("error creating default workspace: %w", err)
|
||||
}
|
||||
_, err = CreateTab(ctx, defaultWs.OID, "", true)
|
||||
_, err = CreateTab(ctx, defaultWs.OID, "", true, true)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error creating tab: %w", err)
|
||||
}
|
||||
|
@ -176,7 +176,7 @@ func CheckAndFixWindow(ctx context.Context, windowId string) *waveobj.Window {
|
||||
}
|
||||
if len(ws.TabIds) == 0 {
|
||||
log.Printf("fixing workspace with no tabs %q (in checkAndFixWindow)\n", ws.OID)
|
||||
_, err = CreateTab(ctx, ws.OID, "", true)
|
||||
_, err = CreateTab(ctx, ws.OID, "", true, false)
|
||||
if err != nil {
|
||||
log.Printf("error creating tab (in checkAndFixWindow): %v\n", err)
|
||||
}
|
||||
|
@ -20,6 +20,7 @@ func CreateWorkspace(ctx context.Context, name string, icon string, color string
|
||||
ws := &waveobj.Workspace{
|
||||
OID: uuid.NewString(),
|
||||
TabIds: []string{},
|
||||
PinnedTabIds: []string{},
|
||||
Name: name,
|
||||
Icon: icon,
|
||||
Color: color,
|
||||
@ -37,11 +38,13 @@ func DeleteWorkspace(ctx context.Context, workspaceId string, force bool) (bool,
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("error getting workspace: %w", err)
|
||||
}
|
||||
if workspace.Name != "" && workspace.Icon != "" && !force && len(workspace.TabIds) > 0 {
|
||||
if workspace.Name != "" && workspace.Icon != "" && !force && len(workspace.TabIds) > 0 && len(workspace.PinnedTabIds) > 0 {
|
||||
log.Printf("Ignoring DeleteWorkspace for workspace %s as it is named\n", workspaceId)
|
||||
return false, nil
|
||||
}
|
||||
for _, tabId := range workspace.TabIds {
|
||||
|
||||
// delete all pinned and unpinned tabs
|
||||
for _, tabId := range append(workspace.TabIds, workspace.PinnedTabIds...) {
|
||||
log.Printf("deleting tab %s\n", tabId)
|
||||
_, err := DeleteTab(ctx, workspaceId, tabId, false)
|
||||
if err != nil {
|
||||
@ -60,7 +63,30 @@ func GetWorkspace(ctx context.Context, wsID string) (*waveobj.Workspace, error)
|
||||
return wstore.DBMustGet[*waveobj.Workspace](ctx, wsID)
|
||||
}
|
||||
|
||||
func createTabObj(ctx context.Context, workspaceId string, name string) (*waveobj.Tab, error) {
|
||||
// returns tabid
|
||||
func CreateTab(ctx context.Context, workspaceId string, tabName string, activateTab bool, pinned bool) (string, error) {
|
||||
if tabName == "" {
|
||||
ws, err := GetWorkspace(ctx, workspaceId)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("workspace %s not found: %w", workspaceId, err)
|
||||
}
|
||||
tabName = "T" + fmt.Sprint(len(ws.TabIds)+1)
|
||||
}
|
||||
tab, err := createTabObj(ctx, workspaceId, tabName, pinned)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("error creating tab: %w", err)
|
||||
}
|
||||
if activateTab {
|
||||
err = SetActiveTab(ctx, workspaceId, tab.OID)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("error setting active tab: %w", err)
|
||||
}
|
||||
}
|
||||
telemetry.GoUpdateActivityWrap(wshrpc.ActivityUpdate{NewTab: 1}, "createtab")
|
||||
return tab.OID, nil
|
||||
}
|
||||
|
||||
func createTabObj(ctx context.Context, workspaceId string, name string, pinned bool) (*waveobj.Tab, error) {
|
||||
ws, err := GetWorkspace(ctx, workspaceId)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("workspace %s not found: %w", workspaceId, err)
|
||||
@ -75,36 +101,17 @@ func createTabObj(ctx context.Context, workspaceId string, name string) (*waveob
|
||||
layoutState := &waveobj.LayoutState{
|
||||
OID: layoutStateId,
|
||||
}
|
||||
if pinned {
|
||||
ws.PinnedTabIds = append(ws.PinnedTabIds, tab.OID)
|
||||
} else {
|
||||
ws.TabIds = append(ws.TabIds, tab.OID)
|
||||
}
|
||||
wstore.DBInsert(ctx, tab)
|
||||
wstore.DBInsert(ctx, layoutState)
|
||||
wstore.DBUpdate(ctx, ws)
|
||||
return tab, nil
|
||||
}
|
||||
|
||||
// returns tabid
|
||||
func CreateTab(ctx context.Context, workspaceId string, tabName string, activateTab bool) (string, error) {
|
||||
if tabName == "" {
|
||||
ws, err := GetWorkspace(ctx, workspaceId)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("workspace %s not found: %w", workspaceId, err)
|
||||
}
|
||||
tabName = "T" + fmt.Sprint(len(ws.TabIds)+1)
|
||||
}
|
||||
tab, err := createTabObj(ctx, workspaceId, tabName)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("error creating tab: %w", err)
|
||||
}
|
||||
if activateTab {
|
||||
err = SetActiveTab(ctx, workspaceId, tab.OID)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("error setting active tab: %w", err)
|
||||
}
|
||||
}
|
||||
telemetry.GoUpdateActivityWrap(wshrpc.ActivityUpdate{NewTab: 1}, "createtab")
|
||||
return tab.OID, nil
|
||||
}
|
||||
|
||||
// Must delete all blocks individually first.
|
||||
// Also deletes LayoutState.
|
||||
// recursive: if true, will recursively close parent window, workspace, if they are empty.
|
||||
@ -114,38 +121,50 @@ func DeleteTab(ctx context.Context, workspaceId string, tabId string, recursive
|
||||
if ws == nil {
|
||||
return "", fmt.Errorf("workspace not found: %q", workspaceId)
|
||||
}
|
||||
|
||||
// ensure tab is in workspace
|
||||
tabIdx := utilfn.FindStringInSlice(ws.TabIds, tabId)
|
||||
tabIdxPinned := utilfn.FindStringInSlice(ws.PinnedTabIds, tabId)
|
||||
if tabIdx != -1 {
|
||||
ws.TabIds = append(ws.TabIds[:tabIdx], ws.TabIds[tabIdx+1:]...)
|
||||
} else if tabIdxPinned != -1 {
|
||||
ws.PinnedTabIds = append(ws.PinnedTabIds[:tabIdxPinned], ws.PinnedTabIds[tabIdxPinned+1:]...)
|
||||
} else {
|
||||
return "", fmt.Errorf("tab %s not found in workspace %s", tabId, workspaceId)
|
||||
}
|
||||
|
||||
// close blocks (sends events + stops block controllers)
|
||||
tab, _ := wstore.DBGet[*waveobj.Tab](ctx, tabId)
|
||||
if tab == nil {
|
||||
return "", fmt.Errorf("tab not found: %q", tabId)
|
||||
}
|
||||
|
||||
// close blocks (sends events + stops block controllers)
|
||||
for _, blockId := range tab.BlockIds {
|
||||
err := DeleteBlock(ctx, blockId, false)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("error deleting block %s: %w", blockId, err)
|
||||
}
|
||||
}
|
||||
tabIdx := utilfn.FindStringInSlice(ws.TabIds, tabId)
|
||||
if tabIdx == -1 {
|
||||
return "", nil
|
||||
}
|
||||
ws.TabIds = append(ws.TabIds[:tabIdx], ws.TabIds[tabIdx+1:]...)
|
||||
|
||||
// if the tab is active, determine new active tab
|
||||
newActiveTabId := ws.ActiveTabId
|
||||
if len(ws.TabIds) > 0 {
|
||||
if ws.ActiveTabId == tabId {
|
||||
if len(ws.TabIds) > 0 && tabIdx != -1 {
|
||||
newActiveTabId = ws.TabIds[max(0, min(tabIdx-1, len(ws.TabIds)-1))]
|
||||
}
|
||||
} else if len(ws.PinnedTabIds) > 0 {
|
||||
newActiveTabId = ws.PinnedTabIds[0]
|
||||
} else {
|
||||
newActiveTabId = ""
|
||||
}
|
||||
}
|
||||
ws.ActiveTabId = newActiveTabId
|
||||
|
||||
wstore.DBUpdate(ctx, ws)
|
||||
wstore.DBDelete(ctx, waveobj.OType_Tab, tabId)
|
||||
wstore.DBDelete(ctx, waveobj.OType_LayoutState, tab.LayoutState)
|
||||
|
||||
// if no tabs remaining, close window
|
||||
if newActiveTabId == "" && recursive {
|
||||
log.Printf("no tabs remaining in workspace %s, closing window\n", workspaceId)
|
||||
windowId, err := wstore.DBFindWindowForWorkspaceId(ctx, workspaceId)
|
||||
if err != nil {
|
||||
return newActiveTabId, fmt.Errorf("unable to find window for workspace id %v: %w", workspaceId, err)
|
||||
@ -159,7 +178,7 @@ func DeleteTab(ctx context.Context, workspaceId string, tabId string, recursive
|
||||
}
|
||||
|
||||
func SetActiveTab(ctx context.Context, workspaceId string, tabId string) error {
|
||||
if tabId != "" {
|
||||
if tabId != "" && workspaceId != "" {
|
||||
workspace, err := GetWorkspace(ctx, workspaceId)
|
||||
if err != nil {
|
||||
return fmt.Errorf("workspace %s not found: %w", workspaceId, err)
|
||||
@ -174,6 +193,30 @@ func SetActiveTab(ctx context.Context, workspaceId string, tabId string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func ChangeTabPinning(ctx context.Context, workspaceId string, tabId string, pinned bool) error {
|
||||
if tabId != "" && workspaceId != "" {
|
||||
workspace, err := GetWorkspace(ctx, workspaceId)
|
||||
if err != nil {
|
||||
return fmt.Errorf("workspace %s not found: %w", workspaceId, err)
|
||||
}
|
||||
if pinned && utilfn.FindStringInSlice(workspace.PinnedTabIds, tabId) == -1 {
|
||||
if utilfn.FindStringInSlice(workspace.TabIds, tabId) == -1 {
|
||||
return fmt.Errorf("tab %s not found in workspace %s", tabId, workspaceId)
|
||||
}
|
||||
workspace.TabIds = utilfn.RemoveElemFromSlice(workspace.TabIds, tabId)
|
||||
workspace.PinnedTabIds = append(workspace.PinnedTabIds, tabId)
|
||||
} else if !pinned && utilfn.FindStringInSlice(workspace.PinnedTabIds, tabId) != -1 {
|
||||
if utilfn.FindStringInSlice(workspace.PinnedTabIds, tabId) == -1 {
|
||||
return fmt.Errorf("tab %s not found in workspace %s", tabId, workspaceId)
|
||||
}
|
||||
workspace.PinnedTabIds = utilfn.RemoveElemFromSlice(workspace.PinnedTabIds, tabId)
|
||||
workspace.TabIds = append([]string{tabId}, workspace.TabIds...)
|
||||
}
|
||||
wstore.DBUpdate(ctx, workspace)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func SendActiveTabUpdate(ctx context.Context, workspaceId string, newActiveTabId string) {
|
||||
eventbus.SendEventToElectron(eventbus.WSEventType{
|
||||
EventType: eventbus.WSEvent_ElectronUpdateActiveTab,
|
||||
@ -181,12 +224,13 @@ func SendActiveTabUpdate(ctx context.Context, workspaceId string, newActiveTabId
|
||||
})
|
||||
}
|
||||
|
||||
func UpdateWorkspaceTabIds(ctx context.Context, workspaceId string, tabIds []string) error {
|
||||
func UpdateWorkspaceTabIds(ctx context.Context, workspaceId string, tabIds []string, pinnedTabIds []string) error {
|
||||
ws, _ := wstore.DBGet[*waveobj.Workspace](ctx, workspaceId)
|
||||
if ws == nil {
|
||||
return fmt.Errorf("workspace not found: %q", workspaceId)
|
||||
}
|
||||
ws.TabIds = tabIds
|
||||
ws.PinnedTabIds = pinnedTabIds
|
||||
wstore.DBUpdate(ctx, ws)
|
||||
return nil
|
||||
}
|
||||
|
@ -350,12 +350,28 @@ func DBFindTabForBlockId(ctx context.Context, blockId string) (string, error) {
|
||||
}
|
||||
|
||||
func DBFindWorkspaceForTabId(ctx context.Context, tabId string) (string, error) {
|
||||
log.Printf("DBFindWorkspaceForTabId tabId: %s\n", tabId)
|
||||
return WithTxRtn(ctx, func(tx *TxWrap) (string, error) {
|
||||
query := `
|
||||
WITH variable(value) AS (
|
||||
SELECT ?
|
||||
)
|
||||
SELECT w.oid
|
||||
FROM db_workspace w, json_each(data->'tabids') je
|
||||
WHERE je.value = ?`
|
||||
return tx.GetString(query, tabId), nil
|
||||
FROM db_workspace w, variable
|
||||
WHERE EXISTS (
|
||||
SELECT 1
|
||||
FROM json_each(w.data, '$.tabids') AS je
|
||||
WHERE je.value = variable.value
|
||||
)
|
||||
OR EXISTS (
|
||||
SELECT 1
|
||||
FROM json_each(w.data, '$.pinnedtabids') AS je
|
||||
WHERE je.value = variable.value
|
||||
);
|
||||
`
|
||||
wsId := tx.GetString(query, tabId)
|
||||
log.Printf("DBFindWorkspaceForTabId wsId: %s\n", wsId)
|
||||
return wsId, nil
|
||||
})
|
||||
}
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user