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:
Evan Simkowitz 2024-12-04 16:34:22 -05:00 committed by GitHub
parent 0145e8fe99
commit aa77b2c259
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 331 additions and 130 deletions

View File

@ -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);
}

View File

@ -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);
}

View File

@ -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", () => {

View File

@ -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))
}
}

View File

@ -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 {

View File

@ -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>
);

View File

@ -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;

View File

@ -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}

View File

@ -1119,6 +1119,7 @@ declare global {
icon: string;
color: string;
tabids: string[];
pinnedtabids: string[];
activetabid: string;
};

View File

@ -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));

View File

@ -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)
}

View File

@ -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)
}

View File

@ -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"`
}

View File

@ -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)
}

View File

@ -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)
}

View File

@ -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
}

View File

@ -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
})
}