From aa77b2c2599f12612b856cca4bc1a4edb340ae55 Mon Sep 17 00:00:00 2001 From: Evan Simkowitz Date: Wed, 4 Dec 2024 16:34:22 -0500 Subject: [PATCH] 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. --- emain/emain-window.ts | 4 +- frontend/app/store/global.ts | 1 + frontend/app/store/keymodel.ts | 24 ++-- frontend/app/store/services.ts | 9 +- frontend/app/tab/tab.scss | 7 +- frontend/app/tab/tab.tsx | 60 +++++--- frontend/app/tab/tabbar.scss | 9 ++ frontend/app/tab/tabbar.tsx | 125 ++++++++++++---- frontend/types/gotypes.d.ts | 1 + frontend/wave.ts | 5 +- pkg/service/windowservice/windowservice.go | 2 +- .../workspaceservice/workspaceservice.go | 37 ++++- pkg/waveobj/wtype.go | 17 +-- pkg/wcore/wcore.go | 2 +- pkg/wcore/window.go | 2 +- pkg/wcore/workspace.go | 134 ++++++++++++------ pkg/wstore/wstore_dbops.go | 22 ++- 17 files changed, 331 insertions(+), 130 deletions(-) diff --git a/emain/emain-window.ts b/emain/emain-window.ts index 85e1334e5..3b3891d9e 100644 --- a/emain/emain-window.ts +++ b/emain/emain-window.ts @@ -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); } diff --git a/frontend/app/store/global.ts b/frontend/app/store/global.ts index 00b167ea1..3f931266f 100644 --- a/frontend/app/store/global.ts +++ b/frontend/app/store/global.ts @@ -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); } diff --git a/frontend/app/store/keymodel.ts b/frontend/app/store/keymodel.ts index be54f9ed7..f49a44979 100644 --- a/frontend/app/store/keymodel.ts +++ b/frontend/app/store/keymodel.ts @@ -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); - genericClose(tabId); + const ws = globalStore.get(atoms.workspace); + if (!ws.pinnedtabids?.includes(tabId)) { + genericClose(tabId); + } return true; }); globalKeyMap.set("Cmd:m", () => { diff --git a/frontend/app/store/services.ts b/frontend/app/store/services.ts index 5abbaef9d..fab70a66d 100644 --- a/frontend/app/store/services.ts +++ b/frontend/app/store/services.ts @@ -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 { + return WOS.callBackendService("workspace", "ChangeTabPinning", Array.from(arguments)) + } + // @returns object updates CloseTab(workspaceId: string, tabId: string, fromElectron: boolean): Promise { return WOS.callBackendService("workspace", "CloseTab", Array.from(arguments)) } // @returns tabId (and object updates) - CreateTab(workspaceId: string, tabName: string, activateTab: boolean): Promise { + CreateTab(workspaceId: string, tabName: string, activateTab: boolean, pinned: boolean): Promise { return WOS.callBackendService("workspace", "CreateTab", Array.from(arguments)) } @@ -195,7 +200,7 @@ class WorkspaceServiceType { } // @returns object updates - UpdateTabIds(workspaceId: string, tabIds: string[]): Promise { + UpdateTabIds(workspaceId: string, tabIds: string[], pinnedTabIds: string[]): Promise { return WOS.callBackendService("workspace", "UpdateTabIds", Array.from(arguments)) } } diff --git a/frontend/app/tab/tab.scss b/frontend/app/tab/tab.scss index f5b8ecc27..0ecdc7052 100644 --- a/frontend/app/tab/tab.scss +++ b/frontend/app/tab/tab.scss @@ -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 { diff --git a/frontend/app/tab/tab.tsx b/frontend/app/tab/tab.tsx index 0b01cf832..ced541e96 100644 --- a/frontend/app/tab/tab.tsx +++ b/frontend/app/tab/tab.tsx @@ -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 | null) => void; onDragStart: (event: React.MouseEvent) => void; onLoaded: () => void; + onPinChange: () => void; } -const Tab = React.memo( +const Tab = memo( forwardRef( ( { 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(WOS.makeORef("tab", id)); + const [tabData, _] = useWaveObjectValue(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) { 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} - + {isPinned ? ( + + ) : ( + + )} ); diff --git a/frontend/app/tab/tabbar.scss b/frontend/app/tab/tabbar.scss index 675f4c74c..3ae29cfea 100644 --- a/frontend/app/tab/tabbar.scss +++ b/frontend/app/tab/tabbar.scss @@ -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; diff --git a/frontend/app/tab/tabbar.tsx b/frontend/app/tab/tabbar.tsx index 119bba05c..8ac01228d 100644 --- a/frontend/app/tab/tabbar.tsx +++ b/frontend/app/tab/tabbar.tsx @@ -101,6 +101,7 @@ const ConfigErrorIcon = ({ buttonRef }: { buttonRef: React.RefObject { const [tabIds, setTabIds] = useState([]); + const [pinnedTabIds, setPinnedTabIds] = useState>(new Set()); const [dragStartPositions, setDragStartPositions] = useState([]); const [draggingTab, setDraggingTab] = useState(); 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) => { + 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 | 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) => {
{tabIds.map((tabId, index) => { + const isPinned = pinnedTabIds.has(tabId); return ( 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} diff --git a/frontend/types/gotypes.d.ts b/frontend/types/gotypes.d.ts index d68fd93d6..4f86c80cf 100644 --- a/frontend/types/gotypes.d.ts +++ b/frontend/types/gotypes.d.ts @@ -1119,6 +1119,7 @@ declare global { icon: string; color: string; tabids: string[]; + pinnedtabids: string[]; activetabid: string; }; diff --git a/frontend/wave.ts b/frontend/wave.ts index 1dace31b1..de2d249cc 100644 --- a/frontend/wave.ts +++ b/frontend/wave.ts @@ -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(WOS.makeORef("client", savedInitOpts.clientId)); const waveWindow = await WOS.reloadWaveObject(WOS.makeORef("window", savedInitOpts.windowId)); const ws = await WOS.reloadWaveObject(WOS.makeORef("workspace", waveWindow.workspaceid)); diff --git a/pkg/service/windowservice/windowservice.go b/pkg/service/windowservice/windowservice.go index ed8345395..2c4e62bcf 100644 --- a/pkg/service/windowservice/windowservice.go +++ b/pkg/service/windowservice/windowservice.go @@ -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) } diff --git a/pkg/service/workspaceservice/workspaceservice.go b/pkg/service/workspaceservice/workspaceservice.go index f3198f727..2c6dfb92d 100644 --- a/pkg/service/workspaceservice/workspaceservice.go +++ b/pkg/service/workspaceservice/workspaceservice.go @@ -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) } diff --git a/pkg/waveobj/wtype.go b/pkg/waveobj/wtype.go index 4e3cc0b34..acb107317 100644 --- a/pkg/waveobj/wtype.go +++ b/pkg/waveobj/wtype.go @@ -165,14 +165,15 @@ type ActiveTabUpdate struct { } type Workspace struct { - OID string `json:"oid"` - Version int `json:"version"` - Name string `json:"name"` - Icon string `json:"icon"` - Color string `json:"color"` - TabIds []string `json:"tabids"` - ActiveTabId string `json:"activetabid"` - Meta MetaMapType `json:"meta"` + OID string `json:"oid"` + Version int `json:"version"` + Name string `json:"name"` + Icon string `json:"icon"` + Color string `json:"color"` + TabIds []string `json:"tabids"` + PinnedTabIds []string `json:"pinnedtabids"` + ActiveTabId string `json:"activetabid"` + Meta MetaMapType `json:"meta"` } func (*Workspace) GetOType() string { diff --git a/pkg/wcore/wcore.go b/pkg/wcore/wcore.go index 35d0ce3fc..a11a5389c 100644 --- a/pkg/wcore/wcore.go +++ b/pkg/wcore/wcore.go @@ -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) } diff --git a/pkg/wcore/window.go b/pkg/wcore/window.go index b989eeeb9..10c1ae407 100644 --- a/pkg/wcore/window.go +++ b/pkg/wcore/window.go @@ -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) } diff --git a/pkg/wcore/workspace.go b/pkg/wcore/workspace.go index 717ff41e6..175a58632 100644 --- a/pkg/wcore/workspace.go +++ b/pkg/wcore/workspace.go @@ -18,11 +18,12 @@ import ( func CreateWorkspace(ctx context.Context, name string, icon string, color string) (*waveobj.Workspace, error) { log.Println("CreateWorkspace") ws := &waveobj.Workspace{ - OID: uuid.NewString(), - TabIds: []string{}, - Name: name, - Icon: icon, - Color: color, + OID: uuid.NewString(), + TabIds: []string{}, + PinnedTabIds: []string{}, + Name: name, + Icon: icon, + Color: color, } wstore.DBInsert(ctx, ws) return ws, nil @@ -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, } - ws.TabIds = append(ws.TabIds, tab.OID) + 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,30 +121,40 @@ 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 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 = "" } - } else { - newActiveTabId = "" } ws.ActiveTabId = newActiveTabId @@ -145,7 +162,9 @@ func DeleteTab(ctx context.Context, workspaceId string, tabId string, recursive 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 } diff --git a/pkg/wstore/wstore_dbops.go b/pkg/wstore/wstore_dbops.go index b8f104b1a..cb5f573c2 100644 --- a/pkg/wstore/wstore_dbops.go +++ b/pkg/wstore/wstore_dbops.go @@ -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 }) }