diff --git a/frontend/app/store/services.ts b/frontend/app/store/services.ts index c54b73d98..dc1e083e7 100644 --- a/frontend/app/store/services.ts +++ b/frontend/app/store/services.ts @@ -12,7 +12,7 @@ class BlockServiceType { } // send command to block - SendCommand(blockid: string, cmd: BlockCommand): Promise { + SendCommand(cmd: string, arg3: BlockCommand): Promise { return WOS.callBackendService("block", "SendCommand", Array.from(arguments)) } } @@ -109,6 +109,11 @@ class ObjectServiceType { return WOS.callBackendService("object", "UpdateObjectMeta", Array.from(arguments)) } + // @returns object updates + UpdateTabName(tabId: string, name: string): Promise { + return WOS.callBackendService("object", "UpdateTabName", Array.from(arguments)) + } + // @returns object updates UpdateWorkspaceTabIds(workspaceId: string, tabIds: string[]): Promise { return WOS.callBackendService("object", "UpdateWorkspaceTabIds", Array.from(arguments)) diff --git a/frontend/app/tab/tab.less b/frontend/app/tab/tab.less index 77a7e24b6..090c54447 100644 --- a/frontend/app/tab/tab.less +++ b/frontend/app/tab/tab.less @@ -32,6 +32,13 @@ transform: translate(-50%, -50%); -webkit-user-select: none; z-index: 3; + + &.focused { + outline: none; + border: 1px solid var(--border-color); + padding: 2px 6px; + border-radius: 2px; + } } .vertical-line { @@ -95,7 +102,7 @@ rgba(0, 0, 0, 0.8) 60%, rgba(0, 0, 0, 0.7) 100% ); - pointer-events: none; /* Prevents the background from capturing mouse events */ + pointer-events: none; } } diff --git a/frontend/app/tab/tab.tsx b/frontend/app/tab/tab.tsx index 9eba6d989..0db583481 100644 --- a/frontend/app/tab/tab.tsx +++ b/frontend/app/tab/tab.tsx @@ -1,10 +1,8 @@ -// Copyright 2024, Command Line Inc. -// SPDX-License-Identifier: Apache-2.0 - import { Button } from "@/element/button"; +import * as services from "@/store/services"; import * as WOS from "@/store/wos"; import { clsx } from "clsx"; -import React, { useEffect, useRef } from "react"; +import { forwardRef, useEffect, useRef, useState } from "react"; import "./tab.less"; @@ -14,18 +12,84 @@ interface TabProps { isBeforeActive: boolean; isDragging: boolean; onSelect: () => void; - onClose: () => void; - onDragStart: (event: MouseEvent) => void; + onClose: (event: React.MouseEvent) => void; + onDragStart: (event: React.MouseEvent) => void; onLoaded: () => void; } -const Tab = React.forwardRef( +const Tab = forwardRef( ({ id, active, isBeforeActive, isDragging, onLoaded, onSelect, onClose, onDragStart }, ref) => { const [tabData, tabLoading] = WOS.useWaveObjectValue(WOS.makeORef("tab", id)); - const name = tabData?.name ?? "..."; + const [originalName, setOriginalName] = useState(""); + const [isEditable, setIsEditable] = useState(false); + const editableRef = useRef(null); + const editableTimeoutRef = useRef(); const loadedRef = useRef(false); + useEffect(() => { + if (tabData?.name) { + setOriginalName(tabData.name); + } + }, [tabData]); + + useEffect(() => { + return () => { + if (editableTimeoutRef.current) { + clearTimeout(editableTimeoutRef.current); + } + }; + }, []); + + const handleDoubleClick = (event) => { + event.stopPropagation(); + setIsEditable(true); + editableTimeoutRef.current = setTimeout(() => { + if (editableRef.current) { + editableRef.current.focus(); + document.execCommand("selectAll", false); + } + }, 0); + }; + + const handleBlur = () => { + let newText = editableRef.current.innerText.trim(); + newText = newText || originalName; + editableRef.current.innerText = newText; + setIsEditable(false); + services.ObjectService.UpdateTabName(id, newText); + }; + + const handleKeyDown = (event) => { + if ((event.metaKey || event.ctrlKey) && event.key === "a") { + event.preventDefault(); + if (editableRef.current) { + const range = document.createRange(); + const selection = window.getSelection(); + range.selectNodeContents(editableRef.current); + selection.removeAllRanges(); + selection.addRange(range); + } + return; + } + + if (event.key === "Enter") { + event.preventDefault(); + if (editableRef.current.innerText.trim() === "") { + editableRef.current.innerText = originalName; + } + editableRef.current.blur(); + } else if (event.key === "Escape") { + editableRef.current.innerText = originalName; + editableRef.current.blur(); + } else if ( + editableRef.current.innerText.length >= 8 && + !["Backspace", "Delete", "ArrowLeft", "ArrowRight"].includes(event.key) + ) { + event.preventDefault(); + } + }; + useEffect(() => { if (!loadedRef.current) { onLoaded(); @@ -33,6 +97,11 @@ const Tab = React.forwardRef( } }, [onLoaded]); + // Prevent drag from being triggered on mousedown + const handleMouseDownOnClose = (event: React.MouseEvent) => { + event.stopPropagation(); + }; + return (
( onClick={onSelect} data-tab-id={id} > -
{name}
+
+ {tabData?.name} +
{!isDragging &&
} {active &&
} -
diff --git a/frontend/app/tab/tabbar.tsx b/frontend/app/tab/tabbar.tsx index afe41a11b..666a16a24 100644 --- a/frontend/app/tab/tabbar.tsx +++ b/frontend/app/tab/tabbar.tsx @@ -425,7 +425,8 @@ const TabBar = ({ workspace }: TabBarProps) => { }, 30); }; - const handleCloseTab = (tabId: string) => { + const handleCloseTab = (event: React.MouseEvent, tabId: string) => { + event.stopPropagation(); services.WindowService.CloseTab(tabId); deleteLayoutStateAtomForTab(tabId); }; @@ -458,7 +459,7 @@ const TabBar = ({ workspace }: TabBarProps) => { onSelect={() => handleSelectTab(tabId)} active={activetabid === tabId} onDragStart={(event) => handleDragStart(event, tabId, tabRefs.current[index])} - onClose={() => handleCloseTab(tabId)} + onClose={(event) => handleCloseTab(event, tabId)} onLoaded={() => handleTabLoaded(tabId)} isBeforeActive={isBeforeActive(tabId)} isDragging={draggingTab === tabId} diff --git a/frontend/types/gotypes.d.ts b/frontend/types/gotypes.d.ts index 12297c12d..cc98f13d6 100644 --- a/frontend/types/gotypes.d.ts +++ b/frontend/types/gotypes.d.ts @@ -31,7 +31,7 @@ declare global { type BlockCommand = { command: string; - } & ( ResolveIdsCommand | BlockSetViewCommand | BlockSetMetaCommand | BlockGetMetaCommand | BlockMessageCommand | BlockInputCommand | BlockAppendFileCommand | BlockAppendIJsonCommand | CreateBlockCommand ); + } & ( CreateBlockCommand | BlockInputCommand | BlockAppendFileCommand | ResolveIdsCommand | BlockMessageCommand | BlockAppendIJsonCommand | BlockSetViewCommand | BlockSetMetaCommand | BlockGetMetaCommand ); // wstore.BlockDef type BlockDef = { @@ -49,6 +49,7 @@ declare global { // wshutil.BlockInputCommand type BlockInputCommand = { + blockid: string; command: "controller:input"; inputdata64?: string; signame?: string; @@ -91,6 +92,7 @@ declare global { // wshutil.CreateBlockCommand type CreateBlockCommand = { command: "createblock"; + tabid: string; blockdef: BlockDef; rtopts?: RuntimeOpts; }; @@ -325,4 +327,4 @@ declare global { } -export {} \ No newline at end of file +export {} diff --git a/pkg/service/objectservice/objectservice.go b/pkg/service/objectservice/objectservice.go index 00e446d4d..a31766b49 100644 --- a/pkg/service/objectservice/objectservice.go +++ b/pkg/service/objectservice/objectservice.go @@ -155,6 +155,23 @@ func (svc *ObjectService) SetActiveTab(uiContext wstore.UIContext, tabId string) return updates, nil } +func (svc *ObjectService) UpdateTabName_Meta() tsgenmeta.MethodMeta { + return tsgenmeta.MethodMeta{ + ArgNames: []string{"uiContext", "tabId", "name"}, + } +} + +func (svc *ObjectService) UpdateTabName(uiContext wstore.UIContext, tabId, name string) (wstore.UpdatesRtnType, error) { + ctx, cancelFn := context.WithTimeout(context.Background(), DefaultTimeout) + defer cancelFn() + ctx = wstore.ContextWithUpdates(ctx) + err := wstore.UpdateTabName(ctx, tabId, name) + if err != nil { + return nil, fmt.Errorf("error updating tab name: %w", err) + } + return wstore.ContextGetUpdatesRtn(ctx), nil +} + func (svc *ObjectService) CreateBlock_Meta() tsgenmeta.MethodMeta { return tsgenmeta.MethodMeta{ ArgNames: []string{"uiContext", "blockDef", "rtOpts"}, diff --git a/pkg/wstore/wstore.go b/pkg/wstore/wstore.go index 70fc1a3b7..b0c5de802 100644 --- a/pkg/wstore/wstore.go +++ b/pkg/wstore/wstore.go @@ -211,6 +211,20 @@ func SetActiveTab(ctx context.Context, windowId string, tabId string) error { }) } +func UpdateTabName(ctx context.Context, tabId, name string) error { + return WithTx(ctx, func(tx *TxWrap) error { + tab, _ := DBGet[*Tab](tx.Context(), tabId) + if tab == nil { + return fmt.Errorf("tab not found: %q", tabId) + } + if tabId != "" { + tab.Name = name + DBUpdate(tx.Context(), tab) + } + return nil + }) +} + func CreateBlock(ctx context.Context, tabId string, blockDef *BlockDef, rtOpts *RuntimeOpts) (*Block, error) { return WithTxRtn(ctx, func(tx *TxWrap) (*Block, error) { tab, _ := DBGet[*Tab](tx.Context(), tabId)